altitude-design 0.3.2 → 0.4.1
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 +2 -0
- package/dist/cli/altitude.mjs +120 -20
- package/dist/guidance/README.md +4 -0
- package/dist/guidance/agent-setup.md +3 -2
- package/dist/guidance/components/AILabel/README.md +60 -0
- package/dist/guidance/components/Accordion/README.md +32 -0
- package/dist/guidance/components/AgentFleet/README.md +81 -0
- package/dist/guidance/components/AgentLanes/README.md +53 -0
- package/dist/guidance/components/AgentRequests/README.md +76 -0
- package/dist/guidance/components/Alert/README.md +71 -0
- package/dist/guidance/components/AltitudeProvider/README.md +111 -0
- package/dist/guidance/components/AppShell/README.md +121 -0
- package/dist/guidance/components/AreaChart/README.md +101 -0
- package/dist/guidance/components/Artifact/README.md +99 -0
- package/dist/guidance/components/Avatar/README.md +75 -0
- package/dist/guidance/components/Await/README.md +128 -0
- package/dist/guidance/components/Badge/README.md +66 -0
- package/dist/guidance/components/BarChart/README.md +109 -0
- package/dist/guidance/components/BarList/README.md +87 -0
- package/dist/guidance/components/Board/README.md +98 -0
- package/dist/guidance/components/Breadcrumb/README.md +66 -0
- package/dist/guidance/components/Button/README.md +58 -0
- package/dist/guidance/components/Card/README.md +52 -0
- package/dist/guidance/components/ChartBrush/README.md +98 -0
- package/dist/guidance/components/ChartContainer/README.md +229 -0
- package/dist/guidance/components/ChartLegend/README.md +78 -0
- package/dist/guidance/components/ChartReference/README.md +115 -0
- package/dist/guidance/components/ChartTooltip/README.md +101 -0
- package/dist/guidance/components/Checkbox/README.md +46 -0
- package/dist/guidance/components/Checklist/README.md +137 -0
- package/dist/guidance/components/CodeBlock/README.md +72 -0
- package/dist/guidance/components/ColorPicker/README.md +69 -0
- package/dist/guidance/components/Combobox/README.md +51 -0
- package/dist/guidance/components/CommandPalette/README.md +204 -0
- package/dist/guidance/components/CommentThread/README.md +81 -0
- package/dist/guidance/components/Confirmation/README.md +94 -0
- package/dist/guidance/components/ContextMenu/README.md +81 -0
- package/dist/guidance/components/Conversation/README.md +169 -0
- package/dist/guidance/components/CopyButton/README.md +54 -0
- package/dist/guidance/components/DataGrid/README.md +157 -0
- package/dist/guidance/components/DatePicker/README.md +116 -0
- package/dist/guidance/components/Dialog/README.md +204 -0
- package/dist/guidance/components/DiffView/README.md +107 -0
- package/dist/guidance/components/DotPlot/README.md +82 -0
- package/dist/guidance/components/Drilldown/README.md +43 -0
- package/dist/guidance/components/DropZone/README.md +71 -0
- package/dist/guidance/components/DropdownMenu/README.md +63 -0
- package/dist/guidance/components/EditRegion/README.md +263 -0
- package/dist/guidance/components/EmptyState/README.md +66 -0
- package/dist/guidance/components/FeatureGate/README.md +67 -0
- package/dist/guidance/components/Field/README.md +46 -0
- package/dist/guidance/components/FilterBar/README.md +132 -0
- package/dist/guidance/components/FormattedNumber/README.md +81 -0
- package/dist/guidance/components/Heatmap/README.md +116 -0
- package/dist/guidance/components/HoldButton/README.md +48 -0
- package/dist/guidance/components/HoverCard/README.md +71 -0
- package/dist/guidance/components/InlineCitation/README.md +56 -0
- package/dist/guidance/components/InlineEdit/README.md +379 -0
- package/dist/guidance/components/Input/README.md +63 -0
- package/dist/guidance/components/InputOTP/README.md +76 -0
- package/dist/guidance/components/ItemGroup/README.md +193 -0
- package/dist/guidance/components/Kbd/README.md +36 -0
- package/dist/guidance/components/LineChart/README.md +109 -0
- package/dist/guidance/components/Link/README.md +69 -0
- package/dist/guidance/components/Markdown/README.md +113 -0
- package/dist/guidance/components/Meter/README.md +81 -0
- package/dist/guidance/components/NotificationCenter/README.md +67 -0
- package/dist/guidance/components/NumberInput/README.md +66 -0
- package/dist/guidance/components/PageHeader/README.md +74 -0
- package/dist/guidance/components/Pagination/README.md +58 -0
- package/dist/guidance/components/PasswordInput/README.md +70 -0
- package/dist/guidance/components/PermissionMatrix/README.md +68 -0
- package/dist/guidance/components/Pile/README.md +54 -0
- package/dist/guidance/components/PlanPicker/README.md +62 -0
- package/dist/guidance/components/Popover/README.md +48 -0
- package/dist/guidance/components/Progress/README.md +41 -0
- package/dist/guidance/components/PromptInput/README.md +138 -0
- package/dist/guidance/components/PropertyList/README.md +137 -0
- package/dist/guidance/components/Question/README.md +81 -0
- package/dist/guidance/components/RadioGroup/README.md +47 -0
- package/dist/guidance/components/RelativeTime/README.md +73 -0
- package/dist/guidance/components/RemovalReceipt/README.md +111 -0
- package/dist/guidance/components/Resizable/README.md +65 -0
- package/dist/guidance/components/SaveBar/README.md +65 -0
- package/dist/guidance/components/SavedViews/README.md +82 -0
- package/dist/guidance/components/ScatterChart/README.md +104 -0
- package/dist/guidance/components/Schedule/README.md +142 -0
- package/dist/guidance/components/ScrollArea/README.md +35 -0
- package/dist/guidance/components/SecretField/README.md +74 -0
- package/dist/guidance/components/Select/README.md +97 -0
- package/dist/guidance/components/Separator/README.md +70 -0
- package/dist/guidance/components/Sheet/README.md +126 -0
- package/dist/guidance/components/Skeleton/README.md +50 -0
- package/dist/guidance/components/Slider/README.md +60 -0
- package/dist/guidance/components/SortableList/README.md +45 -0
- package/dist/guidance/components/Sparkline/README.md +87 -0
- package/dist/guidance/components/SplitButton/README.md +65 -0
- package/dist/guidance/components/Stat/README.md +92 -0
- package/dist/guidance/components/Stepper/README.md +90 -0
- package/dist/guidance/components/Suggestions/README.md +87 -0
- package/dist/guidance/components/Surface/README.md +48 -0
- package/dist/guidance/components/Switch/README.md +38 -0
- package/dist/guidance/components/SyncIndicator/README.md +78 -0
- package/dist/guidance/components/Table/README.md +138 -0
- package/dist/guidance/components/Tabs/README.md +73 -0
- package/dist/guidance/components/TagInput/README.md +116 -0
- package/dist/guidance/components/Terminal/README.md +69 -0
- package/dist/guidance/components/ThreadList/README.md +110 -0
- package/dist/guidance/components/TimeInput/README.md +63 -0
- package/dist/guidance/components/Timeline/README.md +87 -0
- package/dist/guidance/components/Toaster/README.md +54 -0
- package/dist/guidance/components/Toggle/README.md +85 -0
- package/dist/guidance/components/Toolbar/README.md +106 -0
- package/dist/guidance/components/Tooltip/README.md +41 -0
- package/dist/guidance/components/Tour/README.md +73 -0
- package/dist/guidance/components/Trace/README.md +129 -0
- package/dist/guidance/components/Tree/README.md +171 -0
- package/dist/guidance/evidence.json +18 -0
- package/dist/guidance/index.json +811 -7
- package/dist/guidance/llms.txt +153 -2
- package/dist/guidance/manifest.json +133 -12
- package/dist/guidance/patterns/screen-layout.md +1 -1
- package/dist/guidance/references/build/checks/agent-setup.mjs +7 -0
- package/dist/guidance/references/build/checks/guidance.mjs +8 -2
- package/dist/guidance/references/build/checks/source-check.mjs +101 -0
- package/dist/guidance/rules.json +155 -0
- package/dist/guidance/visual/ActionsInPlace.md +463 -0
- package/dist/guidance/visual/Charts.md +119 -0
- package/dist/guidance/visual/Distinctiveness.md +106 -0
- package/dist/guidance/visual/Elevation.md +1 -1
- package/dist/guidance/visual/Mobile.md +64 -0
- package/dist/guidance/visual/Motion.md +225 -0
- package/dist/guidance/visual/README.md +98 -0
- package/dist/guidance/visual/Research.md +1 -1
- package/dist/guidance/visual/Theming.md +349 -0
- package/dist/types/agent-fleet.d.ts +28 -0
- package/dist/types/ai-label.d.ts +7 -0
- package/dist/types/app-shell.d.ts +23 -0
- package/dist/types/area-chart.d.ts +8 -0
- package/dist/types/artifact.d.ts +14 -0
- package/dist/types/bar-chart.d.ts +6 -0
- package/dist/types/bar-list.d.ts +6 -0
- package/dist/types/breadcrumb.d.ts +6 -0
- package/dist/types/chart-brush.d.ts +6 -0
- package/dist/types/chart.d.ts +29 -1
- package/dist/types/checklist.d.ts +6 -0
- package/dist/types/code-block.d.ts +6 -0
- package/dist/types/collections.d.ts +15 -0
- package/dist/types/color-picker.d.ts +6 -0
- package/dist/types/command.d.ts +13 -0
- package/dist/types/comment-thread.d.ts +11 -0
- package/dist/types/confirmation.d.ts +8 -0
- package/dist/types/context-menu.d.ts +11 -0
- package/dist/types/controls.d.ts +174 -10
- package/dist/types/conversation.d.ts +21 -0
- package/dist/types/copy-button.d.ts +6 -0
- package/dist/types/core.d.ts +31 -0
- package/dist/types/data-grid.d.ts +25 -0
- package/dist/types/date-picker.d.ts +6 -0
- package/dist/types/diff-view.d.ts +8 -0
- package/dist/types/dot-plot.d.ts +6 -0
- package/dist/types/edit-region.d.ts +23 -0
- package/dist/types/empty.d.ts +9 -0
- package/dist/types/feature-gate.d.ts +8 -0
- package/dist/types/filter-bar.d.ts +8 -0
- package/dist/types/formatted-number.d.ts +6 -0
- package/dist/types/heatmap.d.ts +6 -0
- package/dist/types/inline-citation.d.ts +6 -0
- package/dist/types/inline-edit.d.ts +7 -0
- package/dist/types/input-otp.d.ts +11 -0
- package/dist/types/item-group.d.ts +29 -0
- package/dist/types/line-chart.d.ts +6 -0
- package/dist/types/link.d.ts +8 -1
- package/dist/types/markdown.d.ts +6 -0
- package/dist/types/meter.d.ts +6 -0
- package/dist/types/nav.d.ts +6 -0
- package/dist/types/notifications.d.ts +6 -0
- package/dist/types/number-input.d.ts +6 -0
- package/dist/types/overlays.d.ts +61 -0
- package/dist/types/page-header.d.ts +14 -0
- package/dist/types/pagination.d.ts +6 -0
- package/dist/types/password-input.d.ts +6 -0
- package/dist/types/permission-matrix.d.ts +7 -0
- package/dist/types/pile.d.ts +6 -0
- package/dist/types/plan-picker.d.ts +6 -0
- package/dist/types/prompt-input.d.ts +11 -0
- package/dist/types/property-list.d.ts +17 -0
- package/dist/types/question.d.ts +11 -0
- package/dist/types/relative-time.d.ts +8 -1
- package/dist/types/removal-receipt.d.ts +18 -1
- package/dist/types/save-bar.d.ts +6 -0
- package/dist/types/saved-views.d.ts +12 -0
- package/dist/types/scatter-chart.d.ts +8 -0
- package/dist/types/schedule.d.ts +21 -0
- package/dist/types/secret-field.d.ts +6 -0
- package/dist/types/select.d.ts +12 -0
- package/dist/types/separator.d.ts +8 -1
- package/dist/types/sheet.d.ts +19 -0
- package/dist/types/sparkline.d.ts +6 -0
- package/dist/types/split-button.d.ts +7 -0
- package/dist/types/stat.d.ts +7 -0
- package/dist/types/stepper.d.ts +12 -0
- package/dist/types/suggestions.d.ts +10 -0
- package/dist/types/sync-indicator.d.ts +8 -0
- package/dist/types/tag-input.d.ts +8 -0
- package/dist/types/terminal.d.ts +6 -0
- package/dist/types/thread-list.d.ts +16 -0
- package/dist/types/time-input.d.ts +6 -0
- package/dist/types/timeline.d.ts +9 -0
- package/dist/types/toggle.d.ts +6 -0
- package/dist/types/toolbar.d.ts +20 -0
- package/dist/types/tour.d.ts +10 -0
- package/dist/types/trace.d.ts +16 -0
- package/dist/types/tree.d.ts +21 -0
- package/dist/types-cjs/agent-fleet.d.cts +28 -0
- package/dist/types-cjs/ai-label.d.cts +7 -0
- package/dist/types-cjs/app-shell.d.cts +23 -0
- package/dist/types-cjs/area-chart.d.cts +8 -0
- package/dist/types-cjs/artifact.d.cts +14 -0
- package/dist/types-cjs/bar-chart.d.cts +6 -0
- package/dist/types-cjs/bar-list.d.cts +6 -0
- package/dist/types-cjs/breadcrumb.d.cts +6 -0
- package/dist/types-cjs/chart-brush.d.cts +6 -0
- package/dist/types-cjs/chart.d.cts +29 -1
- package/dist/types-cjs/checklist.d.cts +6 -0
- package/dist/types-cjs/code-block.d.cts +6 -0
- package/dist/types-cjs/collections.d.cts +15 -0
- package/dist/types-cjs/color-picker.d.cts +6 -0
- package/dist/types-cjs/command.d.cts +13 -0
- package/dist/types-cjs/comment-thread.d.cts +11 -0
- package/dist/types-cjs/confirmation.d.cts +8 -0
- package/dist/types-cjs/context-menu.d.cts +11 -0
- package/dist/types-cjs/controls.d.cts +174 -10
- package/dist/types-cjs/conversation.d.cts +21 -0
- package/dist/types-cjs/copy-button.d.cts +6 -0
- package/dist/types-cjs/core.d.cts +31 -0
- package/dist/types-cjs/data-grid.d.cts +25 -0
- package/dist/types-cjs/date-picker.d.cts +6 -0
- package/dist/types-cjs/diff-view.d.cts +8 -0
- package/dist/types-cjs/dot-plot.d.cts +6 -0
- package/dist/types-cjs/edit-region.d.cts +23 -0
- package/dist/types-cjs/empty.d.cts +9 -0
- package/dist/types-cjs/feature-gate.d.cts +8 -0
- package/dist/types-cjs/filter-bar.d.cts +8 -0
- package/dist/types-cjs/formatted-number.d.cts +6 -0
- package/dist/types-cjs/heatmap.d.cts +6 -0
- package/dist/types-cjs/inline-citation.d.cts +6 -0
- package/dist/types-cjs/inline-edit.d.cts +7 -0
- package/dist/types-cjs/input-otp.d.cts +11 -0
- package/dist/types-cjs/item-group.d.cts +29 -0
- package/dist/types-cjs/line-chart.d.cts +6 -0
- package/dist/types-cjs/link.d.cts +8 -1
- package/dist/types-cjs/markdown.d.cts +6 -0
- package/dist/types-cjs/meter.d.cts +6 -0
- package/dist/types-cjs/nav.d.cts +6 -0
- package/dist/types-cjs/notifications.d.cts +6 -0
- package/dist/types-cjs/number-input.d.cts +6 -0
- package/dist/types-cjs/overlays.d.cts +61 -0
- package/dist/types-cjs/page-header.d.cts +14 -0
- package/dist/types-cjs/pagination.d.cts +6 -0
- package/dist/types-cjs/password-input.d.cts +6 -0
- package/dist/types-cjs/permission-matrix.d.cts +7 -0
- package/dist/types-cjs/pile.d.cts +6 -0
- package/dist/types-cjs/plan-picker.d.cts +6 -0
- package/dist/types-cjs/prompt-input.d.cts +11 -0
- package/dist/types-cjs/property-list.d.cts +17 -0
- package/dist/types-cjs/question.d.cts +11 -0
- package/dist/types-cjs/relative-time.d.cts +8 -1
- package/dist/types-cjs/removal-receipt.d.cts +18 -1
- package/dist/types-cjs/save-bar.d.cts +6 -0
- package/dist/types-cjs/saved-views.d.cts +12 -0
- package/dist/types-cjs/scatter-chart.d.cts +8 -0
- package/dist/types-cjs/schedule.d.cts +21 -0
- package/dist/types-cjs/secret-field.d.cts +6 -0
- package/dist/types-cjs/select.d.cts +12 -0
- package/dist/types-cjs/separator.d.cts +8 -1
- package/dist/types-cjs/sheet.d.cts +19 -0
- package/dist/types-cjs/sparkline.d.cts +6 -0
- package/dist/types-cjs/split-button.d.cts +7 -0
- package/dist/types-cjs/stat.d.cts +7 -0
- package/dist/types-cjs/stepper.d.cts +12 -0
- package/dist/types-cjs/suggestions.d.cts +10 -0
- package/dist/types-cjs/sync-indicator.d.cts +8 -0
- package/dist/types-cjs/tag-input.d.cts +8 -0
- package/dist/types-cjs/terminal.d.cts +6 -0
- package/dist/types-cjs/thread-list.d.cts +16 -0
- package/dist/types-cjs/time-input.d.cts +6 -0
- package/dist/types-cjs/timeline.d.cts +9 -0
- package/dist/types-cjs/toggle.d.cts +6 -0
- package/dist/types-cjs/toolbar.d.cts +20 -0
- package/dist/types-cjs/tour.d.cts +10 -0
- package/dist/types-cjs/trace.d.cts +16 -0
- package/dist/types-cjs/tree.d.cts +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# CopyButton
|
|
2
|
+
|
|
3
|
+
Copies a value and says so: API keys, install commands, invite links, IDs, webhook URLs.
|
|
4
|
+
|
|
5
|
+
**Elevation.** It's a `Button` (ghost by default, or `secondary`), so it's live: it lifts one level when pointed at. **Copying is a press that confirms in words.** Once copied it stays pressed to the ground for `dur-linger` (2s), its icon cross-fades from copy to a check, and its words change to "Copied". Then it springs back. There's no rise and no afterglow, because nothing new arrived on the page. All its words share one cell, so saying "Copied" never changes its width.
|
|
6
|
+
|
|
7
|
+
**When it fails.** The Clipboard API needs a secure context and permission. Where it isn't available, CopyButton falls back to selecting a hidden copy of the text and running the copy command. If that fails too, the button says "Couldn't copy" with an alert icon, and "Couldn't copy. Select the text and press ⌘C." (Ctrl+C off a Mac) is announced. `onError` gets the error.
|
|
8
|
+
|
|
9
|
+
**Accessibility.** It's a plain [button](https://www.w3.org/WAI/ARIA/apg/patterns/button/): Enter or Space copies. The button keeps its name while it says "Copied": a polite status region announces the result instead, so focus never lands on a button whose name just changed. An icon-only CopyButton (no children) is square, named by `label`, and shows `label` in a `Tooltip`. Under forced colours a hovered button gets a 1px border.
|
|
10
|
+
|
|
11
|
+
**Props:** `value` (a string, or a function returning one or a promise of one); `label` (default "Copy"); `copiedLabel` (default "Copied"); `failedLabel` (default "Couldn't copy"); `onCopy(value)`; `onError(error)`; every `Button` prop except `square`, `loading` and `leading`.
|
|
12
|
+
|
|
13
|
+
**You provide:** the value, and a `label` that names what's copied ("Copy API key") when the button is icon-only.
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
<CopyButton value={apiKey} label="Copy API key" />
|
|
17
|
+
<CopyButton value={() => buildInviteUrl()} variant="secondary">Copy invite link</CopyButton>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- Do name what's copied ("Copy API key"), not just "Copy", when there are several on a page.
|
|
21
|
+
- Do put it next to the value it copies, so people can see what they got.
|
|
22
|
+
- Don't confirm a copy with a toast; the button says it where you're looking.
|
|
23
|
+
- Don't make a primary CopyButton: copying is rarely the main thing on a page.
|
|
24
|
+
|
|
25
|
+
<!-- example: generated from build/previews.txt by build/previews.mjs. Edit the preview, not this section. -->
|
|
26
|
+
## Example
|
|
27
|
+
|
|
28
|
+
`CopyButton` as first used in its preview:
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { CopyButton } from 'altitude-design';
|
|
32
|
+
|
|
33
|
+
<CopyButton size="sm" value="sk_live_51Hc8x2Lw9Qe7TfR4mNpZa" label="Copy API key" />
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The full preview, as rendered in Storybook and checked in CI (`cb`, `key`, `row`, `cap` are the preview page's own layout classes, not Altitude's):
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { CopyButton } from 'altitude-design';
|
|
40
|
+
|
|
41
|
+
export default function App() {
|
|
42
|
+
return (
|
|
43
|
+
<div className="cb">
|
|
44
|
+
<div className="key"><code>sk_live_51Hc8x2Lw9Qe7TfR4mNpZa</code><CopyButton size="sm" value="sk_live_51Hc8x2Lw9Qe7TfR4mNpZa" label="Copy API key" /></div>
|
|
45
|
+
<div className="row">
|
|
46
|
+
<CopyButton variant="secondary" value={() => 'https://app.example.com/invite/7Fq2'}>Copy invite link</CopyButton>
|
|
47
|
+
<CopyButton value={() => Promise.reject(new Error('denied'))}>Copy install command</CopyButton>
|
|
48
|
+
</div>
|
|
49
|
+
<p className="cap">Copied: pressed to the ground with a check and "Copied" for two seconds, announced politely; the button keeps its name and its width. The last one fails on purpose, to show what a failure says.</p>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
<!-- /example -->
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# DataGrid
|
|
2
|
+
|
|
3
|
+
A table whose cells you edit one by one, with spreadsheet keys: arrows to move, type to replace, Enter or F2 to edit, Enter and Tab to save and move on, Escape to back out, a block pasted from a spreadsheet. For bulk entry and correction (prices, stock, schedules, mappings), where opening a form per row is the slowest path.
|
|
4
|
+
|
|
5
|
+
**Use `DataTable` instead** when cells aren't edited one by one: its rows are records you select, sort, filter and open, and it's a table, not a grid. DataGrid changes the semantics (`role="grid"`, one Tab stop, roving cell focus), so it's a sibling, not a mode.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<DataGrid<Product>
|
|
9
|
+
aria-label="Inventory"
|
|
10
|
+
rows={products}
|
|
11
|
+
getRowId={(p) => p.id}
|
|
12
|
+
getRowLabel={(p) => p.name}
|
|
13
|
+
columns={[
|
|
14
|
+
{ key: 'name', header: 'Name', editable: true, validate: (v) => v.trim() ? undefined : 'Enter a name.' },
|
|
15
|
+
{ key: 'sku', header: 'SKU' }, // read-only
|
|
16
|
+
{ key: 'stock', header: 'Stock', align: 'end', editable: true, parse: Number, validate: (n) => n >= 0 ? undefined : 'Use 0 or more.' },
|
|
17
|
+
{ key: 'status', header: 'Status', editable: true, cell: (p) => <Badge>{p.status}</Badge>,
|
|
18
|
+
editor: (control) => <Select {...control} items={statuses} defaultOpen /> },
|
|
19
|
+
]}
|
|
20
|
+
onCellSave={async ({ rowId, key, draft }) => { const r = await api.patch(rowId, { [key]: draft }); if (r.ok) setProducts(r.all); return r; }}
|
|
21
|
+
onCellsSave={async (changes) => { const r = await api.batchPatch(changes); if (r.ok) setProducts(r.all); return r; }}
|
|
22
|
+
onUndo={(last) => revert(last)}
|
|
23
|
+
/>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Writes follow the `InlineEdit` contract: resolve `{ ok: true }` after `rows` carries the canonical value, or `{ ok: false, error }`. `onCellsSave` takes a paste or a cleared range as one batch; without it, each cell goes through `onCellSave`. `onUndo` receives the last committed change (or batch); there's no client-side snapshot.
|
|
27
|
+
|
|
28
|
+
Data that arrived from elsewhere (an import, a sync) may already break a column's `validate`. `showInvalid` checks every cell at rest: an invalid value lies in a trench on critical-soft with an alert mark, and its problem is the cell's description, so the work left is visible at a glance and each cell says what's wrong. Fixing the value through `onCellSave` lifts it out.
|
|
29
|
+
|
|
30
|
+
## Elevation
|
|
31
|
+
|
|
32
|
+
- **Cells** are the record, flush in Table's rows. The **active cell** is a 2px `accent-ink` ring, not a height; a **range** is an `accent-soft` tint with `aria-selected`, because choosing a target isn't live content.
|
|
33
|
+
- **Editable columns** carry a pencil in the header and “editable” in its name. Cells don't lift on hover: a click selects; Enter, F2, typing or a double-click edits.
|
|
34
|
+
- **The cell being edited** opens into a well clipped to the cell, with the held rim. An **invalid** value deepens to a trench on `critical-soft`, with its message beside the cell (`role="alert"`, wired by `aria-describedby`).
|
|
35
|
+
- **Saving** shows a small raised plate in the cell after `dur-grace`, with `aria-busy`. A **failed** cell drops to a trench with a critical mark, keeps its old value, and reopening it restores the rejected draft. A **landed** value settles; a pasted range settles as one change.
|
|
36
|
+
- **A custom editor** (a Select) floats its list at level 3 from inside the cell, and commits on choosing.
|
|
37
|
+
|
|
38
|
+
Forced colours: the active cell Highlight outline, the range Highlight, the editing cell dashed (thicker when invalid), pending dotted, failed dashed. `contrast="more"`: the editing well gets the solid inner top edge.
|
|
39
|
+
|
|
40
|
+
## Keyboard
|
|
41
|
+
|
|
42
|
+
The APG grid, with spreadsheet editing:
|
|
43
|
+
|
|
44
|
+
| Mode | Key | |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Navigation | arrows | one cell (mirrored in RTL); Shift extends a range |
|
|
47
|
+
| | Home / End, Ctrl+Home / Ctrl+End | row ends; grid corners |
|
|
48
|
+
| | PageUp / PageDown | ten rows |
|
|
49
|
+
| | Enter, F2, or a character | edit (a character replaces the value) |
|
|
50
|
+
| | Delete / Backspace | clear the cell or range, as one change that can be refused |
|
|
51
|
+
| | Ctrl/Cmd+C, Ctrl/Cmd+V | copy the range as tab-separated text; paste a block from the active cell |
|
|
52
|
+
| | Ctrl/Cmd+Z | `onUndo` with the last change |
|
|
53
|
+
| Editing | Enter / Shift+Enter | save and move down / up |
|
|
54
|
+
| | Tab / Shift+Tab | save and move right / left |
|
|
55
|
+
| | Escape | discard the draft |
|
|
56
|
+
|
|
57
|
+
Clicking another cell saves the open one and moves there, as in a spreadsheet; moving focus out of the grid keeps the draft open and marks it “Not saved yet”, rather than saving or discarding it. Nothing acts during IME composition. Announcements are batched: “Saved”, “4 cells saved”, “1 cell couldn't be saved: Price must be at least 0”, “3 by 4 cells selected”.
|
|
58
|
+
|
|
59
|
+
## Props
|
|
60
|
+
|
|
61
|
+
| Prop | |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `rows`, `getRowId`, `getRowLabel` | the data; the row label names each cell's editor (“Price, Desk lamp”) |
|
|
64
|
+
| `columns` | `{ key, header, label?, align?, editable?, parse?, format?, validate?, cell?, editor?, width? }` |
|
|
65
|
+
| `onCellSave`, `onCellsSave`, `onUndo` | the writes and their reversal |
|
|
66
|
+
| `maxHeight`, `aria-label`, `aria-labelledby` | |
|
|
67
|
+
|
|
68
|
+
## You provide
|
|
69
|
+
|
|
70
|
+
The rows, the writes and their concurrency guards, and undo. Virtualisation, a fill handle and row creation aren't covered yet.
|
|
71
|
+
|
|
72
|
+
## Do and don't
|
|
73
|
+
|
|
74
|
+
- **Do** validate in the column, so a bad value never leaves the cell.
|
|
75
|
+
- **Do** give every row a label people would say, for the cell names.
|
|
76
|
+
- **Don't** use it for records people mostly read, select or open: that's `DataTable`.
|
|
77
|
+
|
|
78
|
+
## Demo and verification
|
|
79
|
+
|
|
80
|
+
The `DataGrid` section of `build/previews.txt` is an inventory grid: the fictional service locks the Desk lamp's price, prices must be 0 or more, and Status uses a Select. `node checks/data-grid.mjs` (from `build/`; `ALT_FIXTURE_ROOT` selects React 19 fixtures) covers the grid keys, type-to-replace, Enter, Tab, F2, Escape, validation, IME Enter, click-to-commit, a refused save and its retry draft, read-only cells, ranges, copy, clearing a range, undo, a paste with one invalid cell, the Select column, and 320/960px layouts in both schemes under reduced motion, forced colours and `contrast="more"`.
|
|
81
|
+
**On a phone.** Under 480px of the frame's width, columns keep at least 6rem (or their `minWidth`) and the table scrolls inside its frame, fading the edge with more columns, instead of squeezing cells. `sticky: true` keeps a column in view over a flush 1px edge. Wide, nothing changes.
|
|
82
|
+
|
|
83
|
+
<!-- example: generated from build/previews.txt by build/previews.mjs. Edit the preview, not this section. -->
|
|
84
|
+
## Example
|
|
85
|
+
|
|
86
|
+
`DataGrid` as first used in its preview:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { Badge, DataGrid, Select } from 'altitude-design';
|
|
90
|
+
|
|
91
|
+
<DataGrid aria-label="Inventory" rows={rows} getRowId={r => r.id} getRowLabel={r => r.name}
|
|
92
|
+
columns={[
|
|
93
|
+
{ key: 'name', header: 'Name', editable: true, validate: v => String(v).trim() ? undefined : 'Enter a name.' },
|
|
94
|
+
{ key: 'sku', header: 'SKU' },
|
|
95
|
+
{ key: 'price', header: 'Price', align: 'end', editable: true, parse: t => t.trim() === '' ? '' : Number(t), format: dgMoney,
|
|
96
|
+
validate: v => v === '' || !Number.isFinite(v) || v < 0 ? 'Use a price of 0 or more.' : undefined, cell: r => `$${r.price}` },
|
|
97
|
+
{ key: 'stock', header: 'Stock', align: 'end', editable: true, parse: t => t.trim() === '' ? 0 : Number(t), format: dgMoney,
|
|
98
|
+
validate: v => Number.isInteger(v) && v >= 0 ? undefined : 'Use a whole number, 0 or more.' },
|
|
99
|
+
{ key: 'status', header: 'Status', editable: true, format: v => DG_STATUS.find(s => s.value === v)?.label ?? '',
|
|
100
|
+
cell: r => <Badge tone={r.status === 'active' ? 'positive' : r.status === 'paused' ? 'caution' : 'neutral'}>{DG_STATUS.find(s => s.value === r.status).label}</Badge>,
|
|
101
|
+
editor: control => <Select {...control} items={DG_STATUS} size="sm" defaultOpen /> },
|
|
102
|
+
]}
|
|
103
|
+
onCellSave={async change => {
|
|
104
|
+
await dgWait(250);
|
|
105
|
+
// The demo service locks the lamp's price during a sale.
|
|
106
|
+
if (change.rowId === 'p1' && change.key === 'price') return { ok: false, error: 'The Desk lamp price is locked during the sale.' };
|
|
107
|
+
apply([change]); return { ok: true };
|
|
108
|
+
}}
|
|
109
|
+
onCellsSave={async changes => { await dgWait(300); apply(changes); return { ok: true }; }}
|
|
110
|
+
onUndo={last => apply(last.map(c => ({ ...c, draft: c.previousValue })))} />
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The full preview, as rendered in Storybook and checked in CI (`dg-demo` is the preview page's own layout class, not Altitude's):
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import * as React from 'react';
|
|
117
|
+
import { Badge, DataGrid, Select } from 'altitude-design';
|
|
118
|
+
|
|
119
|
+
const DG_STATUS = [{ value: 'active', label: 'Active' }, { value: 'paused', label: 'Paused' }, { value: 'retired', label: 'Retired' }];
|
|
120
|
+
const DG_SEED = [
|
|
121
|
+
{ id: 'p1', name: 'Desk lamp', sku: 'LMP-204', price: 49, stock: 120, status: 'active' },
|
|
122
|
+
{ id: 'p2', name: 'Monitor arm', sku: 'ARM-310', price: 89, stock: 34, status: 'active' },
|
|
123
|
+
{ id: 'p3', name: 'Cable tray', sku: 'TRY-118', price: 24, stock: 0, status: 'paused' },
|
|
124
|
+
{ id: 'p4', name: 'Footrest', sku: 'FTR-052', price: 39, stock: 58, status: 'active' },
|
|
125
|
+
{ id: 'p5', name: 'Laptop stand', sku: 'STD-077', price: 59, stock: 12, status: 'retired' },
|
|
126
|
+
];
|
|
127
|
+
const dgWait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
128
|
+
const dgMoney = v => v === '' || v === null || v === undefined ? '' : String(v);
|
|
129
|
+
export default function App() {
|
|
130
|
+
const [rows, setRows] = React.useState(DG_SEED);
|
|
131
|
+
const apply = changes => setRows(list => list.map(row => changes.reduce((r, c) => c.rowId === r.id ? { ...r, [c.key]: c.draft } : r, row)));
|
|
132
|
+
return <div className="dg-demo">
|
|
133
|
+
<DataGrid aria-label="Inventory" rows={rows} getRowId={r => r.id} getRowLabel={r => r.name}
|
|
134
|
+
columns={[
|
|
135
|
+
{ key: 'name', header: 'Name', editable: true, validate: v => String(v).trim() ? undefined : 'Enter a name.' },
|
|
136
|
+
{ key: 'sku', header: 'SKU' },
|
|
137
|
+
{ key: 'price', header: 'Price', align: 'end', editable: true, parse: t => t.trim() === '' ? '' : Number(t), format: dgMoney,
|
|
138
|
+
validate: v => v === '' || !Number.isFinite(v) || v < 0 ? 'Use a price of 0 or more.' : undefined, cell: r => `$${r.price}` },
|
|
139
|
+
{ key: 'stock', header: 'Stock', align: 'end', editable: true, parse: t => t.trim() === '' ? 0 : Number(t), format: dgMoney,
|
|
140
|
+
validate: v => Number.isInteger(v) && v >= 0 ? undefined : 'Use a whole number, 0 or more.' },
|
|
141
|
+
{ key: 'status', header: 'Status', editable: true, format: v => DG_STATUS.find(s => s.value === v)?.label ?? '',
|
|
142
|
+
cell: r => <Badge tone={r.status === 'active' ? 'positive' : r.status === 'paused' ? 'caution' : 'neutral'}>{DG_STATUS.find(s => s.value === r.status).label}</Badge>,
|
|
143
|
+
editor: control => <Select {...control} items={DG_STATUS} size="sm" defaultOpen /> },
|
|
144
|
+
]}
|
|
145
|
+
onCellSave={async change => {
|
|
146
|
+
await dgWait(250);
|
|
147
|
+
// The demo service locks the lamp's price during a sale.
|
|
148
|
+
if (change.rowId === 'p1' && change.key === 'price') return { ok: false, error: 'The Desk lamp price is locked during the sale.' };
|
|
149
|
+
apply([change]); return { ok: true };
|
|
150
|
+
}}
|
|
151
|
+
onCellsSave={async changes => { await dgWait(300); apply(changes); return { ok: true }; }}
|
|
152
|
+
onUndo={last => apply(last.map(c => ({ ...c, draft: c.previousValue })))} />
|
|
153
|
+
<p>Arrows move, type to replace, Enter or F2 to edit, Tab and Enter save and move. Shift+arrows select; copy and paste a block from a spreadsheet. Ctrl+Z undoes.</p>
|
|
154
|
+
</div>;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
<!-- /example -->
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# DatePicker
|
|
2
|
+
|
|
3
|
+
Choose a date in a field, by typing it or by picking it from a month calendar. `DateRangePicker` chooses a span of two dates; `Calendar` is the month grid laid on the page on its own.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<Field label="Due date">
|
|
7
|
+
<DatePicker value={due} onValueChange={setDue} min={today} isUnavailable={(d) => holidays.has(d)} />
|
|
8
|
+
</Field>
|
|
9
|
+
|
|
10
|
+
<Field label="Trip"><DateRangePicker value={{ start, end }} onValueChange={setTrip} /></Field>
|
|
11
|
+
<DateRangePicker value={range} onValueChange={setRange} presets={[datePresets.today(), datePresets.lastDays(7), datePresets.lastDays(30), datePresets.thisQuarter()]} defaultPreset="last-30-days" />
|
|
12
|
+
|
|
13
|
+
<Calendar value={day} onValueChange={setDay} aria-label="Delivery day" />
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Values are ISO calendar dates, `'YYYY-MM-DD'` strings: no time, no time zone, serialisable, and comparable with `<`. That keeps the bundle free of a date library and avoids `Date`'s time-zone traps (midnight UTC is the previous evening in the Americas). Convert at your edges.
|
|
17
|
+
|
|
18
|
+
## Elevation
|
|
19
|
+
|
|
20
|
+
- **The field** is an opening built like `Select`: day, month and year are typed segments in the locale's order. A partial date isn't a value (as a Combobox query isn't), so the floor stays a well; it rises flush only once the field holds a whole, real, allowed date. An impossible or disallowed date drops the field to trench depth with a message (“Enter a real date.”, “Enter a date on or after 26 Oct 2026.”).
|
|
21
|
+
- **The calendar** floats at level 3 (`surface-floating`, `elev-3`) and grows from the field's edge, as a `Popover`.
|
|
22
|
+
- **Days** are flush (ground). At this density a trench per day would be noise, so the RadioGroup's rule is kept by its other half: the chosen day carries the one raised accent mark (`accent` with the sheen, `elev-1`), and choosing another day in the same month carries the mark there (`ease-settle`, `dur-travel-2`). Hovering a day shows a faint ghost of the mark: feedforward.
|
|
23
|
+
- **Today** has an `accent-ink` dot and says “today” in its name. **Unavailable** days are `ink-faint` and say “unavailable”.
|
|
24
|
+
- **A span** (DateRangePicker) is the Slider's build: a sunk `accent-soft` track across the days, between two raised ends. While the end is being chosen, the track previews from the start to the day under the pointer or focus.
|
|
25
|
+
- **Presets** (DateRangePicker `presets`) are a column of rows beside the calendar in the same level-3 panel (above it on narrow screens), built like `Select`'s options: one gliding `accent-soft` highlight, and the chosen row wears the tick at its end. A chosen preset fills the field with its name in `ink` and its dates in `ink-muted` (“Last 30 days · 28 Sept – 27 Oct”), settling with the same brief afterglow as a Select value.
|
|
26
|
+
- **A month change** is lateral, not a change of level, so the grid cross-fades (`dur-travel-1`).
|
|
27
|
+
- **`Calendar`** lies on the page at level 0, with no panel.
|
|
28
|
+
|
|
29
|
+
Without height: segments are spinbuttons with values; chosen days are `aria-selected` with “start date”/“end date” in a range; the field is `aria-invalid` with its message wired by `aria-describedby`, along with the format (“Format: DD/MM/YYYY”). Forced colours: an empty field is dashed, the panel has a 2px border, the chosen day is Highlight, the span is dashed and today is underlined. `contrast="more"`: the chosen mark gets a 2px ink slab.
|
|
30
|
+
|
|
31
|
+
## Keyboard
|
|
32
|
+
|
|
33
|
+
The APG date picker dialog, with React Aria's segmented field:
|
|
34
|
+
|
|
35
|
+
- **Field**: Tab moves between segments; digits fill a segment and move on once no further digit could fit; ↑/↓ step it; Backspace clears it; ←/→ move between segments (mirrored in RTL); a separator key (`/`, `.`, `-`) moves on. Alt+↓ opens the calendar. The calendar button is “Choose date”.
|
|
36
|
+
- **Grid**: ←/→ a day (mirrored in RTL), ↑/↓ a week, Home/End the week's start and end (by the locale's first day), PageUp/PageDown a month, Shift+PageUp/PageDown a year; Enter or Space chooses, closes and returns focus to the calendar button; Escape closes without a change. Only one day is in the Tab sequence. The month heading is a polite live region.
|
|
37
|
+
- **Presets**: opening the panel focuses the preset list, its own Tab stop before the calendar; ↑/↓ move between rows, Home/End jump, Enter or Space chooses and closes. In the field a chosen preset is one button named in full (“Last 30 days, 28 September 2026 to 27 October 2026”); typing a digit, Backspace or F2 on it turns it back into the date segments to type a custom range. Typing dates or picking days makes the range custom, and the preset is cleared.
|
|
38
|
+
- **Range**: the first choice is the start, the second the end (they swap if the end is earlier); the panel says which it's asking for. A span can't cross an unavailable day.
|
|
39
|
+
|
|
40
|
+
## Props
|
|
41
|
+
|
|
42
|
+
| Prop | |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `value`, `defaultValue`, `onValueChange` | `ISODate \| null` (DatePicker), `{ start, end }` (DateRangePicker). `onValueChange` gets a complete, real, allowed date, or `null` when the field is cleared or holds one that isn't allowed |
|
|
45
|
+
| `min`, `max`, `isUnavailable` | limits for choosing and typing |
|
|
46
|
+
| `locale`, `weekStartsOn` | segment order and names (default: the browser's); first day of the week, 0 Sunday … 6 Saturday (default: the locale's, from `Intl.Locale` week info) |
|
|
47
|
+
| `presets` | DateRangePicker: named spans offered beside the calendar. Build them with `datePresets` (`today()`, `yesterday()`, `lastDays(n)` with today included, `thisMonth()`, `lastMonth()`, `thisQuarter()`, `lastQuarter()`, `thisYear()`), or pass `{ key?, label, range }` where `range` is `{ start, end }` or a function of today. A preset whose span falls outside `min`/`max` is disabled |
|
|
48
|
+
| `preset`, `defaultPreset`, `onPresetChange` | the chosen preset's key (`null` when custom), controlled or not. Without a `defaultValue`, `defaultPreset` sets the starting range |
|
|
49
|
+
| `today` | an `ISODate`; relative presets resolve against it, and a chosen one re-resolves when it changes (“Last 7 days” moves with the date). Default: the local date |
|
|
50
|
+
| `id`, `name`, `disabled`, `required`, `invalid`, `size`, `defaultOpen` | as `Select`; `name` submits through a hidden input (`name-start` and `name-end` for a range) |
|
|
51
|
+
|
|
52
|
+
`Calendar`: `value`, `defaultValue`, `onValueChange`, `min`, `max`, `isUnavailable`, `locale`, `weekStartsOn`, `aria-label`.
|
|
53
|
+
|
|
54
|
+
## You provide
|
|
55
|
+
|
|
56
|
+
The value and the rules (limits, unavailable days). Time of day isn't covered (that's a separate `DateTimePicker`).
|
|
57
|
+
|
|
58
|
+
## Do and don't
|
|
59
|
+
|
|
60
|
+
- **Do** let people type: a birthday or a date years away is faster typed than paged to.
|
|
61
|
+
- **Do** give a dashboard's range presets: hardly anyone wants to page a calendar for “the last 30 days”. Lead with the few your readers use.
|
|
62
|
+
- **Do** say why a day is unavailable in the field's hint when it isn't obvious.
|
|
63
|
+
- **Don't** convert to `Date` at midnight local time and back; keep ISO dates until you need a moment in time.
|
|
64
|
+
|
|
65
|
+
## Demo and verification
|
|
66
|
+
|
|
67
|
+
The `DatePicker` section of `build/previews.txt` shows a due date (from today, weekends unavailable, en-GB, open), a trip range and an en-US date. `node checks/date-picker.mjs` (from `build/`; `ALT_FIXTURE_ROOT` selects React 19 fixtures) covers the APG grid keys, RTL arrows, unavailable days, Escape, focus return, segment typing and stepping, impossible dates, Alt+↓, the range flow with its preview, and 320/960px layouts in both schemes under reduced motion, forced colours and `contrast="more"`.
|
|
68
|
+
|
|
69
|
+
<!-- example: generated from build/previews.txt by build/previews.mjs. Edit the preview, not this section. -->
|
|
70
|
+
## Example
|
|
71
|
+
|
|
72
|
+
`DatePicker` as first used in its preview:
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
import { DatePicker } from 'altitude-design';
|
|
76
|
+
|
|
77
|
+
<DatePicker value={due} onValueChange={setDue} min={DP_TODAY} isUnavailable={weekend} locale="en-GB" defaultOpen />
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The full preview, as rendered in Storybook and checked in CI (`dp-demo`, `dp-wide` are the preview page's own layout classes, not Altitude's):
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import * as React from 'react';
|
|
84
|
+
import { Button, DatePicker, DateRangePicker, Field, datePresets } from 'altitude-design';
|
|
85
|
+
|
|
86
|
+
const DP_TODAY = (() => { const t = new Date(); return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`; })();
|
|
87
|
+
const dpShift = (iso, days) => { const [y, m, d] = iso.split('-').map(Number); const t = new Date(Date.UTC(y, m - 1, d + days)); return t.toISOString().slice(0, 10); };
|
|
88
|
+
export default function App() {
|
|
89
|
+
const [due, setDue] = React.useState(dpShift(DP_TODAY, 3));
|
|
90
|
+
const [trip, setTrip] = React.useState({ start: dpShift(DP_TODAY, 9), end: dpShift(DP_TODAY, 13) });
|
|
91
|
+
const [start, setStart] = React.useState(null);
|
|
92
|
+
const [dpToday, setDpToday] = React.useState('2026-10-27');
|
|
93
|
+
const [period, setPeriod] = React.useState({ start: '2026-09-28', end: '2026-10-27' });
|
|
94
|
+
const [preset, setPreset] = React.useState('last-30-days');
|
|
95
|
+
const weekend = iso => { const [y, m, d] = iso.split('-').map(Number); const w = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); return w === 0 || w === 6; };
|
|
96
|
+
return <div className="dp-demo">
|
|
97
|
+
<Field label="Due date" hint="Weekends aren’t available.">
|
|
98
|
+
<DatePicker value={due} onValueChange={setDue} min={DP_TODAY} isUnavailable={weekend} locale="en-GB" defaultOpen />
|
|
99
|
+
</Field>
|
|
100
|
+
<div>
|
|
101
|
+
<Field label="Trip"><DateRangePicker value={trip} onValueChange={setTrip} min={DP_TODAY} locale="en-GB" /></Field>
|
|
102
|
+
<div style={{ height: 24 }} />
|
|
103
|
+
<Field label="Start date (US format)"><DatePicker value={start} onValueChange={setStart} locale="en-US" /></Field>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="dp-wide">
|
|
106
|
+
<Field label="Reporting period" hint={`Presets resolve against today (${dpToday}); they stay relative when it changes.`}>
|
|
107
|
+
<DateRangePicker value={period} onValueChange={setPeriod} preset={preset} onPresetChange={setPreset} today={dpToday} locale="en-GB"
|
|
108
|
+
presets={[datePresets.today(), datePresets.lastDays(7), datePresets.lastDays(30), datePresets.thisMonth(), datePresets.thisQuarter(), datePresets.lastQuarter(),
|
|
109
|
+
{ key: 'launch', label: 'Since launch', range: { start: '2026-03-04', end: dpToday } }]} />
|
|
110
|
+
</Field>
|
|
111
|
+
<div style={{ marginTop: 8 }}><Button size="sm" variant="ghost" onClick={() => setDpToday((t) => dpShift(t, 1))}>Next day</Button></div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
<!-- /example -->
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Dialog
|
|
2
|
+
|
|
3
|
+
Asks for a decision or a focused task at level 5, the summit. The page waits while the dialog is open and through its exit.
|
|
4
|
+
|
|
5
|
+
By default the app scales back 1.5% and desaturates, the scrim fades in with a 3px blur, and the dialog rises 16px from 96% scale while its shadow grows from `elev-1` to `elev-5` (`dur-travel-5`). It closes faster than it opened, sinking. `background="stationary"` keeps the background in place and removes backdrop blur, including a modal Sheet underneath. Dimming and the dialog's own elevation and motion stay the same. `AltitudeProvider recede={false}` suppresses stage recession for either mode; the default scrim still blurs.
|
|
6
|
+
|
|
7
|
+
**Where focus lands.** A form starts at its first eligible field by default. A readable structured surface can use `initialFocus="title"`; Tab then reaches the first control and Shift+Tab the last. `focusSelector` selects a different initial target. `initialFocus={false}` focuses the panel as a safe containment fallback until the application moves focus inside. Hidden, disabled, inert and negative-tabindex controls are excluded from Tab traversal. Composition Escape does not dismiss.
|
|
8
|
+
|
|
9
|
+
**Where focus returns.** Closing retains native background isolation, scroll locking and focus containment through the exit. The captured invoker receives focus afterward when it remains available. If an action removes it, supply a lazy `returnFocus={() => headingRef.current}`; the returned element must be connected, visible, non-inert and in the same document. A static heading needs `tabIndex={-1}`. The surviving provider stage is the final neutral fallback. An older close does not take focus from a newer modal or a valid application completion destination.
|
|
10
|
+
|
|
11
|
+
**On a phone.** It sizes to the visible viewport, not the largest one: with the on-screen keyboard open it shrinks to the part you can see, so its footer and the focused field stay in reach.
|
|
12
|
+
|
|
13
|
+
**You provide:** `title`, `description`, optional body, `footer`, `open`/`onOpenChange` or a `trigger`, and `size`. `dismissible={false}` disables Escape, scrim dismissal and the close affordance. `background` defaults to `'recede'`. `returnFocus` is optional for unchanged invokers.
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
const listHeading = React.useRef<HTMLHeadingElement>(null);
|
|
17
|
+
<Dialog open={open} onOpenChange={setOpen} size="sm"
|
|
18
|
+
background="stationary" title="Delete this file permanently?"
|
|
19
|
+
description={`“${target.name}” will be removed. You won’t be able to restore it.`}
|
|
20
|
+
focusSelector="[data-cancel]" returnFocus={() => listHeading.current}
|
|
21
|
+
footer={<><Button data-cancel variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
|
|
22
|
+
<Button variant="danger" onClick={confirmDelete}>Delete permanently</Button></>} />
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This fragment shows presentation only. Use the executable [confirmation recipe](../../visual/ActionsInPlace.md) for target/revision capture, duplicate suppression, retained errors, pending Close, uncertain outcomes and session-safe completion. The application owns dispatched work; closing a Dialog never implies that a write was cancelled.
|
|
26
|
+
|
|
27
|
+
- Name the action in the confirm button, such as “Delete permanently”. For irreversible actions, initially focus Cancel.
|
|
28
|
+
- Keep essential errors and progress inside the dialog. Background toast actions are temporarily inert; notification text is routed into its live region.
|
|
29
|
+
- Keep one summit. A Dialog may temporarily suspend a modal Sheet. Competing Dialog/CommandPalette requests are rejected, warn in development, and require `open=false` before a fresh attempt. They never queue. A palette command may close the palette and open a Dialog in the same callback.
|
|
30
|
+
|
|
31
|
+
<!-- example: generated from build/previews.txt by build/previews.mjs. Edit the preview, not this section. -->
|
|
32
|
+
## Example
|
|
33
|
+
|
|
34
|
+
`Dialog` as first used in its preview:
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { Alert, Button, Dialog } from 'altitude-design';
|
|
38
|
+
|
|
39
|
+
<Dialog open={open} onOpenChange={setOpen} background={background} size="sm" title="Delete this file permanently?"
|
|
40
|
+
description={`“${target?.name || ''}” will be removed. You won’t be able to restore it.`} focusSelector="[data-cancel]" returnFocus={() => heading.current}
|
|
41
|
+
footer={<><Button data-cancel variant="ghost" onClick={() => setOpen(false)}>{busy ? 'Close' : 'Cancel'}</Button>
|
|
42
|
+
<Button data-confirm variant="danger" disabled={busy || operation?.state === 'conflict'} onClick={confirm}>{busy ? operation?.state === 'checking' ? 'Checking deletion…' : 'Deleting…' : failed ? 'Try deleting again' : 'Delete permanently'}</Button></>}>
|
|
43
|
+
<p className="body dialog-target">{target?.name}<br /><span className="cap">{target?.folder}</span></p>
|
|
44
|
+
{busy && <p role="status">Deletion is in progress. Closing this dialog won’t cancel it.</p>}
|
|
45
|
+
{failed && <Alert tone="critical" title="File not deleted">{operation.message}{operation.state === 'conflict' && <p>Latest file: {operation.current?.name || 'No longer available'}</p>}</Alert>}
|
|
46
|
+
</Dialog>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The full preview, as rendered in Storybook and checked in CI (`dialog-demo`, `cap`, `body`, `dialog-file`, `dialog-target`, `row` are the preview page's own layout classes, not Altitude's):
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import * as React from 'react';
|
|
53
|
+
import { Alert, AltitudeProvider, Button, CommandPalette, Dialog, Input, Sheet, SheetHeader, SheetTitle, Toaster, toast, useAltitude } from 'altitude-design';
|
|
54
|
+
|
|
55
|
+
const dialogFiles = [
|
|
56
|
+
{ id: 'file-brand', name: 'Brand guidelines.pdf', folder: 'Brand', revision: 1 },
|
|
57
|
+
{ id: 'file-brand-draft', name: 'Brand guidelines draft.pdf', folder: 'Brand', revision: 1 },
|
|
58
|
+
{ id: 'file-launch', name: 'Launch checklist.md', folder: 'Launch', revision: 1 },
|
|
59
|
+
];
|
|
60
|
+
function ModalFrameFixture() {
|
|
61
|
+
const [open, setOpen] = React.useState(false), [sheet, setSheet] = React.useState(false);
|
|
62
|
+
React.useEffect(() => { window.altitudeModalFrameFixture = { setOpen, setSheet }; return () => { delete window.altitudeModalFrameFixture; }; }, []);
|
|
63
|
+
return <AltitudeProvider><p>Independent document fixture</p><Button data-frame-trigger onClick={() => setOpen(true)}>Open frame palette</Button>
|
|
64
|
+
<CommandPalette open={open} onOpenChange={setOpen} items={[{ items: [{ label: 'Frame command' }] }]} />
|
|
65
|
+
<Sheet open={sheet} onOpenChange={setSheet}><SheetHeader><SheetTitle>Frame sheet</SheetTitle></SheetHeader><Input aria-label="Frame input" /></Sheet>
|
|
66
|
+
</AltitudeProvider>;
|
|
67
|
+
}
|
|
68
|
+
export default function App() {
|
|
69
|
+
const altitude = useAltitude();
|
|
70
|
+
const releaseLegacy = React.useRef(null);
|
|
71
|
+
const [rows, setRows] = React.useState(dialogFiles);
|
|
72
|
+
const server = React.useRef(dialogFiles.map(file => ({ ...file })));
|
|
73
|
+
const heading = React.useRef(null);
|
|
74
|
+
const [target, setTarget] = React.useState(null);
|
|
75
|
+
const [open, setOpen] = React.useState(false);
|
|
76
|
+
const opened = React.useRef(false); opened.current = open;
|
|
77
|
+
const [background, setBackground] = React.useState('stationary');
|
|
78
|
+
const [fixture, setFixture] = React.useState(new URLSearchParams(window.location.search).get('fixture') || 'success-100');
|
|
79
|
+
const [operation, setOperation] = React.useState(null);
|
|
80
|
+
const [outcome, setOutcome] = React.useState('');
|
|
81
|
+
const [present, setPresent] = React.useState(true);
|
|
82
|
+
const session = React.useRef(0), sequence = React.useRef(0), pending = React.useRef(new Set());
|
|
83
|
+
const failUsed = React.useRef(false), accepted = React.useRef([]);
|
|
84
|
+
const invocation = React.useRef(null), mounted = React.useRef(false), epoch = React.useRef(0);
|
|
85
|
+
const [frame, setFrame] = React.useState(false);
|
|
86
|
+
const frameRoot = React.useRef(null);
|
|
87
|
+
const [mode, setMode] = React.useState('');
|
|
88
|
+
const [sheet, setSheet] = React.useState(false), [nonmodal, setNonmodal] = React.useState(false);
|
|
89
|
+
const [palette, setPalette] = React.useState(false), [competing, setCompeting] = React.useState(false);
|
|
90
|
+
const [controls, setControls] = React.useState(true), [inserted, setInserted] = React.useState(false);
|
|
91
|
+
const [nestedPresent, setNestedPresent] = React.useState(true);
|
|
92
|
+
const [nested, setNested] = React.useState(false), [toastActions, setToastActions] = React.useState(0);
|
|
93
|
+
const latest = React.useRef({});
|
|
94
|
+
React.useEffect(() => { mounted.current = true; return () => { mounted.current = false; epoch.current++; }; }, []);
|
|
95
|
+
const begin = (file, invoker) => {
|
|
96
|
+
if (pending.current.has(file.id)) { setOutcome(`Deletion is already in progress for “${file.name}”.`); return; }
|
|
97
|
+
session.current++; invocation.current = invoker || document.activeElement;
|
|
98
|
+
setTarget({ ...file, session: session.current }); setOperation(null); setOpen(true); setPresent(true);
|
|
99
|
+
};
|
|
100
|
+
const finish = (captured, id, result, ownEpoch) => {
|
|
101
|
+
pending.current.delete(captured.id);
|
|
102
|
+
if (!mounted.current || ownEpoch !== epoch.current) return;
|
|
103
|
+
const matching = session.current === captured.session;
|
|
104
|
+
if (result === 'success') {
|
|
105
|
+
const restoreRemoved = !opened.current && document.activeElement === invocation.current && matching;
|
|
106
|
+
setRows(server.current.map(file => ({ ...file })));
|
|
107
|
+
setOutcome(`Deleted “${captured.name}”.`);
|
|
108
|
+
if (matching) { setOperation({ id, session: captured.session, state: 'done' }); setOpen(false); }
|
|
109
|
+
if (restoreRemoved) requestAnimationFrame(() => heading.current?.focus({ preventScroll: true }));
|
|
110
|
+
} else {
|
|
111
|
+
const message = result === 'conflict' ? 'This file changed. Close this dialog and review the latest file before deleting.' : 'The file was not deleted. Try again.';
|
|
112
|
+
if (matching && opened.current) setOperation({ id, session: captured.session, state: result, message, current: server.current.find(file => file.id === captured.id) });
|
|
113
|
+
else setOutcome(`“${captured.name}”: ${message}`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const confirm = async () => {
|
|
117
|
+
if (!target || pending.current.has(target.id)) return;
|
|
118
|
+
const captured = { ...target }, id = `delete-${++sequence.current}`, ownEpoch = epoch.current, behavior = fixture;
|
|
119
|
+
pending.current.add(captured.id); setOperation({ id, session: captured.session, state: 'pending' });
|
|
120
|
+
const request = { id: captured.id, expectedRevision: captured.revision, operationId: id, outcome: 'pending' };
|
|
121
|
+
accepted.current.push(request);
|
|
122
|
+
const reject = behavior === 'fail-once' && !failUsed.current;
|
|
123
|
+
if (reject) failUsed.current = true;
|
|
124
|
+
if (behavior === 'unmount') setTimeout(() => { if (epoch.current === ownEpoch && mounted.current) { setPresent(false); setOpen(false); } }, 50);
|
|
125
|
+
if (behavior === 'interrupted-after-save') {
|
|
126
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
127
|
+
if (ownEpoch !== epoch.current) return;
|
|
128
|
+
const record = server.current.find(file => file.id === captured.id);
|
|
129
|
+
if (record?.revision === captured.revision) server.current = server.current.filter(file => file.id !== captured.id);
|
|
130
|
+
await new Promise(resolve => setTimeout(resolve, 1100));
|
|
131
|
+
} else await new Promise(resolve => setTimeout(resolve, behavior === 'success-1200' || reject || behavior === 'conflict' ? 1200 : 100));
|
|
132
|
+
if (ownEpoch !== epoch.current) return;
|
|
133
|
+
if (behavior === 'conflict') { server.current = server.current.map(file => file.id === captured.id ? { ...file, name: 'Brand guidelines revised.pdf', revision: file.revision + 1 } : file); setRows(server.current); }
|
|
134
|
+
const current = server.current.find(file => file.id === captured.id);
|
|
135
|
+
let result = !current || current.revision !== captured.revision ? 'conflict' : reject || behavior === 'interrupted-before-save' ? 'failure' : 'success';
|
|
136
|
+
if (result === 'success') server.current = server.current.filter(file => file.id !== captured.id);
|
|
137
|
+
if (behavior.startsWith('interrupted')) {
|
|
138
|
+
if (mounted.current && session.current === captured.session && opened.current) setOperation({ id, session: captured.session, state: 'checking' });
|
|
139
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
140
|
+
result = server.current.some(file => file.id === captured.id) ? 'failure' : 'success';
|
|
141
|
+
}
|
|
142
|
+
request.outcome = result;
|
|
143
|
+
finish(captured, id, result, ownEpoch);
|
|
144
|
+
};
|
|
145
|
+
const reset = () => {
|
|
146
|
+
epoch.current++; sequence.current = 0; failUsed.current = false; accepted.current = []; pending.current.clear(); server.current = dialogFiles.map(file => ({ ...file }));
|
|
147
|
+
setRows(server.current); setOpen(false); setOutcome(''); setOperation(null); setPresent(true); setMode(''); setSheet(false); setNonmodal(false); setPalette(false); setCompeting(false); setNested(false);
|
|
148
|
+
};
|
|
149
|
+
latest.current = { legacyVote: () => { releaseLegacy.current = altitude.pushLayer(); }, legacyRelease: () => releaseLegacy.current?.(), begin, reset, setFrame, setMode, setSheet, setNonmodal, setPalette, setCompeting, setNested, setNestedPresent, setControls, setInserted, setPresent, setBackground, setFixture,
|
|
150
|
+
close: () => setOpen(false), confirm, openFirst: () => begin(server.current[0]),
|
|
151
|
+
changeTarget: () => { server.current = server.current.map(file => file.id === target?.id ? { ...file, name: file.name.replace('.pdf', ' revised.pdf'), revision: file.revision + 1 } : file); setRows(server.current); },
|
|
152
|
+
notify: () => toast({ title: 'Background update', duration: 1200, action: { label: 'Undo background change', onClick: () => setToastActions(n => n + 1) } }),
|
|
153
|
+
snapshot: () => ({ rows, operation, requests: sequence.current, accepted: accepted.current, toastActions, session: session.current, open }) };
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
window.altitudeModalFixture = new Proxy({}, { get: (_, key) => latest.current[key] });
|
|
156
|
+
return () => { delete window.altitudeModalFixture; };
|
|
157
|
+
}, []);
|
|
158
|
+
const busy = operation?.session === target?.session && ['pending', 'checking'].includes(operation?.state);
|
|
159
|
+
const failed = operation?.session === target?.session && ['failure', 'conflict'].includes(operation?.state);
|
|
160
|
+
return <div className="dialog-demo">
|
|
161
|
+
<header><p className="cap">Files / Archived</p><h1 ref={heading} tabIndex={-1}>Archived files</h1></header>
|
|
162
|
+
<p className="body">Archived files can be restored. Permanently deleted files cannot.</p>
|
|
163
|
+
<section aria-label="Archived files">{rows.map(file => <div className="dialog-file" key={file.id}>
|
|
164
|
+
<div><strong>{file.name}</strong><p className="cap">{file.folder}</p></div>
|
|
165
|
+
<Button data-file={file.id} onClick={event => begin(file, event.currentTarget)}>Delete permanently</Button>
|
|
166
|
+
</div>)}</section>
|
|
167
|
+
<p role="status" data-outcome>{outcome}</p>
|
|
168
|
+
{present && <Dialog open={open} onOpenChange={setOpen} background={background} size="sm" title="Delete this file permanently?"
|
|
169
|
+
description={`“${target?.name || ''}” will be removed. You won’t be able to restore it.`} focusSelector="[data-cancel]" returnFocus={() => heading.current}
|
|
170
|
+
footer={<><Button data-cancel variant="ghost" onClick={() => setOpen(false)}>{busy ? 'Close' : 'Cancel'}</Button>
|
|
171
|
+
<Button data-confirm variant="danger" disabled={busy || operation?.state === 'conflict'} onClick={confirm}>{busy ? operation?.state === 'checking' ? 'Checking deletion…' : 'Deleting…' : failed ? 'Try deleting again' : 'Delete permanently'}</Button></>}>
|
|
172
|
+
<p className="body dialog-target">{target?.name}<br /><span className="cap">{target?.folder}</span></p>
|
|
173
|
+
{busy && <p role="status">Deletion is in progress. Closing this dialog won’t cancel it.</p>}
|
|
174
|
+
{failed && <Alert tone="critical" title="File not deleted">{operation.message}{operation.state === 'conflict' && <p>Latest file: {operation.current?.name || 'No longer available'}</p>}</Alert>}
|
|
175
|
+
</Dialog>}
|
|
176
|
+
<details><summary>Demo controls</summary>
|
|
177
|
+
<div className="row"><label>Background <select aria-label="Dialog background" value={background} onChange={event => setBackground(event.target.value)}><option value="stationary">Stationary</option><option value="recede">Default recession</option></select></label>
|
|
178
|
+
<label>Service fixture <select aria-label="Service fixture" value={fixture} onChange={event => setFixture(event.target.value)}><option value="success-100">Success · 100 ms</option><option value="success-1200">Slow success · 1,200 ms</option><option value="fail-once">Fail once, then retry</option><option value="conflict">Record changes before save</option><option value="interrupted-after-save">Connection interrupted after save</option><option value="interrupted-before-save">Connection interrupted before save</option><option value="unmount">Unmount presentation during write</option></select></label>
|
|
179
|
+
<Button onClick={reset}>Reset fixtures</Button></div>
|
|
180
|
+
<div className="row"><Button onClick={() => { setControls(true); setMode('title'); }}>Read a structured dialog</Button><Button onClick={() => setMode('field')}>Edit in a dialog</Button><Button onClick={() => setMode('panel')}>Panel-only dialog</Button><Button onClick={() => setSheet(true)}>Open modal sheet</Button><Button onClick={() => setNonmodal(true)}>Open nonmodal sheet</Button><Button onClick={() => setPalette(true)}>Open command palette</Button><Button onClick={() => setNested(true)}>Open nested-provider dialog</Button></div>
|
|
181
|
+
<p className="cap">Requests: {sequence.current}. Accepted operation log: <output data-operation-log>{JSON.stringify(accepted.current)}</output></p>
|
|
182
|
+
<p className="cap">Deterministic fictional data. Pending close, failure, conflict, unmount and ownership cases are exercised by the canonical regression harness. <a href="https://github.com/richlaconte/altitude/blob/main/build/previews.txt">Example source</a> · <a href="../../ActionsInPlace.md">Actions in place recipe</a></p>
|
|
183
|
+
<button data-background-control onClick={() => setOutcome('Background action')}>Background control</button>
|
|
184
|
+
{inserted && <button data-late-background>New background control</button>}
|
|
185
|
+
{frame && <iframe srcDoc="<!doctype html><html><head></head><body></body></html>" title="Independent document" style={{ width: '100%', height: 360 }} onLoad={event => {
|
|
186
|
+
const doc = event.currentTarget.contentDocument;
|
|
187
|
+
for (const style of document.querySelectorAll('style')) doc.head.append(style.cloneNode(true));
|
|
188
|
+
frameRoot.current = ReactDOM.createRoot(doc.body); frameRoot.current.render(<ModalFrameFixture />);
|
|
189
|
+
}} />}
|
|
190
|
+
</details>
|
|
191
|
+
<Dialog open={!!mode} onOpenChange={value => { if (!value) setMode(''); }} title="Modal focus fixture" initialFocus={mode === 'title' ? 'title' : mode === 'panel' ? false : 'first-field'} dismissible={mode !== 'panel'} background={background}
|
|
192
|
+
footer={mode !== 'panel' && controls ? <><button data-first>First control</button><button data-last>Last control</button></> : null}>
|
|
193
|
+
{mode === 'field' && controls && <Input aria-label="Fixture field" />}<button hidden>Hidden control</button><button disabled>Disabled control</button>
|
|
194
|
+
</Dialog>
|
|
195
|
+
<Dialog open={competing} onOpenChange={setCompeting} title="Competing summit" />
|
|
196
|
+
<Sheet open={sheet} onOpenChange={setSheet}><SheetHeader><SheetTitle>File details</SheetTitle></SheetHeader><Button data-sheet-dialog onClick={() => setMode('title')}>Read details in dialog</Button></Sheet>
|
|
197
|
+
<Sheet open={nonmodal} onOpenChange={setNonmodal} modal={false}><SheetHeader><SheetTitle>File filters</SheetTitle></SheetHeader><Button data-nonmodal-close onClick={() => setNonmodal(false)}>Close filters</Button></Sheet>
|
|
198
|
+
<CommandPalette open={palette} onOpenChange={setPalette} items={[{ items: [{ label: 'Read file details', onSelect: () => setMode('title') }] }]} />
|
|
199
|
+
{nestedPresent && <AltitudeProvider scheme="nightfall" recede={false}><div data-nested-background hidden>Nested workspace</div><Dialog open={nested} onOpenChange={setNested} title="Nested dialog" initialFocus="title" /></AltitudeProvider>}
|
|
200
|
+
<Toaster />
|
|
201
|
+
</div>;
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
<!-- /example -->
|