@ews-admin/global-design-system 1.30.0 → 1.32.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 (41) hide show
  1. package/dist/components/LockScreen/LockScreen.d.ts +5 -4
  2. package/dist/components/LockScreen/LockScreen.d.ts.map +1 -1
  3. package/dist/components/LockScreen/LockScreen.types.d.ts +8 -0
  4. package/dist/components/LockScreen/LockScreen.types.d.ts.map +1 -1
  5. package/dist/components/Logo/Logo.d.ts.map +1 -1
  6. package/dist/components/Logo/logoAssets.d.ts +7 -0
  7. package/dist/components/Logo/logoAssets.d.ts.map +1 -0
  8. package/dist/components/PinCodeInput/PinCodeInput.d.ts +11 -0
  9. package/dist/components/PinCodeInput/PinCodeInput.d.ts.map +1 -0
  10. package/dist/components/PinCodeInput/PinCodeInput.types.d.ts +26 -0
  11. package/dist/components/PinCodeInput/PinCodeInput.types.d.ts.map +1 -0
  12. package/dist/components/PinCodeInput/index.d.ts +3 -0
  13. package/dist/components/PinCodeInput/index.d.ts.map +1 -0
  14. package/dist/hooks/index.d.ts +1 -0
  15. package/dist/hooks/index.d.ts.map +1 -1
  16. package/dist/hooks/useMediaQuery.d.ts +7 -0
  17. package/dist/hooks/useMediaQuery.d.ts.map +1 -0
  18. package/dist/index.css +1 -1
  19. package/dist/index.d.ts +55 -6
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.esm.css +1 -1
  22. package/dist/index.esm.js +255 -63
  23. package/dist/index.esm.js.map +1 -1
  24. package/dist/index.js +256 -62
  25. package/dist/index.js.map +1 -1
  26. package/package.json +2 -1
  27. package/src/assets/logo/logo.png +0 -0
  28. package/src/assets/logo/logoFullWhite.png +0 -0
  29. package/src/assets/logo/logoIcon.png +0 -0
  30. package/src/assets/logo/logoWhite.png +0 -0
  31. package/src/components/LockScreen/LockScreen.tsx +215 -89
  32. package/src/components/LockScreen/LockScreen.types.ts +8 -0
  33. package/src/components/Logo/Logo.tsx +17 -25
  34. package/src/components/Logo/logoAssets.ts +16 -0
  35. package/src/components/PinCodeInput/PinCodeInput.tsx +184 -0
  36. package/src/components/PinCodeInput/PinCodeInput.types.ts +25 -0
  37. package/src/components/PinCodeInput/index.ts +2 -0
  38. package/src/hooks/index.ts +1 -0
  39. package/src/hooks/useMediaQuery.ts +31 -0
  40. package/src/index.ts +4 -0
  41. package/src/types/assets.d.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ews-admin/global-design-system",
3
- "version": "1.30.0",
3
+ "version": "1.32.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",
@@ -32,6 +32,7 @@
32
32
  "@eslint/js": "^9.36.0",
33
33
  "@rollup/plugin-babel": "^6.0.4",
34
34
  "@rollup/plugin-commonjs": "^25.0.7",
35
+ "@rollup/plugin-image": "^3.0.3",
35
36
  "@rollup/plugin-node-resolve": "^15.2.3",
36
37
  "@rollup/plugin-typescript": "^11.1.5",
37
38
  "@storybook/addon-essentials": "^7.5.0",
