@ews-admin/global-design-system 1.29.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.
Files changed (47) hide show
  1. package/dist/components/LockScreen/LockScreen.d.ts +13 -0
  2. package/dist/components/LockScreen/LockScreen.d.ts.map +1 -0
  3. package/dist/components/LockScreen/LockScreen.types.d.ts +73 -0
  4. package/dist/components/LockScreen/LockScreen.types.d.ts.map +1 -0
  5. package/dist/components/LockScreen/index.d.ts +3 -0
  6. package/dist/components/LockScreen/index.d.ts.map +1 -0
  7. package/dist/components/PinCodeInput/PinCodeInput.d.ts +11 -0
  8. package/dist/components/PinCodeInput/PinCodeInput.d.ts.map +1 -0
  9. package/dist/components/PinCodeInput/PinCodeInput.types.d.ts +26 -0
  10. package/dist/components/PinCodeInput/PinCodeInput.types.d.ts.map +1 -0
  11. package/dist/components/PinCodeInput/index.d.ts +3 -0
  12. package/dist/components/PinCodeInput/index.d.ts.map +1 -0
  13. package/dist/components/PinPad/PinPad.d.ts +10 -0
  14. package/dist/components/PinPad/PinPad.d.ts.map +1 -0
  15. package/dist/components/PinPad/PinPad.types.d.ts +24 -0
  16. package/dist/components/PinPad/PinPad.types.d.ts.map +1 -0
  17. package/dist/components/PinPad/index.d.ts +3 -0
  18. package/dist/components/PinPad/index.d.ts.map +1 -0
  19. package/dist/hooks/index.d.ts +3 -0
  20. package/dist/hooks/index.d.ts.map +1 -1
  21. package/dist/hooks/useIdleLock.d.ts +24 -0
  22. package/dist/hooks/useIdleLock.d.ts.map +1 -0
  23. package/dist/hooks/useMediaQuery.d.ts +7 -0
  24. package/dist/hooks/useMediaQuery.d.ts.map +1 -0
  25. package/dist/index.css +1 -1
  26. package/dist/index.d.ts +177 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.esm.css +1 -1
  29. package/dist/index.esm.js +554 -1
  30. package/dist/index.esm.js.map +1 -1
  31. package/dist/index.js +558 -0
  32. package/dist/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/components/LockScreen/LockScreen.tsx +450 -0
  35. package/src/components/LockScreen/LockScreen.types.ts +75 -0
  36. package/src/components/LockScreen/index.ts +6 -0
  37. package/src/components/PinCodeInput/PinCodeInput.tsx +184 -0
  38. package/src/components/PinCodeInput/PinCodeInput.types.ts +25 -0
  39. package/src/components/PinCodeInput/index.ts +2 -0
  40. package/src/components/PinPad/PinPad.tsx +150 -0
  41. package/src/components/PinPad/PinPad.types.ts +23 -0
  42. package/src/components/PinPad/index.ts +2 -0
  43. package/src/hooks/index.ts +3 -0
  44. package/src/hooks/useIdleLock.ts +181 -0
  45. package/src/hooks/useMediaQuery.ts +31 -0
  46. package/src/index.ts +25 -2
  47. package/tailwind.preset.js +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ews-admin/global-design-system",
3
- "version": "1.29.0",
3
+ "version": "1.31.0",
4
4
  "description": "EWS Global Design System - Reusable components for EWS applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",
