@dowel-ui/react 0.4.0 → 0.5.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.
Files changed (146) hide show
  1. package/README.md +9 -5
  2. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
  3. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
  4. package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
  5. package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
  6. package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
  7. package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
  8. package/dist/components/ai-extraction-review/extraction-model.js +91 -0
  9. package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
  10. package/dist/components/ai-extraction-review/index.d.ts +3 -0
  11. package/dist/components/ai-extraction-review/index.js +3 -0
  12. package/dist/components/ai-extraction-review/meta.js +17 -0
  13. package/dist/components/ai-extraction-review/meta.js.map +1 -0
  14. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
  15. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
  16. package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
  17. package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
  18. package/dist/components/ai-suggested-value/index.d.ts +2 -0
  19. package/dist/components/ai-suggested-value/index.js +2 -0
  20. package/dist/components/ai-suggested-value/meta.js +17 -0
  21. package/dist/components/ai-suggested-value/meta.js.map +1 -0
  22. package/dist/components/alert/alert.d.ts +1 -1
  23. package/dist/components/avatar/avatar.d.ts +1 -1
  24. package/dist/components/badge/badge.d.ts +1 -1
  25. package/dist/components/button/button.d.ts +1 -1
  26. package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
  27. package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
  28. package/dist/components/confirm-typed/confirm-typed.js +106 -0
  29. package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
  30. package/dist/components/confirm-typed/index.d.ts +2 -0
  31. package/dist/components/confirm-typed/index.js +2 -0
  32. package/dist/components/confirm-typed/meta.js +17 -0
  33. package/dist/components/confirm-typed/meta.js.map +1 -0
  34. package/dist/components/cron-editor/cron-editor.d.ts +36 -0
  35. package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
  36. package/dist/components/cron-editor/cron-editor.js +493 -0
  37. package/dist/components/cron-editor/cron-editor.js.map +1 -0
  38. package/dist/components/cron-editor/cron-expression.d.ts +82 -0
  39. package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
  40. package/dist/components/cron-editor/cron-expression.js +352 -0
  41. package/dist/components/cron-editor/cron-expression.js.map +1 -0
  42. package/dist/components/cron-editor/index.d.ts +3 -0
  43. package/dist/components/cron-editor/index.js +3 -0
  44. package/dist/components/cron-editor/meta.js +17 -0
  45. package/dist/components/cron-editor/meta.js.map +1 -0
  46. package/dist/components/dns-record/dns-record.d.ts +61 -0
  47. package/dist/components/dns-record/dns-record.d.ts.map +1 -0
  48. package/dist/components/dns-record/dns-record.js +227 -0
  49. package/dist/components/dns-record/dns-record.js.map +1 -0
  50. package/dist/components/dns-record/index.d.ts +2 -0
  51. package/dist/components/dns-record/index.js +2 -0
  52. package/dist/components/dns-record/meta.js +17 -0
  53. package/dist/components/dns-record/meta.js.map +1 -0
  54. package/dist/components/permission-matrix/index.d.ts +3 -0
  55. package/dist/components/permission-matrix/index.js +3 -0
  56. package/dist/components/permission-matrix/meta.js +17 -0
  57. package/dist/components/permission-matrix/meta.js.map +1 -0
  58. package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
  59. package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
  60. package/dist/components/permission-matrix/permission-matrix.js +199 -0
  61. package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
  62. package/dist/components/permission-matrix/permission-model.d.ts +53 -0
  63. package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
  64. package/dist/components/permission-matrix/permission-model.js +64 -0
  65. package/dist/components/permission-matrix/permission-model.js.map +1 -0
  66. package/dist/components/progress/progress.d.ts +1 -1
  67. package/dist/components/secret-field/index.d.ts +2 -0
  68. package/dist/components/secret-field/index.js +2 -0
  69. package/dist/components/secret-field/meta.js +17 -0
  70. package/dist/components/secret-field/meta.js.map +1 -0
  71. package/dist/components/secret-field/secret-field.d.ts +58 -0
  72. package/dist/components/secret-field/secret-field.d.ts.map +1 -0
  73. package/dist/components/secret-field/secret-field.js +168 -0
  74. package/dist/components/secret-field/secret-field.js.map +1 -0
  75. package/dist/components/session-expiry/index.d.ts +2 -0
  76. package/dist/components/session-expiry/index.js +2 -0
  77. package/dist/components/session-expiry/meta.js +17 -0
  78. package/dist/components/session-expiry/meta.js.map +1 -0
  79. package/dist/components/session-expiry/session-expiry.d.ts +64 -0
  80. package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
  81. package/dist/components/session-expiry/session-expiry.js +185 -0
  82. package/dist/components/session-expiry/session-expiry.js.map +1 -0
  83. package/dist/components/shortcut-recorder/index.d.ts +3 -0
  84. package/dist/components/shortcut-recorder/index.js +3 -0
  85. package/dist/components/shortcut-recorder/meta.js +17 -0
  86. package/dist/components/shortcut-recorder/meta.js.map +1 -0
  87. package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
  88. package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
  89. package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
  90. package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
  91. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
  92. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
  93. package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
  94. package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
  95. package/dist/components/spinner/spinner.d.ts +1 -1
  96. package/dist/components/sync-status/index.d.ts +2 -0
  97. package/dist/components/sync-status/index.js +2 -0
  98. package/dist/components/sync-status/meta.js +17 -0
  99. package/dist/components/sync-status/meta.js.map +1 -0
  100. package/dist/components/sync-status/sync-status.d.ts +65 -0
  101. package/dist/components/sync-status/sync-status.d.ts.map +1 -0
  102. package/dist/components/sync-status/sync-status.js +103 -0
  103. package/dist/components/sync-status/sync-status.js.map +1 -0
  104. package/dist/components/toast/toast.d.ts +1 -1
  105. package/dist/index.d.ts +25 -1
  106. package/dist/index.js +16 -2
  107. package/dist/registry/components.d.ts.map +1 -1
  108. package/dist/registry/components.js +73 -53
  109. package/dist/registry/components.js.map +1 -1
  110. package/package.json +4 -4
  111. package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
  112. package/src/components/ai-extraction-review/extraction-model.ts +159 -0
  113. package/src/components/ai-extraction-review/index.ts +13 -0
  114. package/src/components/ai-extraction-review/meta.ts +24 -0
  115. package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
  116. package/src/components/ai-suggested-value/index.ts +6 -0
  117. package/src/components/ai-suggested-value/meta.ts +24 -0
  118. package/src/components/confirm-typed/confirm-typed.tsx +191 -0
  119. package/src/components/confirm-typed/index.ts +1 -0
  120. package/src/components/confirm-typed/meta.ts +22 -0
  121. package/src/components/cron-editor/cron-editor.tsx +676 -0
  122. package/src/components/cron-editor/cron-expression.ts +552 -0
  123. package/src/components/cron-editor/index.ts +23 -0
  124. package/src/components/cron-editor/meta.ts +23 -0
  125. package/src/components/dns-record/dns-record.tsx +315 -0
  126. package/src/components/dns-record/index.ts +7 -0
  127. package/src/components/dns-record/meta.ts +21 -0
  128. package/src/components/permission-matrix/index.ts +14 -0
  129. package/src/components/permission-matrix/meta.ts +23 -0
  130. package/src/components/permission-matrix/permission-matrix.tsx +368 -0
  131. package/src/components/permission-matrix/permission-model.ts +120 -0
  132. package/src/components/secret-field/index.ts +6 -0
  133. package/src/components/secret-field/meta.ts +21 -0
  134. package/src/components/secret-field/secret-field.tsx +284 -0
  135. package/src/components/session-expiry/index.ts +9 -0
  136. package/src/components/session-expiry/meta.ts +23 -0
  137. package/src/components/session-expiry/session-expiry.tsx +321 -0
  138. package/src/components/shortcut-recorder/index.ts +19 -0
  139. package/src/components/shortcut-recorder/meta.ts +22 -0
  140. package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
  141. package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
  142. package/src/components/sync-status/index.ts +9 -0
  143. package/src/components/sync-status/meta.ts +21 -0
  144. package/src/components/sync-status/sync-status.tsx +217 -0
  145. package/src/index.ts +10 -0
  146. package/src/registry/components.ts +20 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * A keyboard shortcut as a value: parsed from the string an app stores,
