@ews-admin/global-design-system 1.30.0 → 1.31.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/dist/components/LockScreen/LockScreen.d.ts +5 -4
- package/dist/components/LockScreen/LockScreen.d.ts.map +1 -1
- package/dist/components/LockScreen/LockScreen.types.d.ts +8 -0
- package/dist/components/LockScreen/LockScreen.types.d.ts.map +1 -1
- package/dist/components/PinCodeInput/PinCodeInput.d.ts +11 -0
- package/dist/components/PinCodeInput/PinCodeInput.d.ts.map +1 -0
- package/dist/components/PinCodeInput/PinCodeInput.types.d.ts +26 -0
- package/dist/components/PinCodeInput/PinCodeInput.types.d.ts.map +1 -0
- package/dist/components/PinCodeInput/index.d.ts +3 -0
- package/dist/components/PinCodeInput/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useMediaQuery.d.ts +7 -0
- package/dist/hooks/useMediaQuery.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +55 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.css +1 -1
- package/dist/index.esm.js +191 -12
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +192 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/LockScreen/LockScreen.tsx +215 -89
- package/src/components/LockScreen/LockScreen.types.ts +8 -0
- package/src/components/PinCodeInput/PinCodeInput.tsx +184 -0
- package/src/components/PinCodeInput/PinCodeInput.types.ts +25 -0
- package/src/components/PinCodeInput/index.ts +2 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useMediaQuery.ts +31 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
|
2
3
|
import { cn } from "../../utils";
|
|
3
4
|
import { Button } from "../Button/Button";
|
|
4
5
|
import { Logo } from "../Logo";
|
|
6
|
+
import { PinCodeInput } from "../PinCodeInput";
|
|
5
7
|
import { PinPad } from "../PinPad";
|
|
6
8
|
import type {
|
|
7
9
|
LockScreenMode,
|
|
@@ -33,14 +35,21 @@ const titleOf = (mode: LockScreenMode, step: Step, texts: LockScreenTexts) => {
|
|
|
33
35
|
}
|
|
34
36
|
};
|
|
35
37
|
|
|
38
|
+
/** Same breakpoint as Tailwind's `sm`. */
|
|
39
|
+
const DESKTOP_QUERY = "(min-width: 640px)";
|
|
40
|
+
|
|
41
|
+
/** Open lock screens, oldest first; only the last one traps focus. */
|
|
42
|
+
const trapStack: object[] = [];
|
|
43
|
+
|
|
36
44
|
const FOCUSABLE =
|
|
37
45
|
'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
|
38
46
|
|
|
39
47
|
/**
|
|
40
48
|
* Blocking PIN screen: creates, checks, changes or resets (with a one-time
|
|
41
|
-
* code) the user's secret code.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
49
|
+
* code) the user's secret code. Below 640px: full screen with an on-screen
|
|
50
|
+
* keypad. From 640px: compact dialog on a grey backdrop with one box per digit,
|
|
51
|
+
* typed on the keyboard. Submit handlers return promises: a rejection clears
|
|
52
|
+
* the code and goes back to the first step, the app shows its message in `error`.
|
|
44
53
|
*/
|
|
45
54
|
const LockScreen = ({
|
|
46
55
|
mode,
|
|
@@ -53,6 +62,8 @@ const LockScreen = ({
|
|
|
53
62
|
onReset,
|
|
54
63
|
onForgot,
|
|
55
64
|
onCancel,
|
|
65
|
+
onSignOut,
|
|
66
|
+
subtitle,
|
|
56
67
|
pinLength = 4,
|
|
57
68
|
otpLength = 6,
|
|
58
69
|
logo,
|
|
@@ -68,6 +79,7 @@ const LockScreen = ({
|
|
|
68
79
|
const [resetKey, setResetKey] = useState(0);
|
|
69
80
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
70
81
|
const mounted = useRef(true);
|
|
82
|
+
const isDesktop = useMediaQuery(DESKTOP_QUERY);
|
|
71
83
|
|
|
72
84
|
const restart = useCallback(
|
|
73
85
|
(failed: boolean) => {
|
|
@@ -93,21 +105,29 @@ const LockScreen = ({
|
|
|
93
105
|
};
|
|
94
106
|
}, []);
|
|
95
107
|
|
|
96
|
-
// Block page scroll and keep focus inside the screen.
|
|
108
|
+
// Block page scroll and keep focus inside the screen. Only the most recently
|
|
109
|
+
// mounted lock screen traps focus: two traps pulling focus back to their own
|
|
110
|
+
// panel would loop forever (e.g. a Storybook docs page rendering every story).
|
|
97
111
|
useEffect(() => {
|
|
112
|
+
const token = {};
|
|
113
|
+
trapStack.push(token);
|
|
98
114
|
const previousOverflow = document.body.style.overflow;
|
|
99
115
|
document.body.style.overflow = "hidden";
|
|
100
116
|
const previousFocus = document.activeElement as HTMLElement | null;
|
|
101
|
-
|
|
117
|
+
// The desktop code boxes focus themselves first.
|
|
118
|
+
if (!panelRef.current?.contains(document.activeElement)) panelRef.current?.focus();
|
|
102
119
|
const keepFocus = (event: FocusEvent) => {
|
|
120
|
+
if (trapStack[trapStack.length - 1] !== token) return;
|
|
103
121
|
const panel = panelRef.current;
|
|
104
122
|
if (panel && !panel.contains(event.target as Node)) panel.focus();
|
|
105
123
|
};
|
|
106
124
|
document.addEventListener("focusin", keepFocus);
|
|
107
125
|
return () => {
|
|
126
|
+
const wasTop = trapStack[trapStack.length - 1] === token;
|
|
127
|
+
trapStack.splice(trapStack.indexOf(token), 1);
|
|
108
128
|
document.body.style.overflow = previousOverflow;
|
|
109
129
|
document.removeEventListener("focusin", keepFocus);
|
|
110
|
-
previousFocus?.focus?.();
|
|
130
|
+
if (wasTop) previousFocus?.focus?.();
|
|
111
131
|
};
|
|
112
132
|
}, []);
|
|
113
133
|
|
|
@@ -185,6 +205,13 @@ const LockScreen = ({
|
|
|
185
205
|
setStep("new");
|
|
186
206
|
};
|
|
187
207
|
|
|
208
|
+
const completeOtp = (code: string) => {
|
|
209
|
+
setLocalError(null);
|
|
210
|
+
setFirstValue(code);
|
|
211
|
+
setInvalid(false);
|
|
212
|
+
setStep("new");
|
|
213
|
+
};
|
|
214
|
+
|
|
188
215
|
const isBusy = busy || pending;
|
|
189
216
|
const shownError = localError ?? error;
|
|
190
217
|
const showForgot =
|
|
@@ -193,82 +220,39 @@ const LockScreen = ({
|
|
|
193
220
|
(step === "unlock" || step === "current");
|
|
194
221
|
const title = titleOf(mode, step, texts);
|
|
195
222
|
|
|
196
|
-
|
|
197
|
-
<div
|
|
198
|
-
className={cn(
|
|
199
|
-
"fixed inset-0 z-[1000] flex justify-center overflow-y-auto bg-white",
|
|
200
|
-
"sm:items-center sm:bg-ews-gray-900/50 sm:backdrop-blur-sm",
|
|
201
|
-
className,
|
|
202
|
-
)}
|
|
203
|
-
data-testid="lock-screen"
|
|
204
|
-
>
|
|
205
|
-
<div
|
|
206
|
-
ref={panelRef}
|
|
207
|
-
role="dialog"
|
|
208
|
-
aria-modal="true"
|
|
209
|
-
aria-label={texts.dialogLabel}
|
|
210
|
-
aria-busy={isBusy}
|
|
211
|
-
tabIndex={-1}
|
|
212
|
-
onKeyDown={trapTab}
|
|
213
|
-
className={cn(
|
|
214
|
-
"flex min-h-full w-full flex-col items-center px-6 pb-8 outline-none",
|
|
215
|
-
"sm:min-h-0 sm:w-auto sm:min-w-[22rem] sm:rounded-2xl sm:bg-white sm:px-10 sm:py-10 sm:shadow-xl",
|
|
216
|
-
)}
|
|
217
|
-
>
|
|
218
|
-
<div className="flex min-h-[33vh] shrink-0 items-center justify-center sm:min-h-0 sm:pb-8">
|
|
219
|
-
{logo ?? <Logo size="lg" variant="normal" />}
|
|
220
|
-
</div>
|
|
223
|
+
const digitLabel = texts.digit ?? texts.progress;
|
|
221
224
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
const desktopContent = (
|
|
226
|
+
<>
|
|
227
|
+
<div className="pb-5">{logo ?? <Logo size="sm" variant="normal" />}</div>
|
|
228
|
+
|
|
229
|
+
{title && (
|
|
230
|
+
<h2 className="text-center text-xl font-semibold text-ews-gray-900">{title}</h2>
|
|
231
|
+
)}
|
|
232
|
+
{subtitle && (
|
|
233
|
+
<p className="mt-1 text-center text-sm text-ews-gray-600">{subtitle}</p>
|
|
234
|
+
)}
|
|
235
|
+
{step === "otp" && texts.otpDescription && (
|
|
236
|
+
<p className="mt-2 text-center text-sm text-ews-gray-600">{texts.otpDescription}</p>
|
|
237
|
+
)}
|
|
227
238
|
|
|
239
|
+
<div className="mt-6">
|
|
228
240
|
{step === "otp" ? (
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
id="lock-screen-otp"
|
|
243
|
-
dir="ltr"
|
|
244
|
-
inputMode="numeric"
|
|
245
|
-
autoComplete="one-time-code"
|
|
246
|
-
maxLength={otpLength}
|
|
247
|
-
value={otp}
|
|
248
|
-
disabled={isBusy}
|
|
249
|
-
aria-label={texts.otpLabel}
|
|
250
|
-
aria-invalid={invalid || undefined}
|
|
251
|
-
onChange={(event) => {
|
|
252
|
-
setOtp(event.target.value.replace(/\D/g, "").slice(0, otpLength));
|
|
253
|
-
setInvalid(false);
|
|
254
|
-
}}
|
|
255
|
-
className={cn(
|
|
256
|
-
"w-full rounded-lg border bg-white px-4 py-3 text-center text-2xl tracking-[0.5em]",
|
|
257
|
-
"focus:border-ews-primary focus:ring-2 focus:ring-ews-primary",
|
|
258
|
-
invalid ? "border-ews-error" : "border-ews-gray-300",
|
|
259
|
-
)}
|
|
260
|
-
/>
|
|
261
|
-
<Button
|
|
262
|
-
type="submit"
|
|
263
|
-
variant="ews-primary"
|
|
264
|
-
fullWidth
|
|
265
|
-
disabled={otp.length !== otpLength || isBusy}
|
|
266
|
-
>
|
|
267
|
-
{texts.otpSubmit}
|
|
268
|
-
</Button>
|
|
269
|
-
</form>
|
|
241
|
+
<PinCodeInput
|
|
242
|
+
key={step}
|
|
243
|
+
length={otpLength}
|
|
244
|
+
masked={false}
|
|
245
|
+
autoComplete="one-time-code"
|
|
246
|
+
onComplete={completeOtp}
|
|
247
|
+
onChange={() => setInvalid(false)}
|
|
248
|
+
disabled={isBusy}
|
|
249
|
+
invalid={invalid}
|
|
250
|
+
resetKey={resetKey}
|
|
251
|
+
digitLabel={digitLabel}
|
|
252
|
+
groupLabel={texts.otpLabel ?? title}
|
|
253
|
+
/>
|
|
270
254
|
) : (
|
|
271
|
-
<
|
|
255
|
+
<PinCodeInput
|
|
272
256
|
key={step}
|
|
273
257
|
length={pinLength}
|
|
274
258
|
onComplete={handlePin}
|
|
@@ -276,26 +260,24 @@ const LockScreen = ({
|
|
|
276
260
|
disabled={isBusy}
|
|
277
261
|
invalid={invalid}
|
|
278
262
|
resetKey={resetKey}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
keypadLabel={texts.keypad}
|
|
263
|
+
digitLabel={digitLabel}
|
|
264
|
+
groupLabel={title}
|
|
282
265
|
/>
|
|
283
266
|
)}
|
|
267
|
+
</div>
|
|
284
268
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
>
|
|
289
|
-
{shownError}
|
|
290
|
-
</p>
|
|
269
|
+
<p role="alert" className="mt-3 min-h-[1.25rem] text-center text-sm text-ews-error">
|
|
270
|
+
{shownError}
|
|
271
|
+
</p>
|
|
291
272
|
|
|
292
|
-
|
|
273
|
+
{(showForgot || (onCancel && texts.cancel) || (onSignOut && texts.signOut)) && (
|
|
274
|
+
<div className="mt-4 flex w-full flex-col items-center gap-2 border-t border-ews-gray-200 pt-4">
|
|
293
275
|
{showForgot && (
|
|
294
276
|
<button
|
|
295
277
|
type="button"
|
|
296
278
|
onClick={onForgot}
|
|
297
279
|
disabled={isBusy}
|
|
298
|
-
className="text-
|
|
280
|
+
className="text-sm font-medium text-ews-primary hover:underline focus-visible:underline"
|
|
299
281
|
>
|
|
300
282
|
{texts.forgot}
|
|
301
283
|
</button>
|
|
@@ -310,7 +292,151 @@ const LockScreen = ({
|
|
|
310
292
|
{texts.cancel}
|
|
311
293
|
</button>
|
|
312
294
|
)}
|
|
295
|
+
{onSignOut && texts.signOut && (
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
onClick={onSignOut}
|
|
299
|
+
disabled={isBusy}
|
|
300
|
+
className="text-sm text-ews-gray-600 hover:underline focus-visible:underline"
|
|
301
|
+
>
|
|
302
|
+
{texts.signOut}
|
|
303
|
+
</button>
|
|
304
|
+
)}
|
|
313
305
|
</div>
|
|
306
|
+
)}
|
|
307
|
+
</>
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<div
|
|
312
|
+
className={cn(
|
|
313
|
+
"fixed inset-0 z-[1000] flex justify-center overflow-y-auto bg-white",
|
|
314
|
+
"sm:items-center sm:bg-ews-gray-900/50 sm:backdrop-blur-sm",
|
|
315
|
+
isDesktop && "p-4",
|
|
316
|
+
className,
|
|
317
|
+
)}
|
|
318
|
+
data-testid="lock-screen"
|
|
319
|
+
data-layout={isDesktop ? "desktop" : "mobile"}
|
|
320
|
+
>
|
|
321
|
+
<div
|
|
322
|
+
ref={panelRef}
|
|
323
|
+
role="dialog"
|
|
324
|
+
aria-modal="true"
|
|
325
|
+
aria-label={texts.dialogLabel}
|
|
326
|
+
aria-busy={isBusy}
|
|
327
|
+
tabIndex={-1}
|
|
328
|
+
onKeyDown={trapTab}
|
|
329
|
+
className={
|
|
330
|
+
isDesktop
|
|
331
|
+
? "flex w-full max-w-[400px] flex-col items-center rounded-2xl bg-white px-8 py-8 shadow-xl outline-none"
|
|
332
|
+
: cn(
|
|
333
|
+
"flex min-h-full w-full flex-col items-center px-6 pb-8 outline-none",
|
|
334
|
+
"sm:min-h-0 sm:w-auto sm:min-w-[22rem] sm:rounded-2xl sm:bg-white sm:px-10 sm:py-10 sm:shadow-xl",
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
>
|
|
338
|
+
{isDesktop ? (
|
|
339
|
+
desktopContent
|
|
340
|
+
) : (
|
|
341
|
+
<>
|
|
342
|
+
<div className="flex min-h-[33vh] shrink-0 items-center justify-center sm:min-h-0 sm:pb-8">
|
|
343
|
+
{logo ?? <Logo size="lg" variant="normal" />}
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{title && (
|
|
347
|
+
<h2 className="mb-8 text-center text-lg font-normal text-ews-gray-900">
|
|
348
|
+
{title}
|
|
349
|
+
</h2>
|
|
350
|
+
)}
|
|
351
|
+
|
|
352
|
+
{step === "otp" ? (
|
|
353
|
+
<form
|
|
354
|
+
onSubmit={submitOtp}
|
|
355
|
+
className="flex w-full max-w-xs flex-col items-center gap-4"
|
|
356
|
+
>
|
|
357
|
+
{texts.otpDescription && (
|
|
358
|
+
<p className="text-center text-sm text-ews-gray-600">
|
|
359
|
+
{texts.otpDescription}
|
|
360
|
+
</p>
|
|
361
|
+
)}
|
|
362
|
+
<label className="sr-only" htmlFor="lock-screen-otp">
|
|
363
|
+
{texts.otpLabel}
|
|
364
|
+
</label>
|
|
365
|
+
<input
|
|
366
|
+
id="lock-screen-otp"
|
|
367
|
+
dir="ltr"
|
|
368
|
+
inputMode="numeric"
|
|
369
|
+
autoComplete="one-time-code"
|
|
370
|
+
maxLength={otpLength}
|
|
371
|
+
value={otp}
|
|
372
|
+
disabled={isBusy}
|
|
373
|
+
aria-label={texts.otpLabel}
|
|
374
|
+
aria-invalid={invalid || undefined}
|
|
375
|
+
onChange={(event) => {
|
|
376
|
+
setOtp(event.target.value.replace(/\D/g, "").slice(0, otpLength));
|
|
377
|
+
setInvalid(false);
|
|
378
|
+
}}
|
|
379
|
+
className={cn(
|
|
380
|
+
"w-full rounded-lg border bg-white px-4 py-3 text-center text-2xl tracking-[0.5em]",
|
|
381
|
+
"focus:border-ews-primary focus:ring-2 focus:ring-ews-primary",
|
|
382
|
+
invalid ? "border-ews-error" : "border-ews-gray-300",
|
|
383
|
+
)}
|
|
384
|
+
/>
|
|
385
|
+
<Button
|
|
386
|
+
type="submit"
|
|
387
|
+
variant="ews-primary"
|
|
388
|
+
fullWidth
|
|
389
|
+
disabled={otp.length !== otpLength || isBusy}
|
|
390
|
+
>
|
|
391
|
+
{texts.otpSubmit}
|
|
392
|
+
</Button>
|
|
393
|
+
</form>
|
|
394
|
+
) : (
|
|
395
|
+
<PinPad
|
|
396
|
+
key={step}
|
|
397
|
+
length={pinLength}
|
|
398
|
+
onComplete={handlePin}
|
|
399
|
+
onChange={() => setInvalid(false)}
|
|
400
|
+
disabled={isBusy}
|
|
401
|
+
invalid={invalid}
|
|
402
|
+
resetKey={resetKey}
|
|
403
|
+
eraseLabel={texts.erase}
|
|
404
|
+
progressLabel={texts.progress}
|
|
405
|
+
keypadLabel={texts.keypad}
|
|
406
|
+
/>
|
|
407
|
+
)}
|
|
408
|
+
|
|
409
|
+
<p
|
|
410
|
+
role="alert"
|
|
411
|
+
className="mt-6 min-h-[1.25rem] text-center text-sm text-ews-error"
|
|
412
|
+
>
|
|
413
|
+
{shownError}
|
|
414
|
+
</p>
|
|
415
|
+
|
|
416
|
+
<div className="mt-auto flex flex-col items-center gap-3 pt-6 sm:mt-4">
|
|
417
|
+
{showForgot && (
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
onClick={onForgot}
|
|
421
|
+
disabled={isBusy}
|
|
422
|
+
className="text-base font-medium text-ews-primary hover:underline focus-visible:underline"
|
|
423
|
+
>
|
|
424
|
+
{texts.forgot}
|
|
425
|
+
</button>
|
|
426
|
+
)}
|
|
427
|
+
{onCancel && texts.cancel && (
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
onClick={onCancel}
|
|
431
|
+
disabled={isBusy}
|
|
432
|
+
className="text-sm text-ews-gray-600 hover:underline focus-visible:underline"
|
|
433
|
+
>
|
|
434
|
+
{texts.cancel}
|
|
435
|
+
</button>
|
|
436
|
+
)}
|
|
437
|
+
</div>
|
|
438
|
+
</>
|
|
439
|
+
)}
|
|
314
440
|
</div>
|
|
315
441
|
</div>
|
|
316
442
|
);
|
|
@@ -30,12 +30,16 @@ export interface LockScreenTexts {
|
|
|
30
30
|
forgot?: string;
|
|
31
31
|
/** Secondary link, shown with `onCancel`. */
|
|
32
32
|
cancel?: string;
|
|
33
|
+
/** Desktop: "Se déconnecter" link, shown with `onSignOut`. */
|
|
34
|
+
signOut?: string;
|
|
33
35
|
/** Accessible label of the erase key. */
|
|
34
36
|
erase: string;
|
|
35
37
|
/** Accessible label of the keypad. */
|
|
36
38
|
keypad?: string;
|
|
37
39
|
/** Text announced when the number of typed digits changes. */
|
|
38
40
|
progress: (filled: number, total: number) => string;
|
|
41
|
+
/** Desktop: accessible label of each box, e.g. (1, 4) => "Chiffre 1 sur 4". Defaults to `progress`. */
|
|
42
|
+
digit?: (position: number, total: number) => string;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface LockScreenProps {
|
|
@@ -57,6 +61,10 @@ export interface LockScreenProps {
|
|
|
57
61
|
onForgot?: () => void;
|
|
58
62
|
/** Shows the "cancel" link (e.g. leave the change or reset flow). */
|
|
59
63
|
onCancel?: () => void;
|
|
64
|
+
/** Desktop: shows the sign out link (with `texts.signOut`). */
|
|
65
|
+
onSignOut?: () => void;
|
|
66
|
+
/** Desktop: line under the title, e.g. "Connecté en tant que Awa Diop". */
|
|
67
|
+
subtitle?: React.ReactNode;
|
|
60
68
|
/** Number of PIN digits. Default 4. */
|
|
61
69
|
pinLength?: number;
|
|
62
70
|
/** Number of one-time code digits. Default 6. */
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../../utils";
|
|
3
|
+
import type { PinCodeInputProps } from "./PinCodeInput.types";
|
|
4
|
+
|
|
5
|
+
const MASK = "•";
|
|
6
|
+
const onlyDigits = (text: string) => text.replace(/\D/g, "");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Row of one-digit boxes for a PIN or a one-time code (desktop keyboards):
|
|
10
|
+
* typing moves to the next box, Backspace goes back, arrows move, pasting a
|
|
11
|
+
* code fills the boxes and `onComplete` fires once every box is filled.
|
|
12
|
+
*/
|
|
13
|
+
const PinCodeInput = ({
|
|
14
|
+
length = 4,
|
|
15
|
+
onComplete,
|
|
16
|
+
onChange,
|
|
17
|
+
masked = true,
|
|
18
|
+
autoComplete = "off",
|
|
19
|
+
autoFocus = true,
|
|
20
|
+
disabled = false,
|
|
21
|
+
invalid = false,
|
|
22
|
+
resetKey,
|
|
23
|
+
digitLabel,
|
|
24
|
+
groupLabel,
|
|
25
|
+
className,
|
|
26
|
+
}: PinCodeInputProps) => {
|
|
27
|
+
const empty = useCallback(() => Array.from({ length }, () => ""), [length]);
|
|
28
|
+
const [digits, setDigits] = useState<string[]>(empty);
|
|
29
|
+
const digitsRef = useRef<string[]>(digits);
|
|
30
|
+
const inputs = useRef<Array<HTMLInputElement | null>>([]);
|
|
31
|
+
|
|
32
|
+
const focusBox = (index: number) => {
|
|
33
|
+
const input = inputs.current[Math.max(0, Math.min(index, length - 1))];
|
|
34
|
+
input?.focus();
|
|
35
|
+
input?.select();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const update = (next: string[]) => {
|
|
39
|
+
digitsRef.current = next;
|
|
40
|
+
setDigits(next);
|
|
41
|
+
const value = next.join("");
|
|
42
|
+
onChange?.(value);
|
|
43
|
+
if (value.length === length && next.every(Boolean)) onComplete(value);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Writes `chars` from box `start` on and focuses the box after them. */
|
|
47
|
+
const fill = (start: number, chars: string) => {
|
|
48
|
+
const next = [...digitsRef.current];
|
|
49
|
+
const written = chars.slice(0, length - start).split("");
|
|
50
|
+
written.forEach((char, offset) => {
|
|
51
|
+
next[start + offset] = char;
|
|
52
|
+
});
|
|
53
|
+
focusBox(start + written.length);
|
|
54
|
+
update(next);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Clear the boxes when the caller asks for it.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const cleared = empty();
|
|
60
|
+
digitsRef.current = cleared;
|
|
61
|
+
setDigits(cleared);
|
|
62
|
+
}, [resetKey, empty]);
|
|
63
|
+
|
|
64
|
+
// Focus the first empty box on mount, after a reset and once re-enabled.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!autoFocus || disabled) return;
|
|
67
|
+
const firstEmpty = digitsRef.current.findIndex((digit) => !digit);
|
|
68
|
+
focusBox(firstEmpty === -1 ? length - 1 : firstEmpty);
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [autoFocus, disabled, resetKey, length]);
|
|
71
|
+
|
|
72
|
+
const handleChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
|
73
|
+
if (disabled) return;
|
|
74
|
+
const { value, selectionStart } = event.target;
|
|
75
|
+
const typed = onlyDigits(value);
|
|
76
|
+
if (!typed) {
|
|
77
|
+
const next = [...digitsRef.current];
|
|
78
|
+
next[index] = "";
|
|
79
|
+
update(next);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// A digit typed next to the one already in the box: keep the new one.
|
|
83
|
+
if (digitsRef.current[index] && value.length === 2) {
|
|
84
|
+
const inserted = value.charAt((selectionStart ?? value.length) - 1);
|
|
85
|
+
if (/\d/.test(inserted)) {
|
|
86
|
+
fill(index, inserted);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// One digit, or a whole code (browser autofill of the one-time code).
|
|
91
|
+
fill(typed.length >= length ? 0 : index, typed);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
95
|
+
if (disabled) return;
|
|
96
|
+
switch (event.key) {
|
|
97
|
+
case "Backspace": {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
const next = [...digitsRef.current];
|
|
100
|
+
const target = next[index] ? index : Math.max(0, index - 1);
|
|
101
|
+
next[target] = "";
|
|
102
|
+
focusBox(target);
|
|
103
|
+
update(next);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
case "Delete": {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
const next = [...digitsRef.current];
|
|
109
|
+
next[index] = "";
|
|
110
|
+
update(next);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
case "ArrowLeft":
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
focusBox(index - 1);
|
|
116
|
+
return;
|
|
117
|
+
case "ArrowRight":
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
focusBox(index + 1);
|
|
120
|
+
return;
|
|
121
|
+
case "Home":
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
focusBox(0);
|
|
124
|
+
return;
|
|
125
|
+
case "End":
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
focusBox(length - 1);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handlePaste = (index: number, event: React.ClipboardEvent<HTMLInputElement>) => {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
if (disabled) return;
|
|
135
|
+
const pasted = onlyDigits(event.clipboardData.getData("text"));
|
|
136
|
+
if (!pasted) return;
|
|
137
|
+
fill(pasted.length >= length ? 0 : index, pasted);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
// Codes stay left-to-right in RTL locales.
|
|
142
|
+
<div
|
|
143
|
+
dir="ltr"
|
|
144
|
+
role="group"
|
|
145
|
+
aria-label={groupLabel}
|
|
146
|
+
data-testid="pin-code-input"
|
|
147
|
+
className={cn("flex justify-center gap-3", invalid && "animate-pin-shake", className)}
|
|
148
|
+
>
|
|
149
|
+
{digits.map((digit, index) => (
|
|
150
|
+
<input
|
|
151
|
+
key={index}
|
|
152
|
+
ref={(element) => {
|
|
153
|
+
inputs.current[index] = element;
|
|
154
|
+
}}
|
|
155
|
+
type="text"
|
|
156
|
+
inputMode="numeric"
|
|
157
|
+
pattern="[0-9]*"
|
|
158
|
+
autoComplete={index === 0 ? autoComplete : "off"}
|
|
159
|
+
autoCorrect="off"
|
|
160
|
+
spellCheck={false}
|
|
161
|
+
value={digit && masked ? MASK : digit}
|
|
162
|
+
disabled={disabled}
|
|
163
|
+
aria-label={digitLabel(index + 1, length)}
|
|
164
|
+
aria-invalid={invalid || undefined}
|
|
165
|
+
data-filled={!!digit}
|
|
166
|
+
onChange={(event) => handleChange(index, event)}
|
|
167
|
+
onKeyDown={(event) => handleKeyDown(index, event)}
|
|
168
|
+
onPaste={(event) => handlePaste(index, event)}
|
|
169
|
+
onFocus={(event) => event.target.select()}
|
|
170
|
+
className={cn(
|
|
171
|
+
"h-14 w-12 rounded-lg border bg-white text-center text-2xl font-medium text-ews-gray-900 outline-none",
|
|
172
|
+
"transition-colors focus:border-ews-primary focus:ring-2 focus:ring-ews-primary",
|
|
173
|
+
"disabled:cursor-not-allowed disabled:opacity-60",
|
|
174
|
+
invalid ? "border-ews-error" : digit ? "border-ews-primary" : "border-ews-gray-300",
|
|
175
|
+
disabled && digit && "animate-pulse",
|
|
176
|
+
)}
|
|
177
|
+
/>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export type { PinCodeInputProps } from "./PinCodeInput.types";
|
|
184
|
+
export { PinCodeInput };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface PinCodeInputProps {
|
|
2
|
+
/** Number of boxes (digits). Default 4. */
|
|
3
|
+
length?: number;
|
|
4
|
+
/** Called once every box is filled, with the full code. */
|
|
5
|
+
onComplete: (code: string) => void;
|
|
6
|
+
/** Called on every change of the typed value. */
|
|
7
|
+
onChange?: (value: string) => void;
|
|
8
|
+
/** Shows bullets instead of the typed digits (PIN). Default true. */
|
|
9
|
+
masked?: boolean;
|
|
10
|
+
/** `autocomplete` of the boxes, e.g. "one-time-code" for an SMS code. Default "off". */
|
|
11
|
+
autoComplete?: string;
|
|
12
|
+
/** Focuses the first empty box on mount, after a reset and when re-enabled. Default true. */
|
|
13
|
+
autoFocus?: boolean;
|
|
14
|
+
/** Ignores input (e.g. while the code is being checked). */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** Shows the boxes in the error color and shakes them. */
|
|
17
|
+
invalid?: boolean;
|
|
18
|
+
/** Changing this value clears the boxes (e.g. after a wrong code). */
|
|
19
|
+
resetKey?: string | number;
|
|
20
|
+
/** Accessible label of each box, e.g. (1, 4) => "Digit 1 of 4". */
|
|
21
|
+
digitLabel: (position: number, total: number) => string;
|
|
22
|
+
/** Accessible label of the group of boxes. */
|
|
23
|
+
groupLabel?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { useDebounce, useDebouncedCallback } from "./useDebounce";
|
|
2
|
+
export { useMediaQuery } from "./useMediaQuery";
|
|
2
3
|
export { useIdleLock } from "./useIdleLock";
|
|
3
4
|
export type { UseIdleLockOptions, UseIdleLockResult } from "./useIdleLock";
|
|
4
5
|
export { useSelectField } from "./useSelectField";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
const matches = (query: string, fallback: boolean) =>
|
|
4
|
+
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
5
|
+
? window.matchMedia(query).matches
|
|
6
|
+
: fallback;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether a CSS media query matches, updated when it changes. Returns
|
|
10
|
+
* `defaultValue` where `matchMedia` does not exist (server rendering, old
|
|
11
|
+
* test environments).
|
|
12
|
+
*/
|
|
13
|
+
export const useMediaQuery = (query: string, defaultValue = false): boolean => {
|
|
14
|
+
const [value, setValue] = useState(() => matches(query, defaultValue));
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
18
|
+
const list = window.matchMedia(query);
|
|
19
|
+
const onChange = () => setValue(list.matches);
|
|
20
|
+
onChange();
|
|
21
|
+
// Safari < 14 only has addListener.
|
|
22
|
+
if (list.addEventListener) {
|
|
23
|
+
list.addEventListener("change", onChange);
|
|
24
|
+
return () => list.removeEventListener("change", onChange);
|
|
25
|
+
}
|
|
26
|
+
list.addListener(onChange);
|
|
27
|
+
return () => list.removeListener(onChange);
|
|
28
|
+
}, [query]);
|
|
29
|
+
|
|
30
|
+
return value;
|
|
31
|
+
};
|