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,379 @@
|
|
|
1
|
+
# InlineEdit
|
|
2
|
+
|
|
3
|
+
Edit one value where its committed value already appears: by default a single-line string, a paragraph with `multiline`, or any value with a custom `editor`. Read mode shows the saved text (or “Not set”) and a visible Edit button. Editing happens in place: the value's text sits in a slot with the control's own inset and font, so when the control opens there the text doesn't move, and Save and Cancel take Edit's place on the same line. It is a group, with normal page Tab order and no focus trap.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
const [name, setName] = React.useState('Brand guidelines.pdf');
|
|
7
|
+
|
|
8
|
+
<InlineEdit
|
|
9
|
+
label="File name"
|
|
10
|
+
value={name}
|
|
11
|
+
onValueChange={setName}
|
|
12
|
+
onSave={async (draft, { previousValue }) => {
|
|
13
|
+
// Application code: enforce the record's server revision and reconcile
|
|
14
|
+
// an interrupted response before reporting a retryable failure.
|
|
15
|
+
const result = await renameFileOnServer(draft, previousValue);
|
|
16
|
+
return result.ok
|
|
17
|
+
? { ok: true, value: result.canonicalName }
|
|
18
|
+
: { ok: false, error: result.userMessage };
|
|
19
|
+
}}
|
|
20
|
+
required
|
|
21
|
+
maxLength={100}
|
|
22
|
+
/>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`value`, `onValueChange`, `onSave` and `label` are required. The application owns the committed string, authorization, validation policy and persistence. `onSave(draft, { previousValue })` is the sole write. It returns `{ ok: true, value: string }` with the canonical value, or `{ ok: false, error: string }` for a known failed write. A throw, rejection or malformed result displays “We couldn’t save this change. Try again.” without exception details.
|
|
26
|
+
|
|
27
|
+
Use an ordinary synchronous state setter for `onValueChange`: it publishes the accepted canonical string exactly once. It must not make another network write, veto success or defer publication. Do not update `value` optimistically from `onSave`; an observed external change means conflict. The captured `previousValue` helps an adapter identify its baseline, but is not a server lock. Enforce a record revision or equivalent server-side concurrency guard.
|
|
28
|
+
|
|
29
|
+
A transport timeout does not prove a write failed. Before throwing or returning a retryable failure, the adapter must reconcile the server record or retry the same logical operation ID idempotently. If the write committed, return canonical success. Only a known uncommitted write may become a new user retry. The preview includes an interrupted-after-save fixture demonstrating one committed mutation and reconciliation of the lost response.
|
|
30
|
+
|
|
31
|
+
## Editing and validation
|
|
32
|
+
|
|
33
|
+
Typing changes local text without trimming. Cancel and Escape discard that text; blur, outside clicks and Tab do nothing to the session. Enter saves (Ctrl/Cmd+Enter with `multiline`), except while IME composition is active (including legacy key code 229). Composition Escape also remains with the IME. Input Enter prevents a surrounding form from submitting.
|
|
34
|
+
|
|
35
|
+
Saving an exact unchanged value closes without validation, persistence, publication or afterglow. `required` defaults to false; when true it rejects the empty string using HTML required semantics. Spaces stay legal unless application validation rejects them. `maxLength` uses JavaScript/HTML UTF-16 string length and the native input limit. Optional `validate(draft)` runs only on Save and returns a user-facing error string or `undefined`, synchronously or as a promise. Use validation to reject whitespace-only or duplicate names when your application requires it.
|
|
36
|
+
|
|
37
|
+
While validation runs, the input is read-only and Save is unavailable; Cancel and Escape remain usable. Late validation results cannot affect a cancelled or replaced session. During persistence the input stays read-only, Save shows loading and Cancel is unavailable. Escape announces “Saving changes. Wait for the result.” It does not cancel a dispatched write. Duplicate and reentrant submissions dispatch at most once. A failed save preserves editable input and the original baseline for retry; typing clears ordinary errors.
|
|
38
|
+
|
|
39
|
+
## External updates and lifecycle
|
|
40
|
+
|
|
41
|
+
An observed `value` change during editing, validation or error retains the draft and blocks Save. The region shows the latest saved value and “Use current value”. That explicit action replaces the draft and baseline and refocuses the input; Cancel displays the latest record.
|
|
42
|
+
|
|
43
|
+
An update during a write latches a conflict even if a later observed update restores the earlier string. The request may finish, but its result is never published through `onValueChange`. The recovery message says the save may have completed and asks the person to check the current record. The application must reconcile its server state; the component cannot roll back a dispatched write or assert authoritative freshness.
|
|
44
|
+
|
|
45
|
+
Unmount invalidates pending work and suppresses subsequent publication, announcements, state and focus work. It does not abort persistence. Applications must still reconcile writes after navigation. Becoming `disabled` prevents new edits and Save while keeping an existing session cancellable and allowing a dispatched write to settle.
|
|
46
|
+
|
|
47
|
+
## Paragraphs: `multiline`
|
|
48
|
+
|
|
49
|
+
`multiline` swaps the Input for a Textarea, for a description, note or comment edited where it's shown.
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
<InlineEdit
|
|
53
|
+
label="Description"
|
|
54
|
+
multiline
|
|
55
|
+
rows={4} // initial height in lines (default 3)
|
|
56
|
+
maxRows={16} // grows with its content up to here, then scrolls (default 12)
|
|
57
|
+
maxLength={2000} // the Textarea's fill from the floor and live count near the limit
|
|
58
|
+
value={task.description}
|
|
59
|
+
onValueChange={setDescription}
|
|
60
|
+
onSave={(draft) => api.setDescription(task.id, draft, task.revision)}
|
|
61
|
+
/>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Enter inserts a newline; Ctrl+Enter (⌘+Enter on Apple platforms) saves; Escape cancels. During IME composition none of these act. The hint under the field names the keys with `Kbd` and describes the textarea. Opening puts the caret at the end instead of selecting everything, because selecting a paragraph invites replacing it by accident. Read mode keeps line breaks (`white-space: pre-wrap`) in a slot with the textarea's inset, with Edit (then Save and Cancel) below it, and shows “Not set” when empty. Opening glides the region's height from the read text to the editor's; after that the textarea grows line by line without animating, and under reduced motion the height changes at once. A chat-style “Enter sends” composer is a different component.
|
|
65
|
+
|
|
66
|
+
`multiline` applies to the default editor; with a custom `editor` it's ignored. `renderValue` still draws read mode if you pass it.
|
|
67
|
+
|
|
68
|
+
## Other values: `editor`, `renderValue` and `commit`
|
|
69
|
+
|
|
70
|
+
`InlineEdit<V>` takes any value type. `renderValue(value)` draws read mode (default: the value as text, or “Not set” when it's `''`, `null`, `undefined` or an empty array). `editor(control)` draws edit mode; spread `control` onto an Altitude control. It carries `value`, `onValueChange`, the `id` the label points at, `aria-describedby` for the error or hint, `invalid`, `readOnly` (checking, saving or blocked by a conflict; changes are ignored then), `disabled` and `autoFocus`. The session is unchanged: baseline, draft, `validate`, the `onSave` contract, conflict latching, unmount safety and focus return all work the same way. Without `editor`, the existing Input is used and nothing about the string case changes.
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
<InlineEdit<Status>
|
|
74
|
+
label="Status"
|
|
75
|
+
value={issue.status}
|
|
76
|
+
onValueChange={setStatus}
|
|
77
|
+
renderValue={(s) => <Badge tone={toneFor(s)}>{labelFor(s)}</Badge>}
|
|
78
|
+
editor={(control) => <Select {...control} items={statuses} />}
|
|
79
|
+
commit="on-choose"
|
|
80
|
+
onSave={(draft) => api.setStatus(issue.id, draft, issue.revision)}
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<InlineEdit<number | null>
|
|
84
|
+
label="Estimate"
|
|
85
|
+
value={task.estimate}
|
|
86
|
+
onValueChange={setEstimate}
|
|
87
|
+
required
|
|
88
|
+
validate={(n) => Number.isInteger(n) && n >= 0 ? undefined : 'Use a whole number, 0 or more.'}
|
|
89
|
+
renderValue={(n) => `${n} points`}
|
|
90
|
+
editor={({ value, onValueChange, ...control }) => (
|
|
91
|
+
<Input {...control} type="number" value={value ?? ''}
|
|
92
|
+
onChange={(e) => onValueChange(e.target.value === '' ? null : Number(e.target.value))} />
|
|
93
|
+
)}
|
|
94
|
+
onSave={(draft) => api.setEstimate(task.id, draft, task.revision)}
|
|
95
|
+
/>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`commit="on-choose"` is for single-choice editors (Select, Combobox, a date picker): choosing a value is the deliberate act, so it dispatches the save. Save is hidden and the hint under the control reads “Choose to save”; Cancel stays, so a pointer user can leave without choosing. Choosing the current value closes without a write. A rejected save keeps the editor open on the chosen value with the error, and adds “Try again”; choosing another value saves that instead. On success the list closes first, then the editor settles, and the status region names the value (“Status set to In review”, from the rendered read value). With the default `commit="explicit"`, Enter in a custom editor's single-line text input saves, as it does in the default Input; a choice control's own Enter (opening or choosing) is left to it.
|
|
99
|
+
|
|
100
|
+
Escape inside an open popup closes the popup only; the next Escape cancels the session. `isEqual(a, b)` (default `Object.is`) decides both whether a draft is unchanged and whether an observed `value` is an external change, so pass one for object values (a date range) whose identity changes on every render. `required` rejects an empty value of any type; `maxLength` applies to strings only. A canonical `onSave` result for a custom editor may be any value except `undefined`; for the default Input it must still be a string.
|
|
101
|
+
|
|
102
|
+
## In place
|
|
103
|
+
|
|
104
|
+
The value lives in a slot that has the control's inset (12px and a transparent 1px border, as `Input` and `Select` have) and, by default, its height (40px, or the value's line height plus 18px if that's taller). The line reaches 13px past its column on each side, so the value's text lines up with the text around it and the opened well reaches into the gutter. The control takes the value's own font: a 19px title is edited as a 19px title. Opening fades in only the well's floor, walls and border around text that stays put; no visible label is added above it (the label names the control for assistive technology). Whatever still changes size glides on the real box, so what's below is pushed rather than jumped: Save and Cancel wrapping onto a line of their own at narrow widths, an error, the key hint. Leave 13px of room either side of an InlineEdit for its well.
|
|
105
|
+
|
|
106
|
+
## Ways to open: `trigger` and `hint`
|
|
107
|
+
|
|
108
|
+
`trigger` sets how editing starts:
|
|
109
|
+
|
|
110
|
+
| `trigger` | read mode | opening |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `'button'` (default) | the value, then a visible Edit button (`editLabel`) | Edit, or Enter/Space on it; the value is selected |
|
|
113
|
+
| `'value'` | one button holding the value, a plate that lifts on hover | click, Enter or Space; the value is selected |
|
|
114
|
+
| `'pencil'` | one button holding the value; a pencil appears at the end of the text on hover or keyboard focus, in space that's always reserved, so showing it moves nothing. Touch screens can't hover, so they always show it | click, Enter or Space; the value is selected |
|
|
115
|
+
| `'field'` | one button holding the value; pointing at it draws the filled input's border and floor around the text, so the click only focuses it | a click puts the caret where it landed; Enter or Space selects the value |
|
|
116
|
+
|
|
117
|
+
With every trigger except `'button'`, the button is named “<label>: <value>. <editLabel>”, `emptyLabel` names an empty value (default “Not set”), and `shortcut` puts a key in the button's `aria-keyshortcuts` for a surrounding list to handle. `PropertyList` uses `'value'` with all three.
|
|
118
|
+
|
|
119
|
+
A trigger that only shows on hover needs a sign at rest, or people won't know to point at the value. `hint="seam"` scores a faint groove under the value: a hairline cut in `line-control` with the well's light lip beneath it, where the well will open. It's an opening's edge drawn on the ground, not a link's underline, so it reads as something that can be opened rather than followed. With `'field'` the field's own border replaces it on hover. It's flat in `contrast="more"` and dashed in forced colours, and not drawn for an empty value, which already shows its own words. Default `hint="none"`.
|
|
120
|
+
|
|
121
|
+
## Focus and accessible equivalents
|
|
122
|
+
|
|
123
|
+
Opening focuses and selects the input (with a custom `editor`, it focuses the editor's first focusable control). Save or Cancel restores focus to Edit only when focus was still in the editor at completion. Completion never steals focus from another part of the page. If Edit is unavailable while the component remains mounted, `focusFallback` may return a connected, enabled, focusable element; otherwise it leaves focus where it is. When a parent removes a row, the parent owns next-row or heading focus.
|
|
124
|
+
|
|
125
|
+
A visually hidden label and a single alert wire the input's name, description and invalid state. A polite status announces checking, saving and accepted success outside the saving region's `aria-busy` subtree; it's never shown, and clears itself after `dur-linger`. The editor stays on the ground with its well open, persistence uses Surface's pending signal, failed writes use its failed treatment, and accepted canonical text settles with an afterglow that fills the value's slot, so it never sits against the text. Text, controls, error icons and borders preserve every state without motion or shadows. Actions wrap below the field at narrow widths.
|
|
126
|
+
|
|
127
|
+
Optional labels are `editLabel` (default `Edit ${label}`), `saveLabel` (“Save changes”) and `cancelLabel` (“Cancel”). `settleDuration` sets how long a saved value's afterglow takes to fade, in ms (default 5000, shorter than `dur-settle` because the person is watching the change). `className` applies to the local group. There is no autosave, history, portal or form abstraction.
|
|
128
|
+
|
|
129
|
+
## Demo and verification
|
|
130
|
+
|
|
131
|
+
The canonical `InlineEdit` section in `build/previews.txt` renders a task title (in its heading font), a multiline description, the task's status (Select, on choose, with a rejected “Blocked”), assignee (Combobox, on choose) and estimate (a numeric Input), an independent filename table cell with this component, and “Ways to open”: a pencil and two fields on hover, with seams. A collapsed documentation harness selects deterministic fixtures before mounting, resets records and counters, delays validation, applies external updates, removes the filename consumer during work and opens a dialog comparison using the same validation and persistence adapter. Fixture controls and operation logs stay outside the product scene.
|
|
132
|
+
|
|
133
|
+
Run `node checks/inline-edit.mjs` from `build/` after generating preview fixtures. `ALT_FIXTURE_ROOT=test/react19` selects the React 19 source fixtures. The checks cover editing in place (the value's text doesn't move, the control takes its font, the pencil and the field move nothing on hover, the caret lands where clicked, the seam, the announcement that clears itself), multiline (newline on Enter, Ctrl/Cmd+Enter, IME, Escape, caret at the end, growth to `maxRows`, line breaks in read mode), custom editors (focus, Escape layering, on-choose saves and rejections, unchanged choices, numeric validation), lifecycle races, retained input, uncertainty reconciliation, conflict latching, keyboard/IME, focus, disabled behavior and the four-scheme responsive/accessibility matrix. Automated checks do not replace the human screen-reader, touch, magnification or formative comparisons recorded in [Actions in place validation](https://github.com/richlaconte/altitude/blob/v0.4.1/project/ActionsInPlaceValidation.md).
|
|
134
|
+
|
|
135
|
+
<!-- example: generated from build/previews.txt by build/previews.mjs. Edit the preview, not this section. -->
|
|
136
|
+
## Example
|
|
137
|
+
|
|
138
|
+
`InlineEdit` as first used in its preview:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
import { InlineEdit } from 'altitude-design';
|
|
142
|
+
|
|
143
|
+
<InlineEdit label="Description" value={task.description} onValueChange={publish('description')} onSave={save('description')}
|
|
144
|
+
multiline rows={3} maxRows={8} maxLength={500} />
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The full preview, as rendered in Storybook and checked in CI (`inline-demo-description`, `inline-demo-prop-label`, `inline-demo-props`, `inline-demo-prop`, `inline-demo-scene`, `inline-demo-task`, `inline-demo-meta`, `inline-demo-harness`, `inline-demo-controls`, `inline-demo-log`, `inline-demo` are the preview page's own layout classes, not Altitude's):
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
import * as React from 'react';
|
|
151
|
+
import { Badge, Button, Combobox, Dialog, Field, InlineEdit, Input, Select, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'altitude-design';
|
|
152
|
+
|
|
153
|
+
const INLINE_RECORDS = Object.freeze([
|
|
154
|
+
Object.freeze({ id: 'task-launch', name: 'Prepare launch checklist', revision: 1 }),
|
|
155
|
+
Object.freeze({ id: 'file-brand', name: 'Brand guidelines.pdf', revision: 1 }),
|
|
156
|
+
Object.freeze({ id: 'file-brand-draft', name: 'Brand guidelines draft.pdf', revision: 1 }),
|
|
157
|
+
Object.freeze({ id: 'file-launch', name: 'Launch checklist.md', revision: 1 }),
|
|
158
|
+
]);
|
|
159
|
+
const INLINE_FIXTURES = ['success-100', 'success-1200', 'fail-once', 'conflict', 'interrupted-after-save'];
|
|
160
|
+
const inlineWait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
161
|
+
const INLINE_STATUSES = [{ value: 'todo', label: 'To do' }, { value: 'progress', label: 'In progress' }, { value: 'review', label: 'In review' }, { value: 'blocked', label: 'Blocked' }];
|
|
162
|
+
const INLINE_TONES = { todo: 'neutral', progress: 'accent', review: 'caution', blocked: 'critical' };
|
|
163
|
+
const INLINE_PEOPLE = [{ value: 'mara', label: 'Mara Quist' }, { value: 'ines', label: 'Inés Ortega' }, { value: 'tobi', label: 'Tobi Adeyemi' }, { value: 'wen', label: 'Li Wen' }];
|
|
164
|
+
const inlineLabel = (list, value) => list.find(option => option.value === value)?.label ?? 'Not set';
|
|
165
|
+
|
|
166
|
+
// Task properties: the same session with a different editor. Status and assignee save on choose; the estimate waits for Save.
|
|
167
|
+
function InlineTaskProperties() {
|
|
168
|
+
const [task, setTask] = React.useState({ status: 'progress', assignee: 'mara', estimate: 3, description: 'Collect sign-off from design, support and legal.\nShare the final list in #launch by Thursday.' });
|
|
169
|
+
const publish = key => value => setTask(task => ({ ...task, [key]: value }));
|
|
170
|
+
const save = key => async draft => {
|
|
171
|
+
await inlineWait(300);
|
|
172
|
+
if (key === 'status' && draft === 'blocked') return { ok: false, error: 'Only the project lead can block a task. Choose another status.' };
|
|
173
|
+
return { ok: true, value: draft };
|
|
174
|
+
};
|
|
175
|
+
return <>
|
|
176
|
+
<div className="inline-demo-description"><span className="inline-demo-prop-label" aria-hidden="true">Description</span>
|
|
177
|
+
<InlineEdit label="Description" value={task.description} onValueChange={publish('description')} onSave={save('description')}
|
|
178
|
+
multiline rows={3} maxRows={8} maxLength={500} /></div>
|
|
179
|
+
<div className="inline-demo-props">
|
|
180
|
+
<div className="inline-demo-prop"><span className="inline-demo-prop-label" aria-hidden="true">Status</span>
|
|
181
|
+
<InlineEdit label="Status" value={task.status} onValueChange={publish('status')} onSave={save('status')} commit="on-choose"
|
|
182
|
+
renderValue={status => <Badge tone={INLINE_TONES[status]}>{inlineLabel(INLINE_STATUSES, status)}</Badge>}
|
|
183
|
+
editor={control => <Select {...control} items={INLINE_STATUSES} size="sm" />} /></div>
|
|
184
|
+
<div className="inline-demo-prop"><span className="inline-demo-prop-label" aria-hidden="true">Assignee</span>
|
|
185
|
+
<InlineEdit label="Assignee" value={task.assignee} onValueChange={publish('assignee')} onSave={save('assignee')} commit="on-choose"
|
|
186
|
+
renderValue={person => inlineLabel(INLINE_PEOPLE, person)}
|
|
187
|
+
editor={control => <Combobox {...control} items={INLINE_PEOPLE} size="sm" placeholder="Type a name" />} /></div>
|
|
188
|
+
<div className="inline-demo-prop"><span className="inline-demo-prop-label" aria-hidden="true">Estimate</span>
|
|
189
|
+
<InlineEdit label="Estimate" value={task.estimate} onValueChange={publish('estimate')} onSave={save('estimate')} required
|
|
190
|
+
validate={points => Number.isInteger(points) && points >= 0 && points <= 40 ? undefined : 'Use a whole number from 0 to 40.'}
|
|
191
|
+
renderValue={points => `${points} point${points === 1 ? '' : 's'}`}
|
|
192
|
+
editor={({ value, onValueChange, ...control }) => <Input {...control} type="number" inputMode="numeric" min={0} max={40} size="sm"
|
|
193
|
+
value={value ?? ''} onChange={event => onValueChange(event.target.value === '' ? null : Number(event.target.value))} />} /></div>
|
|
194
|
+
</div>
|
|
195
|
+
</>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function InlineExample({ fixture, slowValidation, epoch }) {
|
|
199
|
+
const server = React.useRef(null);
|
|
200
|
+
if (!server.current) server.current = new Map(INLINE_RECORDS.map(record => [record.id, { ...record }]));
|
|
201
|
+
const [records, setRecords] = React.useState(() => INLINE_RECORDS.map(record => ({ ...record })));
|
|
202
|
+
const [log, setLog] = React.useState([]);
|
|
203
|
+
const [visible, setVisible] = React.useState(true);
|
|
204
|
+
const [outsideCount, setOutsideCount] = React.useState(0);
|
|
205
|
+
const [baseline, setBaseline] = React.useState(false);
|
|
206
|
+
const sequence = React.useRef(0), failUsed = React.useRef(false), accepted = React.useRef(new Map());
|
|
207
|
+
const alive = React.useRef(true);
|
|
208
|
+
React.useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
|
209
|
+
const record = id => records.find(record => record.id === id);
|
|
210
|
+
const trace = event => { if (alive.current) setLog(log => [...log, event]); };
|
|
211
|
+
const reconcile = id => { if (alive.current) setRecords(records => records.map(record => record.id === id ? { ...server.current.get(id) } : record)); };
|
|
212
|
+
const publish = id => canonicalValue => {
|
|
213
|
+
trace({ event: 'published', id, value: canonicalValue });
|
|
214
|
+
setRecords(records => records.map(record => record.id === id ? { ...server.current.get(id), name: canonicalValue } : record));
|
|
215
|
+
};
|
|
216
|
+
const validate = id => async draft => {
|
|
217
|
+
if (slowValidation) await inlineWait(1200);
|
|
218
|
+
if (!draft.trim()) return 'Enter a name.';
|
|
219
|
+
if ([...server.current.values()].some(record => record.id !== id && record.name === draft.trim())) return 'A file already has this name. Choose another.';
|
|
220
|
+
};
|
|
221
|
+
const save = id => async (draft, { previousValue }) => {
|
|
222
|
+
const expectedRevision = record(id).revision;
|
|
223
|
+
const operationId = `rename-${epoch}-${++sequence.current}`;
|
|
224
|
+
trace({ event: 'requested', id, expectedRevision, operationId, draft });
|
|
225
|
+
const commit = () => {
|
|
226
|
+
if (accepted.current.has(operationId)) return accepted.current.get(operationId);
|
|
227
|
+
const current = server.current.get(id);
|
|
228
|
+
if (current.revision !== expectedRevision || current.name !== previousValue) {
|
|
229
|
+
reconcile(id); trace({ event: 'rejected-stale', id, operationId });
|
|
230
|
+
return { ok: false, error: 'The saved name changed. Review the current name.' };
|
|
231
|
+
}
|
|
232
|
+
const next = { ...current, name: draft.trim(), revision: current.revision + 1 };
|
|
233
|
+
server.current.set(id, next);
|
|
234
|
+
const result = { ok: true, value: next.name };
|
|
235
|
+
accepted.current.set(operationId, result);
|
|
236
|
+
trace({ event: 'committed', id, revision: next.revision, operationId, value: next.name });
|
|
237
|
+
return result;
|
|
238
|
+
};
|
|
239
|
+
if (fixture === 'interrupted-after-save') {
|
|
240
|
+
await inlineWait(100); const result = commit();
|
|
241
|
+
await inlineWait(1100);
|
|
242
|
+
// Simulate a lost acknowledgement, then query/retry the same operation ID.
|
|
243
|
+
// A known committed result is returned as success; no second mutation occurs.
|
|
244
|
+
trace({ event: 'reconciled', id, operationId, revision: server.current.get(id).revision });
|
|
245
|
+
return accepted.current.get(operationId) || result;
|
|
246
|
+
}
|
|
247
|
+
if (fixture === 'conflict') {
|
|
248
|
+
await inlineWait(100);
|
|
249
|
+
const current = server.current.get(id);
|
|
250
|
+
server.current.set(id, { ...current, name: id === 'task-launch' ? 'Prepare updated launch checklist' : 'Brand guidelines revised.pdf', revision: current.revision + 1 });
|
|
251
|
+
reconcile(id); trace({ event: 'external-update', id, revision: current.revision + 1 });
|
|
252
|
+
await inlineWait(1100); return commit();
|
|
253
|
+
}
|
|
254
|
+
if (fixture === 'fail-once' && !failUsed.current) {
|
|
255
|
+
failUsed.current = true; await inlineWait(1200);
|
|
256
|
+
trace({ event: 'rejected', id, operationId });
|
|
257
|
+
return { ok: false, error: 'We couldn’t save this name. Your text is still here. Try again.' };
|
|
258
|
+
}
|
|
259
|
+
await inlineWait(fixture === 'success-1200' ? 1200 : 100);
|
|
260
|
+
return commit();
|
|
261
|
+
};
|
|
262
|
+
const file = record('file-brand');
|
|
263
|
+
const externalUpdate = () => {
|
|
264
|
+
const current = server.current.get('file-brand');
|
|
265
|
+
server.current.set('file-brand', { ...current, name: 'Brand guidelines updated.pdf', revision: current.revision + 1 });
|
|
266
|
+
trace({ event: 'external-update', id: 'file-brand', revision: current.revision + 1 }); reconcile('file-brand');
|
|
267
|
+
};
|
|
268
|
+
return <>
|
|
269
|
+
<div className="inline-demo-scene" data-inline-scene="">
|
|
270
|
+
<section className="inline-demo-task" aria-labelledby="inline-task-title">
|
|
271
|
+
<h2 id="inline-task-title">Launch preparation</h2>
|
|
272
|
+
<InlineEdit label="Task title" editLabel="Edit task title" value={record('task-launch').name} onValueChange={publish('task-launch')} onSave={save('task-launch')} validate={validate('task-launch')} required maxLength={100} />
|
|
273
|
+
<p className="inline-demo-meta">Due Friday · Product team</p>
|
|
274
|
+
<InlineTaskProperties />
|
|
275
|
+
</section>
|
|
276
|
+
<section aria-labelledby="inline-files-title">
|
|
277
|
+
<h2 id="inline-files-title">Files</h2>
|
|
278
|
+
<Table>
|
|
279
|
+
<TableHeader><TableRow><TableHead>File name</TableHead><TableHead>Size</TableHead></TableRow></TableHeader>
|
|
280
|
+
<TableBody>
|
|
281
|
+
{visible && <TableRow><TableCell>
|
|
282
|
+
<InlineEdit label="File name" editLabel="Rename file" value={file.name} onValueChange={publish('file-brand')} onSave={save('file-brand')} validate={validate('file-brand')} required maxLength={100} />
|
|
283
|
+
</TableCell><TableCell>2.4 MB</TableCell></TableRow>}
|
|
284
|
+
<TableRow><TableCell>{record('file-brand-draft').name}<p className="inline-demo-meta">Draft · Updated yesterday</p></TableCell><TableCell>1.8 MB</TableCell></TableRow>
|
|
285
|
+
<TableRow><TableCell>{record('file-launch').name}</TableCell><TableCell>8 KB</TableCell></TableRow>
|
|
286
|
+
</TableBody>
|
|
287
|
+
</Table>
|
|
288
|
+
<Button variant="ghost" onClick={() => setOutsideCount(count => count + 1)}>Show file details</Button>
|
|
289
|
+
{outsideCount > 0 && <p className="inline-demo-meta">Three fictional files · Details opened {outsideCount} time{outsideCount === 1 ? '' : 's'}.</p>}
|
|
290
|
+
</section>
|
|
291
|
+
<InlineWaysToOpen />
|
|
292
|
+
</div>
|
|
293
|
+
<details className="inline-demo-harness">
|
|
294
|
+
<summary>Comparison and fixture controls</summary>
|
|
295
|
+
<p>These controls belong to the documentation harness. The dialog comparison uses the same filename, validation and save adapter. Reset before each trial.</p>
|
|
296
|
+
<div className="inline-demo-controls">
|
|
297
|
+
<Button onClick={externalUpdate}>Apply external filename update</Button>
|
|
298
|
+
<Button onClick={() => { if (!visible) reconcile('file-brand'); setVisible(visible => !visible); }}>{visible ? 'Remove filename editor' : 'Show filename editor'}</Button>
|
|
299
|
+
<Button onClick={() => setBaseline(true)}>Open dialog rename baseline</Button>
|
|
300
|
+
</div>
|
|
301
|
+
<output className="inline-demo-log" data-inline-log="" aria-label="Fixture operation log">{JSON.stringify(log, null, 2)}</output>
|
|
302
|
+
</details>
|
|
303
|
+
<InlineDialogBaseline open={baseline} onOpenChange={setBaseline} value={file.name} validate={validate('file-brand')} onSave={save('file-brand')} onValueChange={publish('file-brand')} />
|
|
304
|
+
</>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function InlineDialogBaseline({ open, onOpenChange, value, validate, onSave, onValueChange }) {
|
|
308
|
+
const [draft, setDraft] = React.useState(value), [error, setError] = React.useState(''), [busy, setBusy] = React.useState('');
|
|
309
|
+
const baseline = React.useRef(value), generation = React.useRef(0), guard = React.useRef(false), latest = React.useRef(value);
|
|
310
|
+
latest.current = value;
|
|
311
|
+
React.useEffect(() => {
|
|
312
|
+
generation.current++; guard.current = false; setBusy(''); setError('');
|
|
313
|
+
if (open) { baseline.current = value; setDraft(value); }
|
|
314
|
+
return () => { generation.current++; };
|
|
315
|
+
}, [open]);
|
|
316
|
+
const submit = async () => {
|
|
317
|
+
if (guard.current) return;
|
|
318
|
+
if (latest.current !== baseline.current) { setError('The saved value changed. Close and reopen to review it.'); return; }
|
|
319
|
+
if (draft === baseline.current) { onOpenChange(false); return; }
|
|
320
|
+
guard.current = true; const token = generation.current; setError(''); setBusy('Checking value');
|
|
321
|
+
const error = draft.length > 100 ? 'Use 100 characters or fewer.' : await validate(draft);
|
|
322
|
+
if (token !== generation.current) return;
|
|
323
|
+
if (error) { guard.current = false; setBusy(''); setError(error); return; }
|
|
324
|
+
setBusy('Saving changes');
|
|
325
|
+
const result = await onSave(draft, { previousValue: baseline.current });
|
|
326
|
+
if (token !== generation.current) return;
|
|
327
|
+
guard.current = false; setBusy('');
|
|
328
|
+
if (latest.current !== baseline.current) { setError('The value changed while saving. Your save may have completed. Check the current record.'); return; }
|
|
329
|
+
if (!result.ok) { setError(result.error); return; }
|
|
330
|
+
onValueChange(result.value); onOpenChange(false);
|
|
331
|
+
};
|
|
332
|
+
return <Dialog open={open} onOpenChange={onOpenChange} title="Rename file" description="Change the name of this file." dismissible={busy !== 'Saving changes'} footer={<>
|
|
333
|
+
<Button variant="ghost" disabled={busy === 'Saving changes'} onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
334
|
+
<Button variant="primary" loading={busy === 'Saving changes'} disabled={busy === 'Checking value'} onClick={submit}>Save changes</Button>
|
|
335
|
+
</>}>
|
|
336
|
+
<Field label="File name" error={error} required><Input value={draft} maxLength={100} readOnly={!!busy} onChange={event => { setDraft(event.target.value); setError(''); }} onKeyDown={event => {
|
|
337
|
+
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
|
338
|
+
if (event.key === 'Enter') { event.preventDefault(); submit(); }
|
|
339
|
+
}} /></Field>
|
|
340
|
+
<p role="status">{busy}</p>
|
|
341
|
+
</Dialog>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The value as its own button: a pencil on hover, or the field itself on hover. The seam says so at rest.
|
|
345
|
+
function InlineWaysToOpen() {
|
|
346
|
+
const [values, setValues] = React.useState({ project: 'Northwind launch', meeting: 'Quarterly planning review', owner: '' });
|
|
347
|
+
const set = key => next => setValues(all => ({ ...all, [key]: next }));
|
|
348
|
+
const save = draft => ({ ok: true, value: draft.trim() });
|
|
349
|
+
return <section aria-labelledby="inline-ways-title">
|
|
350
|
+
<h2 id="inline-ways-title">Ways to open</h2>
|
|
351
|
+
<div className="inline-demo-props">
|
|
352
|
+
<div className="inline-demo-prop"><span className="inline-demo-prop-label">Pencil on hover, with a seam</span>
|
|
353
|
+
<InlineEdit label="Project name" trigger="pencil" hint="seam" value={values.project} onValueChange={set('project')} onSave={save} required /></div>
|
|
354
|
+
<div className="inline-demo-prop"><span className="inline-demo-prop-label">Field on hover, with a seam</span>
|
|
355
|
+
<InlineEdit label="Meeting title" trigger="field" hint="seam" value={values.meeting} onValueChange={set('meeting')} onSave={save} required /></div>
|
|
356
|
+
<div className="inline-demo-prop"><span className="inline-demo-prop-label">Field on hover, empty</span>
|
|
357
|
+
<InlineEdit label="Owner" trigger="field" hint="seam" emptyLabel="Add an owner" value={values.owner} onValueChange={set('owner')} onSave={save} /></div>
|
|
358
|
+
</div>
|
|
359
|
+
</section>;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export default function App() {
|
|
363
|
+
const [fixture, setFixture] = React.useState('success-100'), [slow, setSlow] = React.useState(false);
|
|
364
|
+
const [mounted, setMounted] = React.useState({ fixture: 'success-100', slow: false, epoch: 0 });
|
|
365
|
+
return <div className="inline-demo">
|
|
366
|
+
<InlineExample key={mounted.epoch} fixture={mounted.fixture} slowValidation={mounted.slow} epoch={mounted.epoch} />
|
|
367
|
+
<details className="inline-demo-harness">
|
|
368
|
+
<summary>Choose the next fixture</summary>
|
|
369
|
+
<p>Select before mounting. Reset restores the same fictional records, revisions and request counters.</p>
|
|
370
|
+
<div className="inline-demo-controls">
|
|
371
|
+
<label>Rename fixture<select value={fixture} onChange={event => setFixture(event.target.value)}>{INLINE_FIXTURES.map(name => <option key={name}>{name}</option>)}</select></label>
|
|
372
|
+
<label><input type="checkbox" checked={slow} onChange={event => setSlow(event.target.checked)} />Delay validation by 1,200 ms</label>
|
|
373
|
+
<Button onClick={() => setMounted({ fixture, slow, epoch: mounted.epoch + 1 })}>Reset example</Button>
|
|
374
|
+
</div>
|
|
375
|
+
</details>
|
|
376
|
+
</div>;
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
<!-- /example -->
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
A single-line text field. A field is an **opening cut into the page**, and you can see into it.
|
|
4
|
+
|
|
5
|
+
Altitude has one camera, slightly in front of and above the page: under a raised face you see its front wall (a button's wall), and inside an opening you see its **back wall**, just below the top edge. That inside wall (`--alt-wall-in`, 4px) is what makes an empty field read as a hole you can put something into.
|
|
6
|
+
|
|
7
|
+
- **Empty:** a well (`surface-well`) with its inside wall showing and a soft occlusion shadow on the floor below it. The placeholder sits on the floor, half a wall lower than it would on a flat field.
|
|
8
|
+
- **Approached:** as the pointer comes within 56px the wall deepens up to 2px more, the way a receptacle opens for something about to be put in. Content sinks with it. Only the opening changes: the field itself never moves. Fields are the one place Altitude responds to a pointer before it arrives; buttons and cards wait until it's on them.
|
|
9
|
+
- **Filled:** as soon as it holds a value, the floor rises flush with the page: the wall shrinks to nothing on `ease-settle`, the text rises with it, and the field turns `surface-raised`. A form's unfilled fields read as holes at a glance.
|
|
10
|
+
- **Focused:** the floor lights up (`surface-raised`), and an empty focused field holds the extra depth approach previewed, even after the pointer moves away, until something is put in it. A field you click into shows nothing more: you know where you clicked, and the caret is there.
|
|
11
|
+
- **Focused from the keyboard:** the rim also darkens to a 2px neutral (`--alt-rim-held`: `ink` mixed 80% into `line-control`, a 1px border doubled by a 1px outline), so you can find the field you tabbed to. No accent and no halo: a form you're working through shouldn't glow at every field. The rim keeps at least a 3:1 change from the resting rim in every scheme, so it meets WCAG's focus-appearance contrast. Whether focus came from the keyboard is decided as it arrives (`useKeyFocus`), so typing into a field you clicked doesn't add the rim, and clicking a field removes it. An invalid field's keyboard rim stays `critical`.
|
|
12
|
+
- **Invalid:** dropped to trench depth (a 6px wall in a `critical` tint, on `critical-soft`), whether or not it's filled. It drops on `ease-sink` rather than shaking.
|
|
13
|
+
- **Disabled:** flat on the ground with a `line` rim, no wall.
|
|
14
|
+
- **Suggested:** a value proposed for the field (an AI completion, autofill, a template) is drawn as a **ghost of the value** in `ink-faint`, the feedforward vocabulary ("the ghost of the check a box would get"). With `suggestion` alone it's the whole value while the field is empty, or the rest of it while what's typed matches its start. With `suggestionMode="append"` it continues the text. **Tab accepts** (the floor rises flush with one afterglow, and `onChange` and `onAcceptSuggestion` hear the new value), **Escape dismisses**, and typing carries on past it. **A suggested value isn't a value**: it's never put in the field, so an empty field with a ghost stays an opening. It's read out as the field's description ("Suggestion: Billing. Press Tab to accept."), Tab moves focus as usual when no ghost is showing, and acceptance is announced. Under forced colours the ghost is `GrayText`.
|
|
15
|
+
|
|
16
|
+
The shadow list is always four layers (wall, occlusion, lip, halo; the halo is unused by Altitude's own states and kept so custom states animate), so every state animates into every other. Empty and filled are detected with CSS (`:placeholder-shown`), so it works for controlled and uncontrolled inputs; when you don't pass a placeholder, a blank one is set for you. In forced-colours mode empty fields have a dashed rim, filled ones a solid rim and invalid ones a thick dashed rim.
|
|
17
|
+
|
|
18
|
+
`Textarea` is the same opening, multi-line and resizable. Give it a `maxLength` and it shows **how full** it is: a faint fill rises from the floor with a waterline at its top, turning `caution` at 90% and `critical` at the limit, with a live count below.
|
|
19
|
+
|
|
20
|
+
**On touch.** Under a coarse pointer the field steps up to `size-field-md`'s 48px, and the text you type is at least 16px, so iOS Safari never zooms the page when the field is focused. Labels stay 14px. See [Theming](../../visual/Theming.md), "Density and target size".
|
|
21
|
+
|
|
22
|
+
**You provide:** `placeholder` in `ink-muted` (a hint, never the label), `size` (`sm` 34px, `md` 40px), optional `leading` and `trailing` nodes (16px icons, a `Kbd`, a unit). For Textarea, optional `maxLength` and `showFill={false}` to hide the fill. For either, an optional `suggestion`, `suggestionMode` and `onAcceptSuggestion`. Wrap either in `Field` for a label and messages.
|
|
23
|
+
|
|
24
|
+
- Do use `Field` rather than a placeholder as the only label.
|
|
25
|
+
- Don't add an accent focus ring or glow to fields, or a rim on click. Keyboard focus on buttons and choice controls is the `accent-ink` ring; on a field the lit floor is the focus, plus the dark rim when you arrived by keyboard.
|
|
26
|
+
- Don't put a suggestion in the value: pass it as `suggestion`, so people can tell what they wrote from what was proposed.
|
|
27
|
+
- Don't place inputs on `surface-well` backgrounds: a well inside a well disappears. Put forms on ground or raised surfaces.
|
|
28
|
+
|
|
29
|
+
<!-- example: generated from build/previews.txt by build/previews.mjs. Edit the preview, not this section. -->
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
`Input` as first used in its preview:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { Input } from 'altitude-design';
|
|
36
|
+
|
|
37
|
+
<Input type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The full preview, as rendered in Storybook and checked in CI (`col`, `grid`, `cap` are the preview page's own layout classes, not Altitude's):
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import * as React from 'react';
|
|
44
|
+
import { Field, Input, Kbd, Textarea } from 'altitude-design';
|
|
45
|
+
|
|
46
|
+
export default function App() {
|
|
47
|
+
const [note, setNote] = React.useState('Ship the cold-start fix behind a flag first.');
|
|
48
|
+
const [email, setEmail] = React.useState('');
|
|
49
|
+
return (
|
|
50
|
+
<div className="col">
|
|
51
|
+
<div className="grid">
|
|
52
|
+
<Field label="Email address"><Input type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>
|
|
53
|
+
<Field label="Billing email"><Input type="email" defaultValue="mara@northwind.dev" /></Field>
|
|
54
|
+
<Field label="Search projects"><Input type="search" placeholder="Project name" leading={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>} trailing={<Kbd>/</Kbd>} /></Field>
|
|
55
|
+
<Field label="Invite by email" error="Enter an email address, like name@company.com."><Input type="email" defaultValue="not-an-email" /></Field>
|
|
56
|
+
<div style={{ gridColumn: '1 / -1' }}><Field label="Note"><Textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={80} placeholder="Leave a note for your team…" /></Field></div>
|
|
57
|
+
</div>
|
|
58
|
+
<p className="cap">An empty field shows its inside wall below the top edge, and deepens as your pointer comes near. Type and the floor rises flush with the page. Invalid drops it to trench depth.</p>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
<!-- /example -->
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# InputOTP
|
|
2
|
+
|
|
3
|
+
A one-time code: two-factor sign-in, email verification, device approval. Four to eight characters, shown as a row of openings.
|
|
4
|
+
|
|
5
|
+
**Elevation.** Completeness, applied per character. Each slot is built like an `Input`: an empty slot is a hole with its back wall showing, and **its floor rises flush as its character lands**, so the code shows how far along it is. The slot the next character goes into is held: its floor is lit, it opens to the depth approach would preview, it shows a caret, and it gets the neutral rim when reached by keyboard. A pasted or autofilled code fills left to right, 35ms a slot. A rejected code drops the whole row to a trench on `critical-soft`, with the message from `Field`. While the code is being checked (`verifying`), nothing more can be typed.
|
|
6
|
+
|
|
7
|
+
**One field, not six.** A single real `<input inputmode="numeric" autocomplete="one-time-code">` lies invisible over the row and drives the slots. So SMS and email autofill, paste (into any slot) and screen readers all see one field. Characters that don't match `pattern` are dropped, so pasting "123-456" gives 123456.
|
|
8
|
+
|
|
9
|
+
**Accessibility.** One label (from `Field`), described as "6 digits", with errors through `Field`. There are no announcements per slot: the slots are `aria-hidden`, and the input is what's read. Typing, Backspace, Left and Right, Home and End work as they do in any text field. `mask` shows • and makes the input a password field. Under forced colours empty slots are dashed and filled ones solid. Under reduced motion the caret doesn't blink and floors change without travel.
|
|
10
|
+
|
|
11
|
+
**Parts.**
|
|
12
|
+
- `InputOTP`: `maxLength`, `value`, `defaultValue`, `onChange`, `onComplete`, `pattern` (`'digits' | 'alphanumeric' |` a RegExp for one character), `mask`, `invalid`, `verifying`, `disabled`.
|
|
13
|
+
- `InputOTPGroup`, `InputOTPSlot` (`index`) and `InputOTPSeparator` lay the slots out. Without children, it's one group of `maxLength` slots.
|
|
14
|
+
|
|
15
|
+
**You provide:** the length, what happens on `onComplete` (verify the code), and where the code was sent, in `Field`'s hint.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
<Field label="Verification code" hint="We sent it to j•••@acme.com." error={error}>
|
|
19
|
+
<InputOTP maxLength={6} value={code} onChange={setCode} onComplete={verify}>
|
|
20
|
+
<InputOTPGroup>{[0, 1, 2].map((i) => <InputOTPSlot key={i} index={i} />)}</InputOTPGroup>
|
|
21
|
+
<InputOTPSeparator />
|
|
22
|
+
<InputOTPGroup>{[3, 4, 5].map((i) => <InputOTPSlot key={i} index={i} />)}</InputOTPGroup>
|
|
23
|
+
</InputOTP>
|
|
24
|
+
</Field>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- Do verify on `onComplete`, without a separate Submit button.
|
|
28
|
+
- Do say where the code went and how to get a new one.
|
|
29
|
+
- Don't split the code into several inputs; autofill and paste break.
|
|
30
|
+
- Don't clear the code on a rejection without saying why; leave it so it can be corrected.
|
|
31
|
+
|
|
32
|
+
<!-- example: generated from build/previews.txt by build/previews.mjs. Edit the preview, not this section. -->
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
`InputOTP` as first used in its preview:
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from 'altitude-design';
|
|
39
|
+
|
|
40
|
+
<InputOTP maxLength={6} value={code} verifying={state === 'verifying'}
|
|
41
|
+
onChange={(c) => { setCode(c); if (state === 'wrong') setState('idle'); }} onComplete={verify}>
|
|
42
|
+
<InputOTPGroup>{[0, 1, 2].map((i) => <InputOTPSlot key={i} index={i} />)}</InputOTPGroup>
|
|
43
|
+
<InputOTPSeparator />
|
|
44
|
+
<InputOTPGroup>{[3, 4, 5].map((i) => <InputOTPSlot key={i} index={i} />)}</InputOTPGroup>
|
|
45
|
+
</InputOTP>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The full preview, as rendered in Storybook and checked in CI (`col` is the preview page's own layout class, not Altitude's):
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import * as React from 'react';
|
|
52
|
+
import { Field, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from 'altitude-design';
|
|
53
|
+
|
|
54
|
+
export default function App() {
|
|
55
|
+
const [code, setCode] = React.useState('');
|
|
56
|
+
const [state, setState] = React.useState('idle');
|
|
57
|
+
const verify = (c) => { setState('verifying'); setTimeout(() => setState(c === '123456' ? 'ok' : 'wrong'), 900); };
|
|
58
|
+
const hint = state === 'verifying' ? 'Checking code…' : state === 'ok' ? 'Verified.' : 'We sent it to j•••@acme.com. Try 123456, or any other code to see a rejection.';
|
|
59
|
+
return (
|
|
60
|
+
<div className="col">
|
|
61
|
+
<Field label="Verification code" hint={hint} error={state === 'wrong' ? 'That code didn\'t work. Check the latest email and try again.' : undefined}>
|
|
62
|
+
<InputOTP maxLength={6} value={code} verifying={state === 'verifying'}
|
|
63
|
+
onChange={(c) => { setCode(c); if (state === 'wrong') setState('idle'); }} onComplete={verify}>
|
|
64
|
+
<InputOTPGroup>{[0, 1, 2].map((i) => <InputOTPSlot key={i} index={i} />)}</InputOTPGroup>
|
|
65
|
+
<InputOTPSeparator />
|
|
66
|
+
<InputOTPGroup>{[3, 4, 5].map((i) => <InputOTPSlot key={i} index={i} />)}</InputOTPGroup>
|
|
67
|
+
</InputOTP>
|
|
68
|
+
</Field>
|
|
69
|
+
<Field label="Backup code" hint="8 letters or digits.">
|
|
70
|
+
<InputOTP maxLength={8} pattern="alphanumeric" defaultValue="K7Q2" />
|
|
71
|
+
</Field>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
<!-- /example -->
|