3
+ * built from a keydown, and written back out for a Mac, for everyone else,
4
+ * and for the ear.
5
+ *
6
+ * Pure, so the modifier canon, the platform rendering and the conflict rule
7
+ * are tested without a keyboard, and so the same `parseShortcut` can run on
8
+ * the server that validates a saved binding.
9
+ *
10
+ * The stored form is platform-neutral: `Mod+Shift+K`, where `Mod` is Command
11
+ * on a Mac and Control elsewhere. That is the one decision every app makes
12
+ * and few make explicitly — a binding saved as `Ctrl+K` on Windows is wrong
13
+ * the moment the same account opens on a Mac.
14
+ */
15
+
16
+ export type Platform = "mac" | "other";
17
+
18
+ export interface Shortcut {
19
+ /** The non-modifier key, canonical: `K`, `1`, `ArrowUp`, `F5`, `Enter`, `Space`. */
20
+ key: string;
21
+ /** Command on a Mac, Control elsewhere. */
22
+ mod: boolean;
23
+ /** Control, held explicitly — on a Mac this is a different key from Mod. */
24
+ ctrl: boolean;
25
+ alt: boolean;
26
+ shift: boolean;
27
+ }
28
+
29
+ const MODIFIER_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "AltGraph", "Fn"]);
30
+
31
+ /** Keys that cannot be a shortcut, because they already mean something. */
32
+ export const RESERVED_KEYS = new Set(["Tab", "Escape"]);
33
+
34
+ const KEY_ALIASES: Record<string, string> = {
35
+ " ": "Space",
36
+ spacebar: "Space",
37
+ esc: "Escape",
38
+ return: "Enter",
39
+ del: "Delete",
40
+ up: "ArrowUp",
41
+ down: "ArrowDown",
42
+ left: "ArrowLeft",
43
+ right: "ArrowRight",
44
+ cmd: "Mod",
45
+ command: "Mod",
46
+ meta: "Mod",
47
+ control: "Ctrl",
48
+ option: "Alt",
49
+ };
50
+
51
+ /** The canonical key name for a token someone typed or stored. */
52
+ function canonicalKey(token: string): string {
53
+ const alias = KEY_ALIASES[token.toLowerCase()];
54
+ if (alias) return alias;
55
+ if (token.length === 1) return token.toUpperCase();
56
+ // ArrowUp, F5, Enter, Backspace, Delete, Home, End, PageUp…
57
+ return token.charAt(0).toUpperCase() + token.slice(1);
58
+ }
59
+
60
+ /**
61
+ * `Mod+Shift+K` to a shortcut. Accepts the spellings people use — `Cmd`,
62
+ * `Command`, `Ctrl`, `Option` — and throws on an empty or modifier-only one.
63
+ */
64
+ export function parseShortcut(text: string): Shortcut {
65
+ const tokens = text
66
+ .split("+")
67
+ .map((token) => token.trim())
68
+ .filter(Boolean);
69
+ if (tokens.length === 0) throw new Error("Enter a shortcut.");
70
+
71
+ const shortcut: Shortcut = { key: "", mod: false, ctrl: false, alt: false, shift: false };
72
+ for (const token of tokens) {
73
+ const canonical = canonicalKey(token);
74
+ if (canonical === "Mod") shortcut.mod = true;
75
+ else if (canonical === "Ctrl") shortcut.ctrl = true;
76
+ else if (canonical === "Alt") shortcut.alt = true;
77
+ else if (canonical === "Shift") shortcut.shift = true;
78
+ else if (shortcut.key) throw new Error(`"${text}" has two keys; a shortcut has one.`);
79
+ else shortcut.key = canonical;
80
+ }
81
+ if (!shortcut.key) throw new Error(`"${text}" is only modifiers; it needs a key.`);
82
+ return shortcut;
83
+ }
84
+
85
+ /** The stored, platform-neutral form. Modifiers in a fixed order, so equal shortcuts are equal strings. */
86
+ export function serializeShortcut(shortcut: Shortcut): string {
87
+ const parts: string[] = [];
88
+ if (shortcut.mod) parts.push("Mod");
89
+ if (shortcut.ctrl) parts.push("Ctrl");
90
+ if (shortcut.alt) parts.push("Alt");
91
+ if (shortcut.shift) parts.push("Shift");
92
+ parts.push(shortcut.key);
93
+ return parts.join("+");
94
+ }
95
+
96
+ export function shortcutsEqual(a: Shortcut, b: Shortcut): boolean {
97
+ return serializeShortcut(a) === serializeShortcut(b);
98
+ }
99
+
100
+ interface KeyLike {
101
+ key: string;
102
+ code?: string;
103
+ metaKey: boolean;
104
+ ctrlKey: boolean;
105
+ altKey: boolean;
106
+ shiftKey: boolean;
107
+ }
108
+
109
+ /**
110
+ * The shortcut a keydown amounts to, or null while only modifiers are held.
111
+ *
112
+ * Letters and digits come from `code`, not `key`: with Shift or Option held,
113
+ * `key` is `K`, `˚` or `∆` depending on the layout and the platform, and a
114
+ * shortcut recorded as Option-∆ is one nobody can read back. `Mod` is Meta
115
+ * on a Mac and Control elsewhere; the other one, when held, is kept as
116
+ * itself.
117
+ */
118
+ export function shortcutFromKey(event: KeyLike, platform: Platform): Shortcut | null {
119
+ if (MODIFIER_KEYS.has(event.key)) return null;
120
+
121
+ let key: string;
122
+ const code = event.code ?? "";
123
+ if (/^Key[A-Z]$/.test(code)) key = code.slice(3);
124
+ else if (/^Digit[0-9]$/.test(code)) key = code.slice(5);
125
+ else key = canonicalKey(event.key);
126
+
127
+ const mac = platform === "mac";
128
+ return {
129
+ key,
130
+ mod: mac ? event.metaKey : event.ctrlKey,
131
+ ctrl: mac ? event.ctrlKey : false,
132
+ alt: event.altKey,
133
+ shift: event.shiftKey,
134
+ };
135
+ }
136
+
137
+ /** Printable keys need a modifier: a bare K as a shortcut fires while someone types a sentence. */
138
+ export function needsModifier(shortcut: Shortcut): boolean {
139
+ const printable = shortcut.key.length === 1 || shortcut.key === "Space";
140
+ return printable && !shortcut.mod && !shortcut.ctrl && !shortcut.alt;
141
+ }
142
+
143
+ const MAC_SYMBOLS: Record<string, string> = {
144
+ Mod: "⌘",
145
+ Ctrl: "⌃",
146
+ Alt: "⌥",
147
+ Shift: "⇧",
148
+ Enter: "↩",
149
+ Backspace: "⌫",
150
+ Delete: "⌦",
151
+ ArrowUp: "↑",
152
+ ArrowDown: "↓",
153
+ ArrowLeft: "←",
154
+ ArrowRight: "→",
155
+ Escape: "⎋",
156
+ Space: "Space",
157
+ };
158
+
159
+ const SPOKEN: Record<Platform, Record<string, string>> = {
160
+ mac: { Mod: "Command", Ctrl: "Control", Alt: "Option", Shift: "Shift" },
161
+ other: { Mod: "Control", Ctrl: "Control", Alt: "Alt", Shift: "Shift" },
162
+ };
163
+
164
+ /** The parts to draw, in order: symbols on a Mac, words elsewhere. */
165
+ export function shortcutParts(shortcut: Shortcut, platform: Platform): string[] {
166
+ const parts: string[] = [];
167
+ const name = (modifier: string) =>
168
+ platform === "mac"
169
+ ? (MAC_SYMBOLS[modifier] ?? modifier)
170
+ : (SPOKEN.other[modifier] ?? modifier);
171
+ if (shortcut.mod) parts.push(name("Mod"));
172
+ if (shortcut.ctrl && !(platform === "other" && shortcut.mod)) parts.push(name("Ctrl"));
173
+ if (shortcut.alt) parts.push(name("Alt"));
174
+ if (shortcut.shift) parts.push(name("Shift"));
175
+ parts.push(platform === "mac" ? (MAC_SYMBOLS[shortcut.key] ?? shortcut.key) : shortcut.key);
176
+ return parts;
177
+ }
178
+
179
+ /** "⌘⇧K" on a Mac, "Ctrl+Shift+K" elsewhere. */
180
+ export function formatShortcut(shortcut: Shortcut, platform: Platform): string {
181
+ return shortcutParts(shortcut, platform).join(platform === "mac" ? "" : "+");
182
+ }
183
+
184
+ /** "Command Shift K" — words, for the ear, whatever the screen shows. */
185
+ export function describeShortcut(shortcut: Shortcut, platform: Platform): string {
186
+ const words: string[] = [];
187
+ const spoken = SPOKEN[platform];
188
+ if (shortcut.mod) words.push(spoken.Mod ?? "Mod");
189
+ if (shortcut.ctrl && !(platform === "other" && shortcut.mod))
190
+ words.push(spoken.Ctrl ?? "Ctrl");
191
+ if (shortcut.alt) words.push(spoken.Alt ?? "Alt");
192
+ if (shortcut.shift) words.push(spoken.Shift ?? "Shift");
193
+ words.push(shortcut.key.replace(/^Arrow/, "Arrow "));
194
+ return words.join(" ");
195
+ }
196
+
197
+ /** Whether the browser is on a Mac, by the only signal it gives. */
198
+ export function detectPlatform(): Platform {
199
+ if (typeof navigator === "undefined") return "other";
200
+ const hint =
201
+ (navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform ??
202
+ navigator.platform;
203
+ return /mac|iphone|ipad|ipod/i.test(hint) ? "mac" : "other";
204
+ }
@@ -0,0 +1,336 @@
1
+ "use client";
2
+
3
+ import {
4
+ useId,
5
+ useState,
6
+ useSyncExternalStore,
7
+ type ComponentPropsWithRef,
8
+ type KeyboardEvent,
9
+ } from "react";
10
+
11
+ import { disabledStyles, focusRing } from "@/lib/styles";
12
+ import { cn } from "@/lib/utils";
13
+
14
+ import {
15
+ describeShortcut,
16
+ detectPlatform,
17
+ needsModifier,
18
+ parseShortcut,
19
+ serializeShortcut,
20
+ shortcutFromKey,
21
+ shortcutParts,
22
+ shortcutsEqual,
23
+ type Platform,
24
+ type Shortcut,
25
+ } from "./shortcut-model";
26
+
27
+ /**
28
+ * Press the keys you want.
29
+ *
30
+ * Linear, Slack, VS Code, Figma and Superhuman each wrote one, and no
31
+ * component library ships the recorder — only the `<kbd>` that displays the
32
+ * result. The recorder is the hard part: a button that, when pressed, stops
33
+ * being a button and starts being a keyboard listener, and has to come back.
34
+ *
35
+ * Three things it does that a listener bolted onto an input does not. It
36
+ * reads letters from `code`, not `key`, so Option-K records as Option K and
37
+ * not as the ˚ the Mac produced. It stores `Mod`, not Command or Control,
38
+ * so a binding saved on one machine is right on the other. And it refuses a
39
+ * bare printable key by saying so, because a shortcut that fires while
40
+ * someone types a sentence is the bug every app that allowed it later fixed.
41
+ *
42
+ * Tab and Escape are never recorded: Tab leaves, Escape cancels, and a
43
+ * recorder that captures both is a keyboard trap with a nice label. A
44
+ * chord that another command already uses is said, with that command's
45
+ * name, and applied only if the person says to use it anyway.
46
+ */
47
+
48
+ export interface TakenShortcut {
49
+ /** Stored form: `Mod+K`. */
50
+ shortcut: string;
51
+ /** What it does: "Search". */
52
+ label: string;
53
+ }
54
+
55
+ export interface ShortcutRecorderProps extends Omit<
56
+ ComponentPropsWithRef<"div">,
57
+ "onChange" | "defaultValue"
58
+ > {
59
+ /** What the shortcut triggers: "Open search". Names the control. */
60
+ label: string;
61
+ /** Stored form, `Mod+Shift+K`, or null for none. Controlled. */
62
+ value?: string | null;
63
+ defaultValue?: string | null;
64
+ onChange?: (value: string | null) => void;
65
+ /** Shortcuts other commands already use. A clash is said, and applied only on request. */
66
+ taken?: TakenShortcut[];
67
+ /** Mac shows ⌘⇧K; everything else shows Ctrl+Shift+K. Detected if omitted. */
68
+ platform?: Platform;
69
+ /** Printable keys need a modifier. On by default, for the reason in the source. */
70
+ requireModifier?: boolean;
71
+ }
72
+
73
+ /* The platform, as an external store that never changes: a constant that the
74
+ server cannot know, so the server snapshot is the neutral one and the real
75
+ answer arrives after hydration without a mismatch. */
76
+ const subscribeToNothing = () => () => undefined;
77
+ const readPlatform = () => detectPlatform();
78
+ const readNeutralPlatform = (): Platform => "other";
79
+
80
+ export function ShortcutRecorder({
81
+ className,
82
+ label,
83
+ value: valueProp,
84
+ defaultValue = null,
85
+ onChange,
86
+ taken = [],
87
+ platform: platformProp,
88
+ requireModifier = true,
89
+ ...props
90
+ }: ShortcutRecorderProps) {
91
+ const id = useId();
92
+ const detected = useSyncExternalStore(subscribeToNothing, readPlatform, readNeutralPlatform);
93
+ const platform = platformProp ?? detected;
94
+
95
+ const [uncontrolled, setUncontrolled] = useState<string | null>(defaultValue);
96
+ const value = valueProp === undefined ? uncontrolled : valueProp;
97
+ const current = value ? safeParse(value) : null;
98
+
99
+ const [recording, setRecording] = useState(false);
100
+ /** Modifiers held mid-chord, shown so the person sees the recorder listening. */
101
+ const [held, setHeld] = useState<Shortcut | null>(null);
102
+ const [conflict, setConflict] = useState<{ shortcut: Shortcut; with: TakenShortcut } | null>(
103
+ null,
104
+ );
105
+ const [announcement, setAnnouncement] = useState("");
106
+
107
+ const commit = (next: Shortcut | null) => {
108
+ const serialized = next ? serializeShortcut(next) : null;
109
+ setUncontrolled(serialized);
110
+ onChange?.(serialized);
111
+ };
112
+
113
+ const stop = () => {
114
+ setRecording(false);
115
+ setHeld(null);
116
+ };
117
+
118
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
119
+ if (!recording) return;
120
+ // Tab leaves. A recorder that keeps it is a keyboard trap.
121
+ if (event.key === "Tab") {
122
+ stop();
123
+ setAnnouncement("Cancelled.");
124
+ return;
125
+ }
126
+ event.preventDefault();
127
+ if (event.key === "Escape") {
128
+ stop();
129
+ setAnnouncement("Cancelled.");
130
+ return;
131
+ }
132
+ if ((event.key === "Backspace" || event.key === "Delete") && !hasModifier(event)) {
133
+ stop();
134
+ commit(null);
135
+ setAnnouncement("Cleared.");
136
+ return;
137
+ }
138
+
139
+ const chord = shortcutFromKey(event.nativeEvent, platform);
140
+ if (!chord) {
141
+ // Only modifiers so far. Show them, keep listening.
142
+ setHeld({
143
+ key: "",
144
+ mod: platform === "mac" ? event.metaKey : event.ctrlKey,
145
+ ctrl: platform === "mac" ? event.ctrlKey : false,
146
+ alt: event.altKey,
147
+ shift: event.shiftKey,
148
+ });
149
+ return;
150
+ }
151
+
152
+ if (requireModifier && needsModifier(chord)) {
153
+ setAnnouncement(
154
+ `${describeShortcut(chord, platform)} needs a modifier — it would fire while typing. Try again.`,
155
+ );
156
+ setHeld(null);
157
+ return;
158
+ }
159
+
160
+ const clash = taken.find((entry) =>
161
+ shortcutsEqual(safeParse(entry.shortcut) ?? chord, chord),
162
+ );
163
+ stop();
164
+ if (clash && !(current && shortcutsEqual(current, chord))) {
165
+ setConflict({ shortcut: chord, with: clash });
166
+ setAnnouncement(
167
+ `${describeShortcut(chord, platform)} is already used by ${clash.label}.`,
168
+ );
169
+ return;
170
+ }
171
+ commit(chord);
172
+ setAnnouncement(`Recorded ${describeShortcut(chord, platform)}.`);
173
+ };
174
+
175
+ const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
176
+ if (!recording || !held) return;
177
+ setHeld({
178
+ key: "",
179
+ mod: platform === "mac" ? event.metaKey : event.ctrlKey,
180
+ ctrl: platform === "mac" ? event.ctrlKey : false,
181
+ alt: event.altKey,
182
+ shift: event.shiftKey,
183
+ });
184
+ };
185
+
186
+ const shown = recording ? held : current;
187
+ const description = recording
188
+ ? "Press the keys you want. Escape cancels, Backspace clears."
189
+ : current
190
+ ? `${describeShortcut(current, platform)}. Press to change.`
191
+ : "Not set. Press to record.";
192
+
193
+ return (
194
+ <div
195
+ data-slot="shortcut-recorder"
196
+ data-state={recording ? "recording" : conflict ? "conflict" : current ? "set" : "empty"}
197
+ className={cn("flex flex-col gap-1.5", className)}
198
+ {...props}
199
+ >
200
+ <span id={`${id}-label`} className="text-xs font-medium">
201
+ {label}
202
+ </span>
203
+
204
+ <div className="flex flex-wrap items-center gap-2">
205
+ <button
206
+ type="button"
207
+ aria-labelledby={`${id}-label`}
208
+ aria-describedby={`${id}-description`}
209
+ aria-pressed={recording}
210
+ className={cn(
211
+ "flex h-9 min-w-32 items-center justify-center gap-1 rounded-md border px-3 font-mono text-sm",
212
+ recording
213
+ ? "border-primary bg-primary/5 text-muted-foreground"
214
+ : "border-input bg-background hover:bg-accent",
215
+ focusRing,
216
+ disabledStyles,
217
+ )}
218
+ onClick={() => {
219
+ if (recording) return;
220
+ setConflict(null);
221
+ setRecording(true);
222
+ setAnnouncement("");
223
+ }}
224
+ onKeyDown={onKeyDown}
225
+ onKeyUp={onKeyUp}
226
+ onBlur={() => {
227
+ if (recording) {
228
+ stop();
229
+ setAnnouncement("Cancelled.");
230
+ }
231
+ }}
232
+ >
233
+ {/* Symbols and key caps are for the eye; the description says the
234
+ same thing in words. */}
235
+ <span aria-hidden="true" className="flex items-center gap-1">
236
+ {shown && (shown.key || shown.mod || shown.ctrl || shown.alt || shown.shift) ? (
237
+ shortcutParts(shown, platform)
238
+ .filter(Boolean)
239
+ .map((part, index) => (
240
+ <kbd
241
+ key={index}
242
+ className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-xs"
243
+ >
244
+ {part}
245
+ </kbd>
246
+ ))
247
+ ) : recording ? (
248
+ <span className="text-xs">Press keys…</span>
249
+ ) : (
250
+ <span className="text-xs text-muted-foreground">Not set</span>
251
+ )}
252
+ </span>
253
+ </button>
254
+
255
+ {current && !recording ? (
256
+ <button
257
+ type="button"
258
+ aria-label={`Clear — ${label}`}
259
+ className={cn(
260
+ "rounded-md border border-input bg-background px-2.5 py-1 text-xs font-medium hover:bg-accent",
261
+ focusRing,
262
+ )}
263
+ onClick={() => {
264
+ commit(null);
265
+ setConflict(null);
266
+ setAnnouncement("Cleared.");
267
+ }}
268
+ >
269
+ Clear
270
+ </button>
271
+ ) : null}
272
+ </div>
273
+
274
+ <p id={`${id}-description`} className="text-xs text-muted-foreground">
275
+ {description}
276
+ </p>
277
+
278
+ {conflict ? (
279
+ <div
280
+ data-slot="shortcut-conflict"
281
+ className="flex flex-wrap items-center gap-2 rounded-md border border-warning/50 bg-warning/5 px-2.5 py-1.5 text-xs"
282
+ >
283
+ <span className="flex-1 text-warning">
284
+ {describeShortcut(conflict.shortcut, platform)} is already used by{" "}
285
+ <strong>{conflict.with.label}</strong>.
286
+ </span>
287
+ <button
288
+ type="button"
289
+ className={cn(
290
+ "rounded-md border border-input bg-background px-2 py-0.5 font-medium hover:bg-accent",
291
+ focusRing,
292
+ )}
293
+ onClick={() => {
294
+ commit(conflict.shortcut);
295
+ setConflict(null);
296
+ setAnnouncement(
297
+ `Recorded ${describeShortcut(conflict.shortcut, platform)}. ${conflict.with.label} no longer has a shortcut.`,
298
+ );
299
+ }}
300
+ >
301
+ Use anyway
302
+ </button>
303
+ <button
304
+ type="button"
305
+ className={cn(
306
+ "rounded-md border border-input bg-background px-2 py-0.5 font-medium hover:bg-accent",
307
+ focusRing,
308
+ )}
309
+ onClick={() => {
310
+ setConflict(null);
311
+ }}
312
+ >
313
+ Keep the old one
314
+ </button>
315
+ </div>
316
+ ) : null}
317
+
318
+ {/* Present from the start, so the first outcome is heard. */}
319
+ <span role="status" aria-live="polite" className="sr-only">
320
+ {announcement}
321
+ </span>
322
+ </div>
323
+ );
324
+ }
325
+
326
+ function hasModifier(event: KeyboardEvent): boolean {
327
+ return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey;
328
+ }
329
+
330
+ function safeParse(text: string): Shortcut | null {
331
+ try {
332
+ return parseShortcut(text);
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ SyncStatus,
3
+ describeSyncState,
4
+ syncState,
5
+ useOnlineStatus,
6
+ type SyncInput,
7
+ type SyncState,
8
+ type SyncStatusProps,
9
+ } from "./sync-status";
@@ -0,0 +1,21 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "sync-status",
5
+ title: "Sync Status",
6
+ description: "Offline, saving, saved or failed — with the count of changes being held.",
7
+ category: "feedback",
8
+ status: "stable",
9
+ dependencies: [],
10
+ registryDependencies: ["button"],
11
+ files: ["sync-status.tsx"],
12
+ a11y:
13
+ "The word is the status and the coloured dot is decoration, hidden from assistive " +
14
+ "technology. The visible text is not a live region: every save flips it and a region on it " +
15
+ "would narrate the whole session. A separate region, present from the first render, " +
16
+ "announces transitions only — went offline with what will happen to the changes, came back " +
17
+ "with what is being saved, could not save, saved after a failure — and never the churn of " +
18
+ "saving itself. Announcing is on by default because going offline is the one thing a reader " +
19
+ "who is typing has to be told, and it can be turned off. The server render assumes online, " +
20
+ "since a page with no interface to report has no business saying offline.",
21
+ });