@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
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { PinCodeInput } from "./PinCodeInput";
2
+ export type { PinCodeInputProps } from "./PinCodeInput";
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { PinPad } from "./PinPad";
2
+ export type { PinPadProps } from "./PinPad";
@@ -1,3 +1,6 @@
1
1
  export { useDebounce, useDebouncedCallback } from "./useDebounce";
2
+ export { useMediaQuery } from "./useMediaQuery";
3
+ export { useIdleLock } from "./useIdleLock";
4
+ export type { UseIdleLockOptions, UseIdleLockResult } from "./useIdleLock";
2
5
  export { useSelectField } from "./useSelectField";
3
6
  export type { UseSelectFieldProps } from "./useSelectField";
@@ -0,0 +1,181 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export interface UseIdleLockOptions {
4
+ /** Inactivity delay before locking. Default 3 minutes. */
5
+ timeoutMs?: number;
6
+ /** Called each time the screen becomes locked (idle, `lock()` or another tab). */
7
+ onLock?: () => void;
8
+ /** localStorage key of the locked flag, also the BroadcastChannel name. */
9
+ storageKey?: string;
10
+ /** Turns the inactivity timer off (e.g. until the session is loaded). Default true. */
11
+ enabled?: boolean;
12
+ }
13
+
14
+ export interface UseIdleLockResult {
15
+ locked: boolean;
16
+ lock: () => void;
17
+ unlock: () => void;
18
+ }
19
+
20
+ const DEFAULT_TIMEOUT_MS = 180_000;
21
+ const DEFAULT_STORAGE_KEY = "ews-idle-lock";
22
+ const ACTIVITY_WRITE_INTERVAL_MS = 5_000;
23
+ const ACTIVITY_EVENTS = [
24
+ "pointerdown",
25
+ "pointermove",
26
+ "keydown",
27
+ "wheel",
28
+ "scroll",
29
+ "touchstart",
30
+ ] as const;
31
+
32
+ const readStorage = (key: string): string | null => {
33
+ try {
34
+ return typeof window === "undefined" ? null : window.localStorage.getItem(key);
35
+ } catch {
36
+ return null;
37
+ }
38
+ };
39
+
40
+ const writeStorage = (key: string, value: string | null) => {
41
+ try {
42
+ if (typeof window === "undefined") return;
43
+ if (value === null) window.localStorage.removeItem(key);
44
+ else window.localStorage.setItem(key, value);
45
+ } catch {
46
+ // Storage unavailable (private mode, quota): the lock still works in this tab.
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Locks the screen after `timeoutMs` without user activity. The locked flag is
52
+ * kept in localStorage (it survives a reload) and shared between tabs through
53
+ * a BroadcastChannel, or the `storage` event where BroadcastChannel is missing:
54
+ * locking or unlocking one tab does the same in every tab. Activity in any tab
55
+ * keeps every tab unlocked.
56
+ */
57
+ export function useIdleLock({
58
+ timeoutMs = DEFAULT_TIMEOUT_MS,
59
+ onLock,
60
+ storageKey = DEFAULT_STORAGE_KEY,
61
+ enabled = true,
62
+ }: UseIdleLockOptions = {}): UseIdleLockResult {
63
+ const activityKey = `${storageKey}:activity`;
64
+ const [locked, setLocked] = useState(() => readStorage(storageKey) === "1");
65
+ const lockedRef = useRef(locked);
66
+ const onLockRef = useRef(onLock);
67
+ const lastActivity = useRef(Date.now());
68
+ const lastActivityWrite = useRef(0);
69
+ const channelRef = useRef<{ postMessage: (message: string) => void } | null>(
70
+ null,
71
+ );
72
+
73
+ useEffect(() => {
74
+ onLockRef.current = onLock;
75
+ }, [onLock]);
76
+
77
+ const markActivity = useCallback(
78
+ (now: number) => {
79
+ lastActivity.current = now;
80
+ if (now - lastActivityWrite.current >= ACTIVITY_WRITE_INTERVAL_MS) {
81
+ lastActivityWrite.current = now;
82
+ writeStorage(activityKey, String(now));
83
+ }
84
+ },
85
+ [activityKey],
86
+ );
87
+
88
+ const applyLocked = useCallback((next: boolean) => {
89
+ if (lockedRef.current === next) return;
90
+ lockedRef.current = next;
91
+ setLocked(next);
92
+ if (next) onLockRef.current?.();
93
+ }, []);
94
+
95
+ const lock = useCallback(() => {
96
+ if (lockedRef.current) return;
97
+ writeStorage(storageKey, "1");
98
+ channelRef.current?.postMessage("lock");
99
+ applyLocked(true);
100
+ }, [storageKey, applyLocked]);
101
+
102
+ const unlock = useCallback(() => {
103
+ lastActivityWrite.current = 0;
104
+ markActivity(Date.now());
105
+ writeStorage(storageKey, null);
106
+ channelRef.current?.postMessage("unlock");
107
+ applyLocked(false);
108
+ }, [storageKey, applyLocked, markActivity]);
109
+
110
+ // Cross-tab sync.
111
+ useEffect(() => {
112
+ if (typeof window === "undefined") return;
113
+ const onRemote = (nextLocked: boolean) => {
114
+ if (!nextLocked) lastActivity.current = Date.now();
115
+ applyLocked(nextLocked);
116
+ };
117
+
118
+ if (typeof window.BroadcastChannel === "function") {
119
+ const channel = new window.BroadcastChannel(storageKey);
120
+ channelRef.current = channel;
121
+ channel.onmessage = (event: MessageEvent) => {
122
+ if (event.data === "lock") onRemote(true);
123
+ else if (event.data === "unlock") onRemote(false);
124
+ };
125
+ return () => {
126
+ channelRef.current = null;
127
+ channel.close();
128
+ };
129
+ }
130
+
131
+ const onStorage = (event: StorageEvent) => {
132
+ if (event.key !== storageKey) return;
133
+ onRemote(event.newValue === "1");
134
+ };
135
+ window.addEventListener("storage", onStorage);
136
+ return () => window.removeEventListener("storage", onStorage);
137
+ }, [storageKey, applyLocked]);
138
+
139
+ // Inactivity timer.
140
+ useEffect(() => {
141
+ if (!enabled || locked || typeof window === "undefined") return;
142
+ lastActivity.current = Date.now();
143
+ let timer: ReturnType<typeof setTimeout>;
144
+
145
+ const idleFor = () => {
146
+ const shared = Number(readStorage(activityKey)) || 0;
147
+ return Date.now() - Math.max(lastActivity.current, shared);
148
+ };
149
+
150
+ const check = () => {
151
+ const idle = idleFor();
152
+ if (idle >= timeoutMs) lock();
153
+ else timer = setTimeout(check, timeoutMs - idle);
154
+ };
155
+
156
+ const onActivity = () => markActivity(Date.now());
157
+
158
+ // Timers are throttled in background tabs: check the delay on return.
159
+ const onVisibility = () => {
160
+ if (document.visibilityState !== "visible") return;
161
+ if (idleFor() >= timeoutMs) lock();
162
+ else onActivity();
163
+ };
164
+
165
+ timer = setTimeout(check, timeoutMs);
166
+ ACTIVITY_EVENTS.forEach((name) =>
167
+ document.addEventListener(name, onActivity, { capture: true, passive: true }),
168
+ );
169
+ document.addEventListener("visibilitychange", onVisibility);
170
+
171
+ return () => {
172
+ clearTimeout(timer);
173
+ ACTIVITY_EVENTS.forEach((name) =>
174
+ document.removeEventListener(name, onActivity, { capture: true }),
175
+ );
176
+ document.removeEventListener("visibilitychange", onVisibility);
177
+ };
178
+ }, [enabled, locked, timeoutMs, activityKey, lock, markActivity]);
179
+
180
+ return { locked, lock, unlock };
181
+ }
@@ -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
+ };
package/src/index.ts CHANGED
@@ -55,6 +55,19 @@ export type { PaymentDueBannerProps } from "./components/PaymentDueBanner";
55
55
  export { SecureLoader } from "./components/SecureLoader";
56
56
  export type { SecureLoaderProps } from "./components/SecureLoader";
57
57
 
58
+ export { PinPad } from "./components/PinPad";
59
+ export type { PinPadProps } from "./components/PinPad";
60
+
61
+ export { PinCodeInput } from "./components/PinCodeInput";
62
+ export type { PinCodeInputProps } from "./components/PinCodeInput";
63
+
64
+ export { LockScreen } from "./components/LockScreen";
65
+ export type {
66
+ LockScreenMode,
67
+ LockScreenProps,
68
+ LockScreenTexts,
69
+ } from "./components/LockScreen";
70
+
58
71
  // Molecules
59
72
  export { SpecialtySearchAutocomplete } from "./molecules";
60
73
  export type { Specialty, SpecialtySearchAutocompleteProps } from "./molecules";
@@ -95,8 +108,18 @@ export type {
95
108
  } from "./utils";
96
109
 
97
110
  // Hooks
98
- export { useDebounce, useDebouncedCallback, useSelectField } from "./hooks";
99
- export type { UseSelectFieldProps } from "./hooks";
111
+ export {
112
+ useDebounce,
113
+ useDebouncedCallback,
114
+ useIdleLock,
115
+ useMediaQuery,
116
+ useSelectField,
117
+ } from "./hooks";
118
+ export type {
119
+ UseIdleLockOptions,
120
+ UseIdleLockResult,
121
+ UseSelectFieldProps,
122
+ } from "./hooks";
100
123
 
101
124
  // Theme
102
125
  export { ThemeProvider, useTheme } from "./theme/ThemeProvider";
@@ -135,6 +135,17 @@ module.exports = {
135
135
  large:
136
136
  "0 10px 40px -10px rgba(0, 0, 0, 0.15), 0 2px 10px -2px rgba(0, 0, 0, 0.05)",
137
137
  },
138
+ // PinPad: wrong PIN feedback
139
+ keyframes: {
140
+ "pin-shake": {
141
+ "0%, 100%": { transform: "translateX(0)" },
142
+ "20%, 60%": { transform: "translateX(-8px)" },
143
+ "40%, 80%": { transform: "translateX(8px)" },
144
+ },
145
+ },
146
+ animation: {
147
+ "pin-shake": "pin-shake 0.4s ease-in-out",
148
+ },
138
149
  },
139
150
  },
140
151
  plugins: [