@nocoo/basalt 2.0.3 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -15
- package/ai/COMPATIBILITY.md +180 -0
- package/ai/INTEGRATION.md +1760 -0
- package/ai/RECIPES.md +161 -0
- package/ai/USAGE.md +99 -0
- package/ai/registry.json +38099 -0
- package/ai/sources.json +10 -0
- package/dist/assets/brand-mark.d.ts +1 -0
- package/dist/assets/brand-mark.js +7 -0
- package/dist/assets/brand-mark.js.map +1 -0
- package/dist/charts/area.d.ts +58 -5
- package/dist/charts/area.js +20 -12
- package/dist/charts/area.js.map +1 -1
- package/dist/charts/bar.d.ts +54 -5
- package/dist/charts/bar.js +16 -10
- package/dist/charts/bar.js.map +1 -1
- package/dist/charts/bullet.d.ts +18 -1
- package/dist/charts/bullet.js +6 -3
- package/dist/charts/bullet.js.map +1 -1
- package/dist/charts/charts.d.ts +58 -5
- package/dist/charts/charts.js +13 -2
- package/dist/charts/charts.js.map +1 -1
- package/dist/charts/config.d.ts +7 -0
- package/dist/charts/config.js +5 -1
- package/dist/charts/config.js.map +1 -1
- package/dist/charts/custom-chart.d.ts +58 -5
- package/dist/charts/custom-chart.js +13 -2
- package/dist/charts/custom-chart.js.map +1 -1
- package/dist/charts/donut.d.ts +18 -1
- package/dist/charts/donut.js +6 -3
- package/dist/charts/donut.js.map +1 -1
- package/dist/charts/frame.d.ts +40 -3
- package/dist/charts/frame.js +55 -28
- package/dist/charts/frame.js.map +1 -1
- package/dist/charts/funnel.d.ts +18 -1
- package/dist/charts/funnel.js +5 -1
- package/dist/charts/funnel.js.map +1 -1
- package/dist/charts/gauge.d.ts +18 -1
- package/dist/charts/gauge.js +38 -20
- package/dist/charts/gauge.js.map +1 -1
- package/dist/charts/grouped-bar.d.ts +54 -5
- package/dist/charts/grouped-bar.js +16 -10
- package/dist/charts/grouped-bar.js.map +1 -1
- package/dist/charts/heatmap-calendar.d.ts +61 -0
- package/dist/charts/heatmap-calendar.js +287 -96
- package/dist/charts/heatmap-calendar.js.map +1 -1
- package/dist/charts/heatmap-matrix.d.ts +99 -0
- package/dist/charts/heatmap-matrix.js +249 -0
- package/dist/charts/heatmap-matrix.js.map +1 -0
- package/dist/charts/line.d.ts +66 -5
- package/dist/charts/line.js +37 -30
- package/dist/charts/line.js.map +1 -1
- package/dist/charts/palette.d.ts +1 -0
- package/dist/charts/palette.js +7 -1
- package/dist/charts/palette.js.map +1 -1
- package/dist/charts/radar.d.ts +18 -1
- package/dist/charts/radar.js +26 -19
- package/dist/charts/radar.js.map +1 -1
- package/dist/charts/sankey.d.ts +18 -1
- package/dist/charts/sankey.js +4 -1
- package/dist/charts/sankey.js.map +1 -1
- package/dist/charts/series.d.ts +5 -7
- package/dist/charts/series.js.map +1 -1
- package/dist/charts/slot-bar.d.ts +38 -6
- package/dist/charts/slot-bar.js +5 -2
- package/dist/charts/slot-bar.js.map +1 -1
- package/dist/charts/sparkline.d.ts +35 -5
- package/dist/charts/sparkline.js +22 -18
- package/dist/charts/sparkline.js.map +1 -1
- package/dist/charts/stacked-bar.d.ts +58 -5
- package/dist/charts/stacked-bar.js +19 -12
- package/dist/charts/stacked-bar.js.map +1 -1
- package/dist/charts/stat-card.d.ts +51 -1
- package/dist/charts/stat-card.js +66 -39
- package/dist/charts/stat-card.js.map +1 -1
- package/dist/charts/timeline.d.ts +3 -0
- package/dist/charts/timeline.js +4 -3
- package/dist/charts/timeline.js.map +1 -1
- package/dist/charts/timeseries.d.ts +58 -5
- package/dist/charts/timeseries.js +13 -2
- package/dist/charts/timeseries.js.map +1 -1
- package/dist/charts/tooltip.d.ts +57 -0
- package/dist/charts/tooltip.js +119 -2
- package/dist/charts/tooltip.js.map +1 -1
- package/dist/components/accordion.d.ts +117 -2
- package/dist/components/accordion.js +20 -13
- package/dist/components/accordion.js.map +1 -1
- package/dist/components/alert-dialog.d.ts +131 -13
- package/dist/components/alert-dialog.js +12 -9
- package/dist/components/alert-dialog.js.map +1 -1
- package/dist/components/app-header.js +18 -17
- package/dist/components/app-header.js.map +1 -1
- package/dist/components/app-shell.js +15 -14
- package/dist/components/app-shell.js.map +1 -1
- package/dist/components/autocomplete.d.ts +4 -0
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/avatar.d.ts +76 -3
- package/dist/components/avatar.js.map +1 -1
- package/dist/components/badge.d.ts +15 -1
- package/dist/components/badge.js +13 -12
- package/dist/components/badge.js.map +1 -1
- package/dist/components/banner.d.ts +88 -2
- package/dist/components/banner.js +41 -40
- package/dist/components/banner.js.map +1 -1
- package/dist/components/basalt-mark.js +14 -8
- package/dist/components/basalt-mark.js.map +1 -1
- package/dist/components/battery-meter.d.ts +10 -0
- package/dist/components/battery-meter.js +56 -0
- package/dist/components/battery-meter.js.map +1 -0
- package/dist/components/breadcrumbs.d.ts +27 -7
- package/dist/components/breadcrumbs.js +21 -20
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/button.d.ts +48 -2
- package/dist/components/button.js +87 -36
- package/dist/components/button.js.map +1 -1
- package/dist/components/chat-bubble.d.ts +7 -0
- package/dist/components/chat-bubble.js +14 -13
- package/dist/components/chat-bubble.js.map +1 -1
- package/dist/components/chat-composer.d.ts +7 -3
- package/dist/components/chat-composer.js.map +1 -1
- package/dist/components/chat-header.d.ts +6 -1
- package/dist/components/chat-header.js +17 -16
- package/dist/components/chat-header.js.map +1 -1
- package/dist/components/chat-inbox.d.ts +7 -2
- package/dist/components/chat-inbox.js.map +1 -1
- package/dist/components/checkbox.js +84 -53
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/clipboard-text.d.ts +13 -2
- package/dist/components/clipboard-text.js +4 -4
- package/dist/components/clipboard-text.js.map +1 -1
- package/dist/components/collapsible.d.ts +58 -15
- package/dist/components/collapsible.js.map +1 -1
- package/dist/components/command-palette.d.ts +151 -61
- package/dist/components/command-palette.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +1 -1
- package/dist/components/confirm-dialog.js +6 -4
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/context-menu.d.ts +210 -2
- package/dist/components/context-menu.js +7 -4
- package/dist/components/context-menu.js.map +1 -1
- package/dist/components/data-table.d.ts +40 -2
- package/dist/components/data-table.js +168 -120
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +53 -2
- package/dist/components/date-picker.js +387 -219
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/delete-resource.d.ts +7 -1
- package/dist/components/delete-resource.js +22 -18
- package/dist/components/delete-resource.js.map +1 -1
- package/dist/components/description-list.js +16 -15
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog.d.ts +141 -12
- package/dist/components/dialog.js +42 -38
- package/dist/components/dialog.js.map +1 -1
- package/dist/components/dock.d.ts +1 -1
- package/dist/components/dock.js +39 -58
- package/dist/components/dock.js.map +1 -1
- package/dist/components/dropdown-menu.d.ts +229 -15
- package/dist/components/dropdown-menu.js +9 -6
- package/dist/components/dropdown-menu.js.map +1 -1
- package/dist/components/editable-nav-item.d.ts +33 -0
- package/dist/components/editable-nav-item.js +90 -0
- package/dist/components/editable-nav-item.js.map +1 -0
- package/dist/components/empty.d.ts +22 -2
- package/dist/components/empty.js +22 -13
- package/dist/components/empty.js.map +1 -1
- package/dist/components/field.js +31 -30
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +37 -0
- package/dist/components/file-dropzone.js +103 -0
- package/dist/components/file-dropzone.js.map +1 -0
- package/dist/components/filter-bar.d.ts +34 -0
- package/dist/components/filter-bar.js +52 -0
- package/dist/components/filter-bar.js.map +1 -0
- package/dist/components/flow.js +19 -18
- package/dist/components/flow.js.map +1 -1
- package/dist/components/grid.d.ts +3 -1
- package/dist/components/grid.js.map +1 -1
- package/dist/components/hover-card.d.ts +111 -1
- package/dist/components/hover-card.js +9 -6
- package/dist/components/hover-card.js.map +1 -1
- package/dist/components/icon-picker.d.ts +34 -0
- package/dist/components/icon-picker.js +77 -0
- package/dist/components/icon-picker.js.map +1 -0
- package/dist/components/inline-editable.d.ts +46 -0
- package/dist/components/inline-editable.js +124 -0
- package/dist/components/inline-editable.js.map +1 -0
- package/dist/components/input-area.js +3 -3
- package/dist/components/input-group.js +7 -7
- package/dist/components/input.js +3 -3
- package/dist/components/label.js +18 -17
- package/dist/components/label.js.map +1 -1
- package/dist/components/layer-card.d.ts +10 -2
- package/dist/components/layer-card.js +71 -68
- package/dist/components/layer-card.js.map +1 -1
- package/dist/components/loader.d.ts +10 -2
- package/dist/components/loader.js.map +1 -1
- package/dist/components/menu-bar.d.ts +174 -6
- package/dist/components/menu-bar.js +33 -29
- package/dist/components/menu-bar.js.map +1 -1
- package/dist/components/meter.d.ts +27 -2
- package/dist/components/meter.js.map +1 -1
- package/dist/components/multi-select.d.ts +64 -0
- package/dist/components/multi-select.js +218 -0
- package/dist/components/multi-select.js.map +1 -0
- package/dist/components/navigation-menu.d.ts +88 -5
- package/dist/components/navigation-menu.js.map +1 -1
- package/dist/components/overlay.js +6 -5
- package/dist/components/overlay.js.map +1 -1
- package/dist/components/page-header.js +23 -21
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/pagination.js +7 -7
- package/dist/components/popover.d.ts +136 -16
- package/dist/components/popover.js +32 -28
- package/dist/components/popover.js.map +1 -1
- package/dist/components/radio.js +38 -37
- package/dist/components/radio.js.map +1 -1
- package/dist/components/resource-list.d.ts +26 -1
- package/dist/components/resource-list.js +28 -15
- package/dist/components/resource-list.js.map +1 -1
- package/dist/components/responsive-master-detail.d.ts +27 -0
- package/dist/components/responsive-master-detail.js +85 -0
- package/dist/components/responsive-master-detail.js.map +1 -0
- package/dist/components/section-rule.js +23 -22
- package/dist/components/section-rule.js.map +1 -1
- package/dist/components/select.d.ts +27 -3
- package/dist/components/select.js +11 -11
- package/dist/components/select.js.map +1 -1
- package/dist/components/sheet.d.ts +115 -11
- package/dist/components/sheet.js +37 -30
- package/dist/components/sheet.js.map +1 -1
- package/dist/components/sidebar.d.ts +109 -17
- package/dist/components/sidebar.js +112 -111
- package/dist/components/sidebar.js.map +1 -1
- package/dist/components/skeleton-line.d.ts +22 -2
- package/dist/components/skeleton-line.js.map +1 -1
- package/dist/components/slider.d.ts +61 -1
- package/dist/components/slider.js +31 -15
- package/dist/components/slider.js.map +1 -1
- package/dist/components/stat-strip.js +16 -15
- package/dist/components/stat-strip.js.map +1 -1
- package/dist/components/switch.js +78 -47
- package/dist/components/switch.js.map +1 -1
- package/dist/components/table-of-contents.js +18 -17
- package/dist/components/table-of-contents.js.map +1 -1
- package/dist/components/table-pager.js +20 -19
- package/dist/components/table-pager.js.map +1 -1
- package/dist/components/table.d.ts +9 -3
- package/dist/components/table.js +19 -18
- package/dist/components/table.js.map +1 -1
- package/dist/components/tabs.js +29 -22
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/tag-badge.d.ts +58 -0
- package/dist/components/tag-badge.js +72 -0
- package/dist/components/tag-badge.js.map +1 -0
- package/dist/components/tag-color-picker.d.ts +20 -0
- package/dist/components/tag-color-picker.js +42 -0
- package/dist/components/tag-color-picker.js.map +1 -0
- package/dist/components/text.js +17 -16
- package/dist/components/text.js.map +1 -1
- package/dist/components/toast.d.ts +173 -3
- package/dist/components/toast.js +1 -1
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.d.ts +88 -2
- package/dist/components/toggle-group.js +32 -31
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle.d.ts +35 -2
- package/dist/components/toggle.js +12 -11
- package/dist/components/toggle.js.map +1 -1
- package/dist/components/tooltip.d.ts +144 -6
- package/dist/components/tooltip.js +9 -6
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/typeahead-field.js +193 -132
- package/dist/components/typeahead-field.js.map +1 -1
- package/dist/components/upload-queue.d.ts +51 -0
- package/dist/components/upload-queue.js +116 -0
- package/dist/components/upload-queue.js.map +1 -0
- package/dist/providers/accent.d.ts +46 -2
- package/dist/providers/accent.js +358 -172
- package/dist/providers/accent.js.map +1 -1
- package/dist/providers/link.d.ts +14 -4
- package/dist/providers/link.js.map +1 -1
- package/dist/providers/theme.d.ts +38 -2
- package/dist/providers/theme.js +160 -27
- package/dist/providers/theme.js.map +1 -1
- package/dist/styles/base.css +83 -0
- package/dist/styles/standalone.css +699 -120
- package/dist/styles/tailwind.css +21 -7
- package/dist/styles/tokens.css +145 -91
- package/dist/utils/control-surface.d.ts +2 -1
- package/dist/utils/control-surface.js +4 -4
- package/dist/utils/control-surface.js.map +1 -1
- package/dist/utils/selection-indicator.js +2 -2
- package/dist/utils/selection-indicator.js.map +1 -1
- package/package.json +8 -4
|
@@ -0,0 +1,1760 @@
|
|
|
1
|
+
# Basalt application chrome
|
|
2
|
+
|
|
3
|
+
This is the one-pass setup for a Basalt app. An MVP is **login + shell + one page**. After that, product work is arranging `LayerCard` on the island. Do not invent a second header, a second rail, or a second title above the content heading.
|
|
4
|
+
|
|
5
|
+
The library owns the rail, the main column, collapse motion, the island, the page heading, and region rules. The app owns navigation data, brand, version, identity, and page bodies.
|
|
6
|
+
|
|
7
|
+
Package: `@nocoo/basalt`. React 19. Tailwind v4. Catalog: https://basaltui.com.
|
|
8
|
+
|
|
9
|
+
For complete, compilable AppFrame, Login, and Resources modules, read `node_modules/@nocoo/basalt/ai/RECIPES.md`. These application-owned recipes are built and exercised from the installed package in standalone, Tailwind, and Next.js consumers. Route, authentication, permissions, and transport remain application responsibilities.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. MVP
|
|
14
|
+
|
|
15
|
+
Ship in this order. Each step has a section below. Do not skip ahead to cards.
|
|
16
|
+
|
|
17
|
+
1. **Providers + CSS** — §2–5
|
|
18
|
+
2. **Login** — full-viewport badge. No shell. §11
|
|
19
|
+
3. **App frame** — skip link, sidebar (product title + version + nav), header (crumbs + current page + top-right), island. §6–10
|
|
20
|
+
4. **First page** — `PageHeader` (title, subtitle, create, filters) then cards. Optional `SectionRule`. §13–15
|
|
21
|
+
|
|
22
|
+
### Who owns what
|
|
23
|
+
|
|
24
|
+
| Layer | Component | Owns | Does not own |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| Login | app page, not `AppShell` | identity badge | rail, header, island |
|
|
27
|
+
| Rail | `Sidebar` | product title, version pill, nav, user | page body |
|
|
28
|
+
| Framework bar | `AppHeader` | ancestor breadcrumbs, current page name, top-right actions (`ThemeToggle`, …) | create, filters, cards |
|
|
29
|
+
| Island | `ContentIsland` | L1 surface and scroll | page chrome |
|
|
30
|
+
| Page heading | `PageHeader` | content title, subtitle, create last, filters | `AppHeader` crumbs |
|
|
31
|
+
| Region | `SectionRule` | dashed title rule, optional hint, optional region actions | `LayerCard.Header` |
|
|
32
|
+
| Card | `LayerCard` | nested surface | page title |
|
|
33
|
+
|
|
34
|
+
### Tree
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
LoginPage ← /login only. No AppShell.
|
|
38
|
+
|
|
39
|
+
AppShell ← everything else
|
|
40
|
+
├── AppSkipLink
|
|
41
|
+
├── Sidebar ← product title + version + nav
|
|
42
|
+
└── AppMain ← id="main-content"
|
|
43
|
+
├── AppHeader ← crumbs + current page | top-right
|
|
44
|
+
└── island wrap ← px-2 pb-2 md:px-3 md:pb-3
|
|
45
|
+
└── ContentIsland
|
|
46
|
+
├── PageHeader ← title + subtitle | create / short filters
|
|
47
|
+
│ ← optional own-row complex filters
|
|
48
|
+
├── SectionRule? ← optional region split
|
|
49
|
+
└── LayerCard… ← all later product UI
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`AppHeader.title` is the current page in the top bar (`h1`, `text-sm`). `PageHeader.title` is the content heading (`h1`, `text-2xl`). They may use the same words. They are different roles. Do not put an icon + page name above `PageHeader` — that duplicates the bar.
|
|
53
|
+
|
|
54
|
+
When `AppHeader` already has breadcrumbs, **omit** `PageHeader.breadcrumbs`.
|
|
55
|
+
|
|
56
|
+
Live: `/login`, shell, first page `/data`, surfaces `/layout`. Catalog: `/ui/page-header`, `/ui/section-rule`.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 2. Install and CSS
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm i @nocoo/basalt lucide-react
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Option A: Vite + Tailwind CSS v4 Setup
|
|
67
|
+
|
|
68
|
+
Install Tailwind CSS v4 and the official Vite plugin:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm i -D tailwindcss @tailwindcss/vite
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Configure `vite.config.ts`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
78
|
+
import react from "@vitejs/plugin-react";
|
|
79
|
+
import { defineConfig } from "vite";
|
|
80
|
+
|
|
81
|
+
export default defineConfig({
|
|
82
|
+
plugins: [tailwindcss(), react()],
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
In the app stylesheet (e.g. `src/index.css`), the exact 3-line import order is required. The `@source` path is relative to **this CSS file** and must scan `node_modules/@nocoo/basalt/dist`:
|
|
87
|
+
|
|
88
|
+
```css
|
|
89
|
+
@source "../node_modules/@nocoo/basalt/dist/**/*.{js,jsx,ts,tsx}";
|
|
90
|
+
@import "@nocoo/basalt/styles/tailwind";
|
|
91
|
+
@import "tailwindcss";
|
|
92
|
+
|
|
93
|
+
@layer base {
|
|
94
|
+
html,
|
|
95
|
+
body,
|
|
96
|
+
#root {
|
|
97
|
+
height: 100%;
|
|
98
|
+
}
|
|
99
|
+
body {
|
|
100
|
+
@apply bg-basalt-background text-basalt-foreground antialiased;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Basalt registers `--basalt-*` tokens and `--color-basalt-*` utilities before Tailwind runs. Use those utilities. Do not add a second color system (`background`, `foreground`, `sidebar`, …) and do not re-declare `--basalt-*`.
|
|
106
|
+
|
|
107
|
+
### Option B: Standalone CSS (No Tailwind)
|
|
108
|
+
|
|
109
|
+
Without Tailwind, import `@nocoo/basalt/styles/standalone` in your application entrypoint instead:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import "@nocoo/basalt/styles/standalone";
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Standalone packages design tokens, control surface utilities, animations, and scoped base resets (`.basalt-ui`) for controls (`box-sizing: border-box`, font inheritance, native button appearance, list resets, and table borders) without injecting global resets or full Tailwind preflight onto host elements; still set `html, body, #root { height: 100% }`.
|
|
116
|
+
|
|
117
|
+
### Theme Pre-Hydration
|
|
118
|
+
|
|
119
|
+
Apply theme on the document **before** React paints:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
let stored: string | null = null;
|
|
123
|
+
try {
|
|
124
|
+
stored = window.localStorage.getItem("theme");
|
|
125
|
+
} catch {
|
|
126
|
+
// Continue with the system theme when browser storage is denied.
|
|
127
|
+
}
|
|
128
|
+
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
129
|
+
const isDark = stored === "dark" || (stored !== "light" && prefersDark);
|
|
130
|
+
document.documentElement.classList.toggle("dark", isDark);
|
|
131
|
+
document.documentElement.classList.toggle("light", !isDark);
|
|
132
|
+
document.documentElement.dataset.mode = isDark ? "dark" : "light";
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 3. Imports
|
|
138
|
+
|
|
139
|
+
Root barrel is small leaves and providers. Shell chrome is granular.
|
|
140
|
+
|
|
141
|
+
| From | Import |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `@nocoo/basalt` | `Button`, `Input`, `LayerCard`, `DescriptionList`, `Sidebar`, `SidebarHeader`, `SidebarNav`, `SidebarFooter`, `SidebarItem`, `SidebarIconItem`, `SidebarPartition`, `SidebarGroup`, `SidebarSearch`, `SidebarUser`, `ContentIsland`, `Sheet`, `SheetContent`, `SheetTitle`, `Avatar`, `AvatarFallback`, `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider`, `ThemeProvider`, `ThemeToggle`, `LinkProvider`, `Toaster`, `CommandPalette`, … |
|
|
144
|
+
| `@nocoo/basalt/components/app-shell` | `AppShell`, `AppMain`, `AppSkipLink` |
|
|
145
|
+
| `@nocoo/basalt/components/app-header` | `AppHeader` |
|
|
146
|
+
| `@nocoo/basalt/components/page-header` | `PageHeader` |
|
|
147
|
+
| `@nocoo/basalt/components/section-rule` | `SectionRule` |
|
|
148
|
+
| `@nocoo/basalt/components/loading-screen` | `LoadingScreen` |
|
|
149
|
+
| `@nocoo/basalt/components/basalt-mark` | `BasaltMark` |
|
|
150
|
+
| `@nocoo/basalt/providers/theme` | `useTheme` (if not taking `ThemeProvider` from the root) |
|
|
151
|
+
| `@nocoo/basalt/charts/*` | charts |
|
|
152
|
+
| `@nocoo/basalt/components/date-picker` | DatePicker |
|
|
153
|
+
| `@nocoo/basalt/components/data-table` | DataTable |
|
|
154
|
+
|
|
155
|
+
Off the root barrel: `AppShell`, `AppMain`, `AppSkipLink`, `AppHeader`, `PageHeader`, `SectionRule`, `LoadingScreen`, `BasaltMark`, charts, DatePicker, DataTable. Import those from the granular paths above. `LayerCard` is on the root barrel **and** `@nocoo/basalt/components/layer-card`.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 4. Provider tree
|
|
160
|
+
|
|
161
|
+
One tree for the whole app. Login and the shell both sit under it.
|
|
162
|
+
|
|
163
|
+
```tsx excerpt:app-provider-tree
|
|
164
|
+
<ThemeProvider>
|
|
165
|
+
<LinkProvider render={AppLink}>
|
|
166
|
+
<TooltipProvider>
|
|
167
|
+
<BrowserRouter>
|
|
168
|
+
<Toaster />
|
|
169
|
+
<Routes>
|
|
170
|
+
<Route path="/login" element={<LoginPage />} />
|
|
171
|
+
<Route element={<AppFrame />}>
|
|
172
|
+
{/* authenticated pages */}
|
|
173
|
+
</Route>
|
|
174
|
+
</Routes>
|
|
175
|
+
</BrowserRouter>
|
|
176
|
+
</TooltipProvider>
|
|
177
|
+
</LinkProvider>
|
|
178
|
+
</ThemeProvider>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`LinkProvider` `render` must map `href` to the app router (React Router `Link`, etc.). External `http(s):` / `mailto:` / `tel:` stay as `<a>`.
|
|
182
|
+
|
|
183
|
+
`TooltipProvider` is required for collapsed-rail tooltips.
|
|
184
|
+
|
|
185
|
+
### ThemeProvider Configuration
|
|
186
|
+
|
|
187
|
+
`ThemeProvider` manages color scheme mode (`"light" | "dark" | "system"`). It synchronizes active classes (`light`/`dark`) and attributes (`data-mode="dark"|"light"`) onto `document.documentElement`, supports cross-tab and same-page synchronization, and provides graceful fallback when storage is restricted.
|
|
188
|
+
|
|
189
|
+
| Prop | Type | Default | Description |
|
|
190
|
+
|---|---|---|---|
|
|
191
|
+
| `children` | `ReactNode` | *(required)* | Application components wrapped by the theme context. |
|
|
192
|
+
| `storageKey` | `string` | `"theme"` | Key used for `localStorage` persistence. |
|
|
193
|
+
| `defaultTheme` | `BasaltTheme` (`"light"` \| `"dark"` \| `"system"`) | `"system"` | Initial fallback theme during SSR or when no stored preference exists. |
|
|
194
|
+
| `persist` | `boolean` | `true` | When `false`, completely isolates the provider from `localStorage` (never reads or writes storage, does not broadcast or listen to external storage events). |
|
|
195
|
+
| `theme` | `BasaltTheme` | `undefined` | Controlled theme value. When supplied, internal state is driven by this prop. |
|
|
196
|
+
| `onThemeChange` | `(theme: BasaltTheme) => void` | `undefined` | Callback fired when a theme change is requested. In controlled mode, callers update `theme`. |
|
|
197
|
+
| `applyToDocument` | `boolean` | `true` | When `false`, disables mutating classes or dataset on `document.documentElement`, allowing a host theme system to manage root DOM state. |
|
|
198
|
+
|
|
199
|
+
- **Hook `useTheme()`**: Returns `{ theme: BasaltTheme, setTheme: (theme: BasaltTheme) => void }`. Throws an error when invoked outside a `ThemeProvider`.
|
|
200
|
+
- **Storage failure resilience**: If reading or writing `localStorage` throws (e.g. quota exceeded or sandboxed iframe security error), `ThemeProvider` catches the error, retains the explicit selection in memory, and suppresses stale bare storage events from rolling back state.
|
|
201
|
+
- **SSR & Hydration**: During SSR, `ThemeProvider` returns controlled `theme` (if provided) or `defaultTheme`. On the client, it hydrates from `localStorage` if `persist=true`. When custom `storageKey` or `defaultTheme` is configured, pre-hydration inline scripts must match the same key and default.
|
|
202
|
+
- **Host management**: Set `persist={false}` and `applyToDocument={false}` when nested inside an external theme system or embedded widget to prevent mutating the global document root or polluting host storage.
|
|
203
|
+
|
|
204
|
+
<a id="accent-provider"></a>
|
|
205
|
+
|
|
206
|
+
### AccentProvider and useAccent
|
|
207
|
+
|
|
208
|
+
`AccentProvider` manages dynamic primary accent color overrides (`--basalt-primary`, `--basalt-primary-foreground`, `--basalt-ring`, and `dataset.accent`). It also supplies twelve raw candy swatches (`--basalt-accent-1` through `--basalt-accent-12`). It does not alter chart palette tokens (`--basalt-chart-*`).
|
|
209
|
+
|
|
210
|
+
#### AccentProvider Configuration
|
|
211
|
+
|
|
212
|
+
| Prop | Type | Default | Description |
|
|
213
|
+
|---|---|---|---|
|
|
214
|
+
| `children` | `ReactNode` | *(required)* | Application components wrapped by the accent context. |
|
|
215
|
+
| `storageKey` | `string` | `"basalt-accent"` | Key used for `localStorage` persistence. |
|
|
216
|
+
| `defaultAccent` | `string` | `"primary"` | Initial accent swatch ID during SSR or when no stored preference exists. |
|
|
217
|
+
| `persist` | `boolean` | `true` | When `false`, isolates the provider from `localStorage` (no storage read/write, no cross-tab sync). |
|
|
218
|
+
| `accent` | `string` | `undefined` | Controlled accent value. When supplied, internal state is driven by this prop. |
|
|
219
|
+
| `onAccentChange` | `(accent: string) => void` | `undefined` | Callback fired when an accent change is requested. |
|
|
220
|
+
| `applyToDocument` | `boolean` | `true` | When `false`, disables writing CSS custom properties and `data-accent` to `document.documentElement`. |
|
|
221
|
+
| `paletteOverrides` | `Readonly<Record<string, { light: string; dark: string }>>` | `undefined` | Optional HSL-channel pairs for the twelve preset IDs. Invalid pairs and unknown IDs are ignored. The application owns persistence. |
|
|
222
|
+
|
|
223
|
+
- **Hook `useAccent()`**: Returns `{ accent: string, setAccent: (id: string) => void, swatches: readonly AccentSwatch[] }`. Throws an error when invoked outside an `AccentProvider`.
|
|
224
|
+
- **`AccentSwatch` Type**:
|
|
225
|
+
- `id: string`: Unique swatch identifier (e.g., `"primary"`, `"teal"`, `"rose"`).
|
|
226
|
+
- `label: string`: Human-readable swatch name.
|
|
227
|
+
- `token: string`: CSS variable token binding (e.g., `"--basalt-accent-1"`).
|
|
228
|
+
- `light: string`: HSL values applied in light mode (e.g., `"204 88% 62%"`).
|
|
229
|
+
- `dark: string`: HSL values applied in dark mode (e.g., `"204 90% 70%"`).
|
|
230
|
+
- **Utilities**:
|
|
231
|
+
- `ACCENT_SWATCHES`: Readonly array of 12 candy-inspired `AccentSwatch` objects: Blue, Ice, Bondi, Green, Lime, Yellow, Tangerine, Strawberry, Pink, Grape, Blueberry, and Pearl. The stable IDs are `primary`, `sky`, `teal`, `green`, `lime`, `amber`, `orange`, `red`, `rose`, `purple`, `indigo`, and `gray`. Use the provider hook for swatches with custom overrides applied.
|
|
232
|
+
- `DEFAULT_ACCENT_ID`: Default accent ID (`"primary"`).
|
|
233
|
+
- `accentSwatchById(id: string | null | undefined): AccentSwatch`: Finds a preset by `id`, mapping legacy IDs to their closest retained hue and defaulting to `ACCENT_SWATCHES[0]` if not found. For example, `jade` becomes `teal`, `magenta` becomes `rose`, and `gold` becomes `amber`; the full mapping is in `ai/COMPATIBILITY.md`.
|
|
234
|
+
- `accentForeground(hsl: string)`: Compares WCAG contrast against white and dark text and returns the higher-contrast pairing (`"0 0% 10%"` or `"0 0% 100%"`). Note: selecting higher contrast does not guarantee arbitrary custom colors reach 4.5:1.
|
|
235
|
+
- `applyAccent(id: string, dark = false): void`: Sets CSS variables `--basalt-primary`, `--basalt-primary-foreground`, `--basalt-ring`, and `dataset.accent` on the document root element. For built-in palette swatches, semantic `--basalt-primary` is derived to guarantee WCAG 4.5:1 text/button contrast across L0–L3 surfaces and 90% hover states without mutating the raw control swatches (`ACCENT_SWATCHES`) or chart palette tokens. This helper applies presets; use `paletteOverrides` on the provider for custom colors. Badge variants pair corresponding semantic foreground tokens (`text-basalt-badge-*-foreground` / `text-basalt-*-foreground`). Accepts optional `dark` flag (defaults to `false`).
|
|
236
|
+
- **Known Limitations & Resilience**: Storage access gracefully degrades in sandboxed or quota-exceeded environments by preserving in-memory choices without throwing. SSR hydration serves controlled `accent` (if provided) or server snapshot defaults (`DEFAULT_ACCENT_ID = "primary"`). Built-in palette accents satisfy WCAG 4.5:1 across surfaces in both themes.
|
|
237
|
+
|
|
238
|
+
### Control and chart palettes
|
|
239
|
+
|
|
240
|
+
Control swatches use candy finishes inspired by classic iMac and iPhone 5C colors. The raw swatch stays bright; semantic primary text and buttons derive a contrast-safe lightness for the current theme. A yellow swatch therefore produces darker primary text in light mode. Custom colors receive the same semantic treatment.
|
|
241
|
+
|
|
242
|
+
```tsx compile:integration-custom-palette
|
|
243
|
+
import { Button } from "@nocoo/basalt/components/button";
|
|
244
|
+
import { AccentProvider } from "@nocoo/basalt/providers/accent";
|
|
245
|
+
|
|
246
|
+
export function CustomBrand() {
|
|
247
|
+
return (
|
|
248
|
+
<AccentProvider
|
|
249
|
+
defaultAccent="primary"
|
|
250
|
+
persist={false}
|
|
251
|
+
paletteOverrides={{ primary: { light: "204 88% 62%", dark: "204 90% 70%" } }}
|
|
252
|
+
>
|
|
253
|
+
<Button>Save changes</Button>
|
|
254
|
+
</AccentProvider>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The catalog's `/palette` editor saves twelve light/dark hex pairs in `localStorage` under `basalt-palette-v1`, separately from the selected ID in `basalt-accent`. Preferences survive reloads and navigation and synchronize across tabs on the same origin. Invalid data falls back to the classic palette; denied reads or writes keep the page usable, and failed saves explicitly report that the change lasts for the current session. Restore classic retains the custom draft for later reuse. This editor and storage format belong to the catalog application; consumers pass their own validated preferences through `paletteOverrides`.
|
|
260
|
+
|
|
261
|
+
Charts use a fixed **five-color** cycle: Blue, Pink, Green, Yellow, Gray. The first four colors use the exact classic control swatch values in each theme; gray retains its existing light/dark values. Marks use solid candy colors without contrasting outlines or shadows. Text continues to use semantic foreground tokens. `CHART_COLORS` and `getChartColor(index)` are independent of control accent selection and custom palettes. Use modulo indexing or `getChartColor` when a chart has more than five series, and use labels or an explicit `series.color` for distinctions beyond color. Existing `chart` keys and numbered chart CSS tokens remain aliases, but the former 24-color values and array length are intentionally replaced. Values heatmaps and Timeline rails also use fixed chart tokens. For tinted Timeline event rows, pass `textColor: "hsl(var(--basalt-foreground))"` with a translucent background utility; both title and subtitle inherit it. Gauge remainder uses `chartMuted`, a neutral track for the active theme; chart labels and tooltip text use semantic foreground tokens. See `ai/COMPATIBILITY.md` before migrating color-indexed code.
|
|
262
|
+
|
|
263
|
+
Raw candy fills can fall below 3:1 against light surfaces. Provide descriptive labels and `summary` / `dataAlternative` so values and relationships do not depend on color alone.
|
|
264
|
+
|
|
265
|
+
### Host-Controlled Preferences Recipe
|
|
266
|
+
|
|
267
|
+
When host applications (e.g. workspace shells, embedded previews, or multi-tenant panels) manage theme and accent state externally, both providers can run in controlled mode with `persist={false}` and `applyToDocument={false}`. The host application handles the actual root DOM classes, variables, or server cookies while child Basalt components consume standard contexts.
|
|
268
|
+
|
|
269
|
+
```tsx compile:integration-host-preferences
|
|
270
|
+
import { useState } from "react";
|
|
271
|
+
import { Button } from "@nocoo/basalt/components/button";
|
|
272
|
+
import { AccentProvider, useAccent } from "@nocoo/basalt/providers/accent";
|
|
273
|
+
import { type BasaltTheme, ThemeProvider, useTheme } from "@nocoo/basalt/providers/theme";
|
|
274
|
+
|
|
275
|
+
function PreferencesControls() {
|
|
276
|
+
const { theme, setTheme } = useTheme();
|
|
277
|
+
const { accent, setAccent } = useAccent();
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
281
|
+
<span className="text-sm font-medium">Theme: {theme}</span>
|
|
282
|
+
<Button
|
|
283
|
+
variant="outline"
|
|
284
|
+
size="sm"
|
|
285
|
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
286
|
+
>
|
|
287
|
+
Toggle {theme === "dark" ? "Light" : "Dark"}
|
|
288
|
+
</Button>
|
|
289
|
+
<span className="text-sm font-medium ml-2">Accent: {accent}</span>
|
|
290
|
+
<Button
|
|
291
|
+
variant="outline"
|
|
292
|
+
size="sm"
|
|
293
|
+
onClick={() => {
|
|
294
|
+
const next = accent === "rose" ? "sky" : "rose";
|
|
295
|
+
setAccent(next);
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
Toggle Accent
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function HostPreferencesApp() {
|
|
305
|
+
const [theme, setTheme] = useState<BasaltTheme>("light");
|
|
306
|
+
const [accent, setAccent] = useState("rose");
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<ThemeProvider
|
|
310
|
+
persist={false}
|
|
311
|
+
applyToDocument={false}
|
|
312
|
+
theme={theme}
|
|
313
|
+
onThemeChange={setTheme}
|
|
314
|
+
>
|
|
315
|
+
<AccentProvider
|
|
316
|
+
persist={false}
|
|
317
|
+
applyToDocument={false}
|
|
318
|
+
accent={accent}
|
|
319
|
+
onAccentChange={setAccent}
|
|
320
|
+
>
|
|
321
|
+
<div className="p-4 border rounded-lg space-y-3">
|
|
322
|
+
<p className="text-xs text-basalt-muted-foreground">
|
|
323
|
+
Host owns document root DOM and persistence. Basalt contexts operate safely without side-effects.
|
|
324
|
+
</p>
|
|
325
|
+
<PreferencesControls />
|
|
326
|
+
</div>
|
|
327
|
+
</AccentProvider>
|
|
328
|
+
</ThemeProvider>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## 5. Color and type
|
|
339
|
+
|
|
340
|
+
Every chrome class uses the `basalt-` prefix:
|
|
341
|
+
|
|
342
|
+
- surfaces: `bg-basalt-background` (L0), `bg-basalt-card` (L1 island), `bg-basalt-secondary` (L2 card), `bg-basalt-bright` (L3 well), `bg-basalt-control` (current-surface controls), `bg-basalt-primary`, `bg-basalt-accent`
|
|
343
|
+
- nest: see **§14**. `ContentIsland` / Dialog / Sheet / AlertDialog set `data-basalt-surface-root`. `LayerCard` and `LayerCard.Well` set `data-basalt-surface`. Do not hand-write `bg-card` wells.
|
|
344
|
+
- text: `text-basalt-foreground`, `text-basalt-muted-foreground`, `text-basalt-primary-foreground`
|
|
345
|
+
- line: `ring-basalt-border`, `border-basalt-border`
|
|
346
|
+
|
|
347
|
+
Icons: `lucide-react`, `strokeWidth={1.5}`, nav size `h-4 w-4 shrink-0`.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
<a id="root-geometry"></a>
|
|
352
|
+
## 6. Root geometry
|
|
353
|
+
|
|
354
|
+
`AppShell` is a full-viewport flex row: `h-screen w-full overflow-hidden bg-basalt-background`.
|
|
355
|
+
|
|
356
|
+
```
|
|
357
|
+
AppShell ← flex row, h-screen, overflow hidden
|
|
358
|
+
├── AppSkipLink ← first child
|
|
359
|
+
├── Sidebar ← the rail (in-flow, not fixed)
|
|
360
|
+
└── AppMain ← flex-1 min-w-0 column, overflow hidden
|
|
361
|
+
├── AppHeader ← h-14
|
|
362
|
+
└── island wrap ← flex-1 min-h-0, px-2 pb-2 md:px-3 md:pb-3
|
|
363
|
+
└── ContentIsland ← page outlet
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### AppShell, AppMain, AppSkipLink API & Contracts
|
|
367
|
+
|
|
368
|
+
- **`AppShell`**: Viewport root container. Inherits standard `HTMLAttributes<HTMLDivElement>`. Renders a full-viewport flex row (`h-screen w-full overflow-hidden bg-basalt-background`). Does not forward ref.
|
|
369
|
+
- **`AppMain`**: Primary content container. Inherits standard `HTMLAttributes<HTMLElement>`. Defaults `id="main-content"` as the skip-link landmark target, which can be overridden by props via `{...props}`. Recommended to keep `"main-content"` matching `AppSkipLink`. Renders a vertical column with `h-full min-w-0 flex-1 flex-col overflow-hidden`. Does not forward ref.
|
|
370
|
+
- **`AppSkipLink`**: Accessibility skip target. Inherits standard `AnchorHTMLAttributes<HTMLAnchorElement>`. Defaults `href="#main-content"`. Renders screen-reader-only element that transitions into absolute focus overlay on keyboard navigation (`sr-only focus:not-sr-only focus:absolute ...`). Does not forward ref.
|
|
371
|
+
|
|
372
|
+
The island wrap is the only extra layout div in the main column. Pages render **inside** `ContentIsland`. Pages do not set `h-screen`, side padding, or a second card around the island.
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## 7. Sidebar is the rail
|
|
377
|
+
|
|
378
|
+
`Sidebar` **is** the column. It already has:
|
|
379
|
+
|
|
380
|
+
- `h-screen`, `flex-col`, `shrink-0`, `sticky top-0`
|
|
381
|
+
- expanded width **260px** (inline `width`)
|
|
382
|
+
- collapsed width **68px**
|
|
383
|
+
- collapse animation `transition-all duration-300 ease-in-out`
|
|
384
|
+
|
|
385
|
+
Children of `Sidebar` are **regions only**: header, search, nav, footer. They fill that column. `SidebarNav` is `flex-1 min-h-0 overflow-y-auto`, so the footer stays at the bottom.
|
|
386
|
+
|
|
387
|
+
Do not wrap those regions in another full-viewport column. Do not set `h-screen`, `w-[260px]`, or `w-[68px]` on an inner element. Width and height stay on `Sidebar`.
|
|
388
|
+
|
|
389
|
+
### Expanded tree
|
|
390
|
+
|
|
391
|
+
```
|
|
392
|
+
Sidebar ← collapsed={false}; owns 260px and h-screen
|
|
393
|
+
├── SidebarHeader ← h-14 px-3 already
|
|
394
|
+
│ └── brand row ← flex, items-center, justify-between, w-full
|
|
395
|
+
│ (no extra horizontal padding)
|
|
396
|
+
├── search wrap ← px-3 pb-1 only
|
|
397
|
+
│ └── SidebarSearch
|
|
398
|
+
├── SidebarNav ← pt-1; flex-1
|
|
399
|
+
│ ├── SidebarPartition ← label; already px-6
|
|
400
|
+
│ └── item stack ← flex flex-col gap-0.5 px-3
|
|
401
|
+
│ └── SidebarItem ← already px-3 py-2.5
|
|
402
|
+
└── SidebarFooter ← px-4 py-3 already
|
|
403
|
+
└── SidebarUser
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
`SidebarHeader` already pads horizontally. Brand, version pill, and collapse control go **directly** in it. The version string is read from the app `package.json` at build time. Never hardcode it.
|
|
407
|
+
|
|
408
|
+
Nav labels use `SidebarPartition`. The item stack is **one** `px-3` column (the same gutter `SidebarGroup` uses). `SidebarItem` already has its own `px-3`. That is the whole horizontal rhythm: partition at 24px, item content at 24px.
|
|
409
|
+
|
|
410
|
+
Collapsible sections use `SidebarGroup` instead of Partition + stack. `SidebarGroup` already includes the item gutter. Do not add another `px-3` around it.
|
|
411
|
+
|
|
412
|
+
```tsx excerpt:sidebar-expanded-structure
|
|
413
|
+
<Sidebar collapsed={collapsed}>
|
|
414
|
+
<SidebarHeader>
|
|
415
|
+
<div className="flex w-full items-center justify-between">
|
|
416
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
417
|
+
<BasaltMark className="h-5 w-5 shrink-0" />
|
|
418
|
+
<span className="truncate text-lg font-semibold text-basalt-foreground md:text-xl">
|
|
419
|
+
Acme
|
|
420
|
+
</span>
|
|
421
|
+
<span className="shrink-0 rounded-md bg-basalt-secondary px-1.5 py-0.5 text-[10px] leading-none font-medium text-basalt-muted-foreground">
|
|
422
|
+
v{version}
|
|
423
|
+
</span>
|
|
424
|
+
</div>
|
|
425
|
+
<Button
|
|
426
|
+
variant="ghost"
|
|
427
|
+
size="icon"
|
|
428
|
+
className="h-7 w-7 shrink-0"
|
|
429
|
+
onClick={onToggle}
|
|
430
|
+
aria-label="Collapse sidebar"
|
|
431
|
+
>
|
|
432
|
+
<PanelLeft aria-hidden="true" />
|
|
433
|
+
</Button>
|
|
434
|
+
</div>
|
|
435
|
+
</SidebarHeader>
|
|
436
|
+
<div className="px-3 pb-1">
|
|
437
|
+
<SidebarSearch onClick={() => setSearchOpen(true)}>Search</SidebarSearch>
|
|
438
|
+
</div>
|
|
439
|
+
<SidebarNav className="pt-1">
|
|
440
|
+
{groups.map((group) => (
|
|
441
|
+
<div key={group.label}>
|
|
442
|
+
<SidebarPartition>{group.label}</SidebarPartition>
|
|
443
|
+
<div className="flex flex-col gap-0.5 px-3">
|
|
444
|
+
{group.items.map((item) => (
|
|
445
|
+
<SidebarItem
|
|
446
|
+
key={item.href}
|
|
447
|
+
active={active(item.href)}
|
|
448
|
+
onClick={() => navigate(item.href)}
|
|
449
|
+
>
|
|
450
|
+
<item.icon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
|
451
|
+
<span className="flex-1 truncate text-left">{item.label}</span>
|
|
452
|
+
</SidebarItem>
|
|
453
|
+
))}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
))}
|
|
457
|
+
</SidebarNav>
|
|
458
|
+
<SidebarFooter>
|
|
459
|
+
<SidebarUser name={name} email={email} avatar={avatar} />
|
|
460
|
+
</SidebarFooter>
|
|
461
|
+
</Sidebar>
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Collapsed tree
|
|
465
|
+
|
|
466
|
+
Pass `collapsed` on `Sidebar`. The element becomes 68px. Children are icon-sized and centered. Still no inner width/height wrapper.
|
|
467
|
+
|
|
468
|
+
```
|
|
469
|
+
Sidebar ← collapsed; owns 68px
|
|
470
|
+
├── SidebarHeader ← justify-center px-0
|
|
471
|
+
├── collapse Button ← ghost icon, mb-1
|
|
472
|
+
├── search SidebarIconItem ← mb-2; tooltip
|
|
473
|
+
├── SidebarNav ← w-full items-center gap-1 pt-1
|
|
474
|
+
│ └── SidebarIconItem ← h-10 w-10; tooltip
|
|
475
|
+
└── SidebarFooter ← flex w-full justify-center px-0
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
```tsx excerpt:sidebar-collapsed-structure
|
|
479
|
+
<Sidebar collapsed={collapsed}>
|
|
480
|
+
<SidebarHeader className="justify-center px-0">
|
|
481
|
+
<BasaltMark className="h-5 w-5" />
|
|
482
|
+
</SidebarHeader>
|
|
483
|
+
<Button
|
|
484
|
+
variant="ghost"
|
|
485
|
+
size="icon"
|
|
486
|
+
className="mb-1 self-center"
|
|
487
|
+
onClick={onToggle}
|
|
488
|
+
aria-label="Expand sidebar"
|
|
489
|
+
>
|
|
490
|
+
<PanelLeft aria-hidden="true" />
|
|
491
|
+
</Button>
|
|
492
|
+
<Tooltip delayDuration={0}>
|
|
493
|
+
<TooltipTrigger asChild>
|
|
494
|
+
<SidebarIconItem
|
|
495
|
+
className="mb-2 self-center"
|
|
496
|
+
onClick={() => setSearchOpen(true)}
|
|
497
|
+
aria-label="Search (⌘K)"
|
|
498
|
+
>
|
|
499
|
+
<Search className="h-4 w-4" strokeWidth={1.5} aria-hidden="true" />
|
|
500
|
+
</SidebarIconItem>
|
|
501
|
+
</TooltipTrigger>
|
|
502
|
+
<TooltipContent side="right" sideOffset={8}>
|
|
503
|
+
Search (⌘K)
|
|
504
|
+
</TooltipContent>
|
|
505
|
+
</Tooltip>
|
|
506
|
+
<SidebarNav className="w-full items-center gap-1 pt-1">
|
|
507
|
+
{items.map((item) => (
|
|
508
|
+
<Tooltip key={item.href} delayDuration={0}>
|
|
509
|
+
<TooltipTrigger asChild>
|
|
510
|
+
<SidebarIconItem
|
|
511
|
+
active={active(item.href)}
|
|
512
|
+
aria-label={item.label}
|
|
513
|
+
className="self-center"
|
|
514
|
+
onClick={() => navigate(item.href)}
|
|
515
|
+
>
|
|
516
|
+
<item.icon className="h-4 w-4" strokeWidth={1.5} />
|
|
517
|
+
</SidebarIconItem>
|
|
518
|
+
</TooltipTrigger>
|
|
519
|
+
<TooltipContent side="right" sideOffset={8}>
|
|
520
|
+
{item.label}
|
|
521
|
+
</TooltipContent>
|
|
522
|
+
</Tooltip>
|
|
523
|
+
))}
|
|
524
|
+
</SidebarNav>
|
|
525
|
+
<SidebarFooter className="flex w-full justify-center px-0">
|
|
526
|
+
<Tooltip delayDuration={0}>
|
|
527
|
+
<TooltipTrigger asChild>
|
|
528
|
+
<span className="inline-flex">{avatar}</span>
|
|
529
|
+
</TooltipTrigger>
|
|
530
|
+
<TooltipContent side="right" sideOffset={8}>
|
|
531
|
+
{name}
|
|
532
|
+
</TooltipContent>
|
|
533
|
+
</Tooltip>
|
|
534
|
+
</SidebarFooter>
|
|
535
|
+
</Sidebar>
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Render expanded **or** collapsed regions from the same `collapsed` flag. One `Sidebar`. The flag is the animation.
|
|
539
|
+
|
|
540
|
+
Portaled UI (`CommandPalette`, dialogs) may be a sibling of these regions inside `Sidebar`. They leave the document via a portal and do not participate in the column.
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
## 8. Collapse, peek, overlay
|
|
545
|
+
|
|
546
|
+
Standard product shell: React state `collapsed` passed into `Sidebar`. A header button flips it. Motion is already on `Sidebar`.
|
|
547
|
+
|
|
548
|
+
`SidebarProvider` is for peek-on-hover, overlay mode, and resize. Use it when those behaviors are required. A normal app shell does not need a second sidebar context.
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## 9. Mobile
|
|
553
|
+
|
|
554
|
+
Breakpoint: `768px`. Below that, the in-flow rail is omitted. The same sidebar component opens inside a left `Sheet`, always expanded (`collapsed={false}`).
|
|
555
|
+
|
|
556
|
+
```tsx excerpt:mobile-sheet-sidebar
|
|
557
|
+
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
558
|
+
<SheetContent
|
|
559
|
+
side="left"
|
|
560
|
+
className="w-[260px] max-w-[260px] border-0 bg-basalt-background p-0"
|
|
561
|
+
>
|
|
562
|
+
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
|
563
|
+
<AppSidebar collapsed={false} onToggle={() => setMobileOpen(false)} />
|
|
564
|
+
</SheetContent>
|
|
565
|
+
</Sheet>
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
`SheetContent` is already `h-full`. `Sidebar` still owns the 260px column inside it. Close the sheet on pathname change. While open, set `document.body.style.overflow = "hidden"` and clear it on close.
|
|
569
|
+
|
|
570
|
+
`AppHeader` `leading` is the menu button on mobile only (`Button variant="ghost" size="icon" className="h-8 w-8"`).
|
|
571
|
+
|
|
572
|
+
Local hook (not in the package):
|
|
573
|
+
|
|
574
|
+
```tsx excerpt:use-is-mobile-hook
|
|
575
|
+
const MOBILE_BREAKPOINT = 768;
|
|
576
|
+
|
|
577
|
+
export function useIsMobile() {
|
|
578
|
+
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
|
|
579
|
+
useEffect(() => {
|
|
580
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
581
|
+
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
582
|
+
mql.addEventListener("change", onChange);
|
|
583
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
584
|
+
return () => mql.removeEventListener("change", onChange);
|
|
585
|
+
}, []);
|
|
586
|
+
return !!isMobile;
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## 10. Frame component
|
|
593
|
+
|
|
594
|
+
```tsx excerpt:app-frame-component
|
|
595
|
+
export function AppFrame() {
|
|
596
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
597
|
+
const isMobile = useIsMobile();
|
|
598
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
599
|
+
const location = useLocation();
|
|
600
|
+
const { theme } = useTheme();
|
|
601
|
+
const crumbs = [{ href: "/", label: "Home" }];
|
|
602
|
+
const title = "Projects";
|
|
603
|
+
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
setMobileOpen(false);
|
|
606
|
+
}, [location.pathname]);
|
|
607
|
+
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
document.body.style.overflow = mobileOpen ? "hidden" : "";
|
|
610
|
+
return () => {
|
|
611
|
+
document.body.style.overflow = "";
|
|
612
|
+
};
|
|
613
|
+
}, [mobileOpen]);
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<AppShell>
|
|
617
|
+
<AppSkipLink>Skip to main content</AppSkipLink>
|
|
618
|
+
{!isMobile ? (
|
|
619
|
+
<AppSidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
|
|
620
|
+
) : (
|
|
621
|
+
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
622
|
+
<SheetContent
|
|
623
|
+
side="left"
|
|
624
|
+
className="w-[260px] max-w-[260px] border-0 bg-basalt-background p-0"
|
|
625
|
+
>
|
|
626
|
+
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
|
627
|
+
<AppSidebar collapsed={false} onToggle={() => setMobileOpen(false)} />
|
|
628
|
+
</SheetContent>
|
|
629
|
+
</Sheet>
|
|
630
|
+
)}
|
|
631
|
+
<AppMain>
|
|
632
|
+
<AppHeader
|
|
633
|
+
leading={
|
|
634
|
+
isMobile ? (
|
|
635
|
+
<Button
|
|
636
|
+
variant="ghost"
|
|
637
|
+
size="icon"
|
|
638
|
+
className="h-8 w-8"
|
|
639
|
+
onClick={() => setMobileOpen(true)}
|
|
640
|
+
aria-label="Open navigation"
|
|
641
|
+
>
|
|
642
|
+
<Menu aria-hidden="true" />
|
|
643
|
+
</Button>
|
|
644
|
+
) : null
|
|
645
|
+
}
|
|
646
|
+
breadcrumbs={crumbs}
|
|
647
|
+
title={title}
|
|
648
|
+
actions={<ThemeToggle aria-label={`Toggle theme (now ${theme})`} />}
|
|
649
|
+
/>
|
|
650
|
+
<div className="flex min-h-0 flex-1 flex-col px-2 pb-2 md:px-3 md:pb-3">
|
|
651
|
+
<ContentIsland>
|
|
652
|
+
<Outlet />
|
|
653
|
+
</ContentIsland>
|
|
654
|
+
</div>
|
|
655
|
+
</AppMain>
|
|
656
|
+
</AppShell>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
<a id="appheader-and-breadcrumbs"></a>
|
|
662
|
+
|
|
663
|
+
`AppHeader` is `h-14`, matching `SidebarHeader`.
|
|
664
|
+
|
|
665
|
+
### AppHeader API & Contracts
|
|
666
|
+
|
|
667
|
+
`AppHeader` inherits root `header` attributes (`HTMLAttributes<HTMLElement>`).
|
|
668
|
+
|
|
669
|
+
- **`title?: string`**: Current page title rendered inside an `h1` (`truncate text-sm font-normal text-basalt-foreground`). (Source type intersection is `HTMLAttributes<HTMLElement> & { title?: ReactNode }`, which narrows valid `title` values to `string | undefined`). Do not also put the current page title in `breadcrumbs`.
|
|
670
|
+
- **`breadcrumbs?: { href?: string; label: ReactNode }[]`**: Ancestor breadcrumb hierarchy displayed before the current page title (ancestors only). Separated by ChevronRight.
|
|
671
|
+
- **`leading?: ReactNode`**: Optional slot before breadcrumbs, typically the mobile hamburger drawer trigger.
|
|
672
|
+
- **`actions?: ReactNode`**: Top-right framework controls (`ThemeToggle`, account dropdown, etc.). This slot is reserved for framework controls, **not** the page-level create button.
|
|
673
|
+
- **Native & Ref Boundary**: Inherits native `HTMLAttributes<HTMLElement>`, but does not forward ref.
|
|
674
|
+
|
|
675
|
+
Product title and version live in `SidebarHeader`, not in `AppHeader`. Read the version from the app `package.json` at build time. Do not hardcode it.
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
679
|
+
## 11. Login — badge card
|
|
680
|
+
|
|
681
|
+
Login is **not** inside `AppShell`. It is a centered badge on the full viewport.
|
|
682
|
+
|
|
683
|
+
Shape: ISO ID card, `aspect-[54/86]`, `w-72`, `rounded-2xl`, `bg-basalt-card`, layered shadow, hairline ring. Primary strip on top, mark and actions in the body, status strip pinned to the bottom.
|
|
684
|
+
|
|
685
|
+
```tsx excerpt:login-badge-page
|
|
686
|
+
export function LoginPage() {
|
|
687
|
+
return (
|
|
688
|
+
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-basalt-background p-4">
|
|
689
|
+
<div className="flex flex-col items-center">
|
|
690
|
+
<div
|
|
691
|
+
data-basalt-surface-root=""
|
|
692
|
+
className="relative flex aspect-[54/86] w-72 flex-col overflow-hidden rounded-2xl bg-basalt-card ring-1 ring-black/[0.08] dark:ring-white/[0.06]"
|
|
693
|
+
style={{
|
|
694
|
+
boxShadow: [
|
|
695
|
+
"0 1px 2px rgba(0,0,0,0.06)",
|
|
696
|
+
"0 4px 8px rgba(0,0,0,0.04)",
|
|
697
|
+
"0 12px 24px rgba(0,0,0,0.06)",
|
|
698
|
+
"0 24px 48px rgba(0,0,0,0.04)",
|
|
699
|
+
"0 0 0 0.5px rgba(0,0,0,0.02)",
|
|
700
|
+
"0 0 60px rgba(0,0,0,0.03)",
|
|
701
|
+
].join(", "),
|
|
702
|
+
}}
|
|
703
|
+
>
|
|
704
|
+
<div className="bg-basalt-primary px-5 py-4">
|
|
705
|
+
<div className="flex items-center justify-between">
|
|
706
|
+
<div className="h-4 w-8 rounded-full bg-basalt-background/80" />
|
|
707
|
+
<div className="flex items-center gap-2">
|
|
708
|
+
<BasaltMark className="h-4 w-4 text-basalt-primary-foreground" />
|
|
709
|
+
<span className="text-sm font-semibold text-basalt-primary-foreground">
|
|
710
|
+
Acme
|
|
711
|
+
</span>
|
|
712
|
+
</div>
|
|
713
|
+
<span className="text-[10px] font-medium tracking-widest text-basalt-primary-foreground/60 uppercase">
|
|
714
|
+
Visitor
|
|
715
|
+
</span>
|
|
716
|
+
</div>
|
|
717
|
+
</div>
|
|
718
|
+
<div className="flex flex-1 flex-col items-center px-6 pt-6 pb-14">
|
|
719
|
+
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-full bg-basalt-secondary p-2.5 ring-1 ring-basalt-border">
|
|
720
|
+
<BasaltMark className="h-10 w-10 text-basalt-muted-foreground" />
|
|
721
|
+
</div>
|
|
722
|
+
<p className="mt-5 text-lg font-semibold text-basalt-foreground">Welcome</p>
|
|
723
|
+
<p className="mt-1 text-xs text-basalt-muted-foreground">Sign in to continue</p>
|
|
724
|
+
<div className="mt-5 h-px w-full bg-basalt-border" />
|
|
725
|
+
<div className="flex-1" />
|
|
726
|
+
<Button variant="secondary" className="w-full rounded-xl py-3">
|
|
727
|
+
Continue
|
|
728
|
+
</Button>
|
|
729
|
+
</div>
|
|
730
|
+
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center border-t border-basalt-border bg-basalt-secondary/50 py-2.5">
|
|
731
|
+
<span className="text-[10px] text-basalt-muted-foreground">Secure sign-in</span>
|
|
732
|
+
</div>
|
|
733
|
+
</div>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
Swap the primary action for the real identity provider. Keep the badge proportions and the three bands (primary header, body, status footer).
|
|
741
|
+
|
|
742
|
+
---
|
|
743
|
+
|
|
744
|
+
<a id="loading"></a>
|
|
745
|
+
|
|
746
|
+
## 12. Loading
|
|
747
|
+
|
|
748
|
+
Boot and route gates use `LoadingScreen` — a centered mark and a 6rem shimmer bar on `bg-basalt-background`. It is a full-viewport status, not a child of the island.
|
|
749
|
+
|
|
750
|
+
### LoadingScreen API & Contracts
|
|
751
|
+
|
|
752
|
+
- **`label?: string`**: Accessible status name on `aria-label` (default: `"Loading"`). Root element carries `role="status"`.
|
|
753
|
+
- **`mark?: ReactNode`**: Centered brand mark slot. Defaults to `<BasaltMark className="h-8 w-8 text-basalt-foreground" />`. Note: passing children to `LoadingScreen` does not override this mark slot; custom brand icons must be provided via the `mark` prop.
|
|
754
|
+
- **Native & Ref Boundary**: Inherits standard `HTMLAttributes<HTMLDivElement>`. Overlays full viewport (`fixed inset-0 z-50 flex items-center justify-center bg-basalt-background`). Does not forward ref.
|
|
755
|
+
|
|
756
|
+
```tsx excerpt:loading-screen-snippet
|
|
757
|
+
<LoadingScreen label="Loading" />
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
## 13. First page
|
|
763
|
+
|
|
764
|
+
When skip link, rail (260 / 68, 300ms), header `h-14`, and island are in place, add routes as `Outlet` pages. Application pages inside the island start with `PageHeader`. The shell file does not grow with page UI.
|
|
765
|
+
|
|
766
|
+
Standalone login, loading, error, and landing pages use their own first-screen structure: `/login` preserves the visitor-badge composition, `/loading` is a named loading status, and `/404` and `/static-page` have independent headings. Library reference pages use their document heading and section navigation. These are deliberate layout exceptions, not alternate application-page templates.
|
|
767
|
+
|
|
768
|
+
The `/forms`, `/settings`, `/data`, and `/chat` examples demonstrate local state and simulated requests, including failure/retry and cancellation. Their viewmodels own data and timers; Views own native FormData, focus, and responsive layout. Replace the local service adapter when integrating a backend. Theme selection uses the shared provider; local profile, uploads, and chat changes do not update a real account.
|
|
769
|
+
|
|
770
|
+
`PageHeader` is flush on `ContentIsland`. Do not wrap it in another card. Do not put an icon row above the heading.
|
|
771
|
+
|
|
772
|
+
### Create and filters
|
|
773
|
+
|
|
774
|
+
- Put the create button last in `actions`.
|
|
775
|
+
- One short filter (search, a single select) stays in `actions`, before create.
|
|
776
|
+
- Two or more filters, or a filter bar, go in `filters` (own row). Create stays in `actions`.
|
|
777
|
+
- If the filter set is large (search plus several segments), keep it **off** `PageHeader.filters`. Put a Filters control in `actions` and reveal the controls below the heading, on the island, with no divider. Open the panel when the URL already has filters.
|
|
778
|
+
- Do not put create in `filters`.
|
|
779
|
+
- Do not pass `breadcrumbs` here if `AppHeader` already has the trail.
|
|
780
|
+
|
|
781
|
+
### Short filters (same row as create)
|
|
782
|
+
|
|
783
|
+
```tsx compile:integration-projects-page-basic
|
|
784
|
+
import { Button } from "@nocoo/basalt/components/button";
|
|
785
|
+
import { Input } from "@nocoo/basalt/components/input";
|
|
786
|
+
import { LayerCard } from "@nocoo/basalt/components/layer-card";
|
|
787
|
+
import { PageHeader } from "@nocoo/basalt/components/page-header";
|
|
788
|
+
|
|
789
|
+
export default function ProjectsPage() {
|
|
790
|
+
return (
|
|
791
|
+
<div className="space-y-8">
|
|
792
|
+
<PageHeader
|
|
793
|
+
title="Projects"
|
|
794
|
+
description="Active work in this workspace."
|
|
795
|
+
actions={
|
|
796
|
+
<>
|
|
797
|
+
<Input placeholder="Search" className="max-w-48" />
|
|
798
|
+
<Button>New project</Button>
|
|
799
|
+
</>
|
|
800
|
+
}
|
|
801
|
+
/>
|
|
802
|
+
<LayerCard>
|
|
803
|
+
<p className="text-sm text-basalt-muted-foreground">List or board.</p>
|
|
804
|
+
</LayerCard>
|
|
805
|
+
</div>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
### Complex filters (own row) and regions
|
|
811
|
+
|
|
812
|
+
```tsx compile:integration-projects-page-full
|
|
813
|
+
import { Button } from "@nocoo/basalt/components/button";
|
|
814
|
+
import { Input } from "@nocoo/basalt/components/input";
|
|
815
|
+
import { LayerCard } from "@nocoo/basalt/components/layer-card";
|
|
816
|
+
import { PageHeader } from "@nocoo/basalt/components/page-header";
|
|
817
|
+
import { SectionRule } from "@nocoo/basalt/components/section-rule";
|
|
818
|
+
|
|
819
|
+
export default function ProjectsPage() {
|
|
820
|
+
return (
|
|
821
|
+
<div className="space-y-8">
|
|
822
|
+
<PageHeader
|
|
823
|
+
title="Projects"
|
|
824
|
+
description="Active work in this workspace."
|
|
825
|
+
actions={
|
|
826
|
+
<>
|
|
827
|
+
<Button variant="outline">Export</Button>
|
|
828
|
+
<Button>New project</Button>
|
|
829
|
+
</>
|
|
830
|
+
}
|
|
831
|
+
filters={
|
|
832
|
+
<>
|
|
833
|
+
<Input placeholder="Owner" className="max-w-48" />
|
|
834
|
+
<Input placeholder="Region" className="max-w-48" />
|
|
835
|
+
<Input placeholder="Risk" className="max-w-48" />
|
|
836
|
+
</>
|
|
837
|
+
}
|
|
838
|
+
/>
|
|
839
|
+
<SectionRule title="Overview" hint="Live totals for the current workspace.">
|
|
840
|
+
<div className="grid grid-cols-2 gap-4">
|
|
841
|
+
<LayerCard>
|
|
842
|
+
<p className="text-xs text-basalt-muted-foreground">Projects</p>
|
|
843
|
+
<p className="text-2xl font-semibold">24</p>
|
|
844
|
+
</LayerCard>
|
|
845
|
+
<LayerCard>
|
|
846
|
+
<p className="text-xs text-basalt-muted-foreground">Ready</p>
|
|
847
|
+
<p className="text-2xl font-semibold">18</p>
|
|
848
|
+
</LayerCard>
|
|
849
|
+
</div>
|
|
850
|
+
</SectionRule>
|
|
851
|
+
<SectionRule
|
|
852
|
+
title="Catalog"
|
|
853
|
+
actions={
|
|
854
|
+
<Button variant="outline" size="sm">
|
|
855
|
+
Filter
|
|
856
|
+
</Button>
|
|
857
|
+
}
|
|
858
|
+
>
|
|
859
|
+
<LayerCard>
|
|
860
|
+
<LayerCard.Header>Items</LayerCard.Header>
|
|
861
|
+
<LayerCard.Body>
|
|
862
|
+
<p className="text-sm text-basalt-muted-foreground">
|
|
863
|
+
Later work is only more cards in this region.
|
|
864
|
+
</p>
|
|
865
|
+
</LayerCard.Body>
|
|
866
|
+
</LayerCard>
|
|
867
|
+
</SectionRule>
|
|
868
|
+
</div>
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
After this page exists, stop adding chrome. New features are `LayerCard` (and leaves inside them) under `PageHeader` / `SectionRule`. Keep view-models free of layout.
|
|
874
|
+
|
|
875
|
+
`PageHeader` and `SectionRule` are not on the root barrel. Live recipe: `/data`. Surfaces: `/layout`.
|
|
876
|
+
|
|
877
|
+
---
|
|
878
|
+
|
|
879
|
+
## 14. Nested surfaces
|
|
880
|
+
|
|
881
|
+
Pages render **inside** `ContentIsland`. That island is already L1. Paint is CSS descendant count. No React context. No `useSurface`.
|
|
882
|
+
|
|
883
|
+
### Levels
|
|
884
|
+
|
|
885
|
+
| Where | Mark | Paint |
|
|
886
|
+
|---|---|---|
|
|
887
|
+
| L0 | `AppShell`, login viewport | `--basalt-background` 94% / 9% |
|
|
888
|
+
| L1 | `ContentIsland`, Dialog, Sheet, AlertDialog, login badge | `--basalt-card` 97% / 10.6% |
|
|
889
|
+
| L2 | first `LayerCard` under a root | `--basalt-secondary` 99% / 12.2% |
|
|
890
|
+
| L3 | `LayerCard.Well` or a nested `LayerCard` | `--basalt-bright` 100% / 14% |
|
|
891
|
+
| L3+ | deeper nested surface | transparent, no ring by default |
|
|
892
|
+
| Overlay | Tooltip, Popover, Dropdown, HoverCard | `--basalt-popover` — not a stack step |
|
|
893
|
+
|
|
894
|
+
`--basalt-muted` is not a content step. Isolated (no root): the outermost `LayerCard` paints as L1; its Well is L2.
|
|
895
|
+
|
|
896
|
+
`LayerCard.Primary` is `Well`. `LayerCard.Secondary` is `Header`. Keep the names; teach Header / Body / Well.
|
|
897
|
+
|
|
898
|
+
### Recipe
|
|
899
|
+
|
|
900
|
+
Unstructured card (KPI, metric, tile) — root padding, one surface:
|
|
901
|
+
|
|
902
|
+
```tsx excerpt:layercard-unstructured
|
|
903
|
+
<LayerCard>
|
|
904
|
+
<p className="text-xs text-basalt-muted-foreground">Sessions</p>
|
|
905
|
+
<p className="text-2xl font-semibold">1,284</p>
|
|
906
|
+
</LayerCard>
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
Structured card — root padding drops. `Body` stays on L2. `Well` raises to L3:
|
|
910
|
+
|
|
911
|
+
```tsx excerpt:layercard-structured-body
|
|
912
|
+
<LayerCard>
|
|
913
|
+
<LayerCard.Header>Account</LayerCard.Header>
|
|
914
|
+
<LayerCard.Body>
|
|
915
|
+
<DescriptionList columns={1}>
|
|
916
|
+
<DescriptionList.Item term="Status">Active</DescriptionList.Item>
|
|
917
|
+
</DescriptionList>
|
|
918
|
+
</LayerCard.Body>
|
|
919
|
+
</LayerCard>
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
```tsx excerpt:layercard-structured-well
|
|
923
|
+
<LayerCard>
|
|
924
|
+
<LayerCard.Header>Activity</LayerCard.Header>
|
|
925
|
+
<LayerCard.Well>
|
|
926
|
+
{/* lists, nested groups, inner frames */}
|
|
927
|
+
</LayerCard.Well>
|
|
928
|
+
</LayerCard>
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
Place cards with Grid or flex. Those classes do not paint. A grid of `LayerCard` on the island is L2. A `LayerCard` inside a Well is L3+.
|
|
932
|
+
|
|
933
|
+
`outlined` draws a hairline when a transparent L3+ nest would otherwise vanish. Default grouping is luminance only.
|
|
934
|
+
|
|
935
|
+
### Controls and tables
|
|
936
|
+
|
|
937
|
+
Input, Select, secondary / outline Button use `bg-basalt-control` (`--basalt-control-fill` from the current surface) plus border. Do not override with `bg-card`, `bg-basalt-card`, or `bg-basalt-background`.
|
|
938
|
+
|
|
939
|
+
`Table` sets `data-basalt-table`. Zebra, hover, and selected paint on `td`, from `--basalt-zebra-fill` / `--basalt-control-fill`.
|
|
940
|
+
|
|
941
|
+
`DescriptionList` is layout and type only. It is not a surface.
|
|
942
|
+
|
|
943
|
+
### Overlays
|
|
944
|
+
|
|
945
|
+
Dialog, AlertDialog, and Sheet portal to `body` and start a **new** L1 root. Do not nest `data-basalt-surface-root` in the same tree.
|
|
946
|
+
|
|
947
|
+
Popover, Tooltip, Dropdown, and HoverCard stay on the popover token. They do not open the stack.
|
|
948
|
+
|
|
949
|
+
### Don't
|
|
950
|
+
|
|
951
|
+
- Hand-write `bg-card` / `bg-muted` / `bg-secondary` wells inside the island
|
|
952
|
+
- Mark page roots with `data-basalt-surface-root` — the island already did
|
|
953
|
+
- Put `h-screen`, side padding, or a second card around `ContentIsland`
|
|
954
|
+
- Mix leftover `standalone.css` with the Tailwind contract
|
|
955
|
+
|
|
956
|
+
### Import
|
|
957
|
+
|
|
958
|
+
```ts
|
|
959
|
+
import { DescriptionList } from "@nocoo/basalt/components/description-list";
|
|
960
|
+
import { LayerCard } from "@nocoo/basalt/components/layer-card";
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
`LayerCard` and `DescriptionList` are also on the root barrel. Chrome (`AppShell`, `AppHeader`, `PageHeader`, `SectionRule`) is not.
|
|
964
|
+
|
|
965
|
+
---
|
|
966
|
+
|
|
967
|
+
## 15. PageHeader and SectionRule
|
|
968
|
+
|
|
969
|
+
`PageHeader` and `SectionRule` are the only page-level chrome inside the island. Import them from granular paths. They are not on the root barrel. Do not wrap the title in an island.
|
|
970
|
+
|
|
971
|
+
### PageHeader
|
|
972
|
+
|
|
973
|
+
Flush heading. Title left. Create last in `actions`. Short filters in `actions`. Complex filters on `filters`.
|
|
974
|
+
|
|
975
|
+
`breadcrumbs` is optional. Use it only when there is no `AppHeader` trail (catalog demos, isolated surfaces). Product pages under `AppFrame` omit it.
|
|
976
|
+
|
|
977
|
+
```tsx excerpt:page-header-snippet
|
|
978
|
+
import { PageHeader } from "@nocoo/basalt/components/page-header";
|
|
979
|
+
|
|
980
|
+
<PageHeader
|
|
981
|
+
title="Projects"
|
|
982
|
+
description="Active work in this workspace."
|
|
983
|
+
actions={
|
|
984
|
+
<>
|
|
985
|
+
<Button variant="outline">Export</Button>
|
|
986
|
+
<Button>New project</Button>
|
|
987
|
+
</>
|
|
988
|
+
}
|
|
989
|
+
filters={<Input placeholder="Owner" className="max-w-48" />}
|
|
990
|
+
/>
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
### SectionRule
|
|
994
|
+
|
|
995
|
+
Title, optional info control, dashed rule, optional actions. Children sit under the rule. Card titles stay on `LayerCard.Header`.
|
|
996
|
+
|
|
997
|
+
```tsx excerpt:section-rule-snippets
|
|
998
|
+
import { SectionRule } from "@nocoo/basalt/components/section-rule";
|
|
999
|
+
|
|
1000
|
+
<SectionRule title="Catalog">
|
|
1001
|
+
<LayerCard>…</LayerCard>
|
|
1002
|
+
</SectionRule>
|
|
1003
|
+
|
|
1004
|
+
<SectionRule title="Catalog" hint="Published items in this workspace.">
|
|
1005
|
+
<LayerCard>…</LayerCard>
|
|
1006
|
+
</SectionRule>
|
|
1007
|
+
|
|
1008
|
+
<SectionRule
|
|
1009
|
+
title="Activity"
|
|
1010
|
+
hint="Events from the last 24 hours."
|
|
1011
|
+
actions={
|
|
1012
|
+
<>
|
|
1013
|
+
<Button variant="outline" size="sm">Export</Button>
|
|
1014
|
+
<Button size="sm">Refresh</Button>
|
|
1015
|
+
</>
|
|
1016
|
+
}
|
|
1017
|
+
>
|
|
1018
|
+
<LayerCard>…</LayerCard>
|
|
1019
|
+
</SectionRule>
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
Stack one `SectionRule` per region. Live recipe: `/layout`. Catalog: `/ui/page-header`, `/ui/section-rule`.
|
|
1023
|
+
|
|
1024
|
+
---
|
|
1025
|
+
|
|
1026
|
+
## 16. Forms, Native Submission, and Reset Handling
|
|
1027
|
+
|
|
1028
|
+
Basalt form controls participate in standard forms and controlled state pipelines.
|
|
1029
|
+
|
|
1030
|
+
### Working Native HTML Form & FormData Pattern
|
|
1031
|
+
|
|
1032
|
+
Standard inputs (`Input`, `InputArea`, `Checkbox`, `Radio`, `Switch`) reliably supply their values via standard `FormData(event.currentTarget)`:
|
|
1033
|
+
|
|
1034
|
+
```tsx compile:integration-profile-form
|
|
1035
|
+
import type React from "react";
|
|
1036
|
+
import { Button, Field, Input, Switch } from "@nocoo/basalt";
|
|
1037
|
+
|
|
1038
|
+
export function ProfileForm() {
|
|
1039
|
+
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
1040
|
+
event.preventDefault();
|
|
1041
|
+
const data = new FormData(event.currentTarget);
|
|
1042
|
+
const email = data.get("email");
|
|
1043
|
+
const notifications = data.get("notifications") === "on";
|
|
1044
|
+
console.log({ email, notifications });
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
return (
|
|
1048
|
+
<form onSubmit={handleSubmit}>
|
|
1049
|
+
<Field label="Email" hint="Your primary email address">
|
|
1050
|
+
<Input name="email" type="email" required />
|
|
1051
|
+
</Field>
|
|
1052
|
+
|
|
1053
|
+
<Field label="Notifications">
|
|
1054
|
+
<Switch name="notifications" defaultChecked />
|
|
1055
|
+
</Field>
|
|
1056
|
+
|
|
1057
|
+
<div className="flex gap-2 mt-4">
|
|
1058
|
+
<Button type="reset" variant="secondary">Reset</Button>
|
|
1059
|
+
<Button type="submit" variant="default">Save Changes</Button>
|
|
1060
|
+
</div>
|
|
1061
|
+
</form>
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
### Controlled DatePicker Pattern
|
|
1067
|
+
|
|
1068
|
+
When using controlled state or building custom form adapters, `DatePicker` accepts an ISO date string (`YYYY-MM-DD`). In addition, you can control the active calendar view month (`YYYY-MM`) independently of the selected value via `month`, `defaultMonth`, and `onMonthChange`:
|
|
1069
|
+
|
|
1070
|
+
```tsx compile:integration-controlled-date-picker
|
|
1071
|
+
import { useState } from "react";
|
|
1072
|
+
import { DatePicker } from "@nocoo/basalt/components/date-picker";
|
|
1073
|
+
|
|
1074
|
+
export function ControlledDatePickerField() {
|
|
1075
|
+
const [selectedDate, setSelectedDate] = useState<string>("2026-09-01");
|
|
1076
|
+
const [activeMonth, setActiveMonth] = useState<string>("2026-09");
|
|
1077
|
+
|
|
1078
|
+
return (
|
|
1079
|
+
<DatePicker
|
|
1080
|
+
value={selectedDate}
|
|
1081
|
+
onChange={setSelectedDate}
|
|
1082
|
+
month={activeMonth}
|
|
1083
|
+
onMonthChange={setActiveMonth}
|
|
1084
|
+
aria-label="Target Date"
|
|
1085
|
+
/>
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
The month configuration rules:
|
|
1091
|
+
|
|
1092
|
+
- **`month` / `defaultMonth`**: Accept standard `YYYY-MM` strings with at least 4-digit positive civil years (e.g. `2026-09` and `10000-01`).
|
|
1093
|
+
- **`defaultMonth` initialization**: `defaultMonth` only sets the initial displayed month on mount; subsequent changes to this prop do not reset the visible month.
|
|
1094
|
+
- **Controlled `month` and fallback**: An active, valid `month` prop controls the displayed view. If the initial configuration is omitted or invalid, it smoothly falls back in sequence to a valid `defaultMonth`, the selected date's month, or the current month today (with the latter two clamped to `min`/`max` bounds).
|
|
1095
|
+
- **Callback isolation**: `onMonthChange` is triggered only by user navigation actions (e.g. Prev/Next button clicks, keyboard page jumps), never during render or prop updates. External changes to `month` alter only the visible calendar page and do not modify the selected ISO date.
|
|
1096
|
+
|
|
1097
|
+
#### Keyboard Navigation and Labels
|
|
1098
|
+
|
|
1099
|
+
The calendar follows the WAI-ARIA grid pattern with a roving tab index:
|
|
1100
|
+
|
|
1101
|
+
- **Arrow keys (`Left` / `Right` / `Up` / `Down`)**: Move focus by day (±1) or week (±7), skipping disabled dates.
|
|
1102
|
+
- **`Home` / `End`**: Jump to the start or end of the current row based on `weekStartsOn` (e.g. `0` for Sunday, `1` for Monday).
|
|
1103
|
+
- **`PageDown` / `PageUp`**: Move by one month, clamping to valid month bounds (e.g. Jan 31 → Feb 28 or 29).
|
|
1104
|
+
- **`Shift + PageDown` / `Shift + PageUp`**: Jump by one full year, maintaining month boundary clamping.
|
|
1105
|
+
- **`Enter` / `Space`**: Select the focused day.
|
|
1106
|
+
- **`Escape`**: Dismiss the calendar popover and return focus to the trigger button.
|
|
1107
|
+
|
|
1108
|
+
For internationalization, `locale` formats month titles and trigger values, while `labels` customizes accessible strings for navigation, validation messages, and placeholders:
|
|
1109
|
+
|
|
1110
|
+
```tsx compile:integration-datepicker-labels
|
|
1111
|
+
import { DatePicker } from "@nocoo/basalt/components/date-picker";
|
|
1112
|
+
|
|
1113
|
+
export function LocalizedDatePickerExample() {
|
|
1114
|
+
return (
|
|
1115
|
+
<DatePicker
|
|
1116
|
+
locale="fr-FR"
|
|
1117
|
+
weekStartsOn={1}
|
|
1118
|
+
labels={{
|
|
1119
|
+
calendar: "Calendrier des dates",
|
|
1120
|
+
previousMonth: "Mois précédent",
|
|
1121
|
+
nextMonth: "Mois suivant",
|
|
1122
|
+
placeholder: "Sélectionnez une date",
|
|
1123
|
+
validationMessage: "Veuillez sélectionner une date valide.",
|
|
1124
|
+
keyboardInstructions:
|
|
1125
|
+
"Utilisez les flèches pour naviguer et Entrée pour valider.",
|
|
1126
|
+
}}
|
|
1127
|
+
/>
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
### Empty states with actions
|
|
1133
|
+
|
|
1134
|
+
`Empty` and `LayerCard.Empty` support custom `children` and an interactive `action` control alongside structured `icon`, `title`, and `description`. Content renders in a consistent visual hierarchy: `icon` → `title` → `description` → `children` → `action`. Both `children` and `action` preserve numeric `0` values without drop.
|
|
1135
|
+
|
|
1136
|
+
```tsx compile:integration-empty-actions
|
|
1137
|
+
import { useState } from "react";
|
|
1138
|
+
import { Button } from "@nocoo/basalt/components/button";
|
|
1139
|
+
import { Empty } from "@nocoo/basalt/components/empty";
|
|
1140
|
+
import { LayerCard } from "@nocoo/basalt/components/layer-card";
|
|
1141
|
+
|
|
1142
|
+
export function EmptyActionExample() {
|
|
1143
|
+
const [created, setCreated] = useState(false);
|
|
1144
|
+
|
|
1145
|
+
return (
|
|
1146
|
+
<LayerCard className="max-w-md p-6">
|
|
1147
|
+
{created ? (
|
|
1148
|
+
<p className="text-sm text-basalt-foreground">Project created successfully.</p>
|
|
1149
|
+
) : (
|
|
1150
|
+
<Empty
|
|
1151
|
+
title="No projects found"
|
|
1152
|
+
description="You haven't created any workspace projects yet."
|
|
1153
|
+
action={
|
|
1154
|
+
<Button size="sm" onClick={() => setCreated(true)}>
|
|
1155
|
+
Create project
|
|
1156
|
+
</Button>
|
|
1157
|
+
}
|
|
1158
|
+
>
|
|
1159
|
+
<span className="text-xs text-basalt-muted-foreground">
|
|
1160
|
+
Get started by launching a new workspace.
|
|
1161
|
+
</span>
|
|
1162
|
+
</Empty>
|
|
1163
|
+
)}
|
|
1164
|
+
</LayerCard>
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
### React Hook Form Adapter
|
|
1170
|
+
|
|
1171
|
+
When building forms with form libraries like `react-hook-form`, wrap composite controls like `DatePicker` using `Controller`. For native controls like `Input`, register them directly with validation rules:
|
|
1172
|
+
|
|
1173
|
+
```tsx compile:integration-rhf-controller
|
|
1174
|
+
import React, { useState } from "react";
|
|
1175
|
+
import { useForm, Controller } from "react-hook-form";
|
|
1176
|
+
import { Button, Field, Input } from "@nocoo/basalt";
|
|
1177
|
+
import { DatePicker } from "@nocoo/basalt/components/date-picker";
|
|
1178
|
+
|
|
1179
|
+
interface ProjectFormValues {
|
|
1180
|
+
projectName: string;
|
|
1181
|
+
startDate: string;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const defaultValues: ProjectFormValues = {
|
|
1185
|
+
projectName: "",
|
|
1186
|
+
startDate: "2026-09-01",
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
export function ProjectSettingsForm() {
|
|
1190
|
+
const [submittedData, setSubmittedData] = useState<ProjectFormValues | null>(null);
|
|
1191
|
+
|
|
1192
|
+
const {
|
|
1193
|
+
register,
|
|
1194
|
+
control,
|
|
1195
|
+
handleSubmit,
|
|
1196
|
+
reset,
|
|
1197
|
+
formState: { errors, isSubmitting },
|
|
1198
|
+
} = useForm<ProjectFormValues>({
|
|
1199
|
+
defaultValues,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
const onSubmit = (values: ProjectFormValues) => {
|
|
1203
|
+
setSubmittedData(values);
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const handleControlledReset = () => {
|
|
1207
|
+
reset(defaultValues);
|
|
1208
|
+
setSubmittedData(null);
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
return (
|
|
1212
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
1213
|
+
<Field
|
|
1214
|
+
label="Project Name"
|
|
1215
|
+
hint="Unique name for your project workspace"
|
|
1216
|
+
error={errors.projectName?.message}
|
|
1217
|
+
>
|
|
1218
|
+
<Input
|
|
1219
|
+
{...register("projectName", {
|
|
1220
|
+
required: "Project name is required",
|
|
1221
|
+
minLength: { value: 3, message: "Minimum 3 characters required" },
|
|
1222
|
+
})}
|
|
1223
|
+
placeholder="e.g. Acme Dashboard"
|
|
1224
|
+
aria-label="Project Name"
|
|
1225
|
+
/>
|
|
1226
|
+
</Field>
|
|
1227
|
+
|
|
1228
|
+
<Controller
|
|
1229
|
+
name="startDate"
|
|
1230
|
+
control={control}
|
|
1231
|
+
rules={{ required: "Start date is required" }}
|
|
1232
|
+
render={({ field }) => (
|
|
1233
|
+
<Field
|
|
1234
|
+
label="Start Date"
|
|
1235
|
+
hint="Initial project milestone date (YYYY-MM-DD)"
|
|
1236
|
+
error={errors.startDate?.message}
|
|
1237
|
+
>
|
|
1238
|
+
<DatePicker
|
|
1239
|
+
name={field.name}
|
|
1240
|
+
value={field.value}
|
|
1241
|
+
onChange={field.onChange}
|
|
1242
|
+
onBlur={field.onBlur}
|
|
1243
|
+
aria-label="Start Date"
|
|
1244
|
+
/>
|
|
1245
|
+
</Field>
|
|
1246
|
+
)}
|
|
1247
|
+
/>
|
|
1248
|
+
|
|
1249
|
+
{submittedData && (
|
|
1250
|
+
<div data-testid="submission-output" className="text-sm font-mono p-2 border rounded">
|
|
1251
|
+
Submitted: {JSON.stringify(submittedData)}
|
|
1252
|
+
</div>
|
|
1253
|
+
)}
|
|
1254
|
+
|
|
1255
|
+
<div className="flex gap-2">
|
|
1256
|
+
<Button
|
|
1257
|
+
type="button"
|
|
1258
|
+
variant="secondary"
|
|
1259
|
+
onClick={handleControlledReset}
|
|
1260
|
+
>
|
|
1261
|
+
Reset
|
|
1262
|
+
</Button>
|
|
1263
|
+
<Button type="submit" variant="default" disabled={isSubmitting}>
|
|
1264
|
+
Save Project
|
|
1265
|
+
</Button>
|
|
1266
|
+
</div>
|
|
1267
|
+
</form>
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
### Known Form Limitations
|
|
1273
|
+
1. `DatePicker` external ref merging & focus target: forwarding a custom `ref` correctly targets the underlying native `input` element with full object/callback/React 19 cleanup support and preserves native form association and reset behavior; note that the forwarded `ref` points to the native input element (not the visual trigger button).
|
|
1274
|
+
2. `Field` composition with Form Library Controllers: `Field` clones its immediate child element to inject accessibility IDs (`id`, `aria-describedby`). When wrapping controls with an abstraction like `react-hook-form`'s `<Controller>`, render `<Field>` **inside** the `Controller`'s `render` prop (wrapping the actual input control) rather than nesting `Controller` inside `Field`.
|
|
1275
|
+
3. `Autocomplete` free-text commit on blur: unselected free text and matching suggestions commit on blur without reclaiming focus, preserving natural Tab / Shift+Tab navigation and pointer departures.
|
|
1276
|
+
4. Native `required` validation on composite controls: empty required `DatePicker` validation surfaces errors on the visible trigger button and directs user focus to the trigger without focusing the hidden 1×1px input.
|
|
1277
|
+
|
|
1278
|
+
---
|
|
1279
|
+
|
|
1280
|
+
## 17. Step-by-Step Migration Guide
|
|
1281
|
+
|
|
1282
|
+
### Migrating to `@nocoo/basalt`
|
|
1283
|
+
|
|
1284
|
+
1. **Step 1: Install Package & Peer Dependencies**
|
|
1285
|
+
```bash
|
|
1286
|
+
bun add @nocoo/basalt lucide-react
|
|
1287
|
+
```
|
|
1288
|
+
2. **Step 2: Configure Stylesheet & Scan Sources**
|
|
1289
|
+
- In Tailwind v4 setups, configure CSS imports with `@source` scanning `dist`:
|
|
1290
|
+
```css
|
|
1291
|
+
@source "../node_modules/@nocoo/basalt/dist/**/*.{js,jsx,ts,tsx}";
|
|
1292
|
+
@import "@nocoo/basalt/styles/tailwind";
|
|
1293
|
+
@import "tailwindcss";
|
|
1294
|
+
```
|
|
1295
|
+
- In non-Tailwind applications, import `@nocoo/basalt/styles/standalone` in your application root instead.
|
|
1296
|
+
- Remove custom `--color-*` or `--basalt-*` overrides in app CSS.
|
|
1297
|
+
3. **Step 3: Establish Outer Frame**
|
|
1298
|
+
- Wrap application routes in `ThemeProvider` and `LinkProvider`.
|
|
1299
|
+
- Implement `/login` as an isolated badge page outside `AppShell`.
|
|
1300
|
+
- Use `AppShell` with `Sidebar` and `AppHeader` for authenticated layout.
|
|
1301
|
+
4. **Step 4: Update Component Imports & Subpaths**
|
|
1302
|
+
- Import base components from `@nocoo/basalt` root.
|
|
1303
|
+
- Import layout chrome (`PageHeader`, `SectionRule`, `AppHeader`, `AppShell`) from granular subpaths (`@nocoo/basalt/components/*`).
|
|
1304
|
+
- Import chart components from `@nocoo/basalt/charts/*` (ensure `recharts` peer dependency is installed).
|
|
1305
|
+
5. **Step 5: Verify Contrast, Surface Tokens & Form Handling**
|
|
1306
|
+
- Verify all content cards use `LayerCard` or surface classes rather than manual border/background combinations.
|
|
1307
|
+
- For composite forms (`DatePicker`, `Autocomplete`), implement controlled state or wrap in form library Controllers (e.g. `react-hook-form`).
|
|
1308
|
+
---
|
|
1309
|
+
|
|
1310
|
+
## 18. Chart Subsystem Primitives
|
|
1311
|
+
|
|
1312
|
+
Basalt charts are composed of responsive frame, legend, and tooltip subsystem primitives. Consuming applications import these granularly from `@nocoo/basalt/charts/*`.
|
|
1313
|
+
|
|
1314
|
+
<a id="chart-frame"></a>
|
|
1315
|
+
|
|
1316
|
+
### ChartFrame & ChartShell (`@nocoo/basalt/charts/frame`)
|
|
1317
|
+
|
|
1318
|
+
- **`ChartFrame`**: Responsive container wrapper around Recharts `ResponsiveContainer`.
|
|
1319
|
+
- **Props (`ChartFrameProps`)**:
|
|
1320
|
+
- `ariaLabel: string` (required): Accessible name for chart graphic. Container element receives `role="group"` and `aria-label={ariaLabel}`.
|
|
1321
|
+
- `children: ReactElement<{ accessibilityLayer?: boolean; "aria-label"?: string; "aria-describedby"?: string }>` (required): Single child Recharts graphic element. Precedence: explicit child `accessibilityLayer` prop takes priority, followed by the wrapper prop, falling back to `true`.
|
|
1322
|
+
- `size?: string` (optional, default: `"h-36 w-56"`): Tailwind sizing utility classes for the plot surface container.
|
|
1323
|
+
- `className?: string` (optional): Additional styles applied to the chart container.
|
|
1324
|
+
- `summary?: ReactNode` (optional): Descriptive text summarizing key data points and keyboard navigation instructions. Automatically associated with the chart and outer group via `useId` and `aria-describedby`.
|
|
1325
|
+
- `dataAlternative?: ReactNode` (optional): Accessible tabular or structured data alternative rendered outside the fixed-size plot area.
|
|
1326
|
+
- `accessibilityLayer?: boolean` (optional, default: `true`): Explicit toggle for keyboard accessibility on the underlying Recharts graphic where supported by the specific chart type. Callers that explicitly disable `accessibilityLayer`, or render non-interactive compact/decorative charts without tooltip exploration (such as Sparkline or SlotBar), must provide a `summary` or `dataAlternative` textual replacement.
|
|
1327
|
+
- **Native & Ref Boundary**: Pure React functional wrapper. Does not forward ref or forward arbitrary div rest props.
|
|
1328
|
+
- **`ChartShell`**: Composite container pairing a `ChartFrame` graphic area above a flexible `legend` slot.
|
|
1329
|
+
- **Props (`ChartShellProps`)**: Extends `ChartFrameProps` with optional `legend?: ReactNode`.
|
|
1330
|
+
- **`ChartAccessibilityProps`**: Utility type `Pick<ChartFrameProps, "summary" | "dataAlternative" | "accessibilityLayer">` exported for chart wrappers providing accessible summaries and data alternatives.
|
|
1331
|
+
|
|
1332
|
+
<a id="chart-legend"></a>
|
|
1333
|
+
|
|
1334
|
+
### ChartLegend (`@nocoo/basalt/charts/legend`)
|
|
1335
|
+
|
|
1336
|
+
- **`ChartLegend`**: Custom chart legend layout supporting multiple series shapes.
|
|
1337
|
+
- **Props**:
|
|
1338
|
+
- `items: ChartSeriesDescriptor[]` (required): Mutable series descriptors array `{ key: string, label?: string, color?: string }[]`. Note: type requires mutable array (not `readonly`). If empty, returns `null`.
|
|
1339
|
+
- `shape?: "bar" | "line" | "area"` (optional, default: `"line"`): Indicator swatch shape.
|
|
1340
|
+
- **Behavior**: Uses `seriesColor(item, index)` to compute indicator fills, falling back to `hsl(var(--basalt-chart-1))` if undefined. Does not forward ref.
|
|
1341
|
+
|
|
1342
|
+
<a id="chart-tooltip"></a>
|
|
1343
|
+
|
|
1344
|
+
### ChartTooltipContent & formatChartNumber (`@nocoo/basalt/charts/tooltip`)
|
|
1345
|
+
|
|
1346
|
+
- **`ChartTooltipContent`**: Popover tooltip body rendered inside Recharts `<Tooltip content={...} />`. Returns `null` (no output) when `active` is not true or when `payload` is empty/undefined.
|
|
1347
|
+
- **Props**:
|
|
1348
|
+
- `active?: boolean`: Active hover state supplied by Recharts.
|
|
1349
|
+
- `payload?: readonly ChartTooltipItem[]`: Hovered item collection.
|
|
1350
|
+
- `label?: string | number`: Tooltip title or category label.
|
|
1351
|
+
- `formatter?: (value: number) => string`: Custom numeric formatter for data values. Defaults to `formatChartNumber`.
|
|
1352
|
+
- **`ChartTooltipItem` Shape**:
|
|
1353
|
+
- `name?: string`: Series name or identifier.
|
|
1354
|
+
- `value?: number | string`: Numerical or string value of the hovered point.
|
|
1355
|
+
- `color?: string`: Item color indicator.
|
|
1356
|
+
- `fill?: string`: SVG fill color indicator.
|
|
1357
|
+
- `stroke?: string`: SVG stroke color indicator.
|
|
1358
|
+
- `dataKey?: string | number`: Recharts series key binding.
|
|
1359
|
+
- **Behavior**: Hides row label text for internal/synthetic keys (`"y"`, `"y2"`, `"y3"`, `"value"`, `"target"`), but still displays their numeric values. Uses `--basalt-popover` background tokens with tabular numeric formatting.
|
|
1360
|
+
- **Composable Tooltip Elements**: Lightweight, zero-Recharts presentation primitives for building custom tooltip layouts. All three components forward native host element props (such as `id`, `aria-*`, `data-*`, style, and event listeners) and preserve custom caller style overrides while omitting `children` to avoid silent element drops:
|
|
1361
|
+
- **`ChartTooltipRow`**: Renders an individual metric row with color dot indicator, series label, and formatted tabular numeric value with optional unit.
|
|
1362
|
+
- `label?: ReactNode`: Metric or series title (preserves numeric `0` and custom nodes; nullish values omit the label element).
|
|
1363
|
+
- `value?: ReactNode`: Metric value. Finite numbers are automatically formatted via `formatter` or `formatChartNumber`. `null` or `undefined` renders placeholder `"—"`.
|
|
1364
|
+
- `unit?: ReactNode`: Optional unit string or node (e.g., `"ms"`, `"req/s"`, `"%"`) appended to the value. Omitted when `value` evaluates to placeholder `"—"`.
|
|
1365
|
+
- `color?: string`: Swatch dot fill color (defaults to `hsl(var(--basalt-chart-1))`).
|
|
1366
|
+
- `formatter?: (value: number) => string`: Optional custom numeric formatter overriding `formatChartNumber`.
|
|
1367
|
+
- `hideIndicator?: boolean`: When true, suppresses rendering of the decorative color dot (default: `false`).
|
|
1368
|
+
- Forwarded native props: Accepts `Omit<ComponentProps<"div">, "children">`.
|
|
1369
|
+
- **`ChartTooltipDivider`**: Renders a horizontal divider rule as a native `<hr>` element (`role="separator"`, `aria-orientation="horizontal"`) with zero borders and explicit 1px height for grouping metric categories.
|
|
1370
|
+
- Forwarded native props: Accepts `Omit<ComponentProps<"hr">, "children">`.
|
|
1371
|
+
- **`ChartTooltipSummary`**: Renders an aggregated metric row (e.g. Total or SLA Ceiling) with bold font weights and optional unit suffix.
|
|
1372
|
+
- `label?: ReactNode`: Summary title (defaults to `"Total"`, preserves numeric `0`).
|
|
1373
|
+
- `value?: ReactNode`: Aggregated value node. Finite numbers are formatted; nullish renders placeholder `"—"`.
|
|
1374
|
+
- `unit?: ReactNode`: Optional unit suffix appended to valid summary values (omitted for `"—"`).
|
|
1375
|
+
- `formatter?: (value: number) => string`: Optional custom numeric formatter.
|
|
1376
|
+
- Forwarded native props: Accepts `Omit<ComponentProps<"div">, "children">`.
|
|
1377
|
+
- **`formatChartNumber(value: number): string`**: Formats numbers via `Intl.NumberFormat` with max 0 decimals for integers and 1 decimal for fractions. Non-finite values return `"—"`.
|
|
1378
|
+
|
|
1379
|
+
<a id="heatmap-calendar"></a>
|
|
1380
|
+
|
|
1381
|
+
### HeatmapCalendar (`@nocoo/basalt/charts/heatmap-calendar`)
|
|
1382
|
+
|
|
1383
|
+
- **`HeatmapCalendar`**: Accessible calendar heatmap component supporting both year-long date/value grids and compact 7-column numeric sequences.
|
|
1384
|
+
- **Props (`HeatmapCalendarYearProps`)**:
|
|
1385
|
+
- `data: HeatmapDataPoint[]` (required): Daily entries `{ date: string, value: number }[]` with ISO date strings (`"YYYY-MM-DD"`).
|
|
1386
|
+
- `year: number` (required): Target calendar year to render.
|
|
1387
|
+
- `colorScale?: readonly string[]` (optional): Array of colors forming intensity gradient. Defaults to `heatmapColorScales.green`.
|
|
1388
|
+
- `valueFormatter?: (value: number, date: string) => string` (optional): Formatting function for tooltip and accessible name.
|
|
1389
|
+
- `metricLabel?: string` (optional, default: `"Value"`): Label for the measured metric.
|
|
1390
|
+
- `cellSize?: number` (optional, default: `12`): Width and height of day cells in pixels.
|
|
1391
|
+
- `cellGap?: number` (optional, default: `2`): Gap between cells in pixels.
|
|
1392
|
+
- `locale?: string` (optional, default: `"en-US"`): BCP 47 locale for month/weekday labels.
|
|
1393
|
+
- `weekdayLabels?: string[]` / `monthLabels?: string[]` (optional): Explicit label overrides.
|
|
1394
|
+
- `lessLabel?: string` / `moreLabel?: string` (optional, default: `"Less"` / `"More"`): Legend bounds text.
|
|
1395
|
+
- `ariaLabel?: string` (optional, default: `"Heatmap calendar"`): Accessible name for the calendar view.
|
|
1396
|
+
- `className?: string` (optional): Additional styles applied to the scrollable wrapper.
|
|
1397
|
+
- **Props (`HeatmapCalendarValuesProps`)**:
|
|
1398
|
+
- `values: number[]` (required): Sequence of values rendered in a compact 7-column matrix with zero-value contrast preservation.
|
|
1399
|
+
- `ariaLabel?: string` (optional, default: `"Heatmap calendar"`): Accessible name for the matrix.
|
|
1400
|
+
- `className?: string` (optional): Additional styles.
|
|
1401
|
+
- **Keyboard Navigation & Single Tab Stop**:
|
|
1402
|
+
- Focus enters the heatmap via a single <kbd>Tab</kbd> stop onto the active day/value cell button.
|
|
1403
|
+
- **Year Grid Navigation**:
|
|
1404
|
+
- <kbd>↑</kbd> / <kbd>↓</kbd>: Navigate vertically between days (previous/next day, ±1 day).
|
|
1405
|
+
- <kbd>←</kbd> / <kbd>→</kbd>: Navigate horizontally between weeks (previous/next week, ±7 days).
|
|
1406
|
+
- <kbd>Home</kbd> / <kbd>End</kbd>: Jump directly to the first/last valid day of the year.
|
|
1407
|
+
- <kbd>Escape</kbd>: Dismisses the day tooltip while preserving focus on the cell button.
|
|
1408
|
+
- <kbd>Tab</kbd>: Leaves the calendar directly to the next external focusable control (no 365-stop tab trap).
|
|
1409
|
+
- **Values Matrix Navigation**:
|
|
1410
|
+
- <kbd>←</kbd> / <kbd>→</kbd>: Step ±1 item in sequence.
|
|
1411
|
+
- <kbd>↑</kbd> / <kbd>↓</kbd>: Step ±7 items (previous/next row in the same column in 7-column layout).
|
|
1412
|
+
- <kbd>Home</kbd> / <kbd>End</kbd>: Jump to first/last value.
|
|
1413
|
+
- <kbd>Escape</kbd>: Dismisses value tooltip while preserving focus on the cell button.
|
|
1414
|
+
- <kbd>Tab</kbd>: Leaves the matrix directly to the next external focusable control.
|
|
1415
|
+
- Zero values render with clear `:focus-visible` outlines without being faded by parent opacity.
|
|
1416
|
+
- **Dynamic Data Updates**: Shrinking or emptying arrays, or switching years, clamps active index safely, restores focus if the element was active, and never steals focus from external controls.
|
|
1417
|
+
|
|
1418
|
+
<a id="heatmap-matrix"></a>
|
|
1419
|
+
|
|
1420
|
+
### HeatmapMatrix (`@nocoo/basalt/charts/heatmap-matrix`)
|
|
1421
|
+
|
|
1422
|
+
- **`HeatmapMatrix`**: Generic two-dimensional intensity matrix for tabular categorical, operational, and schedule telemetry (e.g. days vs hours, regions vs services).
|
|
1423
|
+
- **Props (`HeatmapMatrixProps`)**:
|
|
1424
|
+
- `rowLabels: readonly string[]` (required): Ordered row headers.
|
|
1425
|
+
- `columnLabels: readonly string[]` (required): Ordered column headers.
|
|
1426
|
+
- `values: readonly (readonly (number | null | undefined)[])[]` (required): 2D matrix of values. Supports ragged rows, null, undefined, and non-finite values (treated as missing). Numeric `0` is a valid reading and distinct from missing data.
|
|
1427
|
+
- `domain?: readonly [min: number, max: number]` (optional): Explicit [min, max] domain for color intensity scaling. If reversed [max, min], bounds are automatically normalized to [min, max]; if equal [v, v], values <= v receive level 0 while values > v receive highest level. If non-finite or omitted, dynamically derived solely from visible finite numeric cells in the dataset (or [0, 0] if no finite values exist). Values at or below min receive level 0; values >= max clamp to the highest level.
|
|
1428
|
+
- `colorScale?: readonly string[]` (optional, default: `heatmapColorScales.green`): Color intensity levels array. If empty or single-color, gracefully resolves to base level.
|
|
1429
|
+
- `valueFormatter?: (value: number) => string` (optional, default: `(v) => v.toLocaleString()`): Formatter for numeric values.
|
|
1430
|
+
- `missingLabel?: string` (optional, default: `"—"`): Text representation for missing, null, undefined, or non-finite readings.
|
|
1431
|
+
- `metricLabel?: string` (optional, default: `"Heatmap matrix"`): Metric label for accessible descriptions.
|
|
1432
|
+
- `ariaLabel?: string` (optional, default: `"Heatmap matrix"`): Accessible name for the grid region.
|
|
1433
|
+
- `cellSize?: number` (optional, default: `16`): Cell height (and default width) in pixels.
|
|
1434
|
+
- `columnWidth?: number` (optional): Cell and column width in pixels. When omitted, falls back to `cellSize` for square cells. Allows wider rectangular cells to fit readable column headers (e.g. timestamps or service names).
|
|
1435
|
+
- `cellGap?: number` (optional, default: `2`): Gap between adjacent cells in pixels.
|
|
1436
|
+
- `lessLabel?: string` / `moreLabel?: string` (optional, default: `"Less"` / `"More"`): Legend bounds text.
|
|
1437
|
+
- `showLegend?: boolean` (optional, default: `true`): Whether to display the bottom color intensity scale legend.
|
|
1438
|
+
- `renderTooltip?: (cell: HeatmapMatrixCellContext) => ReactNode` (optional): Custom cell popover tooltip renderer. Replaces default tooltip contents while integrating composable parts (`ChartTooltipRow`, `ChartTooltipDivider`, `ChartTooltipSummary`).
|
|
1439
|
+
- Native props: Forwards `Omit<ComponentProps<"div">, "children">` and React 19 callback ref with cleanup to the root container.
|
|
1440
|
+
- **Keyboard Navigation & ARIA Semantics**:
|
|
1441
|
+
- Complete `role="grid"` structure with table headers (`columnheader`, `rowheader`) and cells (`gridcell`).
|
|
1442
|
+
- Single roving <kbd>Tab</kbd> stop onto the active cell.
|
|
1443
|
+
- Directional keys <kbd>←</kbd> / <kbd>→</kbd> navigate columns; <kbd>↑</kbd> / <kbd>↓</kbd> navigate rows.
|
|
1444
|
+
- <kbd>Home</kbd> / <kbd>End</kbd> jump to the first/last column of the current row; <kbd>PageUp</kbd> / <kbd>PageDown</kbd> jump to the first/last row of the current column.
|
|
1445
|
+
- <kbd>Escape</kbd> dismisses open cell tooltips while preserving cell focus.
|
|
1446
|
+
- Mobile horizontal scrolling: wide grids scroll horizontally without document overflow, keeping active cells visible during keyboard navigation.
|
|
1447
|
+
- Focus stability: shrinking or emptying matrix dimensions clamps active indices without stealing focus from external elements.
|
|
1448
|
+
|
|
1449
|
+
<a id="stat-card"></a>
|
|
1450
|
+
|
|
1451
|
+
### StatCard & StatGrid (`@nocoo/basalt/charts/stat-card`)
|
|
1452
|
+
|
|
1453
|
+
- **`StatCard`**: High-level KPI and telemetry presentation card supporting incremental interactive slots and status state transitions.
|
|
1454
|
+
- **Props (`StatCardProps`)**:
|
|
1455
|
+
- `value: string | number` (required): Core metric value. Formatted via `toLocaleString()` if numeric. Overridden when `status` is provided.
|
|
1456
|
+
- `title?: string` / `label?: string` (optional): Heading text (`title` takes precedence over `label`).
|
|
1457
|
+
- `subtitle?: string` (optional): Supporting text below value.
|
|
1458
|
+
- `icon?: LucideIcon` (optional): Decorative icon displayed in header.
|
|
1459
|
+
- `iconColor?: string` (optional, default: `"text-basalt-muted-foreground"`): Tailwind text color class for icon.
|
|
1460
|
+
- `trend?: { value: number; label?: string }` (optional): Default percentage trend with automatic positive/negative color coding.
|
|
1461
|
+
- `action?: ReactNode` (optional): Header interactive element (e.g., info tooltip button). When provided, switches card role to `group`.
|
|
1462
|
+
- `status?: ReactNode` (optional): State replacement slot (e.g., loading skeleton, error message, or empty indicator) replacing the numeric `value`. Does not mask caller-managed `subtitle` or `trend`. When callers display an error or empty state, they should omit `trend` so stale metrics are not reported.
|
|
1463
|
+
- `trendContent?: ReactNode` (optional): Custom trend replacement slot (e.g., custom badge, sparkline, or localized text) replacing default `trend`.
|
|
1464
|
+
- `children?: ReactNode` (optional): Custom arbitrary content rendered inside the card below header.
|
|
1465
|
+
- `ariaLabel?: string` (optional): Overrides automatic accessible name calculation.
|
|
1466
|
+
- `className?: string` (optional): Additional container classes.
|
|
1467
|
+
- **Accessibility & Role Semantics**:
|
|
1468
|
+
- Pure display cards render as `role="img"` with an automated compound accessible label.
|
|
1469
|
+
- When `action`, `status`, `trendContent`, or `children` slots are present, the card switches to `role="group"` so interactive descendants remain discoverable and navigable in assistive technology trees.
|
|
1470
|
+
- Falsy values `null`, `undefined`, and `false` are cleanly disregarded without creating empty gap wrappers; `0` is treated as valid content.
|
|
1471
|
+
- **`StatGrid`**: Responsive grid container organizing multiple `StatCard` items into 2, 3, or 4 column layouts.
|
|
1472
|
+
- **Props (`StatGridProps`)**: `columns?: 2 | 3 | 4` (default: `4`), `className?: string`, `children: ReactNode`.
|
|
1473
|
+
|
|
1474
|
+
<a id="dynamic-chart-series"></a>
|
|
1475
|
+
|
|
1476
|
+
### Dynamic Chart Series & Multi-Key Datasets (`@nocoo/basalt/charts/*`)
|
|
1477
|
+
|
|
1478
|
+
Basalt cartesian charts (`LineChart`, `AreaChart`, `BarChart`, `StackedBarChart`, `GroupedBarChart`, `Charts`, `Timeseries`, `CustomChart`, `Sparkline`, and `SlotBarChart`) support arbitrary, type-safe data keys beyond the legacy `y`, `y2`, and `y3` series convention:
|
|
1479
|
+
|
|
1480
|
+
- **Generic Data Binding**:
|
|
1481
|
+
- `data: TData[]`: Generic record where `TData extends { x: string | number }`. Caller datasets can contain any number of arbitrary metric keys (e.g., `edgeCache`, `p95US`, `workersCPU`), nullable numbers (`number | null`), and optional numeric properties (`number | undefined`).
|
|
1482
|
+
- `series?: Array<ChartSeriesDescriptor<K>>`: Strongly-typed array of series descriptors. Series keys `K` are automatically constrained to `LineChartNumericKeys<TData>` (excluding coordinate `x` and rejecting non-numeric fields such as strings, booleans, or typos). Arbitrary keys require explicit series; omitted or empty series retain each chart's legacy `y`, `y2`, and `y3` series fallback. Note that TypeScript type inference does not automatically enumerate object keys at runtime.
|
|
1483
|
+
- Backward compatibility: Defaults to `XYPoint` (`{ x, y, y2?, y3? }`) with `XYSeriesDescriptor` when generic parameters are omitted.
|
|
1484
|
+
- **Enhanced Axis & Formatting Props** (available on full plot charts: `LineChart`, `AreaChart`, `BarChart`, `StackedBarChart`, `GroupedBarChart`, `Charts`, `Timeseries`, `CustomChart`):
|
|
1485
|
+
- `xValueFormatter?: (value: string | number) => string`: Formats category or time tick labels along the X-axis.
|
|
1486
|
+
- `valueFormatter?: (value: number) => string`: Formats numeric Y-axis tick values and fallback tooltip values.
|
|
1487
|
+
- `yDomain?: LineChartAxisDomain`: Custom numerical bounds or keywords (`"auto"`, `"dataMin"`, `"dataMax"`) for the Y-axis.
|
|
1488
|
+
- **Stacking & Composition** (compact charts `Sparkline` and `SlotBarChart` intentionally retain compact geometry without axis, legend, or tooltip layers):
|
|
1489
|
+
- `stackOffset?: "none" | "expand" | "wiggle" | "silhouette"`: Stacking algorithm for `AreaChart` and `StackedBarChart`. Setting `"expand"` normalizes values to 100% proportional areas/bars.
|
|
1490
|
+
- `legend?: ReactNode | ((props: { items: Array<ChartSeriesDescriptor<K>> }) => ReactNode)`: Replaces default legend with custom JSX or an interactive render function receiving resolved series descriptors. Falsy values `null`, `undefined`, and `false` are omitted while `0` is preserved as valid content.
|
|
1491
|
+
- `customTooltip?: (props: { active?: boolean; payload?: readonly ChartTooltipItem[]; label?: string | number }) => ReactNode`: Custom popover tooltip renderer receiving Recharts payload, hover state, and coordinate label for custom unit and percentage calculations.
|
|
1492
|
+
|
|
1493
|
+
---
|
|
1494
|
+
|
|
1495
|
+
## 19. Complete Framework Recipes & Compilable Guides
|
|
1496
|
+
|
|
1497
|
+
### 1. Vite + Standalone CSS Integration (No Tailwind)
|
|
1498
|
+
|
|
1499
|
+
For applications that choose not to install or configure Tailwind CSS, Basalt distributes an all-in-one stylesheet `@nocoo/basalt/styles/standalone`. This bundle includes CSS design tokens, typography, and control surfaces.
|
|
1500
|
+
|
|
1501
|
+
```tsx compile:integration-vite-standalone
|
|
1502
|
+
import "@nocoo/basalt/styles/standalone";
|
|
1503
|
+
import React, { useState } from "react";
|
|
1504
|
+
import { Button, Input, LayerCard, ThemeProvider, ThemeToggle } from "@nocoo/basalt";
|
|
1505
|
+
import { PageHeader } from "@nocoo/basalt/components/page-header";
|
|
1506
|
+
|
|
1507
|
+
export function StandaloneViteApp() {
|
|
1508
|
+
const [query, setQuery] = useState("");
|
|
1509
|
+
|
|
1510
|
+
return (
|
|
1511
|
+
<ThemeProvider>
|
|
1512
|
+
<div className="min-h-screen bg-basalt-background text-basalt-foreground p-6 space-y-6">
|
|
1513
|
+
<PageHeader
|
|
1514
|
+
title="Standalone Workspace"
|
|
1515
|
+
description="Rendered with @nocoo/basalt/styles/standalone without Tailwind preflight"
|
|
1516
|
+
actions={<ThemeToggle aria-label="Toggle visual theme" />}
|
|
1517
|
+
/>
|
|
1518
|
+
<LayerCard>
|
|
1519
|
+
<LayerCard.Header>
|
|
1520
|
+
<span className="font-semibold text-basalt-foreground">Project Search</span>
|
|
1521
|
+
</LayerCard.Header>
|
|
1522
|
+
<LayerCard.Body>
|
|
1523
|
+
<div className="flex gap-3 max-w-md">
|
|
1524
|
+
<Input
|
|
1525
|
+
value={query}
|
|
1526
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
1527
|
+
placeholder="Search resources..."
|
|
1528
|
+
aria-label="Search resources"
|
|
1529
|
+
/>
|
|
1530
|
+
<Button variant="default" onClick={() => console.log(query)}>
|
|
1531
|
+
Search
|
|
1532
|
+
</Button>
|
|
1533
|
+
</div>
|
|
1534
|
+
</LayerCard.Body>
|
|
1535
|
+
</LayerCard>
|
|
1536
|
+
</div>
|
|
1537
|
+
</ThemeProvider>
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
```
|
|
1541
|
+
|
|
1542
|
+
### 2. Next.js App Router Client Boundary & SSR Theme Setup
|
|
1543
|
+
|
|
1544
|
+
Basalt controls depend on browser event handling and DOM lifecycle observers. In Next.js App Router applications, mount Basalt components within a dedicated client boundary.
|
|
1545
|
+
|
|
1546
|
+
#### Next.js Root Layout (`app/layout.tsx`) with Global CSS & Pre-Hydration Script
|
|
1547
|
+
|
|
1548
|
+
In Next.js App Router, import your global stylesheet (e.g. `app/globals.css` containing the Tailwind v4 contract or `@nocoo/basalt/styles/standalone`) at the root layout.
|
|
1549
|
+
`ThemeProvider` applies the active theme to `document.documentElement` (`class="dark"|"light"` and `data-mode="dark"|"light"`). By default, it reads and persists to `localStorage` under key `"theme"` with fallback `"system"`. If custom `storageKey` or `defaultTheme` is configured on `ThemeProvider`, the pre-hydration script must use the identical key and fallback. When an external host system or meta-framework manages root theme attributes, configure `persist={false}` and `applyToDocument={false}`.
|
|
1550
|
+
|
|
1551
|
+
To eliminate flash of unstyled content (FOUC) under default persistence, inject a pre-hydration script into the root HTML layout before React hydrates:
|
|
1552
|
+
|
|
1553
|
+
```tsx compile:integration-nextjs-root-layout
|
|
1554
|
+
import type React from "react";
|
|
1555
|
+
import type { ReactNode } from "react";
|
|
1556
|
+
// In your Next.js application, import the global stylesheet:
|
|
1557
|
+
// import "./globals.css";
|
|
1558
|
+
// or with standalone styles:
|
|
1559
|
+
import "@nocoo/basalt/styles/standalone";
|
|
1560
|
+
|
|
1561
|
+
const themeInitScript = `(function(){try{var s=localStorage.getItem("theme");var d=window.matchMedia("(prefers-color-scheme: dark)").matches;var isDark=s==="dark"||(s!=="light"&&d);document.documentElement.classList.toggle("dark",isDark);document.documentElement.classList.toggle("light",!isDark);document.documentElement.dataset.mode=isDark?"dark":"light";}catch(e){}})();`;
|
|
1562
|
+
|
|
1563
|
+
export default function RootLayout({
|
|
1564
|
+
children,
|
|
1565
|
+
}: {
|
|
1566
|
+
children: ReactNode;
|
|
1567
|
+
}) {
|
|
1568
|
+
return (
|
|
1569
|
+
<html lang="en" suppressHydrationWarning>
|
|
1570
|
+
<head>
|
|
1571
|
+
<script
|
|
1572
|
+
dangerouslySetInnerHTML={{
|
|
1573
|
+
__html: themeInitScript,
|
|
1574
|
+
}}
|
|
1575
|
+
/>
|
|
1576
|
+
</head>
|
|
1577
|
+
<body className="min-h-screen bg-basalt-background text-basalt-foreground antialiased">
|
|
1578
|
+
{children}
|
|
1579
|
+
</body>
|
|
1580
|
+
</html>
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
```
|
|
1584
|
+
|
|
1585
|
+
#### Client Page Component (`app/dashboard/page.tsx`)
|
|
1586
|
+
|
|
1587
|
+
In Next.js App Router, mark interactive client components with `"use client"`. Place client UI in a client component or export it as default page export for a route:
|
|
1588
|
+
|
|
1589
|
+
```tsx compile:integration-nextjs-client-boundary
|
|
1590
|
+
"use client";
|
|
1591
|
+
|
|
1592
|
+
import React, { useState } from "react";
|
|
1593
|
+
import { Button, Input, LayerCard, ThemeProvider, ThemeToggle } from "@nocoo/basalt";
|
|
1594
|
+
|
|
1595
|
+
export default function NextClientDashboardPage() {
|
|
1596
|
+
const [metricName, setMetricName] = useState("Daily Active Users");
|
|
1597
|
+
|
|
1598
|
+
return (
|
|
1599
|
+
<ThemeProvider>
|
|
1600
|
+
<div className="p-6 space-y-4">
|
|
1601
|
+
<div className="flex justify-between items-center">
|
|
1602
|
+
<h2 className="text-xl font-bold text-basalt-foreground">Next.js Client Dashboard</h2>
|
|
1603
|
+
<ThemeToggle aria-label="Toggle theme mode" />
|
|
1604
|
+
</div>
|
|
1605
|
+
<LayerCard>
|
|
1606
|
+
<LayerCard.Header>Metric Details</LayerCard.Header>
|
|
1607
|
+
<LayerCard.Body>
|
|
1608
|
+
<Input
|
|
1609
|
+
value={metricName}
|
|
1610
|
+
onChange={(e) => setMetricName(e.target.value)}
|
|
1611
|
+
aria-label="Metric Name"
|
|
1612
|
+
/>
|
|
1613
|
+
<div className="mt-4">
|
|
1614
|
+
<Button variant="outline" onClick={() => setMetricName("Default Metric")}>
|
|
1615
|
+
Reset Metric
|
|
1616
|
+
</Button>
|
|
1617
|
+
</div>
|
|
1618
|
+
</LayerCard.Body>
|
|
1619
|
+
</LayerCard>
|
|
1620
|
+
</div>
|
|
1621
|
+
</ThemeProvider>
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
```
|
|
1625
|
+
|
|
1626
|
+
### 3. Router Navigation Adapter with LinkProvider
|
|
1627
|
+
|
|
1628
|
+
Basalt components that perform client navigation (such as breadcrumbs, sidebars, and custom action links) consume navigation through `LinkProvider`. `LinkProvider` accepts a custom component via the `render` prop:
|
|
1629
|
+
- Prop shape: `render: ComponentType<{ href: string; className?: string; children?: ReactNode }>`
|
|
1630
|
+
- Custom components consume the active link implementation using `useLinkComponent()`, which is exported from `@nocoo/basalt/providers/link` (and re-exported by `@nocoo/basalt/components/link`). It returns either the custom adapter or the fallback `"a"`.
|
|
1631
|
+
|
|
1632
|
+
The following compilable example demonstrates a real router link adapter integrated with `react-router` (`Link`, `Routes`, `Route`, `MemoryRouter`):
|
|
1633
|
+
|
|
1634
|
+
```tsx compile:integration-router-adapter
|
|
1635
|
+
import type { ComponentType, ReactNode } from "react";
|
|
1636
|
+
import React from "react";
|
|
1637
|
+
import { MemoryRouter, Routes, Route, Link as RouterLink, useLocation } from "react-router";
|
|
1638
|
+
import { LinkProvider } from "@nocoo/basalt";
|
|
1639
|
+
import { useLinkComponent } from "@nocoo/basalt/providers/link";
|
|
1640
|
+
|
|
1641
|
+
// Real router adapter mapping Basalt href/className/children to React Router Link
|
|
1642
|
+
export const ReactRouterLinkAdapter: ComponentType<{
|
|
1643
|
+
href: string;
|
|
1644
|
+
className?: string;
|
|
1645
|
+
children?: ReactNode;
|
|
1646
|
+
}> = ({ href, className, children }) => {
|
|
1647
|
+
const isExternal =
|
|
1648
|
+
href.startsWith("http://") ||
|
|
1649
|
+
href.startsWith("https://") ||
|
|
1650
|
+
href.startsWith("//") ||
|
|
1651
|
+
href.startsWith("mailto:") ||
|
|
1652
|
+
href.startsWith("tel:");
|
|
1653
|
+
|
|
1654
|
+
if (isExternal) {
|
|
1655
|
+
return (
|
|
1656
|
+
<a href={href} className={className} rel="noopener noreferrer" target="_blank">
|
|
1657
|
+
{children}
|
|
1658
|
+
</a>
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
return (
|
|
1663
|
+
<RouterLink to={href} className={className}>
|
|
1664
|
+
{children}
|
|
1665
|
+
</RouterLink>
|
|
1666
|
+
);
|
|
1667
|
+
};
|
|
1668
|
+
|
|
1669
|
+
export function RoutedNavigationSection() {
|
|
1670
|
+
return (
|
|
1671
|
+
<MemoryRouter initialEntries={["/dashboard"]}>
|
|
1672
|
+
<LinkProvider render={ReactRouterLinkAdapter}>
|
|
1673
|
+
<nav className="flex gap-4 p-4 border-b border-basalt-border">
|
|
1674
|
+
<NavigationItem href="/dashboard">Dashboard</NavigationItem>
|
|
1675
|
+
<NavigationItem href="/settings">Settings</NavigationItem>
|
|
1676
|
+
<NavigationItem href="https://docs.hexly.ai">External Docs</NavigationItem>
|
|
1677
|
+
</nav>
|
|
1678
|
+
<div className="p-4">
|
|
1679
|
+
<Routes>
|
|
1680
|
+
<Route path="/dashboard" element={<DashboardPage />} />
|
|
1681
|
+
<Route path="/settings" element={<SettingsPage />} />
|
|
1682
|
+
</Routes>
|
|
1683
|
+
</div>
|
|
1684
|
+
</LinkProvider>
|
|
1685
|
+
</MemoryRouter>
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function NavigationItem({ href, children }: { href: string; children: ReactNode }) {
|
|
1690
|
+
const LinkComponent = useLinkComponent();
|
|
1691
|
+
return (
|
|
1692
|
+
<LinkComponent href={href} className="text-sm font-medium hover:underline text-basalt-foreground">
|
|
1693
|
+
{children}
|
|
1694
|
+
</LinkComponent>
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function DashboardPage() {
|
|
1699
|
+
const location = useLocation();
|
|
1700
|
+
return <div data-testid="active-route">Active Location: {location.pathname}</div>;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function SettingsPage() {
|
|
1704
|
+
const location = useLocation();
|
|
1705
|
+
return <div data-testid="active-route">Settings Location: {location.pathname}</div>;
|
|
1706
|
+
}
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
### 4. Native Form Submission and Reset Handling
|
|
1710
|
+
|
|
1711
|
+
For lightweight forms without third-party form libraries, native HTML forms paired with `FormData` provide reliable data collection across Basalt controls:
|
|
1712
|
+
|
|
1713
|
+
```tsx compile:integration-native-form-reset
|
|
1714
|
+
import type React from "react";
|
|
1715
|
+
import { useState } from "react";
|
|
1716
|
+
import { Button, Field, Input, Switch } from "@nocoo/basalt";
|
|
1717
|
+
|
|
1718
|
+
export function UserPreferencesForm() {
|
|
1719
|
+
const [feedback, setFeedback] = useState<string>("");
|
|
1720
|
+
|
|
1721
|
+
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
1722
|
+
event.preventDefault();
|
|
1723
|
+
const data = new FormData(event.currentTarget);
|
|
1724
|
+
const username = String(data.get("username") ?? "");
|
|
1725
|
+
const emailAlerts = data.get("emailAlerts") === "on";
|
|
1726
|
+
setFeedback(`Saved preferences for ${username} (alerts: ${emailAlerts ? "enabled" : "disabled"})`);
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
const handleReset = (event: React.FormEvent<HTMLFormElement>) => {
|
|
1730
|
+
setFeedback("Form reset to default values");
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
return (
|
|
1734
|
+
<form onSubmit={handleSubmit} onReset={handleReset} className="space-y-4 max-w-sm">
|
|
1735
|
+
<Field label="Username" hint="Public profile handle">
|
|
1736
|
+
<Input name="username" defaultValue="johndoe" required />
|
|
1737
|
+
</Field>
|
|
1738
|
+
|
|
1739
|
+
<Field label="Email Alerts" hint="Receive daily system digests">
|
|
1740
|
+
<Switch name="emailAlerts" defaultChecked />
|
|
1741
|
+
</Field>
|
|
1742
|
+
|
|
1743
|
+
{feedback && (
|
|
1744
|
+
<div role="status" className="text-sm text-basalt-muted-foreground">
|
|
1745
|
+
{feedback}
|
|
1746
|
+
</div>
|
|
1747
|
+
)}
|
|
1748
|
+
|
|
1749
|
+
<div className="flex gap-2">
|
|
1750
|
+
<Button type="reset" variant="secondary">
|
|
1751
|
+
Reset
|
|
1752
|
+
</Button>
|
|
1753
|
+
<Button type="submit" variant="default">
|
|
1754
|
+
Save
|
|
1755
|
+
</Button>
|
|
1756
|
+
</div>
|
|
1757
|
+
</form>
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
```
|