@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,284 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ type ComponentPropsWithRef,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ import { Button } from "@/components/button";
13
+ import { Input } from "@/components/input";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ /**
17
+ * An API key, token or signing secret, in the three states it actually has.
18
+ *
19
+ * Stripe, GitHub, OpenAI, Vercel and every developer product with a settings
20
+ * page draw this by hand, and the states are the same everywhere: shown once
21
+ * at creation and never again; hidden but revealable, for a secret the server
22
+ * can show again; and gone, where only a prefix and the last four remain and
23
+ * the only thing left to do is regenerate. No component library ships it —
24
+ * the nearest thing is a password input, which is a control for entering a
25
+ * secret you know, not for handling one you have just been given.
26
+ *
27
+ * "Shown once" is a first-class state rather than a toast, because it is the
28
+ * one that costs people money: the key is on screen, the tab closes, and the
29
+ * next hour is spent regenerating it and updating every client. So the field
30
+ * says it in a sentence beside the value, and the way out is a button that
31
+ * says what it means — "I have saved it" — rather than the value quietly
32
+ * vanishing on navigation.
33
+ *
34
+ * While hidden, the secret is not in the DOM. The preview is what is
35
+ * rendered, so a screenshot, an extension or a devtools pane sees the prefix
36
+ * and the last four, which is what the server itself keeps. Reveals are
37
+ * reported, since an audit log of who looked is the reason the hidden state
38
+ * exists. Copy works while hidden, because the point of the key is to be
39
+ * pasted, not read.
40
+ */
41
+
42
+ export type SecretFieldState = "shown" | "hidden" | "revealed" | "gone";
43
+
44
+ export interface SecretFieldProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
45
+ label: string;
46
+ /** The secret. Omit once it can no longer be shown. */
47
+ value?: string;
48
+ /** What stands in for it while hidden or gone. Derived from the value when there is one. */
49
+ preview?: string;
50
+ /** This is the only time the value can be seen. */
51
+ once?: boolean;
52
+ /** The reader has saved a shown-once value. Drop `value` in response. */
53
+ onAcknowledge?: () => void;
54
+ /** Called each time the value is shown or hidden, so reveals can be logged. */
55
+ onRevealChange?: (revealed: boolean) => void;
56
+ /** Offers to regenerate. Confirmed inline first, because it revokes the current one. */
57
+ onRegenerate?: () => void;
58
+ /** Said before regenerating. */
59
+ regenerateWarning?: string;
60
+ /** Metadata — when it was created, when it was last used. */
61
+ children?: ReactNode;
62
+ }
63
+
64
+ /**
65
+ * A prefix and the last four, the way a server keeps a key it has hashed.
66
+ * The prefix is kept through the last underscore in the first twelve
67
+ * characters — `sk_live_`, `ghp_`, `whsec_` — so the reader can tell which
68
+ * key this is without being shown any of it.
69
+ */
70
+ export function maskSecret(value: string, keepEnd = 4): string {
71
+ const underscore = value.slice(0, 12).lastIndexOf("_");
72
+ let prefix = underscore > 0 ? value.slice(0, underscore + 1) : value.slice(0, 4);
73
+ // Never show enough of a short secret to reconstruct it.
74
+ if (prefix.length + keepEnd + 4 > value.length) prefix = "";
75
+ if (value.length < keepEnd + 4) return "••••••••";
76
+ return `${prefix}…${value.slice(-keepEnd)}`;
77
+ }
78
+
79
+ export function SecretField({
80
+ className,
81
+ label,
82
+ value,
83
+ preview,
84
+ once = false,
85
+ onAcknowledge,
86
+ onRevealChange,
87
+ onRegenerate,
88
+ regenerateWarning = "Regenerating revokes this one immediately. Anything still using it stops working.",
89
+ children,
90
+ ...props
91
+ }: SecretFieldProps) {
92
+ const id = useId();
93
+ const [revealed, setRevealed] = useState(false);
94
+ const [confirming, setConfirming] = useState(false);
95
+ const [copy, setCopy] = useState<"idle" | "copied" | "error">("idle");
96
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
97
+
98
+ useEffect(
99
+ () => () => {
100
+ if (timer.current) clearTimeout(timer.current);
101
+ },
102
+ [],
103
+ );
104
+
105
+ const state: SecretFieldState =
106
+ value === undefined ? "gone" : once ? "shown" : revealed ? "revealed" : "hidden";
107
+ const visible = state === "shown" || state === "revealed";
108
+ const masked = preview ?? (value !== undefined ? maskSecret(value) : "••••••••");
109
+
110
+ const copyValue = () => {
111
+ if (value === undefined) return;
112
+ const settle = (next: "copied" | "error") => {
113
+ setCopy(next);
114
+ if (timer.current) clearTimeout(timer.current);
115
+ timer.current = setTimeout(() => {
116
+ setCopy("idle");
117
+ }, 2000);
118
+ };
119
+ // Wrapped so a missing clipboard API throws into the same failure path
120
+ // as a refused one, rather than throwing out of the click handler.
121
+ Promise.resolve()
122
+ .then(() => navigator.clipboard.writeText(value))
123
+ .then(
124
+ () => {
125
+ settle("copied");
126
+ },
127
+ () => {
128
+ settle("error");
129
+ },
130
+ );
131
+ };
132
+
133
+ const setReveal = (next: boolean) => {
134
+ setRevealed(next);
135
+ onRevealChange?.(next);
136
+ };
137
+
138
+ const note =
139
+ state === "shown"
140
+ ? "Shown once. Copy it now — it cannot be shown again."
141
+ : state === "gone"
142
+ ? "Cannot be shown again."
143
+ : state === "hidden"
144
+ ? "Hidden. Copy works without revealing it."
145
+ : "Revealed.";
146
+
147
+ return (
148
+ <div
149
+ data-slot="secret-field"
150
+ data-state={state}
151
+ className={cn("flex flex-col gap-2", className)}
152
+ {...props}
153
+ >
154
+ <label htmlFor={`${id}-value`} className="text-xs font-medium">
155
+ {label}
156
+ </label>
157
+
158
+ <div className="flex flex-wrap items-center gap-2">
159
+ {/* Read-only rather than disabled, so it can be focused, selected and
160
+ read. While hidden the DOM holds the preview, not the secret. */}
161
+ <Input
162
+ id={`${id}-value`}
163
+ readOnly
164
+ inputSize="sm"
165
+ value={visible ? value : masked}
166
+ spellCheck={false}
167
+ autoComplete="off"
168
+ aria-describedby={`${id}-note`}
169
+ // Claims a readable width first; the buttons wrap under it when the
170
+ // row is short, rather than the key being squeezed to a fragment.
171
+ className="min-w-0 flex-1 basis-64 font-mono"
172
+ onFocus={(event) => {
173
+ if (visible) event.currentTarget.select();
174
+ }}
175
+ />
176
+
177
+ {value !== undefined && !once ? (
178
+ <Button
179
+ variant="outline"
180
+ size="sm"
181
+ aria-pressed={revealed}
182
+ aria-label={`${revealed ? "Hide" : "Reveal"} — ${label}`}
183
+ onClick={() => {
184
+ setReveal(!revealed);
185
+ }}
186
+ >
187
+ {revealed ? "Hide" : "Reveal"}
188
+ </Button>
189
+ ) : null}
190
+
191
+ {value !== undefined ? (
192
+ <Button
193
+ variant="outline"
194
+ size="sm"
195
+ data-state={copy}
196
+ aria-label={`Copy — ${label}`}
197
+ onClick={copyValue}
198
+ >
199
+ {copy === "copied" ? "Copied" : "Copy"}
200
+ </Button>
201
+ ) : null}
202
+
203
+ {state === "shown" && onAcknowledge ? (
204
+ <Button variant="primary" size="sm" onClick={onAcknowledge}>
205
+ I have saved it
206
+ </Button>
207
+ ) : null}
208
+
209
+ {onRegenerate && !confirming ? (
210
+ <Button
211
+ variant="outline"
212
+ size="sm"
213
+ aria-label={`Regenerate — ${label}`}
214
+ onClick={() => {
215
+ setConfirming(true);
216
+ }}
217
+ >
218
+ Regenerate…
219
+ </Button>
220
+ ) : null}
221
+ </div>
222
+
223
+ <p
224
+ id={`${id}-note`}
225
+ data-slot="secret-field-note"
226
+ className={cn("text-xs", state === "shown" ? "text-warning" : "text-muted-foreground")}
227
+ >
228
+ {note}
229
+ </p>
230
+
231
+ {/* Present from the start, so the first copy is heard. Failure is said
232
+ too: the clipboard can be refused, and a button that only ever says
233
+ "Copied" would be lying the one time it matters. */}
234
+ <span role="status" aria-live="polite" className="sr-only">
235
+ {copy === "copied"
236
+ ? "Copied to the clipboard"
237
+ : copy === "error"
238
+ ? "Could not copy. Select the value and copy it by hand."
239
+ : ""}
240
+ </span>
241
+ {copy === "error" ? (
242
+ <p data-slot="secret-field-copy-error" className="text-xs text-destructive">
243
+ Could not copy. Select the value and copy it by hand.
244
+ </p>
245
+ ) : null}
246
+
247
+ {children}
248
+
249
+ {confirming ? (
250
+ <div
251
+ data-slot="secret-field-confirm"
252
+ role="group"
253
+ aria-labelledby={`${id}-confirm`}
254
+ className="flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3"
255
+ >
256
+ <p id={`${id}-confirm`} className="text-xs text-destructive">
257
+ {regenerateWarning}
258
+ </p>
259
+ <div className="flex flex-wrap gap-2">
260
+ <Button
261
+ variant="destructive"
262
+ size="sm"
263
+ onClick={() => {
264
+ setConfirming(false);
265
+ onRegenerate?.();
266
+ }}
267
+ >
268
+ Regenerate
269
+ </Button>
270
+ <Button
271
+ variant="outline"
272
+ size="sm"
273
+ onClick={() => {
274
+ setConfirming(false);
275
+ }}
276
+ >
277
+ Cancel
278
+ </Button>
279
+ </div>
280
+ </div>
281
+ ) : null}
282
+ </div>
283
+ );
284
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ SessionExpiry,
3
+ describeCountdown,
4
+ formatCountdown,
5
+ secondsLeft,
6
+ sessionPhase,
7
+ type SessionExpiryProps,
8
+ type SessionPhase,
9
+ } from "./session-expiry";
@@ -0,0 +1,23 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "session-expiry",
5
+ title: "Session Expiry",
6
+ description:
7
+ "The idle-timeout warning WCAG 2.2.1 asks for: warned, extendable, and not dismissable by accident.",
8
+ category: "feedback",
9
+ status: "stable",
10
+ dependencies: [],
11
+ registryDependencies: ["button", "dialog"],
12
+ files: ["session-expiry.tsx"],
13
+ a11y:
14
+ "An alert dialog, named by its heading and described by the sentence that says how long is " +
15
+ "left. Escape and the backdrop do nothing, because dismissing a session warning without " +
16
+ "choosing is choosing nothing; the two buttons are the way out, and focus opens on the safe " +
17
+ "one. The visible countdown is not live — a number every second for two minutes drowns the " +
18
+ "question — and a separate status region speaks four times: when the warning opens, at one " +
19
+ "minute, thirty seconds and ten, still saying a threshold a slow tick skipped over. The " +
20
+ "clock is read in an effect, so the server renders nothing rather than a countdown from the " +
21
+ "wrong instant. This satisfies the warn-and-extend half of WCAG 2.2.1 Timing Adjustable; the " +
22
+ "twenty-second minimum is the default window's business and the application's.",
23
+ });
@@ -0,0 +1,321 @@
1
+ "use client";
2
+
3
+ import {
4
+ memo,
5
+ useEffect,
6
+ useId,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ type ReactNode,
11
+ } from "react";
12
+
13
+ import { Button } from "@/components/button";
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from "@/components/dialog";
22
+ import { cn } from "@/lib/utils";
23
+
24
+ /**
25
+ * "Your session ends in two minutes. Stay signed in?"
26
+ *
27
+ * Every product with an idle timeout builds this, and WCAG 2.2.1 (Timing
28
+ * Adjustable) says what it has to do: warn before the time runs out, and
29
+ * give at least twenty seconds to extend it with a simple action. Most
30
+ * implementations get the first half — a toast, a modal — and fail the
31
+ * second in one of two ways: the warning can be dismissed without choosing,
32
+ * so a reader who closed it to see the page underneath is signed out with
33
+ * no further word; or the countdown is a live region, so a screen reader
34
+ * user hears a number every second for two minutes and cannot hear the
35
+ * choice they are being asked to make.
36
+ *
37
+ * So this is an alert dialog that cannot be waved away. Escape and the
38
+ * backdrop do nothing, because dismissing a session warning without
39
+ * choosing is choosing nothing, and the two buttons are the two things a
40
+ * person can actually do. The countdown ticks on screen and is announced at
41
+ * four moments — when the warning opens, at one minute, thirty seconds and
42
+ * ten — which is enough to know how long is left and quiet enough to hear
43
+ * the question.
44
+ *
45
+ * When time runs out, `onExpire` fires once and the dialog says so, with a
46
+ * slot for whatever the application offers next. It does not sign anyone
47
+ * out; the server did that, and this is the part that tells the person.
48
+ */
49
+
50
+ export type SessionPhase = "active" | "warning" | "expired";
51
+
52
+ /** Where a session stands, given the clock. Pure, for tests and for the server. */
53
+ export function sessionPhase(expiresAt: Date, now: Date, warnBefore: number): SessionPhase {
54
+ const remaining = expiresAt.getTime() - now.getTime();
55
+ if (remaining <= 0) return "expired";
56
+ if (remaining <= warnBefore) return "warning";
57
+ return "active";
58
+ }
59
+
60
+ /** Whole seconds left, never negative. */
61
+ export function secondsLeft(expiresAt: Date, now: Date): number {
62
+ return Math.max(0, Math.ceil((expiresAt.getTime() - now.getTime()) / 1000));
63
+ }
64
+
65
+ /** "1:59" for the screen. */
66
+ export function formatCountdown(seconds: number): string {
67
+ const minutes = Math.floor(seconds / 60);
68
+ const rest = seconds % 60;
69
+ return `${String(minutes)}:${String(rest).padStart(2, "0")}`;
70
+ }
71
+
72
+ /** "1 minute 59 seconds" for the ear. */
73
+ export function describeCountdown(seconds: number): string {
74
+ const minutes = Math.floor(seconds / 60);
75
+ const rest = seconds % 60;
76
+ const parts: string[] = [];
77
+ if (minutes > 0) parts.push(`${String(minutes)} ${minutes === 1 ? "minute" : "minutes"}`);
78
+ if (rest > 0 || minutes === 0)
79
+ parts.push(`${String(rest)} ${rest === 1 ? "second" : "seconds"}`);
80
+ return parts.join(" ");
81
+ }
82
+
83
+ /** The seconds-left values at which the countdown is said aloud. */
84
+ const ANNOUNCE_AT = [60, 30, 10];
85
+
86
+ function subscribeToClock(callback: () => void) {
87
+ const timer = setInterval(callback, 1000);
88
+ return () => {
89
+ clearInterval(timer);
90
+ };
91
+ }
92
+ /** Whole seconds, so the snapshot is stable within a second and React does not re-render for nothing. */
93
+ const readClockSeconds = () => Math.floor(Date.now() / 1000);
94
+ const readNoClock = () => null;
95
+
96
+ export interface SessionExpiryProps {
97
+ /** When the session ends. Move it forward after a successful extend. */
98
+ expiresAt: Date;
99
+ /** How long before the end to warn, in milliseconds. Two minutes by default. */
100
+ warnBefore?: number;
101
+ /**
102
+ * The clock. Supply it to drive the countdown yourself, or in tests; omit
103
+ * and the component ticks once a second while mounted.
104
+ */
105
+ now?: Date;
106
+ /** Keep the session going. May return a promise; the button waits for it. */
107
+ onExtend: () => void | Promise<void>;
108
+ /** Called once when the time runs out. */
109
+ onExpire?: () => void;
110
+ /** Offered as the other choice. Omit to offer only staying signed in. */
111
+ onSignOut?: () => void;
112
+ /** The warning's heading. */
113
+ heading?: string;
114
+ /** What is at stake, in the reader's language: "Unsaved edits will be lost." */
115
+ children?: ReactNode;
116
+ /** What to offer once the session has ended: a sign-in link, usually. */
117
+ expiredAction?: ReactNode;
118
+ className?: string;
119
+ }
120
+
121
+ export function SessionExpiry({
122
+ expiresAt,
123
+ warnBefore = 120_000,
124
+ now: nowProp,
125
+ onExtend,
126
+ onExpire,
127
+ onSignOut,
128
+ heading = "Your session is about to end",
129
+ children,
130
+ expiredAction,
131
+ className,
132
+ }: SessionExpiryProps) {
133
+ const id = useId();
134
+
135
+ // The clock as an external store that ticks once a second. The server
136
+ // snapshot is null, so the server and the first client render agree on
137
+ // showing nothing; the real instant arrives in the re-render after.
138
+ const tickSeconds = useSyncExternalStore(subscribeToClock, readClockSeconds, readNoClock);
139
+ const tick = tickSeconds === null ? null : new Date(tickSeconds * 1000);
140
+
141
+ const now = nowProp ?? tick;
142
+ const phase = now ? sessionPhase(expiresAt, now, warnBefore) : "active";
143
+ const seconds = now ? secondsLeft(expiresAt, now) : 0;
144
+
145
+ const [announcement, setAnnouncement] = useState("");
146
+
147
+ // Announced at the moments that matter, not every second. Tracked as the
148
+ // last threshold spoken so a tick that skips a value still says it once.
149
+ const [spoken, setSpoken] = useState<number | null>(null);
150
+ const [endedFor, setEndedFor] = useState<number | null>(null);
151
+
152
+ if (phase === "warning") {
153
+ if (spoken === null) {
154
+ setSpoken(Number.POSITIVE_INFINITY);
155
+ setAnnouncement(`${heading}. ${describeCountdown(seconds)} left.`);
156
+ } else {
157
+ const due = ANNOUNCE_AT.find((at) => seconds <= at && at < spoken);
158
+ if (due !== undefined) {
159
+ setSpoken(due);
160
+ setAnnouncement(`${describeCountdown(seconds)} left.`);
161
+ }
162
+ }
163
+ } else if (spoken !== null && phase === "active") {
164
+ // Extended. The next warning starts its announcements afresh.
165
+ setSpoken(null);
166
+ setAnnouncement("");
167
+ } else if (phase === "expired" && endedFor !== expiresAt.getTime()) {
168
+ setEndedFor(expiresAt.getTime());
169
+ setAnnouncement("Your session has ended.");
170
+ }
171
+
172
+ // The one side effect: telling the application, once per expiry. A ref
173
+ // rather than state, because nothing rendered depends on it.
174
+ const firedFor = useRef<number | null>(null);
175
+ useEffect(() => {
176
+ if (phase !== "expired" || firedFor.current === expiresAt.getTime()) return;
177
+ firedFor.current = expiresAt.getTime();
178
+ onExpire?.();
179
+ }, [phase, expiresAt, onExpire]);
180
+
181
+ const open = phase !== "active";
182
+
183
+ // Once told to close, the dialog is left alone: the countdown it shows is
184
+ // frozen and its props stop changing, so the clock's ticks no longer
185
+ // re-render a surface that is mid-exit. A dialog re-rendered every second
186
+ // while closing never finishes closing.
187
+ const [shown, setShown] = useState(seconds);
188
+ if (open && shown !== seconds) setShown(seconds);
189
+
190
+ return (
191
+ <ExpiryDialog
192
+ id={id}
193
+ open={open}
194
+ phase={phase}
195
+ seconds={shown}
196
+ heading={heading}
197
+ announcement={announcement}
198
+ onExtend={onExtend}
199
+ onSignOut={onSignOut}
200
+ expiredAction={expiredAction}
201
+ className={className}
202
+ >
203
+ {children}
204
+ </ExpiryDialog>
205
+ );
206
+ }
207
+
208
+ interface ExpiryDialogProps {
209
+ id: string;
210
+ open: boolean;
211
+ phase: SessionPhase;
212
+ seconds: number;
213
+ heading: string;
214
+ announcement: string;
215
+ onExtend: () => void | Promise<void>;
216
+ onSignOut?: () => void;
217
+ expiredAction?: ReactNode;
218
+ className?: string;
219
+ children?: ReactNode;
220
+ }
221
+
222
+ const ExpiryDialog = memo(function ExpiryDialog({
223
+ id,
224
+ open,
225
+ phase,
226
+ seconds,
227
+ heading,
228
+ announcement,
229
+ onExtend,
230
+ onSignOut,
231
+ expiredAction,
232
+ className,
233
+ children,
234
+ }: ExpiryDialogProps) {
235
+ const stayRef = useRef<HTMLButtonElement>(null);
236
+ const [extending, setExtending] = useState(false);
237
+
238
+ const extend = () => {
239
+ const result = onExtend();
240
+ if (result instanceof Promise) {
241
+ setExtending(true);
242
+ void result.finally(() => {
243
+ setExtending(false);
244
+ });
245
+ }
246
+ };
247
+
248
+ return (
249
+ <Dialog open={open}>
250
+ <DialogContent
251
+ role="alertdialog"
252
+ showCloseButton={false}
253
+ aria-describedby={`${id}-description`}
254
+ data-slot="session-expiry"
255
+ data-phase={phase}
256
+ className={cn("max-w-sm", className)}
257
+ // Dismissing a session warning without choosing is choosing nothing,
258
+ // so the gestures that dismiss a dialog do nothing here. The buttons
259
+ // are the way out, and there are two of them.
260
+ onEscapeKeyDown={(event) => {
261
+ event.preventDefault();
262
+ }}
263
+ onPointerDownOutside={(event) => {
264
+ event.preventDefault();
265
+ }}
266
+ onInteractOutside={(event) => {
267
+ event.preventDefault();
268
+ }}
269
+ // Focus lands on the safe choice. The first tabbable would be "Sign
270
+ // out now", and a reflexive Enter on that is the outcome the whole
271
+ // dialog exists to prevent.
272
+ onOpenAutoFocus={(event) => {
273
+ event.preventDefault();
274
+ stayRef.current?.focus();
275
+ }}
276
+ >
277
+ <DialogHeader>
278
+ <DialogTitle>{phase === "expired" ? "Your session has ended" : heading}</DialogTitle>
279
+ <DialogDescription id={`${id}-description`}>
280
+ {phase === "expired" ? (
281
+ "You were signed out after a period of inactivity."
282
+ ) : (
283
+ <>
284
+ You will be signed out in{" "}
285
+ <span data-slot="session-expiry-countdown" className="font-medium tabular-nums">
286
+ <span aria-hidden="true">{formatCountdown(seconds)}</span>
287
+ <span className="sr-only">{describeCountdown(seconds)}</span>
288
+ </span>
289
+ .
290
+ </>
291
+ )}
292
+ </DialogDescription>
293
+ </DialogHeader>
294
+
295
+ {children ? <div className="text-sm text-muted-foreground">{children}</div> : null}
296
+
297
+ {/* The countdown above is not live. This is, and it speaks four times. */}
298
+ <span role="status" aria-live="polite" className="sr-only">
299
+ {announcement}
300
+ </span>
301
+
302
+ <DialogFooter>
303
+ {phase === "expired" ? (
304
+ expiredAction
305
+ ) : (
306
+ <>
307
+ {onSignOut ? (
308
+ <Button variant="outline" onClick={onSignOut}>
309
+ Sign out now
310
+ </Button>
311
+ ) : null}
312
+ <Button ref={stayRef} loading={extending} onClick={extend}>
313
+ Stay signed in
314
+ </Button>
315
+ </>
316
+ )}
317
+ </DialogFooter>
318
+ </DialogContent>
319
+ </Dialog>
320
+ );
321
+ });
@@ -0,0 +1,19 @@
1
+ export {
2
+ ShortcutRecorder,
3
+ type ShortcutRecorderProps,
4
+ type TakenShortcut,
5
+ } from "./shortcut-recorder";
6
+ export {
7
+ RESERVED_KEYS,
8
+ describeShortcut,
9
+ detectPlatform,
10
+ formatShortcut,
11
+ needsModifier,
12
+ parseShortcut,
13
+ serializeShortcut,
14
+ shortcutFromKey,
15
+ shortcutParts,
16
+ shortcutsEqual,
17
+ type Platform,
18
+ type Shortcut,
19
+ } from "./shortcut-model";
@@ -0,0 +1,22 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "shortcut-recorder",
5
+ title: "Shortcut Recorder",
6
+ description:
7
+ "Press the keys you want: records a chord, platform-aware, and says when it clashes.",
8
+ category: "form",
9
+ status: "stable",
10
+ dependencies: [],
11
+ registryDependencies: [],
12
+ files: ["shortcut-model.ts", "shortcut-recorder.tsx"],
13
+ a11y:
14
+ "The recorder is a button named by what the shortcut does and described by its value in " +
15
+ "words — Command Shift K — while the key caps on it are hidden from assistive technology, " +
16
+ "since ⌘⇧K read aloud is noise. While recording it is aria-pressed and its description " +
17
+ "says what to do; Tab always leaves and Escape always cancels, so it is never a keyboard " +
18
+ "trap. Every outcome is announced through a status region present from the start: recorded, " +
19
+ "refused with the reason, clashing with which command, cancelled, cleared. Symbols are shown " +
20
+ "on a Mac and words elsewhere, and the description uses the platform's own names for its " +
21
+ "keys.",
22
+ });