altitude-design 0.2.1 → 0.3.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 +41 -6
- package/dist/adapters/codemirror.cjs +88 -0
- package/dist/adapters/codemirror.cjs.map +7 -0
- package/dist/adapters/codemirror.js +67 -0
- package/dist/adapters/codemirror.js.map +7 -0
- package/dist/adapters/react-flow.css +102 -0
- package/dist/adapters/tiptap.css +71 -0
- package/dist/altitude.css +3121 -244
- package/dist/altitude.iife.js +33 -5
- package/dist/altitude.no-fonts.css +3121 -244
- package/dist/chart-themes.cjs +195 -0
- package/dist/chart-themes.cjs.map +7 -0
- package/dist/chart-themes.js +165 -0
- package/dist/chart-themes.js.map +7 -0
- package/dist/cli/altitude.mjs +134 -0
- package/dist/guidance/README.md +32 -0
- package/dist/guidance/agent-setup.md +22 -0
- package/dist/guidance/app-skeleton.md +17 -0
- package/dist/guidance/evidence.json +95 -0
- package/dist/guidance/index.json +493 -0
- package/dist/guidance/llms.txt +20 -0
- package/dist/guidance/manifest.json +92 -0
- package/dist/guidance/pattern.schema.json +181 -0
- package/dist/guidance/patterns/agent-action.json +77 -0
- package/dist/guidance/patterns/agent-action.md +39 -0
- package/dist/guidance/patterns/assistant-conversation.json +77 -0
- package/dist/guidance/patterns/assistant-conversation.md +50 -0
- package/dist/guidance/patterns/async-collection.json +71 -0
- package/dist/guidance/patterns/async-collection.md +29 -0
- package/dist/guidance/patterns/audit-log.json +75 -0
- package/dist/guidance/patterns/audit-log.md +27 -0
- package/dist/guidance/patterns/auth.json +86 -0
- package/dist/guidance/patterns/auth.md +29 -0
- package/dist/guidance/patterns/billing.json +80 -0
- package/dist/guidance/patterns/billing.md +29 -0
- package/dist/guidance/patterns/consequential-action.json +76 -0
- package/dist/guidance/patterns/consequential-action.md +27 -0
- package/dist/guidance/patterns/developer-settings.json +79 -0
- package/dist/guidance/patterns/developer-settings.md +29 -0
- package/dist/guidance/patterns/edit-record.json +74 -0
- package/dist/guidance/patterns/edit-record.md +51 -0
- package/dist/guidance/patterns/import.json +77 -0
- package/dist/guidance/patterns/import.md +31 -0
- package/dist/guidance/patterns/members.json +77 -0
- package/dist/guidance/patterns/members.md +27 -0
- package/dist/guidance/patterns/onboarding.json +78 -0
- package/dist/guidance/patterns/onboarding.md +31 -0
- package/dist/guidance/patterns/review-submit.json +72 -0
- package/dist/guidance/patterns/review-submit.md +27 -0
- package/dist/guidance/patterns/screen-layout.json +79 -0
- package/dist/guidance/patterns/screen-layout.md +40 -0
- package/dist/guidance/patterns/settings.json +78 -0
- package/dist/guidance/patterns/settings.md +32 -0
- package/dist/guidance/patterns/sync.json +77 -0
- package/dist/guidance/patterns/sync.md +31 -0
- package/dist/guidance/principles.md +23 -0
- package/dist/guidance/references/build/checks/agent-action-pattern.mjs +57 -0
- package/dist/guidance/references/build/checks/agent-setup.mjs +78 -0
- package/dist/guidance/references/build/checks/assistant-conversation-pattern.mjs +49 -0
- package/dist/guidance/references/build/checks/audit-log-pattern.mjs +63 -0
- package/dist/guidance/references/build/checks/auth-pattern.mjs +67 -0
- package/dist/guidance/references/build/checks/billing-pattern.mjs +63 -0
- package/dist/guidance/references/build/checks/developer-settings-pattern.mjs +76 -0
- package/dist/guidance/references/build/checks/guidance.mjs +71 -0
- package/dist/guidance/references/build/checks/import-pattern.mjs +69 -0
- package/dist/guidance/references/build/checks/members-pattern.mjs +71 -0
- package/dist/guidance/references/build/checks/onboarding-pattern.mjs +54 -0
- package/dist/guidance/references/build/checks/pattern-types.mjs +13 -0
- package/dist/guidance/references/build/checks/pattern-workflows.mjs +226 -0
- package/dist/guidance/references/build/checks/primary-scope.mjs +49 -0
- package/dist/guidance/references/build/checks/render-tells.mjs +72 -0
- package/dist/guidance/references/build/checks/screen-layout-pattern.mjs +111 -0
- package/dist/guidance/references/build/checks/semantics.mjs +72 -0
- package/dist/guidance/references/build/checks/settings-pattern.mjs +73 -0
- package/dist/guidance/references/build/checks/sync-pattern.mjs +62 -0
- package/dist/guidance/references/build/checks/tells-lint.mjs +164 -0
- package/dist/guidance/references/build/fixtures/pattern-workflows.tsx +83 -0
- package/dist/guidance/references/build/fixtures/patterns/agent-action.jsx +60 -0
- package/dist/guidance/references/build/fixtures/patterns/app-skeleton.jsx +94 -0
- package/dist/guidance/references/build/fixtures/patterns/assistant-conversation.jsx +79 -0
- package/dist/guidance/references/build/fixtures/patterns/audit-log.jsx +81 -0
- package/dist/guidance/references/build/fixtures/patterns/auth.jsx +150 -0
- package/dist/guidance/references/build/fixtures/patterns/billing.jsx +96 -0
- package/dist/guidance/references/build/fixtures/patterns/developer-settings.jsx +95 -0
- package/dist/guidance/references/build/fixtures/patterns/import.jsx +102 -0
- package/dist/guidance/references/build/fixtures/patterns/members.jsx +106 -0
- package/dist/guidance/references/build/fixtures/patterns/onboarding.jsx +70 -0
- package/dist/guidance/references/build/fixtures/patterns/screen-layout-decision.jsx +74 -0
- package/dist/guidance/references/build/fixtures/patterns/screen-layout-editor.jsx +56 -0
- package/dist/guidance/references/build/fixtures/patterns/screen-layout-report.jsx +42 -0
- package/dist/guidance/references/build/fixtures/patterns/settings.jsx +73 -0
- package/dist/guidance/references/build/fixtures/patterns/sync.jsx +78 -0
- package/dist/guidance/references/project/components/src/pattern-async-collection.tsx +57 -0
- package/dist/guidance/references/project/components/src/pattern-consequential-action.tsx +77 -0
- package/dist/guidance/references/project/components/src/pattern-edit-record.tsx +127 -0
- package/dist/guidance/references/project/components/src/pattern-review-submit.tsx +47 -0
- package/dist/guidance/references/project/components/src/settings.tsx +79 -0
- package/dist/guidance/references/scripts/build-framework-fixture.mjs +20 -0
- package/dist/guidance/rules.json +653 -0
- package/dist/guidance/visual/Elevation.md +194 -0
- package/dist/guidance/visual/Fundamentals.md +83 -0
- package/dist/guidance/visual/Research.md +1620 -0
- package/dist/index.cjs +18792 -1925
- package/dist/index.cjs.map +4 -4
- package/dist/index.js +18792 -1925
- package/dist/index.js.map +4 -4
- package/dist/tokens.css +84 -84
- package/dist/tokens.json +301 -128
- package/dist/types/adapter-codemirror.d.ts +13 -0
- package/dist/types/agent-fleet.d.ts +166 -0
- package/dist/types/ai-label.d.ts +30 -0
- package/dist/types/app-shell.d.ts +83 -0
- package/dist/types/area-chart.d.ts +80 -0
- package/dist/types/artifact.d.ts +65 -0
- package/dist/types/bar-chart.d.ts +83 -0
- package/dist/types/bar-list.d.ts +35 -0
- package/dist/types/breadcrumb.d.ts +62 -0
- package/dist/types/chart-brush.d.ts +35 -0
- package/dist/types/chart-themes.d.ts +229 -0
- package/dist/types/chart.d.ts +570 -0
- package/dist/types/checklist.d.ts +71 -0
- package/dist/types/code-block.d.ts +32 -0
- package/dist/types/collections.d.ts +111 -4
- package/dist/types/color-picker.d.ts +38 -0
- package/dist/types/command.d.ts +6 -3
- package/dist/types/comment-thread.d.ts +48 -0
- package/dist/types/confirmation.d.ts +60 -0
- package/dist/types/context-menu.d.ts +17 -0
- package/dist/types/controls.d.ts +100 -17
- package/dist/types/conversation.d.ts +130 -0
- package/dist/types/copy-button.d.ts +19 -0
- package/dist/types/core.d.ts +139 -10
- package/dist/types/data-grid.d.ts +76 -0
- package/dist/types/date-picker.d.ts +96 -0
- package/dist/types/diff-view.d.ts +54 -0
- package/dist/types/dot-plot.d.ts +54 -0
- package/dist/types/edit-region.d.ts +121 -0
- package/dist/types/feature-gate.d.ts +22 -0
- package/dist/types/filter-bar.d.ts +65 -0
- package/dist/types/formatted-number.d.ts +38 -0
- package/dist/types/heatmap.d.ts +64 -0
- package/dist/types/in-place.d.ts +24 -0
- package/dist/types/index.d.ts +158 -15
- package/dist/types/inline-citation.d.ts +35 -0
- package/dist/types/inline-edit.d.ts +71 -0
- package/dist/types/input-otp.d.ts +37 -0
- package/dist/types/item-group.d.ts +104 -0
- package/dist/types/line-chart.d.ts +116 -0
- package/dist/types/link.d.ts +11 -0
- package/dist/types/markdown.d.ts +88 -0
- package/dist/types/meter.d.ts +43 -0
- package/dist/types/modal-state.d.ts +62 -0
- package/dist/types/nav.d.ts +8 -1
- package/dist/types/notifications.d.ts +42 -0
- package/dist/types/number-input.d.ts +30 -0
- package/dist/types/overlays.d.ts +51 -17
- package/dist/types/page-header.d.ts +27 -0
- package/dist/types/pagination.d.ts +29 -0
- package/dist/types/palette.d.ts +64 -0
- package/dist/types/password-input.d.ts +22 -0
- package/dist/types/pattern-assistant-conversation.d.ts +55 -0
- package/dist/types/pattern-async-collection.d.ts +34 -0
- package/dist/types/pattern-consequential-action.d.ts +53 -0
- package/dist/types/pattern-edit-record.d.ts +72 -0
- package/dist/types/pattern-review-submit.d.ts +43 -0
- package/dist/types/permission-matrix.d.ts +34 -0
- package/dist/types/plan-picker.d.ts +40 -0
- package/dist/types/prompt-input.d.ts +106 -0
- package/dist/types/property-list.d.ts +67 -0
- package/dist/types/question.d.ts +64 -0
- package/dist/types/relative-time.d.ts +21 -0
- package/dist/types/removal-receipt.d.ts +1180 -0
- package/dist/types/resizable.d.ts +48 -0
- package/dist/types/roving.d.ts +44 -0
- package/dist/types/save-bar.d.ts +38 -0
- package/dist/types/saved-views.d.ts +72 -0
- package/dist/types/scatter-chart.d.ts +60 -0
- package/dist/types/schedule.d.ts +80 -0
- package/dist/types/secret-field.d.ts +33 -0
- package/dist/types/select.d.ts +44 -2
- package/dist/types/separator.d.ts +11 -0
- package/dist/types/settings.d.ts +39 -0
- package/dist/types/sheet.d.ts +1 -1
- package/dist/types/sparkline.d.ts +46 -0
- package/dist/types/split-button.d.ts +21 -0
- package/dist/types/stat.d.ts +54 -0
- package/dist/types/stepper.d.ts +6 -3
- package/dist/types/suggestions.d.ts +29 -0
- package/dist/types/suspense.d.ts +12 -1
- package/dist/types/sync-indicator.d.ts +24 -0
- package/dist/types/tag-input.d.ts +65 -0
- package/dist/types/terminal.d.ts +33 -0
- package/dist/types/thread-list.d.ts +58 -0
- package/dist/types/time-input.d.ts +33 -0
- package/dist/types/timeline.d.ts +38 -0
- package/dist/types/toggle.d.ts +46 -0
- package/dist/types/toolbar.d.ts +34 -0
- package/dist/types/tour.d.ts +23 -0
- package/dist/types/trace.d.ts +65 -0
- package/dist/types/tree.d.ts +71 -0
- package/dist/types-cjs/adapter-codemirror.d.cts +13 -0
- package/dist/types-cjs/agent-fleet.d.cts +166 -0
- package/dist/types-cjs/ai-label.d.cts +30 -0
- package/dist/types-cjs/app-shell.d.cts +83 -0
- package/dist/types-cjs/area-chart.d.cts +80 -0
- package/dist/types-cjs/artifact.d.cts +65 -0
- package/dist/types-cjs/bar-chart.d.cts +83 -0
- package/dist/types-cjs/bar-list.d.cts +35 -0
- package/dist/types-cjs/breadcrumb.d.cts +62 -0
- package/dist/types-cjs/chart-brush.d.cts +35 -0
- package/dist/types-cjs/chart-themes.d.cts +229 -0
- package/dist/types-cjs/chart.d.cts +570 -0
- package/dist/types-cjs/checklist.d.cts +71 -0
- package/dist/types-cjs/code-block.d.cts +32 -0
- package/dist/types-cjs/collections.d.cts +246 -0
- package/dist/types-cjs/color-picker.d.cts +38 -0
- package/dist/types-cjs/command.d.cts +66 -0
- package/dist/types-cjs/comment-thread.d.cts +48 -0
- package/dist/types-cjs/confirmation.d.cts +60 -0
- package/dist/types-cjs/context-menu.d.cts +17 -0
- package/dist/types-cjs/controls.d.cts +360 -0
- package/dist/types-cjs/conversation.d.cts +130 -0
- package/dist/types-cjs/copy-button.d.cts +19 -0
- package/dist/types-cjs/core.d.cts +256 -0
- package/dist/types-cjs/data-grid.d.cts +76 -0
- package/dist/types-cjs/date-picker.d.cts +96 -0
- package/dist/types-cjs/diff-view.d.cts +54 -0
- package/dist/types-cjs/dot-plot.d.cts +54 -0
- package/dist/types-cjs/edit-region.d.cts +121 -0
- package/dist/types-cjs/empty.d.cts +24 -0
- package/dist/types-cjs/feature-gate.d.cts +22 -0
- package/dist/types-cjs/filter-bar.d.cts +65 -0
- package/dist/types-cjs/formatted-number.d.cts +38 -0
- package/dist/types-cjs/heatmap.d.cts +64 -0
- package/dist/types-cjs/in-place.d.cts +24 -0
- package/dist/types-cjs/index.d.cts +154 -0
- package/dist/types-cjs/inline-citation.d.cts +35 -0
- package/dist/types-cjs/inline-edit.d.cts +71 -0
- package/dist/types-cjs/input-otp.d.cts +37 -0
- package/dist/types-cjs/item-group.d.cts +104 -0
- package/dist/types-cjs/line-chart.d.cts +116 -0
- package/dist/types-cjs/link.d.cts +11 -0
- package/dist/types-cjs/markdown.d.cts +88 -0
- package/dist/types-cjs/meter.d.cts +43 -0
- package/dist/types-cjs/modal-state.d.cts +62 -0
- package/dist/types-cjs/nav.d.cts +25 -0
- package/dist/types-cjs/notifications.d.cts +42 -0
- package/dist/types-cjs/number-input.d.cts +30 -0
- package/dist/types-cjs/overlays.d.cts +170 -0
- package/dist/types-cjs/page-header.d.cts +27 -0
- package/dist/types-cjs/pagination.d.cts +29 -0
- package/dist/types-cjs/palette.d.cts +64 -0
- package/dist/types-cjs/password-input.d.cts +22 -0
- package/dist/types-cjs/pattern-assistant-conversation.d.cts +55 -0
- package/dist/types-cjs/pattern-async-collection.d.cts +34 -0
- package/dist/types-cjs/pattern-consequential-action.d.cts +53 -0
- package/dist/types-cjs/pattern-edit-record.d.cts +72 -0
- package/dist/types-cjs/pattern-review-submit.d.cts +43 -0
- package/dist/types-cjs/permission-matrix.d.cts +34 -0
- package/dist/types-cjs/pile.d.cts +27 -0
- package/dist/types-cjs/plan-picker.d.cts +40 -0
- package/dist/types-cjs/prompt-input.d.cts +106 -0
- package/dist/types-cjs/property-list.d.cts +67 -0
- package/dist/types-cjs/question.d.cts +64 -0
- package/dist/types-cjs/relative-time.d.cts +21 -0
- package/dist/types-cjs/removal-receipt.d.cts +1180 -0
- package/dist/types-cjs/resizable.d.cts +48 -0
- package/dist/types-cjs/roving.d.cts +44 -0
- package/dist/types-cjs/save-bar.d.cts +38 -0
- package/dist/types-cjs/saved-views.d.cts +72 -0
- package/dist/types-cjs/scatter-chart.d.cts +60 -0
- package/dist/types-cjs/schedule.d.cts +80 -0
- package/dist/types-cjs/secret-field.d.cts +33 -0
- package/dist/types-cjs/select.d.cts +109 -0
- package/dist/types-cjs/separator.d.cts +11 -0
- package/dist/types-cjs/settings.d.cts +39 -0
- package/dist/types-cjs/sheet.d.cts +27 -0
- package/dist/types-cjs/sparkline.d.cts +46 -0
- package/dist/types-cjs/split-button.d.cts +21 -0
- package/dist/types-cjs/stat.d.cts +54 -0
- package/dist/types-cjs/stepper.d.cts +26 -0
- package/dist/types-cjs/suggestions.d.cts +29 -0
- package/dist/types-cjs/suspense.d.cts +85 -0
- package/dist/types-cjs/sync-indicator.d.cts +24 -0
- package/dist/types-cjs/tag-input.d.cts +65 -0
- package/dist/types-cjs/terminal.d.cts +33 -0
- package/dist/types-cjs/thread-list.d.cts +58 -0
- package/dist/types-cjs/time-input.d.cts +33 -0
- package/dist/types-cjs/timeline.d.cts +38 -0
- package/dist/types-cjs/toggle.d.cts +46 -0
- package/dist/types-cjs/toolbar.d.cts +34 -0
- package/dist/types-cjs/tour.d.cts +23 -0
- package/dist/types-cjs/trace.d.cts +65 -0
- package/dist/types-cjs/tree.d.cts +71 -0
- package/package.json +89 -9
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Sync pattern (project/framework/patterns/sync.md): each change stays pending until the server acknowledges it, and the
|
|
2
|
+
// SyncIndicator counts the queue; offline, changes wait and nothing claims to be saved; a conflict shows both versions,
|
|
3
|
+
// theirs in their presence colour with their name, and asks keep mine, keep theirs or keep both; an expiring session
|
|
4
|
+
// asks in a stationary dialog and keeps the draft.
|
|
5
|
+
// The lines between the preview markers are its "SyncPattern" preview (scripts/pattern-previews.mjs copies them);
|
|
6
|
+
// build/checks/pattern-types.mjs type-checks this file against the public declarations and fails if the two differ.
|
|
7
|
+
import * as React from 'react';
|
|
8
|
+
import * as A from 'altitude-design';
|
|
9
|
+
// preview:start
|
|
10
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
function App() {
|
|
12
|
+
const [online, setOnline] = React.useState(true);
|
|
13
|
+
const live = React.useRef(true); live.current = online;
|
|
14
|
+
const [tasks, setTasks] = React.useState([
|
|
15
|
+
{ id: 't1', title: 'Draft the launch email', done: true }, { id: 't2', title: 'Book the venue', done: false }, { id: 't3', title: 'Send the press kit', done: false },
|
|
16
|
+
]);
|
|
17
|
+
const [writes, setWrites] = React.useState(/** @type {Record<string, 'pending' | 'committed' | 'failed'>} */ ({}));
|
|
18
|
+
const [syncedAt, setSyncedAt] = React.useState(new Date());
|
|
19
|
+
const [conflict, setConflict] = React.useState(/** @type {{ mine: string, theirs: string } | null} */ (null));
|
|
20
|
+
const [brief, setBrief] = React.useState('Launch on 4 December, in Lisbon.');
|
|
21
|
+
const [message, setMessage] = React.useState('');
|
|
22
|
+
const [expiring, setExpiring] = React.useState(false);
|
|
23
|
+
const [left, setLeft] = React.useState(120);
|
|
24
|
+
React.useEffect(() => { if (!expiring) return; const t = setInterval(() => setLeft((s) => Math.max(0, s - 1)), 1000); return () => clearInterval(t); }, [expiring]);
|
|
25
|
+
const queue = Object.keys(writes).filter((k) => writes[k] === 'pending');
|
|
26
|
+
const send = async (ids) => {
|
|
27
|
+
await wait(700);
|
|
28
|
+
if (!live.current) return;
|
|
29
|
+
setWrites((w) => { const n = { ...w }; ids.forEach((id) => { n[id] = 'committed'; }); return n; });
|
|
30
|
+
setSyncedAt(new Date());
|
|
31
|
+
};
|
|
32
|
+
const toggle = (id, done) => { setTasks((l) => l.map((t) => (t.id === id ? { ...t, done } : t))); setWrites((w) => ({ ...w, [id]: 'pending' })); void send([id]); };
|
|
33
|
+
const goOnline = (v) => { setOnline(v); live.current = v; if (v && queue.length) void send(queue); };
|
|
34
|
+
const resolve = (choice) => {
|
|
35
|
+
if (!conflict) return;
|
|
36
|
+
setBrief(choice === 'mine' ? conflict.mine : choice === 'theirs' ? conflict.theirs : `${conflict.mine}\n${conflict.theirs}`);
|
|
37
|
+
setMessage(choice === 'mine' ? 'Kept your version. Jane Park’s version is in the history.' : choice === 'theirs' ? 'Kept Jane Park’s version. Yours is in the history.' : 'Kept both versions, one after the other.');
|
|
38
|
+
setConflict(null); setSyncedAt(new Date());
|
|
39
|
+
requestAnimationFrame(() => document.getElementById('sy-brief-h')?.focus());
|
|
40
|
+
};
|
|
41
|
+
const minutes = Math.ceil(left / 60);
|
|
42
|
+
return (
|
|
43
|
+
<main className="sy-page">
|
|
44
|
+
<header className="sy-head"><h2 className="sy-h">Launch plan</h2>
|
|
45
|
+
<A.SyncIndicator state={conflict ? 'conflict' : !online ? 'offline' : queue.length ? 'saving' : 'saved'} pending={queue.length} conflicts={conflict ? 1 : 0} lastSyncedAt={syncedAt}
|
|
46
|
+
onOpenConflicts={() => document.getElementById('sy-conflict-h')?.focus()} />
|
|
47
|
+
</header>
|
|
48
|
+
<div className="sy-demo">
|
|
49
|
+
<A.Switch label="Online" checked={online} onCheckedChange={goOnline} />
|
|
50
|
+
<A.Button size="sm" disabled={!!conflict} onClick={() => setConflict({ mine: 'Launch on 4 December, in Lisbon.', theirs: 'Launch on 11 December, in Porto.' })}>Simulate a conflict</A.Button>
|
|
51
|
+
<A.Button size="sm" onClick={() => { setLeft(120); setExpiring(true); }}>Simulate session expiry</A.Button>
|
|
52
|
+
</div>
|
|
53
|
+
<section aria-labelledby="sy-tasks-h" className="sy-section"><h3 id="sy-tasks-h" className="sy-sub">Tasks</h3>
|
|
54
|
+
<ul className="sy-tasks">{tasks.map((t) => <li key={t.id}><A.Surface level={0} status={writes[t.id]} className="sy-task">
|
|
55
|
+
<A.Checkbox label={t.title} checked={t.done} onCheckedChange={(v) => toggle(t.id, v)} />
|
|
56
|
+
<span className="sy-state">{writes[t.id] === 'pending' ? (online ? 'Saving…' : 'Waiting to sync') : ''}</span>
|
|
57
|
+
</A.Surface></li>)}</ul>
|
|
58
|
+
</section>
|
|
59
|
+
<section aria-labelledby="sy-brief-h" className="sy-section"><h3 id="sy-brief-h" tabIndex={-1} className="sy-sub">Brief</h3>
|
|
60
|
+
{conflict ? <A.Surface level={1} className="sy-conflict" role="group" aria-labelledby="sy-conflict-h">
|
|
61
|
+
<h4 id="sy-conflict-h" tabIndex={-1} className="sy-ch">Changed by Jane Park while you were editing</h4>
|
|
62
|
+
<div className="sy-versions">
|
|
63
|
+
<div className="sy-version"><div className="sy-who">Your version</div><p>{conflict.mine}</p></div>
|
|
64
|
+
<div className="sy-version" data-presence="2"><div className="sy-who"><A.Avatar name="Jane Park" size={20} /> Jane Park’s version</div><p>{conflict.theirs}</p></div>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="sy-actions"><A.Button onClick={() => resolve('mine')}>Keep mine</A.Button><A.Button onClick={() => resolve('theirs')}>Keep theirs</A.Button><A.Button onClick={() => resolve('both')}>Keep both</A.Button></div>
|
|
67
|
+
</A.Surface> : <p className="sy-brief">{brief}</p>}
|
|
68
|
+
</section>
|
|
69
|
+
<p className="sy-note" role="status">{message}</p>
|
|
70
|
+
<A.Dialog open={expiring} background="stationary" dismissible={false} size="sm" onOpenChange={setExpiring}
|
|
71
|
+
title={left > 0 ? `Your session ends in ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}` : 'Your session ended'}
|
|
72
|
+
description={left > 0 ? 'Stay signed in to keep working. Your unsaved changes are kept on this device either way.' : 'Sign in again to continue. Your unsaved changes are kept on this device.'}
|
|
73
|
+
footer={<><A.Button onClick={() => setExpiring(false)}>Sign out</A.Button><A.Button variant="primary" onClick={() => setExpiring(false)}>{left > 0 ? 'Stay signed in' : 'Sign in again'}</A.Button></>} />
|
|
74
|
+
</main>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
// preview:end
|
|
78
|
+
export default App;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { useIsoLayoutEffect } from './core';
|
|
3
|
+
export type CollectionPhase = 'loading' | 'refreshing' | 'populated' | 'empty' | 'no-results' | 'failed';
|
|
4
|
+
export interface AsyncCollectionOptions<T, Q> {
|
|
5
|
+
initialQuery: Q;
|
|
6
|
+
/** Own query semantics, permissions, pagination and URL/history in the application. Abort is advisory. */
|
|
7
|
+
load: (query: Q, context: { signal: AbortSignal }) => readonly T[] | Promise<readonly T[]>;
|
|
8
|
+
getKey: (item: T) => string;
|
|
9
|
+
isFiltered: (query: Q) => boolean;
|
|
10
|
+
/** preserve deliberately retains keys absent from the current page/filter; reconcile deleted identities in the app. */
|
|
11
|
+
selection: 'clear-on-query' | 'preserve';
|
|
12
|
+
messages?: Partial<{ loading: string; refreshing: string; empty: string; noResults: string; failed: string; results: (count: number) => string }>;
|
|
13
|
+
}
|
|
14
|
+
/** Stale-response-safe collection, with explicit selection policy. See altitude-design/guidance/patterns/async-collection.md. */
|
|
15
|
+
export function useAsyncCollection<T, Q>(options: AsyncCollectionOptions<T, Q>) {
|
|
16
|
+
const [state, setState] = React.useState(() => ({ query: options.initialQuery, items: [] as readonly T[], phase: 'loading' as CollectionPhase,
|
|
17
|
+
error: '', status: '', selectedKeys: new Set<string>() as ReadonlySet<string> }));
|
|
18
|
+
const current = React.useRef(state);
|
|
19
|
+
const config = React.useRef(options); config.current = options;
|
|
20
|
+
const mounted = React.useRef(false), loaded = React.useRef(false), generation = React.useRef(0);
|
|
21
|
+
const active = React.useRef<AbortController | null>(null);
|
|
22
|
+
const publish = (patch: Partial<typeof state>) => { current.current = { ...current.current, ...patch }; if (mounted.current) setState(current.current); };
|
|
23
|
+
const fetchItems = async (query: Q) => {
|
|
24
|
+
const run = ++generation.current;
|
|
25
|
+
active.current?.abort(); const controller = new AbortController(); active.current = controller;
|
|
26
|
+
const opts = config.current;
|
|
27
|
+
const messages = { loading: 'Loading results', refreshing: 'Refreshing results', empty: 'No records yet', noResults: 'No matching results',
|
|
28
|
+
failed: 'We couldn’t load the results. Try again.', results: (count: number) => `${count} ${count === 1 ? 'result' : 'results'}`, ...opts.messages };
|
|
29
|
+
publish({ phase: loaded.current ? 'refreshing' : 'loading', error: '', status: loaded.current ? messages.refreshing : messages.loading });
|
|
30
|
+
try {
|
|
31
|
+
const items = await opts.load(query, { signal: controller.signal });
|
|
32
|
+
if (!mounted.current || run !== generation.current) return;
|
|
33
|
+
if (!Array.isArray(items)) throw Error('Expected items');
|
|
34
|
+
const keys = items.map(opts.getKey);
|
|
35
|
+
if (keys.some(key => typeof key !== 'string' || !key) || new Set(keys).size !== keys.length) throw Error('Expected unique stable keys');
|
|
36
|
+
loaded.current = true;
|
|
37
|
+
const phase = items.length ? 'populated' : opts.isFiltered(query) ? 'no-results' : 'empty';
|
|
38
|
+
publish({ items, phase, status: phase === 'populated' ? messages.results(items.length) : phase === 'no-results' ? messages.noResults : messages.empty });
|
|
39
|
+
} catch {
|
|
40
|
+
if (mounted.current && run === generation.current) publish({ phase: 'failed', error: messages.failed, status: messages.failed });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
useIsoLayoutEffect(() => { mounted.current = true; void fetchItems(current.current.query);
|
|
44
|
+
return () => { mounted.current = false; generation.current++; active.current?.abort(); }; }, []);
|
|
45
|
+
const setQuery = (query: Q) => {
|
|
46
|
+
publish({ query, ...(config.current.selection === 'clear-on-query' ? { selectedKeys: new Set<string>() } : {}) });
|
|
47
|
+
void fetchItems(query);
|
|
48
|
+
};
|
|
49
|
+
const clearSelection = () => publish({ selectedKeys: new Set<string>() });
|
|
50
|
+
return { ...state, setQuery, refresh: () => { void fetchItems(current.current.query); }, reset: () => setQuery(config.current.initialQuery), clearSelection,
|
|
51
|
+
toggleSelection: (key: string) => {
|
|
52
|
+
const selected = new Set(current.current.selectedKeys);
|
|
53
|
+
if (selected.has(key)) selected.delete(key);
|
|
54
|
+
else if (current.current.items.some(item => config.current.getKey(item) === key)) selected.add(key);
|
|
55
|
+
publish({ selectedKeys: selected });
|
|
56
|
+
} };
|
|
57
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { useIsoLayoutEffect } from './core';
|
|
3
|
+
export type ActionResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
4
|
+
export type UndoResult = { ok: true } | { ok: false; error: string };
|
|
5
|
+
export type ConsequentialPhase = 'idle' | 'confirming' | 'pending' | 'failed' | 'succeeded' | 'undoing' | 'undone';
|
|
6
|
+
interface ActionBase<T> {
|
|
7
|
+
/** Render target and consequence alongside the trigger and in any confirmation. */
|
|
8
|
+
target: string;
|
|
9
|
+
consequence: string;
|
|
10
|
+
execute: () => ActionResult<T> | Promise<ActionResult<T>>;
|
|
11
|
+
/** Connected, visible next logical destination when the original trigger disappears. */
|
|
12
|
+
focusFallback?: () => HTMLElement | null;
|
|
13
|
+
messages?: Partial<{ pending: string; succeeded: string; failed: string; undoing: string; undone: string }>;
|
|
14
|
+
}
|
|
15
|
+
export type ConsequentialActionOptions<T> = ActionBase<T> & (
|
|
16
|
+
{ requiresConfirmation: true; undo?: (result: T) => UndoResult | Promise<UndoResult> } |
|
|
17
|
+
{ requiresConfirmation: false; undo: (result: T) => UndoResult | Promise<UndoResult> }
|
|
18
|
+
);
|
|
19
|
+
const focusable = (element: HTMLElement | null | undefined): element is HTMLElement => !!element?.isConnected &&
|
|
20
|
+
!element.closest('[inert]') && !element.matches(':disabled,[aria-disabled="true"]') && element.getClientRects().length > 0 &&
|
|
21
|
+
getComputedStyle(element).visibility !== 'hidden';
|
|
22
|
+
/** Confirmation records intent; only execute success records completion. Undo is offered only with an application-supplied inverse.
|
|
23
|
+
* Compose Dialog/Alert/Button with the returned content and states. See altitude-design/guidance/patterns/consequential-action.md. */
|
|
24
|
+
export function useConsequentialAction<T>(options: ConsequentialActionOptions<T>) {
|
|
25
|
+
const [state, setState] = React.useState({ phase: 'idle' as ConsequentialPhase, error: '', status: '', result: undefined as T | undefined });
|
|
26
|
+
const current = React.useRef(state), mounted = React.useRef(false), trigger = React.useRef<HTMLElement | null>(null);
|
|
27
|
+
const active = React.useRef(options);
|
|
28
|
+
const returnFocus = React.useRef(false);
|
|
29
|
+
const failedOperation = React.useRef<'execute' | 'undo'>('execute');
|
|
30
|
+
const messages = { pending: 'Completing action', succeeded: 'Action completed', failed: 'We couldn’t complete the action. Try again.',
|
|
31
|
+
undoing: 'Undoing action', undone: 'Action undone', ...options.messages };
|
|
32
|
+
useIsoLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
33
|
+
const publish = (patch: Partial<typeof state>) => { current.current = { ...current.current, ...patch }; if (mounted.current) setState(current.current); };
|
|
34
|
+
const restoreFocus = () => {
|
|
35
|
+
// Don't steal focus when the person deliberately moved elsewhere during a pending operation.
|
|
36
|
+
const focused = document.activeElement;
|
|
37
|
+
if (focused && focused !== document.body && focused !== trigger.current && !focused.closest('[role="dialog"],[role="alertdialog"]')) return;
|
|
38
|
+
const target = focusable(trigger.current) ? trigger.current : active.current.focusFallback?.();
|
|
39
|
+
if (focusable(target)) target.focus();
|
|
40
|
+
};
|
|
41
|
+
const execute = async () => {
|
|
42
|
+
if (!mounted.current || !['idle', 'confirming', 'failed', 'undone'].includes(current.current.phase)) return;
|
|
43
|
+
failedOperation.current = 'execute'; publish({ phase: 'pending', error: '', status: messages.pending });
|
|
44
|
+
let result: ActionResult<T>;
|
|
45
|
+
try { result = await active.current.execute(); } catch { result = { ok: false, error: messages.failed }; }
|
|
46
|
+
if (!mounted.current) return;
|
|
47
|
+
if (!result || typeof result !== 'object' || (result.ok === true ? !('value' in result) : result.ok !== false || !result.error || typeof result.error !== 'string')) result = { ok: false, error: messages.failed };
|
|
48
|
+
if (!result.ok) publish({ phase: 'failed', error: result.error, status: result.error });
|
|
49
|
+
else { returnFocus.current = true; publish({ phase: 'succeeded', result: result.value, status: messages.succeeded }); }
|
|
50
|
+
};
|
|
51
|
+
const undo = async () => {
|
|
52
|
+
if (!active.current.undo || !mounted.current || !(current.current.phase === 'succeeded' || (current.current.phase === 'failed' && failedOperation.current === 'undo'))) return;
|
|
53
|
+
failedOperation.current = 'undo'; publish({ phase: 'undoing', error: '', status: messages.undoing });
|
|
54
|
+
let result: UndoResult;
|
|
55
|
+
try { result = await active.current.undo!(current.current.result as T); } catch { result = { ok: false, error: messages.failed }; }
|
|
56
|
+
if (!mounted.current) return;
|
|
57
|
+
if (!result || typeof result !== 'object' || (result.ok !== true && (result.ok !== false || typeof result.error !== 'string' || !result.error))) result = { ok: false, error: messages.failed };
|
|
58
|
+
if (!result.ok) publish({ phase: 'failed', error: result.error, status: result.error });
|
|
59
|
+
else { returnFocus.current = true; publish({ phase: 'undone', status: messages.undone }); }
|
|
60
|
+
};
|
|
61
|
+
useIsoLayoutEffect(() => { if (returnFocus.current) { returnFocus.current = false; restoreFocus(); } }, [state.phase]);
|
|
62
|
+
const content = state.phase === 'idle' || state.phase === 'undone' ? options : active.current;
|
|
63
|
+
return { ...state, target: content.target, consequence: content.consequence,
|
|
64
|
+
canUndo: !!active.current.undo && (state.phase === 'succeeded' || (state.phase === 'failed' && failedOperation.current === 'undo')),
|
|
65
|
+
request: () => {
|
|
66
|
+
if (!['idle', 'undone'].includes(current.current.phase)) return;
|
|
67
|
+
active.current = options;
|
|
68
|
+
trigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
69
|
+
if (options.requiresConfirmation) publish({ phase: 'confirming', error: '', status: '' }); else void execute();
|
|
70
|
+
},
|
|
71
|
+
confirm: () => { if (current.current.phase === 'confirming') void execute(); },
|
|
72
|
+
cancel: () => { if (current.current.phase === 'confirming' || (current.current.phase === 'failed' && failedOperation.current === 'execute')) {
|
|
73
|
+
returnFocus.current = true; publish({ phase: 'idle', error: '', status: '' });
|
|
74
|
+
} },
|
|
75
|
+
retry: () => { if (current.current.phase === 'failed') return failedOperation.current === 'undo' ? undo() : execute(); },
|
|
76
|
+
undo: active.current.undo ? undo : undefined };
|
|
77
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { useIsoLayoutEffect } from './core';
|
|
3
|
+
|
|
4
|
+
export type FieldErrors<T> = Partial<Record<keyof T, string>>;
|
|
5
|
+
export type EditRecordResult<T> = { ok: true; value: T } | { ok: false; error: string; errors?: FieldErrors<T> };
|
|
6
|
+
export type EditRecordPhase = 'editing' | 'invalid' | 'submitting' | 'saved' | 'failed';
|
|
7
|
+
export type LeaveReason = 'cancel' | 'back' | 'navigation';
|
|
8
|
+
export interface EditRecordOptions<T extends object> {
|
|
9
|
+
/** Initial snapshot only. Use reset to deliberately adopt another record; keep snapshots immutable. */
|
|
10
|
+
initialValue: T;
|
|
11
|
+
validate: (draft: T) => FieldErrors<T>;
|
|
12
|
+
/** Submit validates every field; blur additionally validates the blurred field. Corrections revalidate visible errors. */
|
|
13
|
+
validationMode: 'submit' | 'blur';
|
|
14
|
+
save: (draft: T, context: { previousValue: T }) => EditRecordResult<T> | Promise<EditRecordResult<T>>;
|
|
15
|
+
/** Supply semantic equality for nested values. The default compares top-level fields with Object.is. */
|
|
16
|
+
isEqual?: (a: T, b: T) => boolean;
|
|
17
|
+
messages?: Partial<{ saving: string; saved: string; failed: string; invalid: string; newerEdits: string }>;
|
|
18
|
+
}
|
|
19
|
+
const shallowEqual = <T extends object>(a: T, b: T) => Object.keys(a).length === Object.keys(b).length &&
|
|
20
|
+
Object.keys(a).every(key => Object.is(a[key as keyof T], b[key as keyof T]));
|
|
21
|
+
const cleanErrors = <T,>(errors: FieldErrors<T>): FieldErrors<T> => Object.fromEntries(
|
|
22
|
+
Object.entries(errors).filter(([, value]) => typeof value === 'string' && value.length > 0)) as FieldErrors<T>;
|
|
23
|
+
|
|
24
|
+
/** Small record workflow. Persistence, concurrency/version checks, authorization and route interception belong to the app.
|
|
25
|
+
* See altitude-design/guidance/patterns/edit-record.md. A response never replaces edits made after dispatch.
|
|
26
|
+
*/
|
|
27
|
+
export function useEditRecord<T extends object>(options: EditRecordOptions<T>) {
|
|
28
|
+
const [state, setState] = React.useState(() => ({ draft: options.initialValue, baseline: options.initialValue,
|
|
29
|
+
errors: {} as FieldErrors<T>, error: '', phase: 'editing' as EditRecordPhase, status: '', submitCount: 0 }));
|
|
30
|
+
const current = React.useRef(state);
|
|
31
|
+
const revision = React.useRef(0);
|
|
32
|
+
const mounted = React.useRef(false);
|
|
33
|
+
const leaving = React.useRef(false);
|
|
34
|
+
const messages = { saving: 'Saving changes', saved: 'Changes saved', failed: 'We couldn’t save your changes. Try again.',
|
|
35
|
+
invalid: 'Check the highlighted fields.', newerEdits: 'The earlier changes were saved. Your newer edits still need saving.', ...options.messages };
|
|
36
|
+
useIsoLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
37
|
+
const publish = (patch: Partial<typeof state>) => { current.current = { ...current.current, ...patch }; if (mounted.current) setState(current.current); };
|
|
38
|
+
const equal = options.isEqual || shallowEqual;
|
|
39
|
+
const validate = (draft: T) => cleanErrors(options.validate(draft));
|
|
40
|
+
const setDraft = (draft: T) => {
|
|
41
|
+
revision.current++;
|
|
42
|
+
const previous = current.current;
|
|
43
|
+
const visible = Object.keys(previous.errors) as (keyof T)[];
|
|
44
|
+
const checked = visible.length ? validate(draft) : {} as FieldErrors<T>;
|
|
45
|
+
const errors = Object.fromEntries(visible.filter(key => checked[key]).map(key => [key, checked[key]])) as FieldErrors<T>;
|
|
46
|
+
publish({ draft, errors, error: '', status: previous.phase === 'submitting' ? previous.status : '',
|
|
47
|
+
phase: previous.phase === 'submitting' ? 'submitting' : Object.keys(errors).length ? 'invalid' : 'editing' });
|
|
48
|
+
};
|
|
49
|
+
const validateField = (key: keyof T) => {
|
|
50
|
+
if (options.validationMode !== 'blur' || current.current.phase === 'submitting') return;
|
|
51
|
+
const checked = validate(current.current.draft);
|
|
52
|
+
const errors = { ...current.current.errors }; delete errors[key]; if (checked[key]) errors[key] = checked[key];
|
|
53
|
+
publish({ errors, phase: Object.keys(errors).length ? 'invalid' : 'editing' });
|
|
54
|
+
};
|
|
55
|
+
const validateAll = () => {
|
|
56
|
+
if (current.current.phase === 'submitting') return false;
|
|
57
|
+
const errors = validate(current.current.draft);
|
|
58
|
+
publish({ errors, phase: Object.keys(errors).length ? 'invalid' : 'editing', error: '',
|
|
59
|
+
status: Object.keys(errors).length ? messages.invalid : '', submitCount: current.current.submitCount + 1 });
|
|
60
|
+
return Object.keys(errors).length === 0;
|
|
61
|
+
};
|
|
62
|
+
const submit = async (): Promise<EditRecordResult<T> | undefined> => {
|
|
63
|
+
if (!mounted.current || current.current.phase === 'submitting') return;
|
|
64
|
+
const snapshot = current.current;
|
|
65
|
+
const errors = validate(snapshot.draft);
|
|
66
|
+
if (Object.keys(errors).length) { publish({ errors, phase: 'invalid', status: messages.invalid, error: '', submitCount: snapshot.submitCount + 1 }); return; }
|
|
67
|
+
const dispatchedRevision = revision.current;
|
|
68
|
+
// Synchronous lock precedes invocation, protecting repeated and reentrant activation.
|
|
69
|
+
publish({ errors: {}, error: '', phase: 'submitting', status: messages.saving, submitCount: snapshot.submitCount + 1 });
|
|
70
|
+
let result: EditRecordResult<T>;
|
|
71
|
+
try { result = await options.save(snapshot.draft, { previousValue: snapshot.baseline }); }
|
|
72
|
+
catch { result = { ok: false, error: messages.failed }; }
|
|
73
|
+
if (!mounted.current) return;
|
|
74
|
+
if (!result || typeof result !== 'object' || (result.ok === true ? !result.value || typeof result.value !== 'object' : result.ok !== false || typeof result.error !== 'string' || !result.error)) result = { ok: false, error: messages.failed };
|
|
75
|
+
const newer = dispatchedRevision !== revision.current;
|
|
76
|
+
if (result.ok) publish({ baseline: result.value, ...(newer ? {} : { draft: result.value }),
|
|
77
|
+
phase: newer ? 'editing' : 'saved', status: newer ? messages.newerEdits : messages.saved });
|
|
78
|
+
else publish({ phase: 'failed', error: result.error, status: result.error, errors: newer ? current.current.errors : cleanErrors(result.errors || {}) });
|
|
79
|
+
return result;
|
|
80
|
+
};
|
|
81
|
+
const reset = (value: T = current.current.baseline) => {
|
|
82
|
+
if (current.current.phase === 'submitting') return false;
|
|
83
|
+
revision.current++; publish({ draft: value, baseline: value, phase: 'editing', errors: {}, error: '', status: '' }); return true;
|
|
84
|
+
};
|
|
85
|
+
/** Wire every cancel/Back/router transition through this adapter. It does not intercept history or cancel server writes.
|
|
86
|
+
* Pending writes block leaving; dirty drafts require an explicit app-owned discard decision. */
|
|
87
|
+
const requestLeave = async (reason: LeaveReason, confirmDiscard: (context: { reason: LeaveReason; draft: T }) => boolean | Promise<boolean>) => {
|
|
88
|
+
if (current.current.phase === 'submitting' || leaving.current) return false;
|
|
89
|
+
if (equal(current.current.draft, current.current.baseline)) return true;
|
|
90
|
+
const seen = revision.current; leaving.current = true;
|
|
91
|
+
try {
|
|
92
|
+
const discard = await confirmDiscard({ reason, draft: current.current.draft });
|
|
93
|
+
if (!mounted.current || !discard || seen !== revision.current || (current.current.phase as EditRecordPhase) === 'submitting') return false;
|
|
94
|
+
return reset();
|
|
95
|
+
} finally { leaving.current = false; }
|
|
96
|
+
};
|
|
97
|
+
return { ...state, dirty: !equal(state.draft, state.baseline), setDraft,
|
|
98
|
+
setField: <K extends keyof T>(key: K, value: T[K]) => setDraft({ ...current.current.draft, [key]: value }),
|
|
99
|
+
validateField, validateAll, submit, reset, requestLeave };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ErrorSummaryProps {
|
|
103
|
+
errors: readonly { id: string; label: string; message: string }[];
|
|
104
|
+
title?: string;
|
|
105
|
+
/** Change after an unsuccessful submit to focus the summary. Omit to keep focus where it is. */
|
|
106
|
+
focusKey?: number;
|
|
107
|
+
className?: string;
|
|
108
|
+
}
|
|
109
|
+
/** Render from the same errors passed to Field; use each input's actual id. Inline Field owns alert announcements. */
|
|
110
|
+
export function ErrorSummary({ errors, title = 'Check these fields', focusKey, className }: ErrorSummaryProps) {
|
|
111
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
112
|
+
const id = React.useId();
|
|
113
|
+
const focusedKey = React.useRef<number | undefined>(undefined);
|
|
114
|
+
useIsoLayoutEffect(() => {
|
|
115
|
+
if (focusKey && errors.length && focusedKey.current !== focusKey) { focusedKey.current = focusKey; ref.current?.focus(); }
|
|
116
|
+
}, [focusKey, errors.length]);
|
|
117
|
+
if (!errors.length) return null;
|
|
118
|
+
return <div ref={ref} tabIndex={-1} role="region" aria-labelledby={id} className={['alt-error-summary', className].filter(Boolean).join(' ')}>
|
|
119
|
+
<h2 id={id}>{title}</h2><ul>{errors.map(error => <li key={error.id}><a href={`#${encodeURIComponent(error.id)}`}
|
|
120
|
+
onClick={event => { const input = document.getElementById(error.id); if (input) { event.preventDefault(); input.focus(); input.scrollIntoView({ block: 'nearest' }); } }}>
|
|
121
|
+
{error.label}: {error.message}</a></li>)}</ul>
|
|
122
|
+
</div>;
|
|
123
|
+
}
|
|
124
|
+
/** Place outside any aria-busy region so pending and completion remain announced. */
|
|
125
|
+
export function WorkflowStatus({ children, className }: { children?: React.ReactNode; className?: string }) {
|
|
126
|
+
return <div className={className} role="status" aria-live="polite" aria-atomic="true">{children}</div>;
|
|
127
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { useIsoLayoutEffect } from './core';
|
|
3
|
+
import { useEditRecord } from './pattern-edit-record';
|
|
4
|
+
import type { FieldErrors } from './pattern-edit-record';
|
|
5
|
+
export type ReviewSubmitResult<T, R> = { ok: true; receipt: R } | { ok: false; error: string; errors?: FieldErrors<T> };
|
|
6
|
+
export interface ReviewSubmitOptions<T extends object, R> {
|
|
7
|
+
/** Supply any recovered draft here. Durable storage, step dependencies and cross-session resume are application responsibilities. */
|
|
8
|
+
initialValue: T;
|
|
9
|
+
validate: (draft: T) => FieldErrors<T>;
|
|
10
|
+
submit: (draft: T) => ReviewSubmitResult<T, R> | Promise<ReviewSubmitResult<T, R>>;
|
|
11
|
+
isEqual?: (a: T, b: T) => boolean;
|
|
12
|
+
messages?: Partial<{ submitting: string; complete: string; failed: string; invalid: string }>;
|
|
13
|
+
}
|
|
14
|
+
/** Review/correct/submit composition with a persistent application-provided receipt. No automatic persistence or fabricated resume.
|
|
15
|
+
* See altitude-design/guidance/patterns/review-submit.md. Mount a new workflow to start a distinct transaction after completion. */
|
|
16
|
+
export function useReviewSubmit<T extends object, R>(options: ReviewSubmitOptions<T, R>) {
|
|
17
|
+
const [stage, setStage] = React.useState<'editing' | 'review' | 'submitting' | 'complete'>('editing');
|
|
18
|
+
const [receipt, setReceipt] = React.useState<R>();
|
|
19
|
+
const current = React.useRef(stage), mounted = React.useRef(false), accepted = React.useRef<R | undefined>(undefined);
|
|
20
|
+
useIsoLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
21
|
+
const messages = { submitting: 'Sending your answers', complete: 'Your answers were received', failed: 'We couldn’t send your answers. Try again.', invalid: 'Check the highlighted fields.', ...options.messages };
|
|
22
|
+
const form = useEditRecord({ initialValue: options.initialValue, validate: options.validate, validationMode: 'submit', isEqual: options.isEqual,
|
|
23
|
+
messages: { saving: messages.submitting, saved: messages.complete, failed: messages.failed, invalid: messages.invalid },
|
|
24
|
+
save: async draft => {
|
|
25
|
+
const result = await options.submit(draft);
|
|
26
|
+
if (result?.ok === true && result.receipt !== undefined && result.receipt !== null) { accepted.current = result.receipt; return { ok: true, value: draft }; }
|
|
27
|
+
if (result?.ok === false) return result;
|
|
28
|
+
return { ok: false, error: messages.failed };
|
|
29
|
+
} });
|
|
30
|
+
const move = (next: typeof stage) => { current.current = next; if (mounted.current) setStage(next); };
|
|
31
|
+
const editable = () => current.current === 'editing';
|
|
32
|
+
return { draft: form.draft, errors: form.errors, error: form.error, status: form.status, submitCount: form.submitCount,
|
|
33
|
+
dirty: form.dirty, phase: stage, receipt,
|
|
34
|
+
setDraft: (value: T) => { if (editable()) form.setDraft(value); },
|
|
35
|
+
setField: <K extends keyof T>(key: K, value: T[K]) => { if (editable()) form.setField(key, value); },
|
|
36
|
+
review: () => { if (editable() && form.validateAll()) move('review'); },
|
|
37
|
+
correct: () => { if (current.current === 'review') move('editing'); },
|
|
38
|
+
submit: async () => {
|
|
39
|
+
if (current.current !== 'review') return;
|
|
40
|
+
move('submitting');
|
|
41
|
+
const result = await form.submit();
|
|
42
|
+
if (!mounted.current) return;
|
|
43
|
+
if (result?.ok) { setReceipt(accepted.current); move('complete'); }
|
|
44
|
+
else move('review');
|
|
45
|
+
},
|
|
46
|
+
requestLeave: form.requestLeave };
|
|
47
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cx, Icons, useDepth } from './core';
|
|
3
|
+
import type { DepthProps } from './core';
|
|
4
|
+
|
|
5
|
+
/* ───────────────────────── Settings ───────────────────────── */
|
|
6
|
+
|
|
7
|
+
export interface SettingsSectionProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
|
|
8
|
+
/** Every section has a heading; it names the region. */
|
|
9
|
+
title: React.ReactNode;
|
|
10
|
+
description?: React.ReactNode;
|
|
11
|
+
/** 'critical': the danger zone. A critical rim and the same height as every other section. */
|
|
12
|
+
tone?: 'critical';
|
|
13
|
+
/** Default 2. */
|
|
14
|
+
headingLevel?: 2 | 3;
|
|
15
|
+
/** Why this person can't change these settings ("Only workspace admins can change billing details"). The rows are disabled and say why. */
|
|
16
|
+
readOnlyReason?: React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One section of a settings page: a raised Card (level 1) with a heading and rows. The danger zone is the same card with
|
|
20
|
+
* a critical rim, never higher or louder: its consequences are said in its rows' words.
|
|
21
|
+
*/
|
|
22
|
+
export function SettingsSection({ title, description, tone, headingLevel = 2, readOnlyReason, className, children, ...rest }: SettingsSectionProps) {
|
|
23
|
+
const id = React.useId();
|
|
24
|
+
const H = `h${headingLevel}` as 'h2';
|
|
25
|
+
return (
|
|
26
|
+
<section className={cx('alt-card alt-settings-section', className)} data-tone={tone} aria-labelledby={id + '-title'} {...rest}>
|
|
27
|
+
<header className="alt-settings-head">
|
|
28
|
+
<H className="alt-settings-title" id={id + '-title'}>{title}</H>
|
|
29
|
+
{description && <p className="alt-settings-desc">{description}</p>}
|
|
30
|
+
{readOnlyReason && <p className="alt-settings-readonly" id={id + '-ro'}><Icons.Lock size={14} aria-hidden="true" />{readOnlyReason}</p>}
|
|
31
|
+
</header>
|
|
32
|
+
{readOnlyReason
|
|
33
|
+
? <fieldset className="alt-settings-rows" disabled aria-describedby={id + '-ro'}>{children}</fieldset>
|
|
34
|
+
: <div className="alt-settings-rows">{children}</div>}
|
|
35
|
+
</section>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SettingsRowProps extends DepthProps {
|
|
40
|
+
label: React.ReactNode;
|
|
41
|
+
/** What the setting does, beside its label. */
|
|
42
|
+
description?: React.ReactNode;
|
|
43
|
+
/** Replaces the description and marks the control invalid. */
|
|
44
|
+
error?: React.ReactNode;
|
|
45
|
+
/**
|
|
46
|
+
* The words for an apply-now control's write. Defaults: pending "Saving…", committed "Saved", failed "Couldn't save
|
|
47
|
+
* this change. Try again." Pass the status of the write the control made; the row hovers while it's pending.
|
|
48
|
+
*/
|
|
49
|
+
statusText?: Partial<Record<'pending' | 'committed' | 'failed', React.ReactNode>>;
|
|
50
|
+
className?: string;
|
|
51
|
+
/** Exactly one control: Switch, Select, Input, Button… It's labelled and described by the row. */
|
|
52
|
+
children: React.ReactElement<any>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A setting: label and description on the start side, the control on the end side, stacked when the section is narrow.
|
|
56
|
+
* A control that applies immediately reports its write through `status` (pending hovers, committed lands, failed drops
|
|
57
|
+
* into a trench), with the state in words. Fields saved together belong to one draft with a SaveBar instead.
|
|
58
|
+
*/
|
|
59
|
+
export function SettingsRow({ label, description, error, status, settle, heldBy, relevance, statusText, className, children }: SettingsRowProps) {
|
|
60
|
+
const id = React.useId();
|
|
61
|
+
const controlId = children.props.id || id + '-control';
|
|
62
|
+
const { style, ...depth } = useDepth({ settle, status, heldBy, relevance });
|
|
63
|
+
const words = status && ({ pending: 'Saving…', committed: 'Saved', failed: 'Couldn’t save this change. Try again.', ...statusText })[status];
|
|
64
|
+
const describedBy = [children.props['aria-describedby'], (error || description) && id + '-desc', words && id + '-status'].filter(Boolean).join(' ') || undefined;
|
|
65
|
+
return (
|
|
66
|
+
<div className={cx('alt-settings-row', className)} {...depth} style={style}>
|
|
67
|
+
<div className="alt-settings-text">
|
|
68
|
+
<label className="alt-settings-label" htmlFor={controlId}>{label}</label>
|
|
69
|
+
{error
|
|
70
|
+
? <p className="alt-settings-error" id={id + '-desc'} role="alert"><Icons.AlertCircle size={14} />{error}</p>
|
|
71
|
+
: description && <p className="alt-settings-rowdesc" id={id + '-desc'}>{description}</p>}
|
|
72
|
+
</div>
|
|
73
|
+
<div className="alt-settings-control">
|
|
74
|
+
{React.cloneElement(children, { id: controlId, 'aria-describedby': describedBy, ...(error ? { invalid: true } : null) })}
|
|
75
|
+
<span className="alt-settings-status" id={id + '-status'} role="status">{words}</span>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Runnable reference fixture using one installed Altitude/React graph.
|
|
2
|
+
// Consumer prerequisite: npm install --save-dev esbuild
|
|
3
|
+
// node PATH/TO/THIS/SCRIPT --out PATH
|
|
4
|
+
import { build } from 'esbuild';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
const args=process.argv.slice(2),output=args[args.indexOf('--out')+1];
|
|
10
|
+
if(!args.includes('--out')||!output||output.startsWith('--'))throw Error('Supply --out PATH for the generated preview files.');
|
|
11
|
+
const out=resolve(output),require=createRequire(resolve(process.cwd(),'package.json'));
|
|
12
|
+
const pkg=dirname(require.resolve('altitude-design/package.json'));
|
|
13
|
+
const fixture=resolve(dirname(fileURLToPath(import.meta.url)),'../build/fixtures/pattern-workflows.tsx');
|
|
14
|
+
mkdirSync(out,{recursive:true});
|
|
15
|
+
await build({stdin:{contents:`import * as React from 'react'; import {createRoot} from 'react-dom/client'; import {AltitudeProvider} from 'altitude-design'; import {PatternWorkflows} from ${JSON.stringify(fixture)}; createRoot(document.getElementById('root')).render(<AltitudeProvider><PatternWorkflows/></AltitudeProvider>);`,loader:'tsx',resolveDir:process.cwd()},
|
|
16
|
+
alias:{react:dirname(require.resolve('react/package.json')),'react-dom':dirname(require.resolve('react-dom/package.json')),'altitude-design':resolve(pkg,'dist/index.js')},
|
|
17
|
+
bundle:true,format:'iife',outfile:resolve(out,'bundle.js'),define:{'process.env.NODE_ENV':'"development"'}});
|
|
18
|
+
writeFileSync(resolve(out,'altitude.css'),readFileSync(resolve(pkg,'dist/altitude.no-fonts.css')));
|
|
19
|
+
writeFileSync(resolve(out,'index.html'),'<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altitude workflow examples</title><link rel="stylesheet" href="altitude.css"><style>main{max-width:48rem;margin:auto;padding:1rem}section{margin-block:2rem;display:grid;gap:1rem}form{display:grid;gap:1rem}h1,h2,p,li{overflow-wrap:anywhere}button{margin-inline-end:.5rem}</style><body><div id="root"></div><script src="bundle.js"></script></body></html>');
|
|
20
|
+
console.log(`Open ${resolve(out,'index.html')}. This demo uses in-memory data; reload resets it.`);
|