@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,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,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
|
+
});
|