@ews-admin/global-design-system 1.29.0 → 1.30.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 +12 -0
- package/dist/components/LockScreen/LockScreen.d.ts.map +1 -0
- package/dist/components/LockScreen/LockScreen.types.d.ts +65 -0
- package/dist/components/LockScreen/LockScreen.types.d.ts.map +1 -0
- package/dist/components/LockScreen/index.d.ts +3 -0
- package/dist/components/LockScreen/index.d.ts.map +1 -0
- package/dist/components/PinPad/PinPad.d.ts +10 -0
- package/dist/components/PinPad/PinPad.d.ts.map +1 -0
- package/dist/components/PinPad/PinPad.types.d.ts +24 -0
- package/dist/components/PinPad/PinPad.types.d.ts.map +1 -0
- package/dist/components/PinPad/index.d.ts +3 -0
- package/dist/components/PinPad/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useIdleLock.d.ts +24 -0
- package/dist/hooks/useIdleLock.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +128 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.css +1 -1
- package/dist/index.esm.js +375 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +377 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/LockScreen/LockScreen.tsx +324 -0
- package/src/components/LockScreen/LockScreen.types.ts +67 -0
- package/src/components/LockScreen/index.ts +6 -0
- package/src/components/PinPad/PinPad.tsx +150 -0
- package/src/components/PinPad/PinPad.types.ts +23 -0
- package/src/components/PinPad/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useIdleLock.ts +181 -0
- package/src/index.ts +21 -2
- package/tailwind.preset.js +11 -0
package/package.json
CHANGED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../../utils";
|
|
3
|
+
import { Button } from "../Button/Button";
|
|
4
|
+
import { Logo } from "../Logo";
|
|
5
|
+
import { PinPad } from "../PinPad";
|
|
6
|
+
import type {
|
|
7
|
+
LockScreenMode,
|
|
8
|
+
LockScreenProps,
|
|
9
|
+
LockScreenTexts,
|
|
10
|
+
} from "./LockScreen.types";
|
|
11
|
+
|
|
12
|
+
type Step = "unlock" | "current" | "otp" | "new" | "confirm";
|
|
13
|
+
|
|
14
|
+
const FIRST_STEP: Record<LockScreenMode, Step> = {
|
|
15
|
+
create: "new",
|
|
16
|
+
unlock: "unlock",
|
|
17
|
+
change: "current",
|
|
18
|
+
reset: "otp",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const titleOf = (mode: LockScreenMode, step: Step, texts: LockScreenTexts) => {
|
|
22
|
+
switch (step) {
|
|
23
|
+
case "unlock":
|
|
24
|
+
return texts.unlockTitle;
|
|
25
|
+
case "current":
|
|
26
|
+
return texts.currentTitle;
|
|
27
|
+
case "otp":
|
|
28
|
+
return texts.otpTitle;
|
|
29
|
+
case "new":
|
|
30
|
+
return mode === "create" ? texts.createTitle : texts.newTitle;
|
|
31
|
+
case "confirm":
|
|
32
|
+
return texts.confirmTitle;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const FOCUSABLE =
|
|
37
|
+
'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Blocking PIN screen: creates, checks, changes or resets (with a one-time
|
|
41
|
+
* code) the user's secret code. Full screen on mobile, centered card on a grey
|
|
42
|
+
* backdrop from 640px. Submit handlers return promises: a rejection clears the
|
|
43
|
+
* code and goes back to the first step, the app shows its message in `error`.
|
|
44
|
+
*/
|
|
45
|
+
const LockScreen = ({
|
|
46
|
+
mode,
|
|
47
|
+
texts,
|
|
48
|
+
error,
|
|
49
|
+
busy = false,
|
|
50
|
+
onCreate,
|
|
51
|
+
onUnlock,
|
|
52
|
+
onChangePin,
|
|
53
|
+
onReset,
|
|
54
|
+
onForgot,
|
|
55
|
+
onCancel,
|
|
56
|
+
pinLength = 4,
|
|
57
|
+
otpLength = 6,
|
|
58
|
+
logo,
|
|
59
|
+
className,
|
|
60
|
+
}: LockScreenProps) => {
|
|
61
|
+
const [step, setStep] = useState<Step>(FIRST_STEP[mode]);
|
|
62
|
+
const [firstValue, setFirstValue] = useState(""); // current PIN or one-time code
|
|
63
|
+
const [newPin, setNewPin] = useState("");
|
|
64
|
+
const [otp, setOtp] = useState("");
|
|
65
|
+
const [localError, setLocalError] = useState<string | null>(null);
|
|
66
|
+
const [invalid, setInvalid] = useState(false);
|
|
67
|
+
const [pending, setPending] = useState(false);
|
|
68
|
+
const [resetKey, setResetKey] = useState(0);
|
|
69
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
const mounted = useRef(true);
|
|
71
|
+
|
|
72
|
+
const restart = useCallback(
|
|
73
|
+
(failed: boolean) => {
|
|
74
|
+
setStep(FIRST_STEP[mode]);
|
|
75
|
+
setFirstValue("");
|
|
76
|
+
setNewPin("");
|
|
77
|
+
setOtp("");
|
|
78
|
+
setInvalid(failed);
|
|
79
|
+
setResetKey((key) => key + 1);
|
|
80
|
+
},
|
|
81
|
+
[mode],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
restart(false);
|
|
86
|
+
setLocalError(null);
|
|
87
|
+
}, [restart]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
mounted.current = true;
|
|
91
|
+
return () => {
|
|
92
|
+
mounted.current = false;
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// Block page scroll and keep focus inside the screen.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const previousOverflow = document.body.style.overflow;
|
|
99
|
+
document.body.style.overflow = "hidden";
|
|
100
|
+
const previousFocus = document.activeElement as HTMLElement | null;
|
|
101
|
+
panelRef.current?.focus();
|
|
102
|
+
const keepFocus = (event: FocusEvent) => {
|
|
103
|
+
const panel = panelRef.current;
|
|
104
|
+
if (panel && !panel.contains(event.target as Node)) panel.focus();
|
|
105
|
+
};
|
|
106
|
+
document.addEventListener("focusin", keepFocus);
|
|
107
|
+
return () => {
|
|
108
|
+
document.body.style.overflow = previousOverflow;
|
|
109
|
+
document.removeEventListener("focusin", keepFocus);
|
|
110
|
+
previousFocus?.focus?.();
|
|
111
|
+
};
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const trapTab = (event: React.KeyboardEvent) => {
|
|
115
|
+
if (event.key !== "Tab" || !panelRef.current) return;
|
|
116
|
+
const items = Array.from(
|
|
117
|
+
panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
118
|
+
);
|
|
119
|
+
if (items.length === 0) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const first = items[0];
|
|
124
|
+
const last = items[items.length - 1];
|
|
125
|
+
const active = document.activeElement;
|
|
126
|
+
if (event.shiftKey && (active === first || active === panelRef.current)) {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
last.focus();
|
|
129
|
+
} else if (!event.shiftKey && active === last) {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
first.focus();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const submit = async (call: (() => Promise<unknown>) | undefined) => {
|
|
136
|
+
if (!call) return;
|
|
137
|
+
setPending(true);
|
|
138
|
+
try {
|
|
139
|
+
await call();
|
|
140
|
+
if (mounted.current) restart(false);
|
|
141
|
+
} catch {
|
|
142
|
+
if (mounted.current) restart(true);
|
|
143
|
+
} finally {
|
|
144
|
+
if (mounted.current) setPending(false);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const handlePin = (pin: string) => {
|
|
149
|
+
setLocalError(null);
|
|
150
|
+
switch (step) {
|
|
151
|
+
case "unlock":
|
|
152
|
+
void submit(onUnlock && (() => onUnlock(pin)));
|
|
153
|
+
return;
|
|
154
|
+
case "current":
|
|
155
|
+
setFirstValue(pin);
|
|
156
|
+
setStep("new");
|
|
157
|
+
return;
|
|
158
|
+
case "new":
|
|
159
|
+
setNewPin(pin);
|
|
160
|
+
setStep("confirm");
|
|
161
|
+
return;
|
|
162
|
+
case "confirm":
|
|
163
|
+
if (pin !== newPin) {
|
|
164
|
+
setLocalError(texts.mismatchError);
|
|
165
|
+
setInvalid(true);
|
|
166
|
+
setNewPin("");
|
|
167
|
+
setStep("new");
|
|
168
|
+
setResetKey((key) => key + 1);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (mode === "create") void submit(onCreate && (() => onCreate(pin)));
|
|
172
|
+
else if (mode === "change")
|
|
173
|
+
void submit(onChangePin && (() => onChangePin(firstValue, pin)));
|
|
174
|
+
else if (mode === "reset")
|
|
175
|
+
void submit(onReset && (() => onReset(firstValue, pin)));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const submitOtp = (event: React.FormEvent) => {
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
if (otp.length !== otpLength) return;
|
|
183
|
+
setFirstValue(otp);
|
|
184
|
+
setInvalid(false);
|
|
185
|
+
setStep("new");
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const isBusy = busy || pending;
|
|
189
|
+
const shownError = localError ?? error;
|
|
190
|
+
const showForgot =
|
|
191
|
+
!!onForgot &&
|
|
192
|
+
!!texts.forgot &&
|
|
193
|
+
(step === "unlock" || step === "current");
|
|
194
|
+
const title = titleOf(mode, step, texts);
|
|
195
|
+
|
|
196
|
+
return (
|
|
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>
|
|
221
|
+
|
|
222
|
+
{title && (
|
|
223
|
+
<h2 className="mb-8 text-center text-lg font-normal text-ews-gray-900">
|
|
224
|
+
{title}
|
|
225
|
+
</h2>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{step === "otp" ? (
|
|
229
|
+
<form
|
|
230
|
+
onSubmit={submitOtp}
|
|
231
|
+
className="flex w-full max-w-xs flex-col items-center gap-4"
|
|
232
|
+
>
|
|
233
|
+
{texts.otpDescription && (
|
|
234
|
+
<p className="text-center text-sm text-ews-gray-600">
|
|
235
|
+
{texts.otpDescription}
|
|
236
|
+
</p>
|
|
237
|
+
)}
|
|
238
|
+
<label className="sr-only" htmlFor="lock-screen-otp">
|
|
239
|
+
{texts.otpLabel}
|
|
240
|
+
</label>
|
|
241
|
+
<input
|
|
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>
|
|
270
|
+
) : (
|
|
271
|
+
<PinPad
|
|
272
|
+
key={step}
|
|
273
|
+
length={pinLength}
|
|
274
|
+
onComplete={handlePin}
|
|
275
|
+
onChange={() => setInvalid(false)}
|
|
276
|
+
disabled={isBusy}
|
|
277
|
+
invalid={invalid}
|
|
278
|
+
resetKey={resetKey}
|
|
279
|
+
eraseLabel={texts.erase}
|
|
280
|
+
progressLabel={texts.progress}
|
|
281
|
+
keypadLabel={texts.keypad}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
<p
|
|
286
|
+
role="alert"
|
|
287
|
+
className="mt-6 min-h-[1.25rem] text-center text-sm text-ews-error"
|
|
288
|
+
>
|
|
289
|
+
{shownError}
|
|
290
|
+
</p>
|
|
291
|
+
|
|
292
|
+
<div className="mt-auto flex flex-col items-center gap-3 pt-6 sm:mt-4">
|
|
293
|
+
{showForgot && (
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={onForgot}
|
|
297
|
+
disabled={isBusy}
|
|
298
|
+
className="text-base font-medium text-ews-primary hover:underline focus-visible:underline"
|
|
299
|
+
>
|
|
300
|
+
{texts.forgot}
|
|
301
|
+
</button>
|
|
302
|
+
)}
|
|
303
|
+
{onCancel && texts.cancel && (
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
onClick={onCancel}
|
|
307
|
+
disabled={isBusy}
|
|
308
|
+
className="text-sm text-ews-gray-600 hover:underline focus-visible:underline"
|
|
309
|
+
>
|
|
310
|
+
{texts.cancel}
|
|
311
|
+
</button>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
export type {
|
|
320
|
+
LockScreenMode,
|
|
321
|
+
LockScreenProps,
|
|
322
|
+
LockScreenTexts,
|
|
323
|
+
} from "./LockScreen.types";
|
|
324
|
+
export { LockScreen };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
export type LockScreenMode = "create" | "unlock" | "change" | "reset";
|
|
4
|
+
|
|
5
|
+
/** Every text of the lock screen, already translated by the app. */
|
|
6
|
+
export interface LockScreenTexts {
|
|
7
|
+
/** Accessible name of the dialog. */
|
|
8
|
+
dialogLabel: string;
|
|
9
|
+
/** create: "Choose your secret code". */
|
|
10
|
+
createTitle?: string;
|
|
11
|
+
/** create / change / reset: "Confirm your secret code". */
|
|
12
|
+
confirmTitle?: string;
|
|
13
|
+
/** unlock: "Enter your secret code". */
|
|
14
|
+
unlockTitle?: string;
|
|
15
|
+
/** change: "Enter your current secret code". */
|
|
16
|
+
currentTitle?: string;
|
|
17
|
+
/** change / reset: "Choose your new secret code". */
|
|
18
|
+
newTitle?: string;
|
|
19
|
+
/** reset: title of the one-time code step. */
|
|
20
|
+
otpTitle?: string;
|
|
21
|
+
/** reset: explanation under the title (e.g. "Code sent by SMS to ••••12"). */
|
|
22
|
+
otpDescription?: string;
|
|
23
|
+
/** reset: label of the one-time code input. */
|
|
24
|
+
otpLabel?: string;
|
|
25
|
+
/** reset: button that validates the one-time code. */
|
|
26
|
+
otpSubmit?: string;
|
|
27
|
+
/** Shown when the confirmation does not match the first PIN. */
|
|
28
|
+
mismatchError: string;
|
|
29
|
+
/** Link at the bottom ("Code secret oublié ?"), shown with `onForgot`. */
|
|
30
|
+
forgot?: string;
|
|
31
|
+
/** Secondary link, shown with `onCancel`. */
|
|
32
|
+
cancel?: string;
|
|
33
|
+
/** Accessible label of the erase key. */
|
|
34
|
+
erase: string;
|
|
35
|
+
/** Accessible label of the keypad. */
|
|
36
|
+
keypad?: string;
|
|
37
|
+
/** Text announced when the number of typed digits changes. */
|
|
38
|
+
progress: (filled: number, total: number) => string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LockScreenProps {
|
|
42
|
+
mode: LockScreenMode;
|
|
43
|
+
texts: LockScreenTexts;
|
|
44
|
+
/** Error from the app (e.g. "Wrong code, 3 attempts left"). */
|
|
45
|
+
error?: string | null;
|
|
46
|
+
/** Forces the busy state (in addition to pending submit promises). */
|
|
47
|
+
busy?: boolean;
|
|
48
|
+
/** create: called with the confirmed PIN. */
|
|
49
|
+
onCreate?: (pin: string) => Promise<unknown>;
|
|
50
|
+
/** unlock: called with the typed PIN. */
|
|
51
|
+
onUnlock?: (pin: string) => Promise<unknown>;
|
|
52
|
+
/** change: called once the current PIN and the confirmed new PIN are typed. */
|
|
53
|
+
onChangePin?: (currentPin: string, newPin: string) => Promise<unknown>;
|
|
54
|
+
/** reset: called once the one-time code and the confirmed new PIN are typed. */
|
|
55
|
+
onReset?: (otp: string, newPin: string) => Promise<unknown>;
|
|
56
|
+
/** unlock / change: shows the "forgot" link. */
|
|
57
|
+
onForgot?: () => void;
|
|
58
|
+
/** Shows the "cancel" link (e.g. leave the change or reset flow). */
|
|
59
|
+
onCancel?: () => void;
|
|
60
|
+
/** Number of PIN digits. Default 4. */
|
|
61
|
+
pinLength?: number;
|
|
62
|
+
/** Number of one-time code digits. Default 6. */
|
|
63
|
+
otpLength?: number;
|
|
64
|
+
/** Replaces the default Medecine360 logo. */
|
|
65
|
+
logo?: React.ReactNode;
|
|
66
|
+
className?: string;
|
|
67
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Delete } from "lucide-react";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "../../utils";
|
|
4
|
+
import type { PinPadProps } from "./PinPad.types";
|
|
5
|
+
|
|
6
|
+
const DIGIT_ROWS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
|
7
|
+
|
|
8
|
+
const keyClass =
|
|
9
|
+
"flex h-16 w-16 items-center justify-center justify-self-center rounded-full text-3xl font-light text-ews-gray-900 " +
|
|
10
|
+
"transition-colors hover:bg-ews-gray-100 active:bg-ews-gray-200 " +
|
|
11
|
+
"focus-visible:ring-2 focus-visible:ring-ews-primary disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* iOS-style PIN entry: a row of dots filled as digits are typed and a 3x4
|
|
15
|
+
* keypad. Texts are provided by the caller (already translated).
|
|
16
|
+
*/
|
|
17
|
+
const PinPad = ({
|
|
18
|
+
length = 4,
|
|
19
|
+
onComplete,
|
|
20
|
+
onChange,
|
|
21
|
+
disabled = false,
|
|
22
|
+
invalid = false,
|
|
23
|
+
resetKey,
|
|
24
|
+
captureKeyboard = true,
|
|
25
|
+
eraseLabel,
|
|
26
|
+
progressLabel,
|
|
27
|
+
keypadLabel,
|
|
28
|
+
className,
|
|
29
|
+
}: PinPadProps) => {
|
|
30
|
+
const [value, setValue] = useState("");
|
|
31
|
+
const valueRef = useRef("");
|
|
32
|
+
|
|
33
|
+
const update = useCallback(
|
|
34
|
+
(next: string) => {
|
|
35
|
+
valueRef.current = next;
|
|
36
|
+
setValue(next);
|
|
37
|
+
onChange?.(next);
|
|
38
|
+
if (next.length === length) onComplete(next);
|
|
39
|
+
},
|
|
40
|
+
[length, onChange, onComplete],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Clear the typed digits when the caller asks for it.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
valueRef.current = "";
|
|
46
|
+
setValue("");
|
|
47
|
+
}, [resetKey]);
|
|
48
|
+
|
|
49
|
+
const press = useCallback(
|
|
50
|
+
(digit: string) => {
|
|
51
|
+
if (disabled || valueRef.current.length >= length) return;
|
|
52
|
+
update(valueRef.current + digit);
|
|
53
|
+
},
|
|
54
|
+
[disabled, length, update],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const erase = useCallback(() => {
|
|
58
|
+
if (disabled || valueRef.current.length === 0) return;
|
|
59
|
+
update(valueRef.current.slice(0, -1));
|
|
60
|
+
}, [disabled, update]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!captureKeyboard) return;
|
|
64
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
65
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
66
|
+
const target = event.target as HTMLElement | null;
|
|
67
|
+
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) return;
|
|
68
|
+
if (/^[0-9]$/.test(event.key)) {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
press(event.key);
|
|
71
|
+
} else if (event.key === "Backspace") {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
erase();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
document.addEventListener("keydown", onKeyDown);
|
|
77
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
78
|
+
}, [captureKeyboard, press, erase]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className={cn("flex flex-col items-center gap-10", className)}>
|
|
82
|
+
{/* Keypads stay left-to-right in RTL locales, like phone keypads. */}
|
|
83
|
+
<div
|
|
84
|
+
dir="ltr"
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
data-testid="pin-dots"
|
|
87
|
+
className={cn("flex gap-5", invalid && "animate-pin-shake")}
|
|
88
|
+
>
|
|
89
|
+
{Array.from({ length }, (_, index) => (
|
|
90
|
+
<span
|
|
91
|
+
key={index}
|
|
92
|
+
data-filled={index < value.length}
|
|
93
|
+
className={cn(
|
|
94
|
+
"h-3.5 w-3.5 rounded-full transition-colors",
|
|
95
|
+
invalid
|
|
96
|
+
? "bg-ews-error"
|
|
97
|
+
: index < value.length
|
|
98
|
+
? "bg-ews-primary"
|
|
99
|
+
: "bg-ews-primary-light",
|
|
100
|
+
disabled && index < value.length && "animate-pulse",
|
|
101
|
+
)}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
<p className="sr-only" aria-live="polite" aria-atomic="true">
|
|
106
|
+
{progressLabel(value.length, length)}
|
|
107
|
+
</p>
|
|
108
|
+
|
|
109
|
+
<div
|
|
110
|
+
dir="ltr"
|
|
111
|
+
role="group"
|
|
112
|
+
aria-label={keypadLabel}
|
|
113
|
+
className="grid grid-cols-3 gap-x-8 gap-y-4"
|
|
114
|
+
>
|
|
115
|
+
{DIGIT_ROWS.map((digit) => (
|
|
116
|
+
<button
|
|
117
|
+
key={digit}
|
|
118
|
+
type="button"
|
|
119
|
+
className={keyClass}
|
|
120
|
+
disabled={disabled}
|
|
121
|
+
onClick={() => press(digit)}
|
|
122
|
+
>
|
|
123
|
+
{digit}
|
|
124
|
+
</button>
|
|
125
|
+
))}
|
|
126
|
+
<span aria-hidden="true" />
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className={keyClass}
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
onClick={() => press("0")}
|
|
132
|
+
>
|
|
133
|
+
0
|
|
134
|
+
</button>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className={cn(keyClass, "text-ews-gray-700")}
|
|
138
|
+
disabled={disabled || value.length === 0}
|
|
139
|
+
aria-label={eraseLabel}
|
|
140
|
+
onClick={erase}
|
|
141
|
+
>
|
|
142
|
+
<Delete className="h-7 w-7" strokeWidth={1.5} aria-hidden="true" />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export type { PinPadProps } from "./PinPad.types";
|
|
150
|
+
export { PinPad };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface PinPadProps {
|
|
2
|
+
/** Number of digits of the PIN. Default 4. */
|
|
3
|
+
length?: number;
|
|
4
|
+
/** Called once the last digit is typed, with the full PIN. */
|
|
5
|
+
onComplete: (pin: string) => void;
|
|
6
|
+
/** Called on every change of the typed value. */
|
|
7
|
+
onChange?: (value: string) => void;
|
|
8
|
+
/** Ignores taps and key presses (e.g. while the PIN is being checked). */
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** Shows the dots in the error color and shakes them. */
|
|
11
|
+
invalid?: boolean;
|
|
12
|
+
/** Changing this value clears the typed digits (e.g. after a wrong PIN). */
|
|
13
|
+
resetKey?: string | number;
|
|
14
|
+
/** Listens to the physical keyboard (digits and Backspace). Default true. */
|
|
15
|
+
captureKeyboard?: boolean;
|
|
16
|
+
/** Accessible label of the erase key. */
|
|
17
|
+
eraseLabel: string;
|
|
18
|
+
/** Text announced to screen readers when the number of typed digits changes. */
|
|
19
|
+
progressLabel: (filled: number, total: number) => string;
|
|
20
|
+
/** Accessible label of the keypad group. */
|
|
21
|
+
keypadLabel?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { useDebounce, useDebouncedCallback } from "./useDebounce";
|
|
2
|
+
export { useIdleLock } from "./useIdleLock";
|
|
3
|
+
export type { UseIdleLockOptions, UseIdleLockResult } from "./useIdleLock";
|
|
2
4
|
export { useSelectField } from "./useSelectField";
|
|
3
5
|
export type { UseSelectFieldProps } from "./useSelectField";
|