altitude-design 0.3.1 → 0.4.0
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 +1 -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 +25 -0
- package/dist/guidance/components/Accordion/README.md +7 -0
- package/dist/guidance/components/AgentFleet/README.md +34 -0
- package/dist/guidance/components/AgentLanes/README.md +21 -0
- package/dist/guidance/components/AgentRequests/README.md +29 -0
- package/dist/guidance/components/Alert/README.md +22 -0
- package/dist/guidance/components/AltitudeProvider/README.md +48 -0
- package/dist/guidance/components/AppShell/README.md +51 -0
- package/dist/guidance/components/AreaChart/README.md +43 -0
- package/dist/guidance/components/Artifact/README.md +30 -0
- package/dist/guidance/components/Avatar/README.md +34 -0
- package/dist/guidance/components/Await/README.md +80 -0
- package/dist/guidance/components/Badge/README.md +22 -0
- package/dist/guidance/components/BarChart/README.md +47 -0
- package/dist/guidance/components/BarList/README.md +29 -0
- package/dist/guidance/components/Board/README.md +31 -0
- package/dist/guidance/components/Breadcrumb/README.md +35 -0
- package/dist/guidance/components/Button/README.md +17 -0
- package/dist/guidance/components/Card/README.md +14 -0
- package/dist/guidance/components/ChartBrush/README.md +41 -0
- package/dist/guidance/components/ChartContainer/README.md +152 -0
- package/dist/guidance/components/ChartLegend/README.md +35 -0
- package/dist/guidance/components/ChartReference/README.md +41 -0
- package/dist/guidance/components/ChartTooltip/README.md +38 -0
- package/dist/guidance/components/Checkbox/README.md +11 -0
- package/dist/guidance/components/Checklist/README.md +71 -0
- package/dist/guidance/components/CodeBlock/README.md +28 -0
- package/dist/guidance/components/ColorPicker/README.md +30 -0
- package/dist/guidance/components/Combobox/README.md +16 -0
- package/dist/guidance/components/CommandPalette/README.md +27 -0
- package/dist/guidance/components/CommentThread/README.md +30 -0
- package/dist/guidance/components/Confirmation/README.md +34 -0
- package/dist/guidance/components/ContextMenu/README.md +30 -0
- package/dist/guidance/components/Conversation/README.md +45 -0
- package/dist/guidance/components/CopyButton/README.md +23 -0
- package/dist/guidance/components/DataGrid/README.md +82 -0
- package/dist/guidance/components/DatePicker/README.md +67 -0
- package/dist/guidance/components/Dialog/README.md +29 -0
- package/dist/guidance/components/DiffView/README.md +30 -0
- package/dist/guidance/components/DotPlot/README.md +38 -0
- package/dist/guidance/components/Drilldown/README.md +15 -0
- package/dist/guidance/components/DropZone/README.md +21 -0
- package/dist/guidance/components/DropdownMenu/README.md +19 -0
- package/dist/guidance/components/EditRegion/README.md +104 -0
- package/dist/guidance/components/EmptyState/README.md +17 -0
- package/dist/guidance/components/FeatureGate/README.md +28 -0
- package/dist/guidance/components/Field/README.md +10 -0
- package/dist/guidance/components/FilterBar/README.md +69 -0
- package/dist/guidance/components/FormattedNumber/README.md +37 -0
- package/dist/guidance/components/Heatmap/README.md +45 -0
- package/dist/guidance/components/HoldButton/README.md +16 -0
- package/dist/guidance/components/HoverCard/README.md +18 -0
- package/dist/guidance/components/InlineCitation/README.md +24 -0
- package/dist/guidance/components/InlineEdit/README.md +133 -0
- package/dist/guidance/components/Input/README.md +27 -0
- package/dist/guidance/components/InputOTP/README.md +30 -0
- package/dist/guidance/components/ItemGroup/README.md +93 -0
- package/dist/guidance/components/Kbd/README.md +9 -0
- package/dist/guidance/components/LineChart/README.md +47 -0
- package/dist/guidance/components/Link/README.md +28 -0
- package/dist/guidance/components/Markdown/README.md +32 -0
- package/dist/guidance/components/Meter/README.md +29 -0
- package/dist/guidance/components/NotificationCenter/README.md +28 -0
- package/dist/guidance/components/NumberInput/README.md +29 -0
- package/dist/guidance/components/PageHeader/README.md +29 -0
- package/dist/guidance/components/Pagination/README.md +25 -0
- package/dist/guidance/components/PasswordInput/README.md +34 -0
- package/dist/guidance/components/PermissionMatrix/README.md +28 -0
- package/dist/guidance/components/Pile/README.md +12 -0
- package/dist/guidance/components/PlanPicker/README.md +25 -0
- package/dist/guidance/components/Popover/README.md +12 -0
- package/dist/guidance/components/Progress/README.md +10 -0
- package/dist/guidance/components/PromptInput/README.md +40 -0
- package/dist/guidance/components/PropertyList/README.md +57 -0
- package/dist/guidance/components/Question/README.md +27 -0
- package/dist/guidance/components/RadioGroup/README.md +10 -0
- package/dist/guidance/components/RelativeTime/README.md +26 -0
- package/dist/guidance/components/RemovalReceipt/README.md +34 -0
- package/dist/guidance/components/Resizable/README.md +36 -0
- package/dist/guidance/components/SaveBar/README.md +27 -0
- package/dist/guidance/components/SavedViews/README.md +24 -0
- package/dist/guidance/components/ScatterChart/README.md +41 -0
- package/dist/guidance/components/Schedule/README.md +76 -0
- package/dist/guidance/components/ScrollArea/README.md +7 -0
- package/dist/guidance/components/SecretField/README.md +28 -0
- package/dist/guidance/components/Select/README.md +27 -0
- package/dist/guidance/components/Separator/README.md +26 -0
- package/dist/guidance/components/Sheet/README.md +32 -0
- package/dist/guidance/components/Skeleton/README.md +14 -0
- package/dist/guidance/components/Slider/README.md +25 -0
- package/dist/guidance/components/SortableList/README.md +11 -0
- package/dist/guidance/components/Sparkline/README.md +29 -0
- package/dist/guidance/components/SplitButton/README.md +26 -0
- package/dist/guidance/components/Stat/README.md +31 -0
- package/dist/guidance/components/Stepper/README.md +29 -0
- package/dist/guidance/components/Suggestions/README.md +29 -0
- package/dist/guidance/components/Surface/README.md +20 -0
- package/dist/guidance/components/Switch/README.md +9 -0
- package/dist/guidance/components/SyncIndicator/README.md +24 -0
- package/dist/guidance/components/Table/README.md +76 -0
- package/dist/guidance/components/Tabs/README.md +18 -0
- package/dist/guidance/components/TagInput/README.md +66 -0
- package/dist/guidance/components/Terminal/README.md +27 -0
- package/dist/guidance/components/ThreadList/README.md +37 -0
- package/dist/guidance/components/TimeInput/README.md +26 -0
- package/dist/guidance/components/Timeline/README.md +32 -0
- package/dist/guidance/components/Toaster/README.md +16 -0
- package/dist/guidance/components/Toggle/README.md +33 -0
- package/dist/guidance/components/Toolbar/README.md +37 -0
- package/dist/guidance/components/Tooltip/README.md +12 -0
- package/dist/guidance/components/Tour/README.md +29 -0
- package/dist/guidance/components/Trace/README.md +37 -0
- package/dist/guidance/components/Tree/README.md +86 -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/index.cjs.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types-cjs/index.d.cts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
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"`.
|
|
@@ -0,0 +1,29 @@
|
|
|
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.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# DiffView
|
|
2
|
+
|
|
3
|
+
What changed between two versions: version history, audit-log detail ("Plan: Pro → Business"), config changes before a deploy, and edits an AI proposes to a document or record.
|
|
4
|
+
|
|
5
|
+
**Elevation.** Both versions are the record, so it lies flush on the ground with a `line` rim, as `CodeBlock` does. It has no height of its own. **Never colour alone.**
|
|
6
|
+
- **Additions** are `positive-soft`, with a `+` sign and "Added" in their name (`<ins>`).
|
|
7
|
+
- **Removals** are `critical-soft`, struck through, with a `−` sign and "Removed" (`<del>`).
|
|
8
|
+
- **Words that changed** inside a line take a stronger tint. A removal and the addition that replaces it are paired in order, within a block of changed lines.
|
|
9
|
+
- **Long unchanged runs** fold behind a lid ("6 unchanged lines"), keeping `context` lines around each change. A single unchanged line is never folded.
|
|
10
|
+
- **In `mode="properties"`**, each changed field shows its old value struck through beside the new.
|
|
11
|
+
|
|
12
|
+
**Restoring a version is a consequential action**, not something the diff does: follow the consequential-action pattern (a confirmation, then a receipt).
|
|
13
|
+
|
|
14
|
+
**Accessibility.** A `figure` named by its caption, with a summary first ("3 additions, 2 removals", "2 properties changed"). Previous change and Next change move focus from change to change, and so do the `n` and `p` keys while focus is inside. Signs are hidden; "Added" and "Removed" are read. Under forced colours the tints are dropped, and the signs and strike-through carry the meaning. Under `contrast="more"` each changed line gets a 2px edge in its tone.
|
|
15
|
+
|
|
16
|
+
**Props:** `mode` (`'text' | 'properties'`); `before`, `after` (texts, or records); `fields` (`{ key, label, format? }[]`, for properties); `context` (default 3); `caption`; `hunks` (precomputed line operations for large inputs; `diffLines(before, after)` makes them).
|
|
17
|
+
|
|
18
|
+
**Reviewing a proposed change.** With `review` (text mode), each change is a labelled group ("Change 3 of 7, lines 40 to 52") raised as a proposal, with Accept and Reject; the header counts "3 of 7 changes reviewed" and offers Accept all and Reject all; n and p (or ] and [) move between undecided changes. `decisions` is yours (by id, `change-1` in order; `diffChanges` lists them), as is applying them. Accepted lands flush with one afterglow; rejected is muted and collapses to "Rejected · show" after `dur-linger`. Decisions are announced ("Change 3 accepted. 4 left."). Property diffs aren't reviewable yet.
|
|
19
|
+
|
|
20
|
+
**You provide:** the two versions and a caption that names them.
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
<DiffView caption="Version 4 → 5" before={v4.body} after={v5.body} context={3} />
|
|
24
|
+
<DiffView mode="properties" caption="Subscription changed" before={prev} after={next} fields={[{ key: 'plan', label: 'Plan' }]} />
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- Do say which versions are compared in the caption.
|
|
28
|
+
- Do keep a little context around changes, so they can be read.
|
|
29
|
+
- Don't show a diff in red and green alone.
|
|
30
|
+
- Don't restore from inside the diff without a confirmation.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# DotPlot
|
|
2
|
+
|
|
3
|
+
Values compared across categories where zero isn't part of the story: ten services' p95, all between 180 and 240 ms; scores by team with their spread; last quarter against this one. A `BarChart` must stand on zero, which squashes differences of a few percent into identical bars, and bars of averages carry a measured misreading: values inside the bar are judged likelier than values equally far outside it (Newman & Scholl, 2012), which dot plots reduce (Okan et al., 2018). Without this form people reach for bars and either squash the data or truncate the axis, which Altitude forbids.
|
|
4
|
+
|
|
5
|
+
**Why it's flat.** The values are the record, so the dots lie on the plot as a `ScatterChart`'s do: at least 8px, a `data-*` fill (a marker shape per series when there's more than one), and a 2px ring of the plot's own colour so neighbours stay apart. Never raised, never shaded. The value is position, never height, on an axis that needn't include zero; with no zero there's no ground line, only the plot's foot, as on a `LineChart` that leaves zero off.
|
|
6
|
+
|
|
7
|
+
**Rows.** Categories run down the side, as on a horizontal `BarChart`, with the same label budget. Each row has a 1px `line` guide from its label across the plot. `sort="desc"` orders the rows by value (the first series', or `to`'s), and re-sorted rows are carried to their places.
|
|
8
|
+
|
|
9
|
+
**A spread.** `range={['p10', 'p90']}` draws a flat band of the series colour at 16% behind the dot, with round ends: an uncertainty band, as on a `LineChart`, never error bars, which readers misjudge (Correll & Gleicher, 2014). `rangeLabel` says what it is ("10th–90th percentile"). It joins the axis, the readout, each point's name ("Billing, p95, 236 ms. 10th–90th percentile 190 ms to 320 ms.") and a column of the table.
|
|
10
|
+
|
|
11
|
+
**Before and after.** `from="q2" to="q3"` makes a dumbbell: the earlier dot hollow, the later filled, both in one colour because they're one entity at two times, joined by a 2px `ink-muted` link. The change is said in words, as `compare` says it: "Up 7.5% from Q2" in the later dot's name, a row in the readout, "Q2" and "vs Q2" columns in the table, and "Against Q2: 4 rose, 1 fell." in the summary.
|
|
12
|
+
|
|
13
|
+
**Without colour.** Under forced colours the dots are `CanvasText` ringed in `Canvas` (a hollow dot is a `CanvasText` ring on `Canvas`), the spread `GrayText`, the link `CanvasText`. With `contrast="more"` the row guides are `line-control`, as gridlines are.
|
|
14
|
+
|
|
15
|
+
**Motion.** The container's engine: a rescale first, then the dots, their spreads and their links glide to their new values on `ease-glide`; re-sorted rows are carried. Nothing grows in on first render. Under reduced motion values change in place.
|
|
16
|
+
|
|
17
|
+
**Accessibility.** The keyboard path is one Tab stop: the summary, then the series, then the points down the rows. The generated summary names the range, the lowest and highest, and the widest spread. The readout, each point's name and the table view carry the spread and the change.
|
|
18
|
+
|
|
19
|
+
**Props:** `data`, `x`; `sort`; `range`, `rangeLabel`, `rangeSeries`; `from` and `to`; `zero` (default false); `relevance` and `rescale`; `selected`, `onSelect`, `drill`; `focus`; `tooltip`; `empty`.
|
|
20
|
+
|
|
21
|
+
**Selecting, drilling and filtering** work as on every chart: a mark that acts gets a 2px `ink` outline round its dots under the pointer or keyboard, never a lift; misses recede to 35% where they stand.
|
|
22
|
+
|
|
23
|
+
**You provide:** a `ChartContainer` whose `config` names and formats the value (or `from` and `to`), the records, and usually a `summary` or a `takeaway` that says what the comparison means.
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
<ChartContainer config={{ p95: { label: 'p95', format: (v) => `${v} ms` } }}>
|
|
27
|
+
<DotPlot data={services} x="service" range={['p10', 'p90']} rangeLabel="10th–90th percentile" sort="desc" />
|
|
28
|
+
</ChartContainer>
|
|
29
|
+
|
|
30
|
+
<ChartContainer config={{ q2: { label: 'Q2' }, q3: { label: 'Q3' } }}>
|
|
31
|
+
<DotPlot data={teams} x="team" from="q2" to="q3" sort="desc" />
|
|
32
|
+
</ChartContainer>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- Do use it when the differences are a few percent of the values, or when each value is an average with a spread.
|
|
36
|
+
- Do sort the rows unless their order means something.
|
|
37
|
+
- Don't use it for change over time; that's a `LineChart`, which implies continuity.
|
|
38
|
+
- Don't set `zero` just to have it: if zero matters, a `BarChart` is usually the better form.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Drilldown
|
|
2
|
+
|
|
3
|
+
Navigates a hierarchy as a **stack**. Each place you go into is laid on top of the one before it; going back lifts sheets off the top.
|
|
4
|
+
|
|
5
|
+
- Do use `Breadcrumb` for the same staircase in an app with routes; the two share one implementation.
|
|
6
|
+
- **The breadcrumb is a staircase.** Every crumb is a step standing on the same ground line, so all their contact shadows line up; a deeper place is a *taller* step (its wall is 3px longer), not one floating higher. Face and wall share one hairline (`--alt-ring`), so the top edge of a white step always reads against a light page, and every step casts its contact shadow at the same ground line. The root is the lowest step and where you are now is the tallest. Every step except the current one is a button: it rises 1px when pointed at and presses down to the ground when clicked, and choosing it climbs back to that place. When you go deeper, the new step rises up out of the ground.
|
|
7
|
+
- **The view sits on a pile.** One sheet shows beneath it for each level you've descended (up to three), the same language as `Pile`: depth you can see at rest.
|
|
8
|
+
- **Transitions follow the stack.** Going deeper, the child is laid on top while the parent is pressed beneath it; going back, the top sheet lifts off and the parent comes up from under it. Focus moves to each new view.
|
|
9
|
+
|
|
10
|
+
**You provide:** `root`, a tree of `{ id, title, description?, children?, content? }`. A node with children lists them; a leaf shows its `content`.
|
|
11
|
+
|
|
12
|
+
- Use it for nested places people move through (workspace → project → environment → build).
|
|
13
|
+
- Don't use it for steps in a process; a stack implies you can always lift back out.
|
|
14
|
+
|
|
15
|
+
**Charts that drill.** A node with both `content` and `children` shows its content, and the content opens the children itself: a chart's `drill={(d) => childId}`, or `useDrilldown().open(id)` from your own component. After a step, focus moves to the new view's own first stop when it names one (`data-drill-focus`, as a chart's summary does), otherwise to the view.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# DropZone
|
|
2
|
+
|
|
3
|
+
A receptacle that opens only when there's something to receive: flush at rest, sunk while a file is in flight, deeper under the pointer.
|
|
4
|
+
|
|
5
|
+
At rest it's a dashed outline on the ground. As soon as files are dragged anywhere over the page it sinks into a well (armed), so every place that can take the file announces itself. Hovering it deepens it to a trench with an `accent-ink` rim and `accent-soft` fill (over). Dropped files settle in with an afterglow. Clicking, Enter or Space opens the file picker.
|
|
6
|
+
|
|
7
|
+
**You provide:** `onFiles`, optional `accept` and `multiple`, the zone's content (an icon and "Drop files or browse"). `state` forces a visual state for documentation.
|
|
8
|
+
|
|
9
|
+
- Don't leave drop zones armed when nothing is being dragged; an always-open well stops meaning anything.
|
|
10
|
+
|
|
11
|
+
## The queue
|
|
12
|
+
|
|
13
|
+
Pass `queue` and each file you're handling is a slat under the zone, named by its file and size:
|
|
14
|
+
|
|
15
|
+
- **uploading** is a task, so a `Progress` fill rises out of its trench ("Uploading, 40%"), with Cancel;
|
|
16
|
+
- **processing** is a write the server hasn't confirmed, so the slat **hovers as pending** ("Processing"), with Cancel;
|
|
17
|
+
- **done** lands and settles with the afterglow ("Uploaded"), with Remove;
|
|
18
|
+
- **failed** drops to a trench with its reason and **Retry**, and Remove.
|
|
19
|
+
|
|
20
|
+
`UploadItem` is `{ id, name, size?, state, progress?, error? }`. `onRetry`, `onCancel` and `onRemove` get the id, and their buttons name the file ("Retry upload of report.pdf"). Progress is announced at 25, 50, 75 and 100%, and on processing, finishing and failing, never continuously. Afterglows start at most four at once.
|
|
21
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# DropdownMenu
|
|
2
|
+
|
|
3
|
+
Lists actions for a trigger; one highlight glides between items, which arrive in quick sequence.
|
|
4
|
+
|
|
5
|
+
The menu rises like a Popover. Items stagger in at 16ms each. A single `accent-soft` highlight (`critical-soft` on a destructive item) follows the pointer or arrow keys on `ease-settle` instead of each row blinking on and off. Arrow keys, Home and End move; Enter selects; Escape and Tab close and return focus.
|
|
6
|
+
|
|
7
|
+
**On touch.** An item's `shortcut` is shown only where there's likely a keyboard: on a touch-only device it's hidden until a key is pressed in the session. The shortcut still works.
|
|
8
|
+
|
|
9
|
+
**On a narrow screen.** It opens where its `side` and `align` put it, then shifts sideways to stay 8px inside the screen, and it's never wider than the screen less 16px.
|
|
10
|
+
|
|
11
|
+
**As a sheet.** `presentation="auto"` (the default) opens the menu as a sheet at the bottom edge on a narrow touch screen (under 640px, with a coarse pointer), where the thumb is: full width less 8px, clear of the safe areas, a head with its `title` (default: the trigger's label) and Close, and 44px rows. It's still the menu, at level 3, with the same keyboard and roles. `"anchored"` or `"sheet"` forces one.
|
|
12
|
+
|
|
13
|
+
**You provide:** `trigger` (usually a Button with a `ChevronDown` trailing icon) and `items`: actions (`label`, optional `icon`, `shortcut`, `onSelect`, `tone: 'critical'`, `disabled`), `{ type: 'separator' }` and `{ type: 'label', label }`.
|
|
14
|
+
|
|
15
|
+
**Predicted items.** Mark items you expect the person to want with `predicted: true`. They appear at once; the others fade in over `dur-onset` (500ms). Nothing moves, so the menu stays learnable. In Findlater et al.'s studies this made selection faster than a static menu and than highlighting, but only when predictions were right about four times in five; misses cost a little time. Leave it off unless your predictions are that good.
|
|
16
|
+
|
|
17
|
+
- Do use `ContextMenu` for the same menu opened where something is right-clicked; the two share one implementation.
|
|
18
|
+
- Do use `checked` for an item that turns something on or off: it becomes a `menuitemcheckbox` with a check, and every item keeps a slot for the check so labels line up.
|
|
19
|
+
- Do put destructive actions last, after a separator, in `critical`.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# EditRegion
|
|
2
|
+
|
|
3
|
+
Edit several fields of one record where the record is shown, and save them together as one write. Use it when two or more related values change together (a contact's name, email and role; a task's title, assignee and due date): several `InlineEdit`s would save one at a time and could leave the record half-changed, and a `Dialog` would cover the neighbouring records people look at to judge what a reasonable value is.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<EditRegion label="Contact" value={contact} onValueChange={setContact}
|
|
7
|
+
validate={(draft) => draft.email.includes('@') ? undefined : { email: 'Enter an email address.' }}
|
|
8
|
+
onSave={async (draft, { previousValue, changed }) => {
|
|
9
|
+
const result = await api.patchContact(contact.id, pick(draft, changed), contact.revision);
|
|
10
|
+
return result.ok ? { ok: true, value: result.contact } : { ok: false, error: result.message, fieldErrors: result.fieldErrors };
|
|
11
|
+
}}>
|
|
12
|
+
<EditRegionView>
|
|
13
|
+
<strong>{contact.name}</strong> <span>{contact.email}</span>
|
|
14
|
+
</EditRegionView>
|
|
15
|
+
<EditRegionForm>
|
|
16
|
+
{(field) => <>
|
|
17
|
+
<Field label="Name" required error={field.error('name')}><Input {...field('name')} /></Field>
|
|
18
|
+
<Field label="Email" required error={field.error('email')}><Input type="email" {...field('email')} /></Field>
|
|
19
|
+
<Field label="Role"><Select {...field.value('role')} items={roles} /></Field>
|
|
20
|
+
<Switch {...field.checked('notify')} label="Email them when this project changes" />
|
|
21
|
+
</>}
|
|
22
|
+
</EditRegionForm>
|
|
23
|
+
</EditRegion>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Elevation
|
|
27
|
+
|
|
28
|
+
- **Read mode** is the record on the ground (level 0): the values as text and a visible ghost “Edit <label>” button.
|
|
29
|
+
- **Editing** lifts the region into a static raised card (level 1, `surface-raised`, `elev-1`, a `line-control` border), as an `Accordion` lid lifts over its compartment. The card's padding and border are there in read mode too, transparent, under a matching negative margin, so lifting changes colour and shadow but never moves the record. Leave room for that margin either side: `space-3` plus 1px, or 22px with `layout="in-place"`. It doesn't lift on hover: it's being worked in, not offered. The fields inside are openings: empty wells, filled flush, invalid trenches, as `Input`, `Select` and `Field` draw them.
|
|
30
|
+
- **Saving** uses `Surface status="pending"`: after `dur-grace` it hovers at level 2, so a fast save shows nothing. **A known failure** drops to the failed trench with a critical `Alert` inside the editor; the draft stays. **Accepted** values settle back to the ground with one afterglow for the region, however many fields changed.
|
|
31
|
+
- **An outside change** while editing keeps the draft, blocks Save and shows a caution `Alert` “This record changed” with “Use current values”.
|
|
32
|
+
|
|
33
|
+
The words carry every state without height: the form's name, the button labels, `aria-invalid` and error text on fields, `aria-busy` on the form while saving, and the polite status (“Checking”, “Saving changes”, “Saved” or the error), which is announced, never shown, and clears itself after `dur-linger`. The afterglow fills the padded card, so it never sits against the text. Under forced colours the editor is a raised Surface with a border; under `contrast="more"` it gets level 1's ink slab.
|
|
34
|
+
|
|
35
|
+
## Props
|
|
36
|
+
|
|
37
|
+
| Prop | |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `label` | names the region: the form is “Edit <label>” |
|
|
40
|
+
| `value`, `onValueChange` | the committed record, and a synchronous setter for the accepted canonical record (no network write) |
|
|
41
|
+
| `onSave(draft, { previousValue, changed })` | the sole write. Return `{ ok: true, value }` with the canonical record, or `{ ok: false, error, fieldErrors? }` for a known failure. A throw or malformed result shows a generic retry message |
|
|
42
|
+
| `validate(draft)` | on Save: an error per field, or `undefined`; sync or a promise |
|
|
43
|
+
| `open`, `defaultOpen`, `onOpenChange` | control the session. Closing a controlled region doesn't cancel a dispatched save |
|
|
44
|
+
| `disabled`, `initialFocus`, `focusFallback`, `isEqual` | as `InlineEdit`; `initialFocus` names a field key; `isEqual` compares one field's values (default `Object.is`) |
|
|
45
|
+
| `editLabel`, `saveLabel`, `cancelLabel` | default “Edit <label>”, “Save changes”, “Cancel” |
|
|
46
|
+
| `settleDuration` | how long the afterglow takes to fade after a save, in ms. Default 5000 (shorter than `dur-settle`, because the person is watching the change) |
|
|
47
|
+
| `layout` | `'form'` (default): editing opens your form. `'in-place'`: each value's well opens where its text is (below) |
|
|
48
|
+
|
|
49
|
+
Parts: `EditRegionView` (read mode; `trigger={false}` to place `EditRegionTrigger` yourself), `EditRegionValue` (one value in read mode, named by its field; `reserve` for labelled rows), `EditRegionForm` (edit mode; children may be a function of the draft binder; `actions={false}` to place `EditRegionActions` yourself), `EditRegionTrigger`, `EditRegionActions`.
|
|
50
|
+
|
|
51
|
+
The binder: `field(key)` for text controls (`value` + `onChange`, `readOnly` while locked), `field.value(key)` for `Select`, `Combobox` and `Slider` (`value` + `onValueChange`), `field.checked(key)` for `Switch` and `Checkbox`, `field.error(key)` for the field's `Field`, and `field.draft`. Each binding carries `data-field`, which `initialFocus` and error focus use.
|
|
52
|
+
|
|
53
|
+
## In place: `layout="in-place"`
|
|
54
|
+
|
|
55
|
+
With `layout="in-place"` the fields open where the values are, as a single `InlineEdit` does, instead of a form replacing the record.
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
<EditRegion layout="in-place" label={person.name} value={person} onValueChange={…} onSave={…}>
|
|
59
|
+
<EditRegionView>
|
|
60
|
+
<span className="name"><EditRegionValue field="name">{person.name}</EditRegionValue></span>
|
|
61
|
+
<span className="meta"><EditRegionValue field="email">{person.email}</EditRegionValue> · <EditRegionValue field="role">{roleName}</EditRegionValue></span>
|
|
62
|
+
</EditRegionView>
|
|
63
|
+
<EditRegionForm>
|
|
64
|
+
{(field) => <>
|
|
65
|
+
<div className="name"><Field label="Name" error={field.error('name')}><Input {...field('name')} /></Field></div>
|
|
66
|
+
<div className="meta-row">
|
|
67
|
+
<Field label="Email" error={field.error('email')}><Input {...field('email')} /></Field>
|
|
68
|
+
<Field label="Role"><Select {...field.value('role')} items={roles} /></Field>
|
|
69
|
+
</div>
|
|
70
|
+
</>}
|
|
71
|
+
</EditRegionForm>
|
|
72
|
+
</EditRegion>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **Mark each read value** with `EditRegionValue field="…"`, using the same key you bind. A field whose text opens where its value was simply appears; one that opens elsewhere (a control wider or taller than its text pushes the next one along) fades in there. Nothing slides.
|
|
76
|
+
- **Lay the fields out where the values were**, in the same wrappers, so each control takes the value's font. Controls reach 13px past their column on each side, the inset their text sits at, so the text lines up and the well reaches into the gutter: leave 26px more between columns than you want to see. Give rows fixed columns (a grid) rather than columns sized by what's typed, so nothing reflows while the editor opens. The form adds no spacing of its own.
|
|
77
|
+
- **A `Field`'s label is announced, not shown**: the record around the field already names it (a row's label, the value's place). Its error, `aria-invalid` and description still work.
|
|
78
|
+
- **Labelled rows**: `EditRegionValue reserve` gives a read value its control's height and inset now. In a list of label–value rows, Edit all then moves nothing, and only the actions row is added at the end.
|
|
79
|
+
|
|
80
|
+
## Behaviour
|
|
81
|
+
|
|
82
|
+
- Edit opens the form and focuses the first field (or `initialFocus`). Tab moves through the fields and out to the page; blur and outside clicks do nothing to the session (no save on blur, as `InlineEdit`).
|
|
83
|
+
- Enter in a single-line field saves; Ctrl/Cmd+Enter in a textarea saves. Nothing acts during IME composition. Escape cancels, except while a popup inside (a Select's list) is open: that Escape closes the popup, and the next one cancels. Escape never cancels a dispatched save; it announces “Saving changes. Wait for the result.”
|
|
84
|
+
- Saving an unchanged draft closes without a write. `changed` lists only the fields that differ, so the application can send a patch.
|
|
85
|
+
- Validation errors mark each field and focus the first invalid one; typing in a field clears its error. A save's `fieldErrors` are shown the same way, beside the `error` Alert.
|
|
86
|
+
- One write per record may be in flight: Save, Enter and repeated presses dispatch once. While saving, text fields are read-only and choice controls disabled.
|
|
87
|
+
- An observed `value` change during a save latches a conflict: the result is never published, and the message says the save may have completed. The application must reconcile its server state.
|
|
88
|
+
- Completion returns focus to the Edit button only if focus was still in the editor. Unmounting invalidates pending work and suppresses publication.
|
|
89
|
+
- Inside another `<form>`, the editor is a `role="group"` instead of a form, so forms don't nest.
|
|
90
|
+
|
|
91
|
+
## You provide
|
|
92
|
+
|
|
93
|
+
The record, the write (with a server-side revision or equivalent concurrency guard), validation policy, and reconciliation of interrupted writes, exactly as for `InlineEdit`. EditRegion doesn't autosave, keep history, or roll back a dispatched write.
|
|
94
|
+
|
|
95
|
+
## Do and don't
|
|
96
|
+
|
|
97
|
+
- **Do** keep the record's neighbours visible: that's why it edits in place.
|
|
98
|
+
- **Do** use `InlineEdit` for a single value; EditRegion is for values that must change together.
|
|
99
|
+
- **Don't** put more than about five fields in one region. A long form belongs in a `Sheet` beside the page.
|
|
100
|
+
- **Don't** update `value` optimistically from `onSave`: an observed change is treated as someone else's write.
|
|
101
|
+
|
|
102
|
+
## Demo and verification
|
|
103
|
+
|
|
104
|
+
The `EditRegion` section of `build/previews.txt` lists three people with access; each row is an in-place EditRegion over a fictional people service with revisions. Below it, trip details are label–value rows with reserved values. A collapsed harness picks a save fixture (`success`, `slow`, `fail-once`, `conflict-during-save`) and applies an outside update. `node checks/edit-region.mjs` (from `build/`, after rendering previews; `ALT_FIXTURE_ROOT` selects React 19 fixtures) covers editing in place (fields hold their place on every frame while the editor grows, the record below is pushed through the glide, lifting never moves the record, each field opens at its value's x, reserved values open exactly where they were, the padded afterglow, the announcement that clears itself), opening and focus, unchanged saves, one write per save, `changed`, validation and error focus, Escape with an open Select, IME Enter, failure and retry, pending lock, both conflict paths, focus return, and a 320/960px layout in both schemes under reduced motion, forced colours and `contrast="more"`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# EmptyState
|
|
2
|
+
|
|
3
|
+
What a list, board, search or page shows when it has nothing to show: a statement of what's empty, why, and one obvious next step.
|
|
4
|
+
|
|
5
|
+
**Why it's sunk.** An empty region isn't a problem (that's `Alert`) and it isn't a settled container (that's `Card`): it's a space waiting to receive, where the record hasn't landed yet. So it's an opening, a well cut into the page (`surface-well`, `well`) with a dashed `line-control` rim, the same below-ground signal as an empty field or an armed `DropZone`: sunken things want content. Its actions are ordinary raised buttons standing in it, the primary one at level 2. Pointing at or tabbing to the primary action deepens the well to a trench and darkens its rim to `ink-muted`, showing where the record it creates will land. When it appears it sinks in from flush (`dur-travel-1`, `ease-sink`), the inverse of new content arriving lifted; it never pulses or loops, because an empty state must never beg.
|
|
6
|
+
|
|
7
|
+
**Without height.** The rim is dashed while the region is empty, and the title says so in words; the icon is in `currentColor`. Under forced colours the rim is a dashed `CanvasText` border, like every sunk surface; with `contrast="more"` it turns solid.
|
|
8
|
+
|
|
9
|
+
**Accessibility.** It's a `section` named by its title (`aria-labelledby`, an `h3` by default; set `headingLevel` to fit the page's outline) and described by its description. Actions are real buttons or links in the normal tab order: nothing is focused for you and nothing traps focus. When an empty follows something the person did (a filter or a search that matched nothing), pass `announce` and it's said politely once it appears: `true` says the title, a string says that.
|
|
10
|
+
|
|
11
|
+
**You provide:** `title` (what's empty, as a statement), usually a `description` (why, or what will appear here), an `icon` (a Lucide line icon), and `action` (one primary `Button` whose label starts with a verb). Optionally `secondary` (a ghost `Button` or a link), `children` (extra content above the actions), `size="sm"` for a widget or a list, and `announce`.
|
|
12
|
+
|
|
13
|
+
- Do name what's empty and what to do: "No gift ideas yet" and **Add idea**, not "Nothing here" and **OK**.
|
|
14
|
+
- Do say which kind of empty it is in words: nothing yet, nothing matches, or you can't see it. Colour and height don't tell them apart.
|
|
15
|
+
- Do give it one primary action at most; a second one is `secondary`, quieter.
|
|
16
|
+
- Don't use it for an error that stopped content loading: that's a problem to fix, an `Alert`.
|
|
17
|
+
- Don't raise it into a card, or make it the only thing on a page that's still loading: show a `Skeleton` until you know it's empty.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# FeatureGate
|
|
2
|
+
|
|
3
|
+
A feature that needs another plan, shown to people on a plan that doesn't include it: "Export to CSV · Business", "SAML SSO · Enterprise", a menu item with a lock.
|
|
4
|
+
|
|
5
|
+
**Elevation: locked, not disabled.** Disabled means "nothing happens": flush, `ink-faint`, skipped by the keyboard and often by screen readers. Then the people who rely on those never learn the feature exists, and `ink-faint` fails contrast. **A locked control does something**: it explains the plan and offers the upgrade. So it stays live:
|
|
6
|
+
- it keeps the wrapped control's own build and normal ink;
|
|
7
|
+
- **it lifts when pointed at** (hover is a promise);
|
|
8
|
+
- it carries a lock and a flush `Badge` plate naming the plan.
|
|
9
|
+
|
|
10
|
+
Pressing it opens a `Popover` (level 3): "Available on Business", what the plan adds, and "Upgrade to Business" as the popover's one primary action, with "Compare plans" if you give `compareHref`. When this person can't upgrade (`canUpgrade={false}`, not an admin), the button becomes "Ask an admin to upgrade to Business."
|
|
11
|
+
|
|
12
|
+
**Accessibility.** The control stays a button with `aria-haspopup="dialog"`. **It's never `aria-disabled`.** Its name includes the requirement ("Export to CSV, requires Business plan"). It's reachable and activatable by keyboard, Escape closes the explanation, and focus returns to the control. The wrapped control's `onClick` and `disabled` are dropped: what it does while locked is explain.
|
|
13
|
+
|
|
14
|
+
**Props:** `requires`; `description`; `onUpgrade`; `compareHref`; `canUpgrade` (default true); `children` (one control).
|
|
15
|
+
|
|
16
|
+
**You provide:** the control, the plan it needs, what the plan adds, and where upgrading goes.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<FeatureGate requires="Business" onUpgrade={() => openBilling('business')}
|
|
20
|
+
description="Export any view to CSV and schedule exports by email.">
|
|
21
|
+
<Button variant="secondary">Export to CSV</Button>
|
|
22
|
+
</FeatureGate>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Do keep locked features visible, so people know they exist.
|
|
26
|
+
- Do say what the plan adds, not just its name.
|
|
27
|
+
- Don't use `disabled` for a feature that needs another plan.
|
|
28
|
+
- Don't hide the upgrade from people who can't buy it; tell them who can.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Field
|
|
2
|
+
|
|
3
|
+
Pairs one control with its label, an optional hint and an error message that emerges beneath it.
|
|
4
|
+
|
|
5
|
+
`Field` wires the `id`, `aria-describedby`, `required` and `invalid` props onto its single child, so the label, hint and error are announced with the control. Existing child description IDs are preserved and deduplicated with the rendered hint and error IDs. Both hint and error stay available when an error is shown; the control sinks to its invalid state, and the message emerges from under the field (`alt-emerge`, `dur-travel-2`) in `critical` with an icon.
|
|
6
|
+
|
|
7
|
+
**You provide:** `label`, optionally `hint`, `error` (a string or node; falsy when valid) and `required`, and exactly one control as the child. A child’s `required` prop is preserved unless Field explicitly supplies one.
|
|
8
|
+
|
|
9
|
+
- Do write the error as the fix: "Use at least 5 characters", not "Invalid input".
|
|
10
|
+
- Don't show an error before the person has finished with the field; validate on blur or submit.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# FilterBar
|
|
2
|
+
|
|
3
|
+
Build and change a query as a row of conditions, each edited in place: “Status is In review”, “Assignee is Mara Quist”, “Updated between 1 Oct and 20 Oct”. Click a condition's field, operator or value to change just that part; add one with Add filter; remove one with its ×. Pair it with `DataTable`'s `relevance` so the results filter in place: matches rise, misses sink, and nothing reshuffles.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
const fields: FilterField[] = [
|
|
7
|
+
{ key: 'status', label: 'Status', type: 'enum', options: statuses },
|
|
8
|
+
{ key: 'assignee', label: 'Assignee', type: 'enum', options: people, searchable: true },
|
|
9
|
+
{ key: 'updated', label: 'Updated', type: 'date' }, // before, after, between
|
|
10
|
+
{ key: 'title', label: 'Title', type: 'text' }, // contains, does not contain
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
<FilterBar fields={fields} value={filters} onValueChange={setFilters} match={match} onMatchChange={setMatch} resultCount={visible.length} />
|
|
14
|
+
<DataTable rows={rows} relevance={(r) => filters.length ? (matches(r, filters) ? 'match' : 'miss') : undefined} … />
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The value is local query state, `{ id, field, operator, value }[]`, so there's no pending state: the application decides what each condition means (`matches` above). Dates are ISO `'YYYY-MM-DD'` strings; `between` takes `{ start, end }`.
|
|
18
|
+
|
|
19
|
+
## Elevation
|
|
20
|
+
|
|
21
|
+
- **The bar and its conditions** are part of what you're looking at, so they're on the ground: each condition is a plate stamped flush like a Badge, split into segments (field · condition · value · ×).
|
|
22
|
+
- **A segment** opens a picker, so it lifts one level when pointed at or open (hover is a promise). Pickers float at level 3, as `Select`'s list does.
|
|
23
|
+
- **A condition waiting for its value** keeps that segment as an opening: a small dashed well reading “Choose a value”. **Add filter** is an opening too, a dashed well that deepens to a trench under the pointer.
|
|
24
|
+
- **A changed condition** is re-stamped (down 3px, settling), as a Badge whose label changes.
|
|
25
|
+
- **Adding** a condition: the field list sinks, then the new condition's value picker rises. The two never move at once.
|
|
26
|
+
|
|
27
|
+
Without height: the bar is a toolbar named “Filters” (or your `aria-label`); each condition is a group named by its sentence; each segment says what it is and what it does (“Value: In review. Change value”); the remove button names the condition; changes announce the new sentence and, with `resultCount`, the count (“Status is Done. 12 results”). Forced colours: conditions 1px, openings dashed, pickers 2px. `contrast="more"`: a hovered segment gets a 2px slab.
|
|
28
|
+
|
|
29
|
+
## Keyboard
|
|
30
|
+
|
|
31
|
+
The bar is one Tab stop (the toolbar pattern): ←/→ move between segments (mirrored in RTL), Home/End go to the ends. Enter, Space or ↓ opens a segment's picker; in a list, ↑/↓, Home/End and Enter choose; Escape closes it and leaves focus on the segment. Delete or Backspace on any segment removes its whole condition; focus moves to the next condition, else the previous, else Add filter. Text and number pickers apply on Enter; a date range applies once it has both ends.
|
|
32
|
+
|
|
33
|
+
## Props
|
|
34
|
+
|
|
35
|
+
| Prop | |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `fields` | `{ key, label, type, options?, searchable?, operators? }`; operators default by type |
|
|
38
|
+
| `value`, `onValueChange` | the conditions |
|
|
39
|
+
| `match`, `onMatchChange` | `'all'` or `'any'`, shown as a segment once there are two conditions |
|
|
40
|
+
| `resultCount` | announced after each change |
|
|
41
|
+
| `createId`, `aria-label`, `className` | |
|
|
42
|
+
|
|
43
|
+
`filterValueText(field, value)` gives a value as words, for your own summaries.
|
|
44
|
+
|
|
45
|
+
## You provide
|
|
46
|
+
|
|
47
|
+
What each condition means for your rows, and the results. Saved views and nested groups (A and (B or C)) aren't covered.
|
|
48
|
+
|
|
49
|
+
## Do and don't
|
|
50
|
+
|
|
51
|
+
- **Do** pair it with `relevance` so the result set changes in place.
|
|
52
|
+
- **Do** keep fields to the handful people actually filter by; a long field list gets a search field automatically past eight.
|
|
53
|
+
- **Don't** use it for a one-off search box: that's an `Input` in the table's toolbar.
|
|
54
|
+
|
|
55
|
+
## Demo and verification
|
|
56
|
+
|
|
57
|
+
The `FilterBar` section of `build/previews.txt` filters an issue table in place. `node checks/filter-bar.mjs` (from `build/`; `ALT_FIXTURE_ROOT` selects React 19 fixtures) covers the one Tab stop and arrow keys, opening and closing pickers, changing a value and an operator, the announcement with its count, re-stamping, Add filter with its field-then-value sequence and the empty value opening, text and date-range values, Match all/any, removal with focus repair, the paired table's relevance, and 320/960px layouts in both schemes under reduced motion, forced colours and `contrast="more"`.
|
|
58
|
+
|
|
59
|
+
## Nested groups
|
|
60
|
+
|
|
61
|
+
Automation rules, segments and advanced search need nested logic: "Plan is Pro **and** any of (Country is Germany, Seats > 50)". Pass a `FilterTree` and `nesting={2}`:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
<FilterBar fields={fields} nesting={2} value={tree} onValueChange={setTree} />
|
|
65
|
+
// tree: { match: 'all', items: [Filter | { id, match: 'any', items: Filter[] }] }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A group is drawn as **a flush bracket on the ground**: a 1px `line-control` rule around its own conditions, with its own all/any switch (shown even with one condition) and its own Add filter. Groups are part of the record of the query, not live surfaces. Depth is capped at one level of groups so it never becomes a code editor, and groups follow the top level's conditions. "+ Add group" starts an "any of" group, and removing a group's last condition removes the group. `filterSentence(fields, tree)` reads the whole expression in words, and it's announced as it changes. `value: Filter[]` keeps working as before.
|
|
69
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# FormattedNumber
|
|
2
|
+
|
|
3
|
+
A number in a sentence or a table cell, formatted once: invoices, usage ("4.5 GB of 10 GB"), latency ("128 ms"), seats, error rates. `formatNumber()` gives the same text as a string, for `DataTable` columns and chart formatters.
|
|
4
|
+
|
|
5
|
+
**Elevation.** A number is the record, so it lies flush in its line, in tabular figures, at no level. When the value changes, the old figure fades out over the new one (`dur-travel-2`) and the number takes a ground-level tint that decays over `dur-settle`. That's the afterglow without the lift, because a word in a line can't rise. It never counts up, as with `Stat`: counting delays the number for decoration.
|
|
6
|
+
|
|
7
|
+
**Presets** (`format`):
|
|
8
|
+
|
|
9
|
+
| `format` | value | shows |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `number` (default) | any | `1,250` |
|
|
12
|
+
| `currency` | minor units (cents) by default, as payment APIs send them | `€49.00` for `4900`. `minorUnits={false}` takes whole units. A currency with no minor unit (JPY) isn't divided. |
|
|
13
|
+
| `bytes` | bytes | `4.5 GB`, in the largest unit that keeps it at 1 or more; `base={1024}` gives `4.2 GiB` |
|
|
14
|
+
| `duration` | `unit` (default milliseconds) | `128 ms`, `1.6 min`: the largest unit that keeps it at 1 or more |
|
|
15
|
+
| `percent` | a fraction | `1.2%` for `0.0123` with `maximumFractionDigits={1}` |
|
|
16
|
+
| `compact` | any | `4.2K` |
|
|
17
|
+
|
|
18
|
+
Every other `Intl.NumberFormat` option (`maximumFractionDigits`, `signDisplay`, `currencyDisplay`, …) passes through.
|
|
19
|
+
|
|
20
|
+
**Accessibility.** It's a `<data value>` element carrying the raw value. When the text rounds or abbreviates the value (`4.2K`, `4.5 GB`), the exact value ("4,213", "4,512,000,000 bytes") is its `title`. Units whose symbols are ambiguous read aloud are spoken in words ("4.5 gigabytes", "128 milliseconds"). Changes aren't announced: put the number in a live region yourself if a change is something to hear. Under reduced motion there's no cross-fade, but the tint remains (it's colour, not travel). Under forced colours the tint is a `Highlight` outline.
|
|
21
|
+
|
|
22
|
+
**Props:** `value`; `format`; `locale`; `currency`; `minorUnits`; `unit`; `base`; any `Intl.NumberFormat` option; `title`.
|
|
23
|
+
|
|
24
|
+
**You provide:** the raw number and what it is. Format in one place, not with `toFixed` and string concatenation.
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
<FormattedNumber value={4900} format="currency" currency="EUR" /> // €49.00
|
|
28
|
+
<FormattedNumber value={4_512_000_000} format="bytes" /> // 4.5 GB
|
|
29
|
+
<FormattedNumber value={128} format="duration" /> // 128 ms
|
|
30
|
+
<FormattedNumber value={0.123} format="percent" maximumFractionDigits={1} /> // 12.3%
|
|
31
|
+
formatNumber(4213, { format: 'compact' }) // "4.2K"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- Do pass amounts of money in minor units, as your payment provider sends them, or set `minorUnits={false}`.
|
|
35
|
+
- Do use `formatNumber` for table columns and chart formatters, so a number reads the same everywhere.
|
|
36
|
+
- Don't animate digits or count up to a new value.
|
|
37
|
+
- Don't use it for a headline figure; that's a `Stat`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Heatmap
|
|
2
|
+
|
|
3
|
+
A value per cell across two dimensions: load by hour and weekday, error rate by service and region, or activity by day over a year (`variant="calendar"`, as in GitHub's contributions). It renders inside a [`ChartContainer`](../ChartContainer/README.md), which gives it the frame, the readout, the keyboard path, the table view, loading and empty states.
|
|
4
|
+
|
|
5
|
+
**Why it's flat.** The cells are the record, so they lie on the plot: flat fills, parted by a 2px gap of the plot's own colour and rounded at `radius-xs` (less on small cells, so a cell stays a square). No shadow, no wall, no sheen and no gradient. Amounts are carried by the **Amounts** signal's colour channel, the sequential ramp, never by height.
|
|
6
|
+
|
|
7
|
+
**Steps, not a gradient.** Values are binned into two to seven steps (`bins={[0, 1, 4, 10]}` gives "0", "1–3", "4–9", "10+"), spread over `data-seq-1` to `data-seq-7`, so each colour is a class you can name and find in the legend. Zero is the lightest step. Without `bins`, counts get a step of their own for zero and even steps above it, and other values five to seven even steps. `scale="diverging"` uses `data-div-*` around `data-div-mid` for which side of `midpoint` (default 0) a value is on; without `bins` that's seven steps on round numbers, the middle one within a step of the midpoint.
|
|
8
|
+
|
|
9
|
+
**No data.** A cell with no record, or a `null` value, has no fill and a 1px `line` outline, and says "No data". It isn't a well: it isn't waiting for anything.
|
|
10
|
+
|
|
11
|
+
**The legend** is the steps as swatches, each with its range in words, under the cells (`legend={false}` to leave it out). Ranges say their unit once ("−0.5 to +0.5 pts").
|
|
12
|
+
|
|
13
|
+
**Layout.** In the grid, columns fill the width and rows are as tall as a column is wide, up to 34px. In the calendar, a column is a week (`weekStart` 0, Sunday, or 1, Monday), months are named above the week each begins in, and Mon, Wed and Fri down the side. The plot is as tall as its cells need. Cells are at least 10px: narrower than that, the columns wrap into bands (a year into three at 360px; 24 hours into 00–11 and 12–23), each naming its rows and columns.
|
|
14
|
+
|
|
15
|
+
**Reading a cell.** There's no crosshair in a grid, so the cell under the readout wears a 2px `ink` outline. The readout sits beside it: the place ("Tue, 14"), then the value keyed by its step's colour.
|
|
16
|
+
|
|
17
|
+
**Without colour.** Under forced colours and in print, and with `patterns` on the container, each step also gets a pattern density (none, sparse dots, dense dots, hatching, crosshatch), so the ramp survives losing its colours; a diverging scale hatches its two sides in opposite directions. The values stay in the cell's name and the table view. With `contrast="more"` every cell has a 1px `ink` edge.
|
|
18
|
+
|
|
19
|
+
**Motion.** A value change cross-fades the cell's fill over `dur-travel-1`; a whole refresh is one change, so in a `live` container it lights the "Updated" caption, not every cell. Under reduced motion it's instant.
|
|
20
|
+
|
|
21
|
+
**Accessibility.** The keyboard path is one Tab stop; Enter goes from the summary straight to the cells (there's no series level), and Escape comes back. Arrows move cell to cell and stop at the edges; Home and End go to the start and end of the row, and with Ctrl (or Cmd) the grid's; Page Up and Page Down move a month in the calendar. Each cell is named by its row, its column, its value and its step: "Tuesday, 14:00, 312 requests, step 5 of 7." (rows and columns as `yFormat` and `xFormat` write them), or in the calendar "Thursday, Nov 27, 2025, 4 commits, step 3 of 4." A diverging cell also says its side ("below 0"). The focus ring is the cell's own box. Without a written `summary`, the generated one names the highest and lowest cells, how many have no data, and the steps. The table view has a row per cell with its step.
|
|
22
|
+
|
|
23
|
+
**Props:** `data`, `value`; `x` and `y` for the grid, or `variant="calendar"` and `date`; `bins`, `scale`, `midpoint`; `xFormat` and `yFormat` for column and row names; `weekStart`; `legend`; `tooltip`.
|
|
24
|
+
|
|
25
|
+
**Selecting and drilling.** `selected` and `onSelect` make cells a selection that cross-filters the rest of a dashboard; `drill` opens a level down inside a `Drilldown`. `selected` is the record or its id: `"14|Tue"` (x, then y) for a grid, `"2026-03-14"` for a calendar. The selected cell keeps its colour, the rest recede to 35%, and the frame says "Filtered to Tue, 14". See [ChartContainer](../ChartContainer/README.md), "Marks you can select or drill into".
|
|
26
|
+
|
|
27
|
+
**Filtering in place.** `relevance={(d) => 'match' | 'miss' | undefined}` recedes misses to 35% where they stand.
|
|
28
|
+
|
|
29
|
+
**You provide:** a `ChartContainer` whose `config` names the value key (its label and format), the records, and usually a `summary`.
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<ChartContainer config={{ count: { label: 'Commits' } }} title="Commits">
|
|
33
|
+
<Heatmap variant="calendar" data={commits} date="day" value="count" bins={[0, 1, 4, 10]} />
|
|
34
|
+
</ChartContainer>
|
|
35
|
+
|
|
36
|
+
<ChartContainer config={{ requests: { label: 'Requests' } }} title="Requests by hour">
|
|
37
|
+
<Heatmap data={load} x="hour" y="weekday" value="requests" />
|
|
38
|
+
</ChartContainer>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- Do use five to seven steps; more can't be told apart, especially in small cells.
|
|
42
|
+
- Do give zero its own step when zero means "nothing happened".
|
|
43
|
+
- Do use `scale="diverging"` only when the midpoint means something (no change, a target).
|
|
44
|
+
- Don't use a heatmap to compare a handful of values; that's `BarChart`.
|
|
45
|
+
- Don't encode a second measure in cell size or a glow; it's one value per cell.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# HoldButton
|
|
2
|
+
|
|
3
|
+
Commits a consequential action by pushing it through the ground: hold until it sinks into a trench.
|
|
4
|
+
|
|
5
|
+
At rest it's a level-2 button (danger by default). While held, it sinks steadily from its wall into a `trench` over `dur-hold`, while a fuse along its bottom edge burns across in the label colour. Let go early and it springs back on `ease-settle`. A live region says "Keep holding to confirm".
|
|
6
|
+
|
|
7
|
+
**When the hold completes, it's final.** `onConfirm` is called once, the button settles flat on the ground, shows `doneLabel` and can't be pressed again: the action is now part of the record. It stays focusable (it uses `aria-disabled`, not `disabled`) so keyboard focus isn't lost. For actions that can be repeated, set `repeatable` and the button re-arms after a moment.
|
|
8
|
+
|
|
9
|
+
**It never changes width.** Its label, `holdingLabel` and `doneLabel` are laid out in the same place and only the current one shows, so the button is as wide as the longest of them from the start and nothing beside it moves when it completes.
|
|
10
|
+
|
|
11
|
+
This is a design friction: a small, deliberate obstacle in front of an action that's hard to undo, cheaper than a confirmation dialog and harder to trigger by accident than a click.
|
|
12
|
+
|
|
13
|
+
**You provide:** the label ("Hold to delete"), `onConfirm`, optional `holdingLabel` and `doneLabel` (say what happened: "Project deleted"), `variant` (`danger` or `primary`), `duration` in ms, `repeatable`.
|
|
14
|
+
|
|
15
|
+
- Use it for destructive or irreversible actions where the person already knows what will happen.
|
|
16
|
+
- Don't use it when the consequence needs explaining; that's a `Dialog`.
|