Binary file
Binary file
Binary file
@@ -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. 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`.
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
- panelRef.current?.focus();
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
- 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>
223
+ const digitLabel = texts.digit ?? texts.progress;
221
224
 
222
- {title && (
223
- <h2 className="mb-8 text-center text-lg font-normal text-ews-gray-900">
224
- {title}
225
- </h2>
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
- <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>
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
- <PinPad
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
- eraseLabel={texts.erase}
280
- progressLabel={texts.progress}
281
- keypadLabel={texts.keypad}
263
+ digitLabel={digitLabel}
264
+ groupLabel={title}
282
265
  />
283
266
  )}
267
+ </div>
284
268
 
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>
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
- <div className="mt-auto flex flex-col items-center gap-3 pt-6 sm:mt-4">
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-base font-medium text-ews-primary hover:underline focus-visible:underline"
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. */
@@ -1,5 +1,7 @@
1
+ import type { SyntheticEvent } from "react";
1
2
  import { cn } from "../../utils";
2
3
  import type { LogoProps } from "./Logo.types";
4
+ import { logoAssets } from "./logoAssets";
3
5
 
4
6
  const Logo = ({
5
7
  size = "md",
@@ -26,19 +28,19 @@ const Logo = ({
26
28
  xl: "h-16 w-16",
27
29
  };
28
30
 
29
- // Get the appropriate logo image based on variant or custom source
30
- // For iconOnly, always use favicon.ico unless customSrc is provided
31
- const logoSrc =
32
- customSrc ||
33
- (iconOnly
34
- ? "/favicon.ico"
35
- : variant === "white"
36
- ? "/image/logoWhite.png"
37
- : variant === "fullWhite"
38
- ? "/image/logoFullWhite.png"
39
- : variant === "favicon"
40
- ? "/favicon.ico"
41
- : "/image/logo.png");
31
+ // Bundled logo per variant (MED-1629); customSrc wins. iconOnly always uses the icon.
32
+ const logoSrc = customSrc || (iconOnly ? logoAssets.favicon : logoAssets[variant]);
33
+ const fallbackSrc = iconOnly ? logoAssets.favicon : logoAssets.normal;
34
+
35
+ // On a load error, fall back to the bundled logo once, then hide the image (keeps layout).
36
+ const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
37
+ const img = e.currentTarget;
38
+ if (img.getAttribute("src") !== fallbackSrc) {
39
+ img.src = fallbackSrc;
40
+ } else {
41
+ img.style.visibility = "hidden";
42
+ }
43
+ };
42
44
 
43
45
  const isClickable = clickable || !!onClick;
44
46
 
@@ -69,12 +71,7 @@ const Logo = ({
69
71
  src={logoSrc}
70
72
  alt={alt}
71
73
  className="object-contain w-full h-full"
72
- onError={(e) => {
73
- // Fallback to favicon if image fails to load
74
- if (logoSrc !== "/favicon.ico") {
75
- (e.target as unknown as { src: string }).src = "/favicon.ico";
76
- }
77
- }}
74
+ onError={handleError}
78
75
  />
79
76
  </div>
80
77
  );
@@ -107,12 +104,7 @@ const Logo = ({
107
104
  src={logoSrc}
108
105
  alt={alt}
109
106
  className="object-contain w-auto h-full"
110
- onError={(e) => {
111
- // Fallback to favicon if image fails to load
112
- if (logoSrc !== "/favicon.ico") {
113
- (e.target as unknown as { src: string }).src = "/favicon.ico";
114
- }
115
- }}
107
+ onError={handleError}
116
108
  />
117
109
  </div>
118
110
  );
@@ -0,0 +1,16 @@
1
+ /**
2
+ * MED-1629: the Medecine360 logos ship with the package (inlined as data URIs by
3
+ * @rollup/plugin-image), so consuming apps need no `/image/logo*.png` in their public folder.
4
+ * Sized to 2x the largest rendered height (Logo xl = 96px, icon xl = 64px).
5
+ */
6
+ import logoIcon from "../../assets/logo/logoIcon.png";
7
+ import logo from "../../assets/logo/logo.png";
8
+ import logoFullWhite from "../../assets/logo/logoFullWhite.png";
9
+ import logoWhite from "../../assets/logo/logoWhite.png";
10
+
11
+ export const logoAssets = {
12
+ normal: logo,
13
+ white: logoWhite,
14
+ fullWhite: logoFullWhite,
15
+ favicon: logoIcon,
16
+ } as const;