altitude-design 0.3.2 → 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/package.json +1 -1
package/dist/guidance/rules.json
CHANGED
|
@@ -648,6 +648,161 @@
|
|
|
648
648
|
"criteria": [],
|
|
649
649
|
"reviewedAt": "2026-11-01",
|
|
650
650
|
"owner": "Altitude maintainers"
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
"id": "ALT-TOKEN-001",
|
|
654
|
+
"title": "Colours come from the tokens, not literals",
|
|
655
|
+
"authority": "altitude-policy",
|
|
656
|
+
"diagnosticClass": "deterministic",
|
|
657
|
+
"applicability": "Colour values in an application's CSS declarations, inline styles and colour props.",
|
|
658
|
+
"exceptions": [
|
|
659
|
+
"Defining tokens: a custom property (--brand: #…) or a custom scheme's token entries.",
|
|
660
|
+
"AltitudeProvider accent, which takes a colour literal by design.",
|
|
661
|
+
"A colour that must be literal (a third-party brand mark), marked altitude-ignore: ALT-TOKEN-001 with the reason."
|
|
662
|
+
],
|
|
663
|
+
"maturity": "supported",
|
|
664
|
+
"evidenceStatus": "engineering-tested",
|
|
665
|
+
"sources": [
|
|
666
|
+
"altitude-design-guide"
|
|
667
|
+
],
|
|
668
|
+
"repair": "Use a token (var(--ink-muted), var(--accent), var(--surface-raised)). A brand colour goes to AltitudeProvider accent; a full palette to a custom scheme.",
|
|
669
|
+
"verification": [
|
|
670
|
+
"build/checks/source-check.mjs"
|
|
671
|
+
],
|
|
672
|
+
"criteria": [],
|
|
673
|
+
"reviewedAt": "2026-11-02",
|
|
674
|
+
"owner": "Altitude maintainers"
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
"id": "ALT-SETUP-002",
|
|
678
|
+
"title": "Altitude's stylesheet is imported",
|
|
679
|
+
"authority": "altitude-policy",
|
|
680
|
+
"diagnosticClass": "deterministic",
|
|
681
|
+
"applicability": "An application that imports components from altitude-design.",
|
|
682
|
+
"exceptions": [
|
|
683
|
+
"A page that loads the stylesheet with a <link> outside the scanned sources."
|
|
684
|
+
],
|
|
685
|
+
"maturity": "supported",
|
|
686
|
+
"evidenceStatus": "engineering-tested",
|
|
687
|
+
"sources": [
|
|
688
|
+
"altitude-design-guide"
|
|
689
|
+
],
|
|
690
|
+
"repair": "Import altitude-design/altitude.css (or altitude.no-fonts.css) once at the app's entry.",
|
|
691
|
+
"verification": [
|
|
692
|
+
"build/checks/source-check.mjs"
|
|
693
|
+
],
|
|
694
|
+
"criteria": [],
|
|
695
|
+
"reviewedAt": "2026-11-02",
|
|
696
|
+
"owner": "Altitude maintainers"
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
"id": "ALT-SETUP-003",
|
|
700
|
+
"title": "The app is wrapped in AltitudeProvider",
|
|
701
|
+
"authority": "altitude-policy",
|
|
702
|
+
"diagnosticClass": "deterministic",
|
|
703
|
+
"applicability": "An application that imports components from altitude-design.",
|
|
704
|
+
"exceptions": [
|
|
705
|
+
"Several providers on purpose (separate React roots, previews): reported for review, not as an error."
|
|
706
|
+
],
|
|
707
|
+
"maturity": "supported",
|
|
708
|
+
"evidenceStatus": "engineering-tested",
|
|
709
|
+
"sources": [
|
|
710
|
+
"altitude-design-guide"
|
|
711
|
+
],
|
|
712
|
+
"repair": "Render one AltitudeProvider around the whole app; it sets the scheme and hosts dialogs and toasts.",
|
|
713
|
+
"verification": [
|
|
714
|
+
"build/checks/source-check.mjs"
|
|
715
|
+
],
|
|
716
|
+
"criteria": [],
|
|
717
|
+
"reviewedAt": "2026-11-02",
|
|
718
|
+
"owner": "Altitude maintainers"
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
"id": "ALT-CHART-001",
|
|
722
|
+
"title": "No pie or donut charts",
|
|
723
|
+
"authority": "altitude-policy",
|
|
724
|
+
"diagnosticClass": "deterministic",
|
|
725
|
+
"applicability": "Charts drawn with recharts, react-chartjs-2, Chart.js, ECharts or Nivo in an Altitude application.",
|
|
726
|
+
"exceptions": [],
|
|
727
|
+
"maturity": "supported",
|
|
728
|
+
"evidenceStatus": "engineering-tested",
|
|
729
|
+
"sources": [
|
|
730
|
+
"altitude-charts"
|
|
731
|
+
],
|
|
732
|
+
"repair": "Show a part of a whole as a stacked BarChart or a segmented Meter: angle and area are read less accurately than length.",
|
|
733
|
+
"verification": [
|
|
734
|
+
"build/checks/source-check.mjs"
|
|
735
|
+
],
|
|
736
|
+
"criteria": [],
|
|
737
|
+
"reviewedAt": "2026-11-02",
|
|
738
|
+
"owner": "Altitude maintainers"
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
"id": "ALT-COPY-001",
|
|
742
|
+
"title": "Buttons name their outcome, never OK or Submit",
|
|
743
|
+
"authority": "altitude-policy",
|
|
744
|
+
"diagnosticClass": "deterministic",
|
|
745
|
+
"applicability": "Button labels written as literal text.",
|
|
746
|
+
"exceptions": [
|
|
747
|
+
"A label computed at runtime is not checked."
|
|
748
|
+
],
|
|
749
|
+
"maturity": "supported",
|
|
750
|
+
"evidenceStatus": "engineering-tested",
|
|
751
|
+
"sources": [
|
|
752
|
+
"altitude-design-guide"
|
|
753
|
+
],
|
|
754
|
+
"repair": "Start with a verb and name the outcome: \"Save changes\", \"Send invite\".",
|
|
755
|
+
"verification": [
|
|
756
|
+
"build/checks/source-check.mjs"
|
|
757
|
+
],
|
|
758
|
+
"criteria": [],
|
|
759
|
+
"reviewedAt": "2026-11-02",
|
|
760
|
+
"owner": "Altitude maintainers"
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
"id": "ALT-PRIMARY-001",
|
|
764
|
+
"title": "One primary action per view",
|
|
765
|
+
"authority": "altitude-policy",
|
|
766
|
+
"diagnosticClass": "contextual-review",
|
|
767
|
+
"applicability": "Files with more than one primary Button; they may or may not be on screen together.",
|
|
768
|
+
"exceptions": [
|
|
769
|
+
"Primary buttons in different views, steps or dialogs that are never shown together."
|
|
770
|
+
],
|
|
771
|
+
"maturity": "supported",
|
|
772
|
+
"evidenceStatus": "engineering-tested",
|
|
773
|
+
"sources": [
|
|
774
|
+
"altitude-design-guide",
|
|
775
|
+
"altitude-elevation"
|
|
776
|
+
],
|
|
777
|
+
"repair": "Keep one primary, the action the task leads to; make the others secondary or ghost.",
|
|
778
|
+
"verification": [
|
|
779
|
+
"build/checks/source-check.mjs"
|
|
780
|
+
],
|
|
781
|
+
"criteria": [],
|
|
782
|
+
"reviewedAt": "2026-11-02",
|
|
783
|
+
"owner": "Altitude maintainers"
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
"id": "ALT-VISUAL-004",
|
|
787
|
+
"title": "Controls keep Altitude's elevation",
|
|
788
|
+
"authority": "altitude-policy",
|
|
789
|
+
"diagnosticClass": "contextual-review",
|
|
790
|
+
"applicability": "Shadow or transform overrides on Input, Textarea, Select, Combobox, Button, Switch or Checkbox.",
|
|
791
|
+
"exceptions": [
|
|
792
|
+
"An override that doesn't change the control's apparent height."
|
|
793
|
+
],
|
|
794
|
+
"maturity": "supported",
|
|
795
|
+
"evidenceStatus": "engineering-tested",
|
|
796
|
+
"sources": [
|
|
797
|
+
"altitude-elevation"
|
|
798
|
+
],
|
|
799
|
+
"repair": "Remove the override: a control's height says whether you press it or fill it. Use its variant and size props.",
|
|
800
|
+
"verification": [
|
|
801
|
+
"build/checks/source-check.mjs"
|
|
802
|
+
],
|
|
803
|
+
"criteria": [],
|
|
804
|
+
"reviewedAt": "2026-11-02",
|
|
805
|
+
"owner": "Altitude maintainers"
|
|
651
806
|
}
|
|
652
807
|
]
|
|
653
808
|
}
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# Actions in place
|
|
2
|
+
|
|
3
|
+
Finish a small task where it starts. Use [InlineEdit](../components/InlineEdit/README.md) for one editable value, act immediately when the application provides real recovery, and ask for a focused decision when the consequence cannot reliably be undone. The ground remains the committed record. Deciding or typing is not a pending write; dispatched persistence is.
|
|
4
|
+
|
|
5
|
+
The application chooses that policy from its domain. A danger button does not imply irreversibility, and the word “Archive” does not establish a recovery guarantee. Sending a message, publishing externally, and permanent deletion do not belong in the recovery recipe unless the application can actually reverse their effects.
|
|
6
|
+
|
|
7
|
+
## Try the same example in two views
|
|
8
|
+
|
|
9
|
+
- [Focused recovery preview](https://github.com/richlaconte/altitude/blob/v0.4.0/project/components/ActionsInPlace/preview.html) — archive, Undo, and restore later from Archived.
|
|
10
|
+
- [Complete Files example](https://github.com/richlaconte/altitude/blob/v0.4.0/project/components/ActionsInPlace/preview.html#files) — rename with the exported InlineEdit, archive, recover, and permanently delete from Archived.
|
|
11
|
+
- [InlineEdit comparison](https://github.com/richlaconte/altitude/blob/v0.4.0/project/components/InlineEdit/preview.html) and [Dialog presentation comparison](https://github.com/richlaconte/altitude/blob/v0.4.0/project/components/Dialog/preview.html).
|
|
12
|
+
- Generated Storybook stories: `Patterns/ActionsInPlace`, `Inputs/InlineEdit`, and `Overlays/Dialog`.
|
|
13
|
+
|
|
14
|
+
Both Files views use the same source and service in [`build/previews.txt`](https://github.com/richlaconte/altitude/blob/v0.4.0/build/previews.txt). The default has no fixture toolbar in the product. Expand “Example fixtures and source” below the scene to select the deterministic service behavior, reset records/counters, simulate an external change, or inspect accepted requests. “Connection interrupted after save” commits at 100 ms and loses its response at 1,200 ms; the UI must reconcile that result before offering recovery or retry.
|
|
15
|
+
|
|
16
|
+
## Act and recover
|
|
17
|
+
|
|
18
|
+
**Left in place, then closed.** A record that leaves the current view keeps its slot until the result has been seen. Each row is a [RemovalReceipt](../components/RemovalReceipt/README.md). Archive updates the location count at once, and the row's slot says “Archiving “Brand guidelines.pdf”…” in place. Undo is offered only after the server confirms the archive and its new revision. The slot then reads “Brand guidelines.pdf archived.” with View Archived and Undo, at the row's own height, so nothing on the page moves. Undo sits where Archive was, so a second click under the pointer reverses rather than navigates. Undo is a new restore request against that revision; it changes that record's archive state, never a stored snapshot of the entire list.
|
|
19
|
+
|
|
20
|
+
The receipt holds for `dur-linger`. The hold pauses while the receipt is pointed at or focused, and while a modal owns the document. While a mouse is still over the list it waits, up to `dur-settle`, so the next row doesn't move under a pointer reaching for it. Then the receipt lifts off as an Alert does and the gap glides closed. Receipts due within 250ms close together, as one change. Undo during the hold restores the row in place, with an afterglow and focus on its Archive. After the close, Undo moves to a [Toast](../components/Toaster/README.md), which floats over the page and moves nothing; its Undo brings the row back into its place. The toast supplements recovery: Archived remains visible, and its Restore action works after the receipt closes and the toast expires.
|
|
21
|
+
|
|
22
|
+
Restore from Archived and permanent deletion use the same receipt in Archived, without Undo. Undo in place, and outcomes for records that are no longer in view, update a quiet local status region below the list that is present from the initial render; starting a later rename clears the old completion. A held receipt stays in its slot, whatever the search, until it closes. Changing location drops held receipts; Archived is where an archived record went.
|
|
23
|
+
|
|
24
|
+
A failure stays with its record: the row returns from its pending receipt with the error and Retry inline, or the error is shown below the list when that row is outside the current view. Feedback in the region below the list is keyed by operation ID. Only the most recently initiated feedback can replace it, even when independent records complete out of order.
|
|
25
|
+
|
|
26
|
+
One mutation per record may be in flight; other records and search remain available. Optimistic location changes and server acknowledgement are separate. A known rejection reconciles only the affected authoritative record and offers a safe retry. A conflict reconciles the latest version and explains, in the receipt's slot, why the old Undo is unavailable. A known newer revision withdraws an Undo offered in a toast and explains the change below the list. A late receipt is judged against the latest known revision, never the one it describes. A deleted record is a tombstone and cannot be resurrected by an old response.
|
|
27
|
+
|
|
28
|
+
## Confirm a concrete consequence
|
|
29
|
+
|
|
30
|
+
Permanent deletion is available only in Archived in this example. That restriction is application policy. The [Dialog](../components/Dialog/README.md) uses `size="sm"`, `background="stationary"`, `focusSelector="[data-cancel]"`, and a lazy `returnFocus` heading fallback. The exact filename and folder stay inside the accessible dialog, and Cancel receives initial focus.
|
|
31
|
+
|
|
32
|
+
Capture the target’s immutable ID, name, folder, revision, invoking element, and a presentation session ID when opening. Keep this snapshot through the exit animation; do not recalculate it from a changing row index. Opening, Cancel, and Escape before dispatch perform no mutation. Revalidate the expected revision on the server at dispatch. If the target changed, show the changed identity and require a fresh explicit confirmation.
|
|
33
|
+
|
|
34
|
+
Once dispatched, deletion belongs to the application, outside the dialog’s presentation lifecycle. The button says “Deleting…”, and Cancel becomes Close beside “Deletion is in progress. Closing this dialog won’t cancel it.” Close, Escape, and the close icon dismiss the presentation without pretending to cancel the write. A known failure stays inside its matching open session and offers Retry deletion. A closed session reports failure in the originating context. An uncertain response is reconciled before success, failure, or retry is claimed.
|
|
35
|
+
|
|
36
|
+
A successful operation closes only the presentation session that dispatched it. Confirm A → Close → open B → A settles cannot close B or show A’s error in B. Success removes only A. If focus still belongs to A’s removed row or closing dialog, use the nearest surviving action or list heading; if focus moved elsewhere, leave it there. Closing during a pending write and returning focus to its Delete trigger still needs this check when that trigger is eventually removed.
|
|
37
|
+
|
|
38
|
+
Keep one summit. A command-palette action must close its owning palette and transfer ownership through the modal lifecycle before admitting confirmation in the originating page. Do not nest a confirmation or substitute an arbitrary delay for ownership transfer.
|
|
39
|
+
|
|
40
|
+
## Persistence contract
|
|
41
|
+
|
|
42
|
+
The copyable example below uses a fictional application service. Replace it with your authenticated API; these guarantees remain the application’s responsibility:
|
|
43
|
+
|
|
44
|
+
| Boundary | Required guarantee |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| Mutation | Accept `{ id, expectedRevision, operationId }`; apply the intended operation to that ID only and return the authoritative versioned record. |
|
|
47
|
+
| Concurrency | Reject stale revisions and deleted targets. Maintain monotonically increasing revisions, including tombstones. Authorization and concurrency checks happen on the server. |
|
|
48
|
+
| Idempotency | A logical operation keeps its ID across uncertain retries. Deduplicate both overlapping requests and completed operations. A deliberate inverse uses a new ID. |
|
|
49
|
+
| Uncertain result | Reconcile the operation ID or idempotently retry it before declaring failure/success. A transport timeout is not proof of rejection. |
|
|
50
|
+
| Recovery | Persist Archived and implement Restore. Define any expiry or invalidation policy explicitly; never infer it from a UI timer. |
|
|
51
|
+
| Rename | `onSave` performs the sole write and returns the canonical string. `onValueChange` publishes that value synchronously and makes no network request. |
|
|
52
|
+
| Navigation/unmount | Dispatched requests remain application-owned. Late callbacks do not announce, focus, or mutate an unmounted view; the next view loads the current authoritative records. |
|
|
53
|
+
|
|
54
|
+
The local `FileName` wrapper tracks whether that editor is still mounted and clears old quiet completion on entry; InlineEdit retains all editing, keyboard, and focus behavior. A mounted editor publishes through `onValueChange`. If navigation unmounted it, the application adapter reconciles the confirmed result itself, so returning to Files does not show stale data. Known newer revisions invalidate an offered Undo immediately, including when an older receipt arrives afterward.
|
|
55
|
+
|
|
56
|
+
The fixture’s `reconcile(operationId)` stands in for an operation-status endpoint. Its response is an acknowledged operation result, not a guess based on matching visible values. For an API that cannot establish the outcome, retain an explicit unknown state and provide reconciliation; do not offer a new unsafe retry. In production, generate operation IDs with an appropriate unique scheme rather than the example’s local counter.
|
|
57
|
+
|
|
58
|
+
## Complete copyable application recipe
|
|
59
|
+
|
|
60
|
+
This code composes public Altitude APIs. It is application code, not a new Altitude action framework. The runnable preview and Storybook are generated from the canonical source; the check asserts that the recipe body below matches that source. Import the following style block as `actions-in-place.css` alongside the library stylesheet.
|
|
61
|
+
|
|
62
|
+
```jsx
|
|
63
|
+
import * as React from 'react';
|
|
64
|
+
import * as A from 'altitude-design';
|
|
65
|
+
import 'altitude-design/altitude.css';
|
|
66
|
+
import './actions-in-place.css';
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
<!-- recipe-source:start -->
|
|
70
|
+
```jsx
|
|
71
|
+
const FILES_SEED = Object.freeze([
|
|
72
|
+
Object.freeze({ id: 'file-brand', name: 'Brand guidelines.pdf', folder: 'Brand', size: '2.4 MB', revision: 1, archived: false, deleted: false, position: 0 }),
|
|
73
|
+
Object.freeze({ id: 'file-brand-draft', name: 'Brand guidelines draft.pdf', folder: 'Brand', size: '1.8 MB', revision: 1, archived: false, deleted: false, position: 1 }),
|
|
74
|
+
Object.freeze({ id: 'file-launch', name: 'Launch checklist.md', folder: 'Launch', size: '12 KB', revision: 1, archived: false, deleted: false, position: 2 }),
|
|
75
|
+
]);
|
|
76
|
+
function createFilesService(fixture = 'success-100', wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))) {
|
|
77
|
+
const records = new Map(FILES_SEED.map(record => [record.id, { ...record }]));
|
|
78
|
+
const accepted = [], requests = [], receipts = new Map(), flights = new Map();
|
|
79
|
+
let failedOnce = false;
|
|
80
|
+
const read = id => records.has(id) ? { ...records.get(id) } : null;
|
|
81
|
+
const error = (code, message) => Object.assign(new Error(message), { code });
|
|
82
|
+
const change = (id, patch) => { const old = records.get(id); if (old) records.set(id, { ...old, ...patch, id, revision: old.revision + 1 }); };
|
|
83
|
+
const apply = request => {
|
|
84
|
+
const old = records.get(request.id);
|
|
85
|
+
if (!old || old.deleted) throw error('missing', 'This file was removed. It cannot be restored.');
|
|
86
|
+
if (old.revision !== request.expectedRevision) throw error('conflict', 'This file changed. Review its current version before trying again.');
|
|
87
|
+
if (request.kind === 'delete' && !old.archived) throw error('conflict', 'This file is no longer in Archived. Review its current location.');
|
|
88
|
+
const record = { ...old, revision: old.revision + 1 };
|
|
89
|
+
if (request.kind === 'rename') record.name = request.name.trim();
|
|
90
|
+
else if (request.kind === 'delete') record.deleted = true;
|
|
91
|
+
else record.archived = request.kind === 'archive';
|
|
92
|
+
records.set(record.id, record);
|
|
93
|
+
accepted.push({ ...request, revision: record.revision });
|
|
94
|
+
receipts.set(request.operationId, { ...record });
|
|
95
|
+
return { ...record };
|
|
96
|
+
};
|
|
97
|
+
const mutate = request => {
|
|
98
|
+
// Same logical request must not be submitted twice, even if the response was lost.
|
|
99
|
+
if (receipts.has(request.operationId)) return Promise.resolve({ ...receipts.get(request.operationId) });
|
|
100
|
+
if (flights.has(request.operationId)) return flights.get(request.operationId);
|
|
101
|
+
requests.push({ ...request });
|
|
102
|
+
const mode = fixture;
|
|
103
|
+
const flight = (async () => {
|
|
104
|
+
if (mode === 'interrupted-after-save') {
|
|
105
|
+
await wait(100); apply(request); await wait(1100);
|
|
106
|
+
throw error('uncertain', 'Connection interrupted after save.');
|
|
107
|
+
}
|
|
108
|
+
const reject = mode === 'fail-once' && !failedOnce;
|
|
109
|
+
if (reject) failedOnce = true;
|
|
110
|
+
await wait(mode === 'success-1200' || reject || mode === 'conflict' ? 1200 : 100);
|
|
111
|
+
if (reject) throw error('rejected', 'We could not save this change. Try again.');
|
|
112
|
+
if (mode === 'conflict') change(request.id, { name: read(request.id).name.replace(/(\.[^.]+)$/, ' updated$1') });
|
|
113
|
+
return apply(request);
|
|
114
|
+
})();
|
|
115
|
+
flights.set(request.operationId, flight);
|
|
116
|
+
flight.then(() => flights.delete(request.operationId), () => flights.delete(request.operationId));
|
|
117
|
+
return flight;
|
|
118
|
+
};
|
|
119
|
+
return { read, list: () => [...records.values()].map(record => ({ ...record })), accepted, requests, mutate,
|
|
120
|
+
reconcile: async operationId => receipts.has(operationId) ? { ...receipts.get(operationId) } : null,
|
|
121
|
+
change, setFixture: value => { fixture = value; },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// END FILES SERVICE
|
|
125
|
+
// This wrapper reports mount ownership and entry; InlineEdit owns every editing interaction.
|
|
126
|
+
function FileName({ onSave, onBeginEdit, ...props }) {
|
|
127
|
+
const mounted = React.useRef(false);
|
|
128
|
+
React.useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
129
|
+
return <div onClickCapture={event => {
|
|
130
|
+
if (event.target instanceof Element && event.target.closest('button') && !event.currentTarget.querySelector('input')) onBeginEdit?.();
|
|
131
|
+
}}><A.InlineEdit {...props} onSave={(draft, context) => onSave(draft, context, () => mounted.current)} /></div>;
|
|
132
|
+
}
|
|
133
|
+
function FilesExample({ service, full }) {
|
|
134
|
+
const [records, setRecords] = React.useState(() => service.list());
|
|
135
|
+
const recordsRef = React.useRef(records), alive = React.useRef(true);
|
|
136
|
+
const [location, setLocation] = React.useState('active'), [query, setQuery] = React.useState('');
|
|
137
|
+
const [busy, setBusy] = React.useState({}), flights = React.useRef(new Map());
|
|
138
|
+
const [rowErrors, setRowErrors] = React.useState({});
|
|
139
|
+
const [notice, setNotice] = React.useState(null), lastNotice = React.useRef(0), sequence = React.useRef(0);
|
|
140
|
+
const [target, setTarget] = React.useState(null), targetRef = React.useRef(null);
|
|
141
|
+
const [confirmationOpen, setConfirmationOpen] = React.useState(false), openRef = React.useRef(false);
|
|
142
|
+
const [deleteError, setDeleteError] = React.useState(null), [staleIntent, setStaleIntent] = React.useState(false);
|
|
143
|
+
const sessionSequence = React.useRef(0), heading = React.useRef(null), renameResults = React.useRef(new Map());
|
|
144
|
+
// Receipts held in the slot of the row they came from, keyed by record, and Undo still offered in a toast.
|
|
145
|
+
const [held, setHeld] = React.useState({}), heldRef = React.useRef({}), offered = React.useRef(new Map());
|
|
146
|
+
const [arrived, setArrived] = React.useState(null), locationRef = React.useRef(location);
|
|
147
|
+
locationRef.current = location;
|
|
148
|
+
const belongs = (record, where) => !!record && !record.deleted && record.archived === (where === 'archived');
|
|
149
|
+
const publishHeld = next => { heldRef.current = next; setHeld(next); };
|
|
150
|
+
const dropHeld = id => { const { [id]: _, ...rest } = heldRef.current; publishHeld(rest); };
|
|
151
|
+
const go = where => { publishHeld({}); setLocation(where); };
|
|
152
|
+
const recoveryAtCurrentRevision = (message, record) => {
|
|
153
|
+
if (!message?.undo || !record || message.record.id !== record.id || (message.record.revision === record.revision && record.archived && !record.deleted)) return message;
|
|
154
|
+
return { ...message, record, undo: false, tone: 'critical', route: !record.deleted, routeLocation: record.archived ? 'archived' : 'active',
|
|
155
|
+
title: record.deleted ? 'This file was removed.' : 'This file changed after it was archived.',
|
|
156
|
+
body: record.deleted ? 'It cannot be restored.' : `“${record.name}” is current. Review it in ${record.archived ? 'Archived' : 'Files'}.` };
|
|
157
|
+
};
|
|
158
|
+
const publishRecords = next => { recordsRef.current = next; setRecords(next); };
|
|
159
|
+
const reconcileRecord = record => {
|
|
160
|
+
if (!alive.current || !record) return;
|
|
161
|
+
publishRecords(recordsRef.current.map(old => old.id === record.id && old.revision <= record.revision ? record : old));
|
|
162
|
+
const latest = recordsRef.current.find(item => item.id === record.id);
|
|
163
|
+
setNotice(current => recoveryAtCurrentRevision(current, latest));
|
|
164
|
+
// A known newer revision invalidates an offered Undo at once, in its slot or in its toast.
|
|
165
|
+
const entry = heldRef.current[record.id];
|
|
166
|
+
if (entry && !entry.pending) {
|
|
167
|
+
const next = recoveryAtCurrentRevision(entry, latest);
|
|
168
|
+
if (next !== entry && belongs(latest, entry.location)) { dropHeld(record.id); announce({ ...next, sequence: ++sequence.current }); }
|
|
169
|
+
else if (next !== entry) publishHeld({ ...heldRef.current, [record.id]: next });
|
|
170
|
+
}
|
|
171
|
+
const toastOffer = offered.current.get(record.id);
|
|
172
|
+
if (toastOffer && recoveryAtCurrentRevision(toastOffer, latest) !== toastOffer) {
|
|
173
|
+
A.toast.dismiss(toastOffer.toast); offered.current.delete(record.id);
|
|
174
|
+
announce({ ...recoveryAtCurrentRevision(toastOffer, latest), sequence: ++sequence.current });
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const announce = message => {
|
|
178
|
+
if (!alive.current || message.sequence < lastNotice.current) return;
|
|
179
|
+
lastNotice.current = message.sequence; setNotice(recoveryAtCurrentRevision(message, recordsRef.current.find(record => record.id === message.id)));
|
|
180
|
+
};
|
|
181
|
+
const reportFailure = message => {
|
|
182
|
+
if (!alive.current) return;
|
|
183
|
+
// A failure stays with its record: in its row, or below the list when that row isn't in view.
|
|
184
|
+
setRowErrors(errors => ({ ...errors, [message.id]: message }));
|
|
185
|
+
};
|
|
186
|
+
const clearRowError = id => setRowErrors(errors => { const next = { ...errors }; delete next[id]; return next; });
|
|
187
|
+
const setOpen = open => { openRef.current = open; setConfirmationOpen(open); };
|
|
188
|
+
const nextOperation = (kind, record, session) => {
|
|
189
|
+
const seq = ++sequence.current;
|
|
190
|
+
return { kind, id: record.id, expectedRevision: record.revision, operationId: `files-${seq}`, sequence: seq, session };
|
|
191
|
+
};
|
|
192
|
+
const setFlight = (id, operation) => {
|
|
193
|
+
if (operation) flights.current.set(id, operation); else flights.current.delete(id);
|
|
194
|
+
if (alive.current) setBusy(Object.fromEntries(flights.current));
|
|
195
|
+
};
|
|
196
|
+
// Repair only focus that belonged to the removed row. Async completion cannot reclaim it later.
|
|
197
|
+
const repairFocus = (id, kind, before = document.activeElement) => {
|
|
198
|
+
const row = before?.closest?.('[data-file-id]');
|
|
199
|
+
if (!row || row.dataset.fileId !== id) return;
|
|
200
|
+
const all = [...document.querySelectorAll(`[data-action="${kind}"]`)];
|
|
201
|
+
const index = all.findIndex(el => el.closest('[data-file-id]') === row), candidates = [...all.slice(index + 1), ...all.slice(0, index).reverse()];
|
|
202
|
+
requestAnimationFrame(() => {
|
|
203
|
+
if (!alive.current || (document.activeElement !== before && !(document.activeElement === document.body && !before.isConnected))) return;
|
|
204
|
+
const next = candidates.find(el => el.isConnected && !el.disabled && !el.closest('[inert]'));
|
|
205
|
+
(next || heading.current)?.focus({ preventScroll: true });
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
const authoritativeResult = async operation => {
|
|
209
|
+
for (;;) {
|
|
210
|
+
try { return await service.mutate(operation); }
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error.code !== 'uncertain') throw error;
|
|
213
|
+
// A timeout is not failure. Reconcile, or retry this same idempotent operation.
|
|
214
|
+
// Never return a retryable failure to InlineEdit while the outcome is unknown.
|
|
215
|
+
const result = await service.reconcile(operation.operationId);
|
|
216
|
+
if (result) return result;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const move = async (kind, record, undo = false) => {
|
|
221
|
+
if (flights.current.has(record.id)) return;
|
|
222
|
+
const operation = nextOperation(kind, record);
|
|
223
|
+
const current = service.read(record.id);
|
|
224
|
+
if (!current || current.deleted || current.revision !== record.revision) {
|
|
225
|
+
const inSlot = !!heldRef.current[record.id];
|
|
226
|
+
reconcileRecord(current);
|
|
227
|
+
// A receipt explains the change in its own slot; anywhere else, the region below the list does.
|
|
228
|
+
if (!inSlot) announce({ ...operation, tone: 'critical', title: current?.deleted ? 'This file was removed.' : 'This file changed after it was archived.', body: current?.deleted ? 'It cannot be restored.' : 'Review it in Archived.', route: true });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (undo) { A.toast.dismiss(offered.current.get(record.id)?.toast); offered.current.delete(record.id); }
|
|
232
|
+
clearRowError(record.id); setFlight(record.id, operation);
|
|
233
|
+
// The row keeps its slot while the write is pending: Undo from its receipt, or a row leaving this view.
|
|
234
|
+
const slot = heldRef.current[record.id] || (belongs(record, locationRef.current) ? { location: locationRef.current } : null);
|
|
235
|
+
if (!heldRef.current[record.id]) repairFocus(record.id, kind === 'archive' ? 'archive' : 'restore');
|
|
236
|
+
if (slot) publishHeld({ ...heldRef.current, [record.id]: { ...operation, location: slot.location, pending: true, undoing: undo && !!heldRef.current[record.id], record, title: `${kind === 'archive' ? 'Archiving' : 'Restoring'} “${record.name}”…` } });
|
|
237
|
+
publishRecords(recordsRef.current.map(old => old.id === record.id ? { ...old, archived: kind === 'archive' } : old));
|
|
238
|
+
try {
|
|
239
|
+
const result = await authoritativeResult(operation);
|
|
240
|
+
if (!alive.current) return;
|
|
241
|
+
reconcileRecord(result);
|
|
242
|
+
// A late receipt describes an older revision than one already known: judge it against the latest.
|
|
243
|
+
const entry = heldRef.current[record.id], latest = recordsRef.current.find(item => item.id === record.id) || result;
|
|
244
|
+
if (entry?.operationId === operation.operationId && belongs(latest, entry.location)) {
|
|
245
|
+
// Undone in place: the row comes back in its slot, and keeps focus that was on its receipt.
|
|
246
|
+
const focusWasHere = document.activeElement === document.body || !!document.activeElement?.closest?.(`[data-file-id="${record.id}"]`);
|
|
247
|
+
dropHeld(record.id);
|
|
248
|
+
if (focusWasHere) requestAnimationFrame(() => document.querySelector(`[data-action="archive"][data-id="${record.id}"]`)?.focus({ preventScroll: true }));
|
|
249
|
+
announce({ ...operation, quiet: true, title: `${result.name} is restored.` });
|
|
250
|
+
} else if (entry?.operationId === operation.operationId) {
|
|
251
|
+
publishHeld({ ...heldRef.current, [record.id]: recoveryAtCurrentRevision({ ...operation, location: entry.location, record: result, title: `${result.name} ${kind === 'archive' ? 'archived' : 'restored'}.`,
|
|
252
|
+
undo: kind === 'archive', route: true, routeLocation: kind === 'archive' ? 'archived' : 'active' }, latest) });
|
|
253
|
+
} else {
|
|
254
|
+
// Not where it happened (Undo from a toast, or the view changed): bring it back here, or say so quietly.
|
|
255
|
+
if (belongs(result, locationRef.current)) setArrived(record.id);
|
|
256
|
+
announce({ ...operation, quiet: true, title: `${result.name} is ${kind === 'archive' ? 'archived' : 'restored'}.` });
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (!alive.current) return;
|
|
260
|
+
if (heldRef.current[record.id]?.operationId === operation.operationId) dropHeld(record.id);
|
|
261
|
+
reconcileRecord(service.read(record.id));
|
|
262
|
+
reportFailure({ ...operation, tone: 'critical', title: kind === 'archive' ? `Could not archive ${record.name}.` : `Could not restore ${record.name}.`,
|
|
263
|
+
body: error.message, retry: error.code === 'rejected' ? { kind, record: service.read(record.id) } : null, route: true });
|
|
264
|
+
} finally { setFlight(record.id, null); }
|
|
265
|
+
};
|
|
266
|
+
// The gap has closed. Undo moves to a toast; Archived → Restore stays the route that doesn't expire.
|
|
267
|
+
const closeReceipt = id => {
|
|
268
|
+
const entry = heldRef.current[id];
|
|
269
|
+
if (!entry || entry.pending) return;
|
|
270
|
+
dropHeld(id);
|
|
271
|
+
if (!entry.undo) return;
|
|
272
|
+
const offer = { ...entry, toast: A.toast({ title: `${entry.record.name} archived.`, action: { label: 'Undo', onClick: () => move('restore', entry.record, true) } }) };
|
|
273
|
+
offered.current.set(id, offer);
|
|
274
|
+
};
|
|
275
|
+
const beginDelete = record => {
|
|
276
|
+
if (flights.current.has(record.id)) return;
|
|
277
|
+
const next = { ...record, session: ++sessionSequence.current, invoker: document.activeElement };
|
|
278
|
+
targetRef.current = next; setTarget(next); setDeleteError(null); setStaleIntent(false); setOpen(true);
|
|
279
|
+
};
|
|
280
|
+
const deleteFile = async () => {
|
|
281
|
+
const captured = targetRef.current;
|
|
282
|
+
if (!captured || flights.current.has(captured.id) || staleIntent) return;
|
|
283
|
+
const current = service.read(captured.id);
|
|
284
|
+
if (!current || current.deleted || current.revision !== captured.revision || !current.archived) {
|
|
285
|
+
reconcileRecord(current); setStaleIntent(true);
|
|
286
|
+
setDeleteError(current?.deleted ? 'This file was removed. Close and review Archived.' : `This file is now “${current?.name || captured.name}”. Review the current file in Archived, then confirm again.`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
clearRowError(captured.id);
|
|
290
|
+
const operation = nextOperation('delete', captured, captured.session);
|
|
291
|
+
setFlight(captured.id, operation); setDeleteError(null);
|
|
292
|
+
try {
|
|
293
|
+
const result = await authoritativeResult(operation);
|
|
294
|
+
if (!alive.current) return;
|
|
295
|
+
// If Close returned focus to the soon-to-disappear Delete button, repair that focus too.
|
|
296
|
+
repairFocus(captured.id, 'delete'); reconcileRecord(result);
|
|
297
|
+
// Seen in Archived, the deletion is told in the row's slot; anywhere else, quietly below the list.
|
|
298
|
+
if (locationRef.current === 'archived') publishHeld({ ...heldRef.current, [captured.id]: { ...operation, location: 'archived', record: result, title: `${captured.name} was permanently deleted.` } });
|
|
299
|
+
else announce({ ...operation, quiet: true, title: `${captured.name} was permanently deleted.` });
|
|
300
|
+
if (openRef.current && targetRef.current?.session === operation.session) setOpen(false);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (!alive.current) return;
|
|
303
|
+
reconcileRecord(service.read(captured.id));
|
|
304
|
+
if (openRef.current && targetRef.current?.session === operation.session) {
|
|
305
|
+
setDeleteError(error.message); setStaleIntent(error.code !== 'rejected');
|
|
306
|
+
} else reportFailure({ ...operation, tone: 'critical', title: `Could not delete ${captured.name}.`, body: error.message, route: true });
|
|
307
|
+
} finally { setFlight(captured.id, null); }
|
|
308
|
+
};
|
|
309
|
+
const rename = async (record, draft, previousValue, isEditorMounted) => {
|
|
310
|
+
const current = recordsRef.current.find(item => item.id === record.id);
|
|
311
|
+
if (!current || current.name !== previousValue || flights.current.has(record.id)) return { ok: false, error: 'This file changed. Review its current value.' };
|
|
312
|
+
const operation = { ...nextOperation('rename', current), name: draft };
|
|
313
|
+
setFlight(record.id, operation);
|
|
314
|
+
try {
|
|
315
|
+
const result = await authoritativeResult(operation);
|
|
316
|
+
// A mounted editor publishes via onValueChange. After navigation the application
|
|
317
|
+
// must still reconcile the write; InlineEdit correctly suppresses its late callback.
|
|
318
|
+
if (isEditorMounted()) renameResults.current.set(record.id, result);
|
|
319
|
+
else reconcileRecord(result);
|
|
320
|
+
return { ok: true, value: result.name };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error.code !== 'rejected') reconcileRecord(service.read(record.id));
|
|
323
|
+
return { ok: false, error: error.message };
|
|
324
|
+
} finally { setFlight(record.id, null); }
|
|
325
|
+
};
|
|
326
|
+
React.useEffect(() => {
|
|
327
|
+
alive.current = true;
|
|
328
|
+
const debug = { service, snapshot: () => ({ records: recordsRef.current, accepted: service.accepted, requests: service.requests }), sync: id => reconcileRecord(service.read(id)) };
|
|
329
|
+
window.__filesDemo = debug;
|
|
330
|
+
return () => {
|
|
331
|
+
alive.current = false; if (window.__filesDemo === debug) delete window.__filesDemo;
|
|
332
|
+
for (const offer of offered.current.values()) A.toast.dismiss(offer.toast);
|
|
333
|
+
};
|
|
334
|
+
}, [service]);
|
|
335
|
+
// A held receipt is the result of what the person just did: it keeps its slot until it closes, whatever the search.
|
|
336
|
+
const shown = records.filter(record => held[record.id]?.location === location || (belongs(record, location) && record.name.toLowerCase().includes(query.toLowerCase())));
|
|
337
|
+
const count = archived => records.filter(record => !record.deleted && record.archived === archived).length;
|
|
338
|
+
const deleting = target && busy[target.id]?.kind === 'delete' && busy[target.id]?.session === target.session;
|
|
339
|
+
const recordError = error => error && <A.Alert key={error.operationId} tone="critical" title={error.title} data-file-error={error.id}
|
|
340
|
+
action={error.retry ? <A.Button size="sm" onClick={() => move(error.retry.kind, error.retry.record)}>Retry {error.retry.kind}</A.Button> : undefined}
|
|
341
|
+
onDismiss={() => setRowErrors(errors => errors[error.id]?.operationId === error.operationId ? Object.fromEntries(Object.entries(errors).filter(([id]) => id !== error.id)) : errors)}>{error.body}</A.Alert>;
|
|
342
|
+
return <main className="aip-product" data-product="files">
|
|
343
|
+
<header className="aip-header"><div><p className="aip-eyebrow">Northstar Studio</p><h2>Files</h2></div><span className="aip-meta">Team library</span></header>
|
|
344
|
+
<div className="aip-toolbar"><nav aria-label="File locations">
|
|
345
|
+
<A.Button variant={location === 'active' ? 'secondary' : 'ghost'} aria-pressed={location === 'active'} onClick={() => go('active')}>Files ({count(false)})</A.Button>
|
|
346
|
+
<A.Button variant={location === 'archived' ? 'secondary' : 'ghost'} aria-pressed={location === 'archived'} onClick={() => go('archived')}>Archived ({count(true)})</A.Button>
|
|
347
|
+
</nav><A.Input aria-label="Search files" placeholder="Search files" value={query} onChange={event => setQuery(event.target.value)} /></div>
|
|
348
|
+
<h3 ref={heading} tabIndex={-1} data-list-heading>{location === 'active' ? 'All files' : 'Archived files'}</h3>
|
|
349
|
+
<ul className="aip-list" aria-label={location === 'active' ? 'All files' : 'Archived files'}>
|
|
350
|
+
{shown.map(record => { const receipt = held[record.id]?.location === location ? held[record.id] : null;
|
|
351
|
+
return <A.RemovalReceipt as="li" key={record.id} data-file-id={record.id} removed={!!receipt} pending={!!receipt?.pending} arrive={arrived === record.id}
|
|
352
|
+
tone={receipt?.tone === 'critical' ? 'critical' : 'neutral'} title={receipt && <>{receipt.title}{receipt.body && <> {receipt.body}</>}</>}
|
|
353
|
+
action={receipt && (receipt.undo || receipt.undoing || receipt.route) && <>
|
|
354
|
+
{receipt.route && <A.Button size="sm" variant="ghost" onClick={() => { setQuery(''); go(receipt.routeLocation); }}>View {receipt.routeLocation === 'active' ? 'Files' : 'Archived'}</A.Button>}
|
|
355
|
+
{/* Undo lands where Archive was, so a second click under the pointer reverses rather than navigates. */}
|
|
356
|
+
{(receipt.undo || receipt.undoing) && <A.Button size="sm" loading={!!receipt.undoing} aria-label={`Undo archive of ${receipt.record.name}`} onClick={() => move('restore', receipt.record, true)}>Undo</A.Button>}</>}
|
|
357
|
+
onClosed={() => closeReceipt(record.id)} focusFallback={() => heading.current}>
|
|
358
|
+
<div className="aip-file"><div className="aip-name">{full ? <FileName label={`File name: ${record.name}`} editLabel="Rename" value={record.name} required maxLength={160} disabled={!!busy[record.id] && busy[record.id].kind !== 'rename'}
|
|
359
|
+
validate={draft => !draft.trim() ? 'Enter a file name.' : recordsRef.current.some(other => other.id !== record.id && !other.deleted && other.name === draft.trim()) ? 'A file already has that name.' : undefined}
|
|
360
|
+
onBeginEdit={() => setNotice(current => current?.quiet ? null : current)}
|
|
361
|
+
onSave={(draft, context, isMounted) => rename(record, draft, context.previousValue, isMounted)}
|
|
362
|
+
onValueChange={name => { const result = renameResults.current.get(record.id); if (result) { reconcileRecord({ ...result, name }); renameResults.current.delete(record.id); } }}
|
|
363
|
+
focusFallback={() => heading.current} /> : <strong>{record.name}</strong>}</div>
|
|
364
|
+
<p className="aip-meta">{record.folder} · {record.size}</p>{recordError(rowErrors[record.id])}</div>
|
|
365
|
+
<div className="aip-row-actions">{record.archived ? <>
|
|
366
|
+
<A.Button size="sm" data-action="restore" data-id={record.id} aria-label={`Restore ${record.name}`} disabled={!!busy[record.id]} onClick={() => move('restore', record)}>Restore</A.Button>
|
|
367
|
+
{full && <A.Button size="sm" variant="ghost" data-action="delete" data-id={record.id} aria-label={`Delete ${record.name} permanently`} aria-disabled={!!busy[record.id]} onClick={() => beginDelete(record)}>Delete permanently</A.Button>}
|
|
368
|
+
</> : <A.Button size="sm" variant="ghost" data-action="archive" data-id={record.id} aria-label={`Archive ${record.name}`} disabled={!!busy[record.id]} onClick={() => move('archive', record)}>Archive</A.Button>}</div>
|
|
369
|
+
</A.RemovalReceipt>; })}
|
|
370
|
+
</ul>
|
|
371
|
+
{Object.values(rowErrors).filter(error => !shown.some(record => record.id === error.id)).map(recordError)}
|
|
372
|
+
{!shown.length && <p className="aip-empty">{query ? 'No files match your search.' : location === 'archived' ? 'No archived files.' : 'No files here. Restore a file from Archived.'}</p>}
|
|
373
|
+
<p className="aip-completion" role="status" aria-live="polite" aria-atomic="true" data-recovery={notice?.quiet ? '' : undefined}>{notice?.quiet && <><A.Icons.Check size={16} aria-hidden="true" />{notice.title}</>}</p>
|
|
374
|
+
{notice && !notice.quiet && <A.Surface className="aip-recovery" level={0} status={notice.pending ? 'pending' : undefined} data-recovery>
|
|
375
|
+
<A.Alert key={notice.operationId} variant={notice.undo || notice.retry || notice.route ? 'raised' : 'flat'} tone={notice.tone || 'neutral'} title={notice.title}
|
|
376
|
+
onDismiss={notice.pending ? undefined : () => setNotice(current => current?.operationId === notice.operationId ? null : current)}
|
|
377
|
+
action={<>{notice.undo && <A.Button size="sm" aria-label={`Undo archive of ${notice.record.name}`} onClick={() => move('restore', notice.record, true)}>Undo</A.Button>}
|
|
378
|
+
{notice.retry && <A.Button size="sm" onClick={() => move(notice.retry.kind, notice.retry.record)}>Retry {notice.retry.kind}</A.Button>}
|
|
379
|
+
{notice.route && <A.Button size="sm" variant="ghost" onClick={() => { setQuery(''); go(notice.routeLocation || 'archived'); }}>View {notice.routeLocation === 'active' ? 'Files' : 'Archived'}</A.Button>}</>}>
|
|
380
|
+
{notice.body}
|
|
381
|
+
</A.Alert>
|
|
382
|
+
</A.Surface>}
|
|
383
|
+
<A.Dialog open={confirmationOpen} onOpenChange={setOpen} size="sm" background="stationary" title="Delete this file permanently?"
|
|
384
|
+
description={`“${target?.name || ''}” will be removed. You won’t be able to restore it.`} focusSelector="[data-cancel]" returnFocus={() => heading.current}
|
|
385
|
+
footer={<><A.Button variant="ghost" data-cancel onClick={() => setOpen(false)}>{deleting ? 'Close' : 'Cancel'}</A.Button><A.Button variant="danger" disabled={staleIntent} loading={!!deleting} onClick={deleteFile}>{deleting ? 'Deleting…' : deleteError && !staleIntent ? 'Retry deletion' : 'Delete permanently'}</A.Button></>}>
|
|
386
|
+
<p className="aip-target"><strong>{target?.name}</strong><span>{target?.folder} · {target?.size}</span></p>
|
|
387
|
+
{deleting && <p role="status">Deletion is in progress. Closing this dialog won’t cancel it.</p>}
|
|
388
|
+
{deleteError && <A.Alert tone="critical" title={deleteError} />}
|
|
389
|
+
</A.Dialog>
|
|
390
|
+
</main>;
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
<!-- recipe-source:end -->
|
|
394
|
+
|
|
395
|
+
Mount it with the normal provider and one `Toaster`, which carries Undo after a receipt closes:
|
|
396
|
+
|
|
397
|
+
```jsx
|
|
398
|
+
export default function FilesApp() {
|
|
399
|
+
const [service] = React.useState(() => createFilesService('success-100'));
|
|
400
|
+
return (
|
|
401
|
+
<A.AltitudeProvider>
|
|
402
|
+
<div className="aip">
|
|
403
|
+
<FilesExample service={service} full />
|
|
404
|
+
</div>
|
|
405
|
+
<A.Toaster />
|
|
406
|
+
</A.AltitudeProvider>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
<!-- recipe-css:start -->
|
|
412
|
+
```css
|
|
413
|
+
.aip { max-width: 880px; margin: 0 auto; }
|
|
414
|
+
.aip-product { color: var(--ink); overflow-wrap: anywhere; }
|
|
415
|
+
.aip-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); margin-bottom: var(--space-5); }
|
|
416
|
+
.aip-header h2 { font: 600 24px/30px var(--font-sans); margin: var(--space-1) 0 0; }
|
|
417
|
+
.aip-eyebrow { font: 400 12.5px/17px var(--font-sans); margin: 0; color: var(--ink-muted); }
|
|
418
|
+
.aip-meta { font: 400 12.5px/17px var(--font-sans); color: var(--ink-muted); margin: var(--space-1) 0 0; }
|
|
419
|
+
.aip-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--space-3); }
|
|
420
|
+
.aip-toolbar nav { display: flex; gap: var(--space-1); }
|
|
421
|
+
.aip-toolbar > .alt-input { flex: 1 1 170px; max-width: 260px; }
|
|
422
|
+
.aip-product h3 { font: 600 19px/26px var(--font-sans); margin: var(--space-6) 0 var(--space-2); }
|
|
423
|
+
.aip-product h3:focus { outline: 2px solid var(--accent-ink); outline-offset: 4px; }
|
|
424
|
+
.aip-list { list-style: none; margin: 0; padding: 0; }
|
|
425
|
+
.aip-list > li { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4) 0; border-bottom: 1px solid var(--line); }
|
|
426
|
+
.aip-file { flex: 1; min-width: 0; }
|
|
427
|
+
.aip-name { font: 400 15px/23px var(--font-sans); }
|
|
428
|
+
.aip-name strong { font-weight: 500; }
|
|
429
|
+
.aip-row-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
|
|
430
|
+
.aip-file > .alt-alert { margin-top: var(--space-3); }
|
|
431
|
+
.aip-recovery { margin-top: var(--space-5); }
|
|
432
|
+
.aip-completion { display: flex; align-items: center; gap: var(--space-2); margin: var(--space-5) 0 0; color: var(--ink-muted); font: 400 13.5px/20px var(--font-sans); }
|
|
433
|
+
.aip-completion svg { flex: none; }
|
|
434
|
+
.aip-completion:empty { margin: 0; }
|
|
435
|
+
.aip-recovery .alt-alert-action { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
|
436
|
+
.aip-empty { padding: var(--space-6) var(--space-3); background: var(--surface-well); border: 1px dashed var(--line-control); border-radius: var(--radius-md); }
|
|
437
|
+
.aip-target { margin: 0; display: grid; gap: var(--space-2); overflow-wrap: anywhere; }
|
|
438
|
+
.aip-target span { color: var(--ink-muted); font: 400 12.5px/17px var(--font-sans); }
|
|
439
|
+
.aip-documentation { margin-top: var(--space-6); border-top: 1px solid var(--line); padding-top: var(--space-4); font: 400 12.5px/17px var(--font-sans); }
|
|
440
|
+
.aip a { color: var(--accent-ink); }
|
|
441
|
+
.aip-harness { margin-top: var(--space-4); color: var(--ink-muted); }
|
|
442
|
+
.aip-harness summary { cursor: pointer; }
|
|
443
|
+
.aip-harness label { display: grid; gap: var(--space-2); }
|
|
444
|
+
.aip-harness select { max-width: 100%; font: inherit; padding: var(--space-2); background: var(--surface-well); color: var(--ink); border: 1px solid var(--line-control); border-radius: var(--radius-sm); }
|
|
445
|
+
.aip-harness-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); }
|
|
446
|
+
.aip-harness pre { overflow: auto; max-height: 220px; font: 12px/1.5 var(--font-mono); }
|
|
447
|
+
@media (max-width: 600px) { #root { padding: 16px; } .aip-list > li { align-items: stretch; flex-direction: column; gap: var(--space-2); } .aip-row-actions { align-self: flex-start; } .aip-toolbar > .alt-input { max-width: none; } .aip-header > .aip-meta { display: none; } .aip-recovery .alt-alert-inner, .aip-file .alt-alert-inner { flex-wrap: wrap; } .aip-recovery .alt-alert-body, .aip-file .alt-alert-body { flex-basis: calc(100% - 60px); } .aip-recovery .alt-alert-action, .aip-file .alt-alert-action { width: 100%; } }
|
|
448
|
+
```
|
|
449
|
+
<!-- recipe-css:end -->
|
|
450
|
+
|
|
451
|
+
## Focus and accessible state
|
|
452
|
+
|
|
453
|
+
Row removal captures focus before changing the list and restores it only if focus still belongs to that row. It prefers the next corresponding action, then the previous action, then the list heading, using `preventScroll`. Asynchronous responses never reclaim focus after someone moves to search or another control. Pointer/touch actions do not send focus to the recovery region.
|
|
454
|
+
|
|
455
|
+
The Alert’s existing live role provides one result announcement. Pending text is outside busy controls, so `aria-busy` does not suppress the only progress announcement. The page remains usable while an ordinary write is pending. A modal makes recovery controls behind it inert regardless of their visual paint order. InlineEdit supplies retained input, validation/error associations, IME handling, and local keyboard behavior; the application does not reimplement them.
|
|
456
|
+
|
|
457
|
+
Every state has text or an ARIA equivalent when motion and shadows are absent. No new depth tier, timer-driven Undo expiry, preview stage, draft queue, hold gesture, or typed-name challenge is introduced.
|
|
458
|
+
|
|
459
|
+
## Verification and scope
|
|
460
|
+
|
|
461
|
+
[`build/checks/actions-in-place.mjs`](https://github.com/richlaconte/altitude/blob/v0.4.0/build/checks/actions-in-place.mjs) exercises the canonical fixture model and browser interactions under React 18 and 19: duplicate requests, revision conflicts, uncertain outcomes, ordered feedback, dismissed/replaced recovery, targeted deletion, pending Close, old/new dialog sessions, focus recovery, reduced motion, increased contrast, forced colors, and narrow layouts. [The validation record](https://github.com/richlaconte/altitude/blob/v0.4.0/project/ActionsInPlaceValidation.md) distinguishes engineering evidence from remaining assistive-technology and participant gates.
|
|
462
|
+
|
|
463
|
+
[EditRegion](../components/EditRegion/README.md) now edits several fields of one record in place with this guide's session rules (issue #12 proposed ItemGroup, PropertyList, DataTable row expansion and Board card editing as its multi-field consumers). ConfirmAction and a shared action hook remain deferred. These demos do not establish two independent real application needs for those abstractions, and they do not establish a measured usability benefit. Structural activation counts and proposed design intent are not participant results.
|