@@ -0,0 +1,450 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useMediaQuery } from "../../hooks/useMediaQuery";
3
+ import { cn } from "../../utils";
4
+ import { Button } from "../Button/Button";
5
+ import { Logo } from "../Logo";
6
+ import { PinCodeInput } from "../PinCodeInput";
7
+ import { PinPad } from "../PinPad";
8
+ import type {
9
+ LockScreenMode,
10
+ LockScreenProps,
11
+ LockScreenTexts,
12
+ } from "./LockScreen.types";
13
+
14
+ type Step = "unlock" | "current" | "otp" | "new" | "confirm";
15
+
16
+ const FIRST_STEP: Record<LockScreenMode, Step> = {
17
+ create: "new",
18
+ unlock: "unlock",
19
+ change: "current",
20
+ reset: "otp",
21
+ };
22
+
23
+ const titleOf = (mode: LockScreenMode, step: Step, texts: LockScreenTexts) => {
24
+ switch (step) {
25
+ case "unlock":
26
+ return texts.unlockTitle;
27
+ case "current":
28
+ return texts.currentTitle;
29
+ case "otp":
30
+ return texts.otpTitle;
31
+ case "new":
32
+ return mode === "create" ? texts.createTitle : texts.newTitle;
33
+ case "confirm":
34
+ return texts.confirmTitle;
35
+ }
36
+ };
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
+
44
+ const FOCUSABLE =
45
+ 'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
46
+
47
+ /**
48
+ * Blocking PIN screen: creates, checks, changes or resets (with a one-time
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`.
53
+ */
54
+ const LockScreen = ({
55
+ mode,
56
+ texts,
57
+ error,
58
+ busy = false,
59
+ onCreate,
60
+ onUnlock,
61
+ onChangePin,
62
+ onReset,
63
+ onForgot,
64
+ onCancel,
65
+ onSignOut,
66
+ subtitle,
67
+ pinLength = 4,
68
+ otpLength = 6,
69
+ logo,
70
+ className,
71
+ }: LockScreenProps) => {
72
+ const [step, setStep] = useState<Step>(FIRST_STEP[mode]);
73
+ const [firstValue, setFirstValue] = useState(""); // current PIN or one-time code
74
+ const [newPin, setNewPin] = useState("");
75
+ const [otp, setOtp] = useState("");
76
+ const [localError, setLocalError] = useState<string | null>(null);
77
+ const [invalid, setInvalid] = useState(false);
78
+ const [pending, setPending] = useState(false);
79
+ const [resetKey, setResetKey] = useState(0);
80
+ const panelRef = useRef<HTMLDivElement>(null);
81
+ const mounted = useRef(true);
82
+ const isDesktop = useMediaQuery(DESKTOP_QUERY);
83
+
84
+ const restart = useCallback(
85
+ (failed: boolean) => {
86
+ setStep(FIRST_STEP[mode]);
87
+ setFirstValue("");
88
+ setNewPin("");
89
+ setOtp("");
90
+ setInvalid(failed);
91
+ setResetKey((key) => key + 1);
92
+ },
93
+ [mode],
94
+ );
95
+
96
+ useEffect(() => {
97
+ restart(false);
98
+ setLocalError(null);
99
+ }, [restart]);
100
+
101
+ useEffect(() => {
102
+ mounted.current = true;
103
+ return () => {
104
+ mounted.current = false;
105
+ };
106
+ }, []);
107
+
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).
111
+ useEffect(() => {
112
+ const token = {};
113
+ trapStack.push(token);
114
+ const previousOverflow = document.body.style.overflow;
115
+ document.body.style.overflow = "hidden";
116
+ const previousFocus = document.activeElement as HTMLElement | null;
117
+ // The desktop code boxes focus themselves first.
118
+ if (!panelRef.current?.contains(document.activeElement)) panelRef.current?.focus();
119
+ const keepFocus = (event: FocusEvent) => {
120
+ if (trapStack[trapStack.length - 1] !== token) return;
121
+ const panel = panelRef.current;
122
+ if (panel && !panel.contains(event.target as Node)) panel.focus();
123
+ };
124
+ document.addEventListener("focusin", keepFocus);
125
+ return () => {
126
+ const wasTop = trapStack[trapStack.length - 1] === token;
127
+ trapStack.splice(trapStack.indexOf(token), 1);
128
+ document.body.style.overflow = previousOverflow;
129
+ document.removeEventListener("focusin", keepFocus);
130
+ if (wasTop) previousFocus?.focus?.();
131
+ };
132
+ }, []);
133
+
134
+ const trapTab = (event: React.KeyboardEvent) => {
135
+ if (event.key !== "Tab" || !panelRef.current) return;
136
+ const items = Array.from(
137
+ panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE),
138
+ );
139
+ if (items.length === 0) {
140
+ event.preventDefault();
141
+ return;
142
+ }
143
+ const first = items[0];
144
+ const last = items[items.length - 1];
145
+ const active = document.activeElement;
146
+ if (event.shiftKey && (active === first || active === panelRef.current)) {
147
+ event.preventDefault();
148
+ last.focus();
149
+ } else if (!event.shiftKey && active === last) {
150
+ event.preventDefault();
151
+ first.focus();
152
+ }
153
+ };
154
+
155
+ const submit = async (call: (() => Promise<unknown>) | undefined) => {
156
+ if (!call) return;
157
+ setPending(true);
158
+ try {
159
+ await call();
160
+ if (mounted.current) restart(false);
161
+ } catch {
162
+ if (mounted.current) restart(true);
163
+ } finally {
164
+ if (mounted.current) setPending(false);
165
+ }
166
+ };
167
+
168
+ const handlePin = (pin: string) => {
169
+ setLocalError(null);
170
+ switch (step) {
171
+ case "unlock":
172
+ void submit(onUnlock && (() => onUnlock(pin)));
173
+ return;
174
+ case "current":
175
+ setFirstValue(pin);
176
+ setStep("new");
177
+ return;
178
+ case "new":
179
+ setNewPin(pin);
180
+ setStep("confirm");
181
+ return;
182
+ case "confirm":
183
+ if (pin !== newPin) {
184
+ setLocalError(texts.mismatchError);
185
+ setInvalid(true);
186
+ setNewPin("");
187
+ setStep("new");
188
+ setResetKey((key) => key + 1);
189
+ return;
190
+ }
191
+ if (mode === "create") void submit(onCreate && (() => onCreate(pin)));
192
+ else if (mode === "change")
193
+ void submit(onChangePin && (() => onChangePin(firstValue, pin)));
194
+ else if (mode === "reset")
195
+ void submit(onReset && (() => onReset(firstValue, pin)));
196
+ return;
197
+ }
198
+ };
199
+
200
+ const submitOtp = (event: React.FormEvent) => {
201
+ event.preventDefault();
202
+ if (otp.length !== otpLength) return;
203
+ setFirstValue(otp);
204
+ setInvalid(false);
205
+ setStep("new");
206
+ };
207
+
208
+ const completeOtp = (code: string) => {
209
+ setLocalError(null);
210
+ setFirstValue(code);
211
+ setInvalid(false);
212
+ setStep("new");
213
+ };
214
+
215
+ const isBusy = busy || pending;
216
+ const shownError = localError ?? error;
217
+ const showForgot =
218
+ !!onForgot &&
219
+ !!texts.forgot &&
220
+ (step === "unlock" || step === "current");
221
+ const title = titleOf(mode, step, texts);
222
+
223
+ const digitLabel = texts.digit ?? texts.progress;
224
+
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
+ )}
238
+
239
+ <div className="mt-6">
240
+ {step === "otp" ? (
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
+ />
254
+ ) : (
255
+ <PinCodeInput
256
+ key={step}
257
+ length={pinLength}
258
+ onComplete={handlePin}
259
+ onChange={() => setInvalid(false)}
260
+ disabled={isBusy}
261
+ invalid={invalid}
262
+ resetKey={resetKey}
263
+ digitLabel={digitLabel}
264
+ groupLabel={title}
265
+ />
266
+ )}
267
+ </div>
268
+
269
+ <p role="alert" className="mt-3 min-h-[1.25rem] text-center text-sm text-ews-error">
270
+ {shownError}
271
+ </p>
272
+
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">
275
+ {showForgot && (
276
+ <button
277
+ type="button"
278
+ onClick={onForgot}
279
+ disabled={isBusy}
280
+ className="text-sm font-medium text-ews-primary hover:underline focus-visible:underline"
281
+ >
282
+ {texts.forgot}
283
+ </button>
284
+ )}
285
+ {onCancel && texts.cancel && (
286
+ <button
287
+ type="button"
288
+ onClick={onCancel}
289
+ disabled={isBusy}
290
+ className="text-sm text-ews-gray-600 hover:underline focus-visible:underline"
291
+ >
292
+ {texts.cancel}
293
+ </button>
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
+ )}
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
+ )}
440
+ </div>
441
+ </div>
442
+ );
443
+ };
444
+
445
+ export type {
446
+ LockScreenMode,
447
+ LockScreenProps,
448
+ LockScreenTexts,
449
+ } from "./LockScreen.types";
450
+ export { LockScreen };
@@ -0,0 +1,75 @@
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
+ /** Desktop: "Se déconnecter" link, shown with `onSignOut`. */
34
+ signOut?: string;
35
+ /** Accessible label of the erase key. */
36
+ erase: string;
37
+ /** Accessible label of the keypad. */
38
+ keypad?: string;
39
+ /** Text announced when the number of typed digits changes. */
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;
43
+ }
44
+
45
+ export interface LockScreenProps {
46
+ mode: LockScreenMode;
47
+ texts: LockScreenTexts;
48
+ /** Error from the app (e.g. "Wrong code, 3 attempts left"). */
49
+ error?: string | null;
50
+ /** Forces the busy state (in addition to pending submit promises). */
51
+ busy?: boolean;
52
+ /** create: called with the confirmed PIN. */
53
+ onCreate?: (pin: string) => Promise<unknown>;
54
+ /** unlock: called with the typed PIN. */
55
+ onUnlock?: (pin: string) => Promise<unknown>;
56
+ /** change: called once the current PIN and the confirmed new PIN are typed. */
57
+ onChangePin?: (currentPin: string, newPin: string) => Promise<unknown>;
58
+ /** reset: called once the one-time code and the confirmed new PIN are typed. */
59
+ onReset?: (otp: string, newPin: string) => Promise<unknown>;
60
+ /** unlock / change: shows the "forgot" link. */
61
+ onForgot?: () => void;
62
+ /** Shows the "cancel" link (e.g. leave the change or reset flow). */
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;
68
+ /** Number of PIN digits. Default 4. */
69
+ pinLength?: number;
70
+ /** Number of one-time code digits. Default 6. */
71
+ otpLength?: number;
72
+ /** Replaces the default Medecine360 logo. */
73
+ logo?: React.ReactNode;
74
+ className?: string;
75
+ }
@@ -0,0 +1,6 @@
1
+ export { LockScreen } from "./LockScreen";
2
+ export type {
3
+ LockScreenMode,
4
+ LockScreenProps,
5
+ LockScreenTexts,
6
+ } from "./LockScreen";