@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.
- package/README.md +9 -5
- package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
- package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
- package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
- package/dist/components/ai-extraction-review/extraction-model.js +91 -0
- package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
- package/dist/components/ai-extraction-review/index.d.ts +3 -0
- package/dist/components/ai-extraction-review/index.js +3 -0
- package/dist/components/ai-extraction-review/meta.js +17 -0
- package/dist/components/ai-extraction-review/meta.js.map +1 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
- package/dist/components/ai-suggested-value/index.d.ts +2 -0
- package/dist/components/ai-suggested-value/index.js +2 -0
- package/dist/components/ai-suggested-value/meta.js +17 -0
- package/dist/components/ai-suggested-value/meta.js.map +1 -0
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/avatar/avatar.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
- package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
- package/dist/components/confirm-typed/confirm-typed.js +106 -0
- package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
- package/dist/components/confirm-typed/index.d.ts +2 -0
- package/dist/components/confirm-typed/index.js +2 -0
- package/dist/components/confirm-typed/meta.js +17 -0
- package/dist/components/confirm-typed/meta.js.map +1 -0
- package/dist/components/cron-editor/cron-editor.d.ts +36 -0
- package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
- package/dist/components/cron-editor/cron-editor.js +493 -0
- package/dist/components/cron-editor/cron-editor.js.map +1 -0
- package/dist/components/cron-editor/cron-expression.d.ts +82 -0
- package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
- package/dist/components/cron-editor/cron-expression.js +352 -0
- package/dist/components/cron-editor/cron-expression.js.map +1 -0
- package/dist/components/cron-editor/index.d.ts +3 -0
- package/dist/components/cron-editor/index.js +3 -0
- package/dist/components/cron-editor/meta.js +17 -0
- package/dist/components/cron-editor/meta.js.map +1 -0
- package/dist/components/dns-record/dns-record.d.ts +61 -0
- package/dist/components/dns-record/dns-record.d.ts.map +1 -0
- package/dist/components/dns-record/dns-record.js +227 -0
- package/dist/components/dns-record/dns-record.js.map +1 -0
- package/dist/components/dns-record/index.d.ts +2 -0
- package/dist/components/dns-record/index.js +2 -0
- package/dist/components/dns-record/meta.js +17 -0
- package/dist/components/dns-record/meta.js.map +1 -0
- package/dist/components/permission-matrix/index.d.ts +3 -0
- package/dist/components/permission-matrix/index.js +3 -0
- package/dist/components/permission-matrix/meta.js +17 -0
- package/dist/components/permission-matrix/meta.js.map +1 -0
- package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
- package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
- package/dist/components/permission-matrix/permission-matrix.js +199 -0
- package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
- package/dist/components/permission-matrix/permission-model.d.ts +53 -0
- package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
- package/dist/components/permission-matrix/permission-model.js +64 -0
- package/dist/components/permission-matrix/permission-model.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/secret-field/index.d.ts +2 -0
- package/dist/components/secret-field/index.js +2 -0
- package/dist/components/secret-field/meta.js +17 -0
- package/dist/components/secret-field/meta.js.map +1 -0
- package/dist/components/secret-field/secret-field.d.ts +58 -0
- package/dist/components/secret-field/secret-field.d.ts.map +1 -0
- package/dist/components/secret-field/secret-field.js +168 -0
- package/dist/components/secret-field/secret-field.js.map +1 -0
- package/dist/components/session-expiry/index.d.ts +2 -0
- package/dist/components/session-expiry/index.js +2 -0
- package/dist/components/session-expiry/meta.js +17 -0
- package/dist/components/session-expiry/meta.js.map +1 -0
- package/dist/components/session-expiry/session-expiry.d.ts +64 -0
- package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
- package/dist/components/session-expiry/session-expiry.js +185 -0
- package/dist/components/session-expiry/session-expiry.js.map +1 -0
- package/dist/components/shortcut-recorder/index.d.ts +3 -0
- package/dist/components/shortcut-recorder/index.js +3 -0
- package/dist/components/shortcut-recorder/meta.js +17 -0
- package/dist/components/shortcut-recorder/meta.js.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
- package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
- package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/sync-status/index.d.ts +2 -0
- package/dist/components/sync-status/index.js +2 -0
- package/dist/components/sync-status/meta.js +17 -0
- package/dist/components/sync-status/meta.js.map +1 -0
- package/dist/components/sync-status/sync-status.d.ts +65 -0
- package/dist/components/sync-status/sync-status.d.ts.map +1 -0
- package/dist/components/sync-status/sync-status.js +103 -0
- package/dist/components/sync-status/sync-status.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +16 -2
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +73 -53
- package/dist/registry/components.js.map +1 -1
- package/package.json +4 -4
- package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
- package/src/components/ai-extraction-review/extraction-model.ts +159 -0
- package/src/components/ai-extraction-review/index.ts +13 -0
- package/src/components/ai-extraction-review/meta.ts +24 -0
- package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
- package/src/components/ai-suggested-value/index.ts +6 -0
- package/src/components/ai-suggested-value/meta.ts +24 -0
- package/src/components/confirm-typed/confirm-typed.tsx +191 -0
- package/src/components/confirm-typed/index.ts +1 -0
- package/src/components/confirm-typed/meta.ts +22 -0
- package/src/components/cron-editor/cron-editor.tsx +676 -0
- package/src/components/cron-editor/cron-expression.ts +552 -0
- package/src/components/cron-editor/index.ts +23 -0
- package/src/components/cron-editor/meta.ts +23 -0
- package/src/components/dns-record/dns-record.tsx +315 -0
- package/src/components/dns-record/index.ts +7 -0
- package/src/components/dns-record/meta.ts +21 -0
- package/src/components/permission-matrix/index.ts +14 -0
- package/src/components/permission-matrix/meta.ts +23 -0
- package/src/components/permission-matrix/permission-matrix.tsx +368 -0
- package/src/components/permission-matrix/permission-model.ts +120 -0
- package/src/components/secret-field/index.ts +6 -0
- package/src/components/secret-field/meta.ts +21 -0
- package/src/components/secret-field/secret-field.tsx +284 -0
- package/src/components/session-expiry/index.ts +9 -0
- package/src/components/session-expiry/meta.ts +23 -0
- package/src/components/session-expiry/session-expiry.tsx +321 -0
- package/src/components/shortcut-recorder/index.ts +19 -0
- package/src/components/shortcut-recorder/meta.ts +22 -0
- package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
- package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
- package/src/components/sync-status/index.ts +9 -0
- package/src/components/sync-status/meta.ts +21 -0
- package/src/components/sync-status/sync-status.tsx +217 -0
- package/src/index.ts +10 -0
- 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,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
|
+
});
|