@ecohouse/ui 0.1.61 → 0.1.62

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 (33) hide show
  1. package/dist/index.cjs +328 -114
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +8 -2
  4. package/dist/index.d.ts +8 -2
  5. package/dist/index.js +329 -116
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/Checkbox/Checkbox.tsx +6 -0
  9. package/src/components/Counter/Counter.test.ts +38 -0
  10. package/src/components/Counter/Counter.tsx +7 -10
  11. package/src/components/Counter/Counter.utils.ts +28 -0
  12. package/src/components/DatePicker/DatePicker.tsx +5 -14
  13. package/src/components/Drawer/Drawer.tsx +15 -12
  14. package/src/components/Modal/Modal.tsx +31 -11
  15. package/src/components/RadioButton/RadioButton.tsx +14 -0
  16. package/src/components/Select/Select.tsx +16 -8
  17. package/src/components/Toggle/Toggle.tsx +6 -0
  18. package/src/components/VerificationCodeInput/VerificationCodeInput.test.ts +60 -12
  19. package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +29 -24
  20. package/src/components/VerificationCodeInput/VerificationCodeInput.utils.ts +53 -13
  21. package/src/index.ts +6 -1
  22. package/src/utils/dateUtils.test.ts +72 -0
  23. package/src/utils/dateUtils.ts +30 -2
  24. package/src/utils/focusTrap.test.ts +26 -0
  25. package/src/utils/focusTrap.ts +39 -0
  26. package/src/utils/overlayStack.test.ts +175 -0
  27. package/src/utils/overlayStack.ts +105 -0
  28. package/src/utils/useHydrated.test.ts +14 -0
  29. package/src/utils/useHydrated.ts +14 -0
  30. package/src/utils/useOverlayLayer.ts +41 -0
  31. package/src/utils/useWebFocusTrap.ts +68 -0
  32. package/src/utils/webToggleKey.test.ts +49 -0
  33. package/src/utils/webToggleKey.ts +23 -0
@@ -22,7 +22,13 @@ import {
22
22
  } from "react-native";
23
23
  import { colors, fonts } from "../../theme";
24
24
  import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
25
- import { normalizeVerificationCode, updateVerificationCode } from "./VerificationCodeInput.utils";
25
+ import {
26
+ isVerificationCodeComplete,
27
+ reconcileVerificationCells,
28
+ toVerificationCells,
29
+ updateVerificationCells,
30
+ verificationCellsToCode,
31
+ } from "./VerificationCodeInput.utils";
26
32
 
27
33
  const DEFAULT_LENGTH = 6;
28
34
  const DESKTOP_CELL_WIDTH = 54.333335876464844;
@@ -85,17 +91,18 @@ export const VerificationCodeInput = forwardRef<
85
91
  ) {
86
92
  const safeLength = Math.max(1, Math.floor(length));
87
93
  const isControlled = typeof value === "string";
88
- const [uncontrolledValue, setUncontrolledValue] = useState(() =>
89
- normalizeVerificationCode(defaultValue, safeLength),
90
- );
94
+ // Fixed-length cells, so clearing a middle cell leaves a gap instead of shifting digits.
95
+ const [cellsState, setCellsState] = useState(() => toVerificationCells(defaultValue, safeLength));
91
96
  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
92
97
  const { width: viewportWidth } = useWindowDimensions();
93
98
  const rootRef = useRef<ComponentRef<typeof View>>(null);
94
99
  const inputRefs = useRef<Array<ComponentRef<typeof TextInput> | null>>([]);
95
- const currentCode = normalizeVerificationCode(
96
- isControlled ? value : uncontrolledValue,
100
+ const cells = reconcileVerificationCells(
101
+ cellsState,
102
+ isControlled ? value : verificationCellsToCode(cellsState),
97
103
  safeLength,
98
104
  );
105
+ const currentCode = verificationCellsToCode(cells);
99
106
 
100
107
  useApplyWebClassName(rootRef, className);
101
108
 
@@ -108,37 +115,35 @@ export const VerificationCodeInput = forwardRef<
108
115
  );
109
116
 
110
117
  const commit = useCallback(
111
- (nextValue: string) => {
112
- const normalized = normalizeVerificationCode(nextValue, safeLength);
113
-
114
- if (!isControlled) {
115
- setUncontrolledValue(normalized);
116
- }
118
+ (nextCells: string[]) => {
119
+ // Kept in controlled mode too, so gaps survive while the parent echoes the value back.
120
+ setCellsState(nextCells);
121
+ const code = verificationCellsToCode(nextCells);
117
122
 
118
- onValueChange?.(normalized);
123
+ onValueChange?.(code);
119
124
 
120
- if (normalized.length === safeLength) {
121
- onComplete?.(normalized);
125
+ if (isVerificationCodeComplete(nextCells)) {
126
+ onComplete?.(code);
122
127
  }
123
128
  },
124
- [isControlled, onComplete, onValueChange, safeLength],
129
+ [onComplete, onValueChange],
125
130
  );
126
131
 
127
132
  useImperativeHandle(
128
133
  forwardedRef,
129
134
  () => ({
130
135
  clear: () => {
131
- commit("");
136
+ commit(toVerificationCells("", safeLength));
132
137
  focus(0);
133
138
  },
134
139
  focus,
135
140
  }),
136
- [commit, focus],
141
+ [commit, focus, safeLength],
137
142
  );
138
143
 
139
144
  const handleChange = (index: number, input: string) => {
140
- const update = updateVerificationCode(currentCode, index, input, safeLength);
141
- commit(update.code);
145
+ const update = updateVerificationCells(cells, index, input, safeLength);
146
+ commit(update.cells);
142
147
  focus(update.nextFocus);
143
148
  };
144
149
 
@@ -148,9 +153,9 @@ export const VerificationCodeInput = forwardRef<
148
153
  ) => {
149
154
  const key = event.nativeEvent.key;
150
155
 
151
- if (key === "Backspace" && !currentCode[index] && index > 0) {
152
- const update = updateVerificationCode(currentCode, index - 1, "", safeLength);
153
- commit(update.code);
156
+ if (key === "Backspace" && !cells[index] && index > 0) {
157
+ const update = updateVerificationCells(cells, index - 1, "", safeLength);
158
+ commit(update.cells);
154
159
  focus(index - 1);
155
160
  return;
156
161
  }
@@ -223,7 +228,7 @@ export const VerificationCodeInput = forwardRef<
223
228
  Platform.OS === "android" ? styles.cellAndroid : null,
224
229
  inputStyle,
225
230
  ]}
226
- value={currentCode[index] ?? ""}
231
+ value={cells[index] ?? ""}
227
232
  />
228
233
  );
229
234
  })}
@@ -2,33 +2,73 @@ export function normalizeVerificationCode(value: string, length: number): string
2
2
  return value.replace(/\D/g, "").slice(0, Math.max(1, length));
3
3
  }
4
4
 
5
- export interface VerificationCodeUpdate {
6
- code: string;
5
+ /** Fills cells from the start of `code`; one entry per cell, `""` marks an empty cell. */
6
+ export function toVerificationCells(code: string, length: number): string[] {
7
+ const safeLength = Math.max(1, length);
8
+ const digits = normalizeVerificationCode(code, safeLength);
9
+ return Array.from({ length: safeLength }, (_, index) => digits[index] ?? "");
10
+ }
11
+
12
+ /** The code reported to consumers: the entered digits in cell order, gaps skipped. */
13
+ export function verificationCellsToCode(cells: readonly string[]): string {
14
+ return cells.join("");
15
+ }
16
+
17
+ export function isVerificationCodeComplete(cells: readonly string[]): boolean {
18
+ return cells.length > 0 && cells.every((cell) => cell !== "");
19
+ }
20
+
21
+ /**
22
+ * Keeps the displayed cells, including gaps, while they still produce `code`;
23
+ * otherwise (a new value from the parent, or a new length) lays `code` out again.
24
+ */
25
+ export function reconcileVerificationCells(
26
+ cells: readonly string[],
27
+ code: string,
28
+ length: number,
29
+ ): string[] {
30
+ const safeLength = Math.max(1, length);
31
+ const normalized = normalizeVerificationCode(code, safeLength);
32
+ if (cells.length === safeLength && verificationCellsToCode(cells) === normalized) {
33
+ return [...cells];
34
+ }
35
+ return toVerificationCells(normalized, safeLength);
36
+ }
37
+
38
+ export interface VerificationCellsUpdate {
39
+ cells: string[];
7
40
  nextFocus: number;
8
41
  }
9
42
 
10
- export function updateVerificationCode(
11
- currentCode: string,
43
+ /**
44
+ * Applies input typed or pasted into cell `index`. Clearing a cell leaves a gap
45
+ * instead of shifting later digits left; pasted digits fill forward from `index`.
46
+ */
47
+ export function updateVerificationCells(
48
+ currentCells: readonly string[],
12
49
  index: number,
13
50
  input: string,
14
51
  length: number,
15
- ): VerificationCodeUpdate {
52
+ ): VerificationCellsUpdate {
16
53
  const safeLength = Math.max(1, length);
17
54
  const safeIndex = Math.min(Math.max(0, index), safeLength - 1);
18
- const current = normalizeVerificationCode(currentCode, safeLength);
55
+ const cells = Array.from({ length: safeLength }, (_, cellIndex) => {
56
+ const cell = currentCells[cellIndex] ?? "";
57
+ return /^\d$/.test(cell) ? cell : "";
58
+ });
19
59
  const insertedDigits = normalizeVerificationCode(input, safeLength - safeIndex);
20
60
 
21
61
  if (!insertedDigits) {
22
- return {
23
- code: `${current.slice(0, safeIndex)}${current.slice(safeIndex + 1)}`,
24
- nextFocus: safeIndex,
25
- };
62
+ cells[safeIndex] = "";
63
+ return { cells, nextFocus: safeIndex };
26
64
  }
27
65
 
66
+ Array.from(insertedDigits).forEach((digit, offset) => {
67
+ cells[safeIndex + offset] = digit;
68
+ });
69
+
28
70
  return {
29
- code: `${current.slice(0, safeIndex)}${insertedDigits}${current.slice(
30
- safeIndex + insertedDigits.length,
31
- )}`.slice(0, safeLength),
71
+ cells,
32
72
  nextFocus: Math.min(safeIndex + insertedDigits.length, safeLength - 1),
33
73
  };
34
74
  }
package/src/index.ts CHANGED
@@ -293,6 +293,11 @@ export {
293
293
  } from "./theme";
294
294
  export type { GreyStep } from "./theme";
295
295
 
296
- export { abbreviateMonthName, formatDate, formatDateDayShortMonth } from "./utils/dateUtils";
296
+ export {
297
+ abbreviateMonthName,
298
+ abbreviateMonthNames,
299
+ formatDate,
300
+ formatDateDayShortMonth,
301
+ } from "./utils/dateUtils";
297
302
  export { resolvePopoverAlign } from "./utils/popoverAlign";
298
303
  export type { PopoverAlign, ResolvePopoverAlignOptions } from "./utils/popoverAlign";
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { abbreviateMonthNames, formatDateDayShortMonth } from "./dateUtils";
3
+
4
+ const ARMENIAN_MONTHS = [
5
+ "Հունվար",
6
+ "Փետրվար",
7
+ "Մարտ",
8
+ "Ապրիլ",
9
+ "Մայիս",
10
+ "Հունիս",
11
+ "Հուլիս",
12
+ "Օգոստոս",
13
+ "Սեպտեմբեր",
14
+ "Հոկտեմբեր",
15
+ "Նոյեմբեր",
16
+ "Դեկտեմբեր",
17
+ ];
18
+
19
+ const ENGLISH_MONTHS = [
20
+ "January",
21
+ "February",
22
+ "March",
23
+ "April",
24
+ "May",
25
+ "June",
26
+ "July",
27
+ "August",
28
+ "September",
29
+ "October",
30
+ "November",
31
+ "December",
32
+ ];
33
+
34
+ describe("abbreviateMonthNames", () => {
35
+ it("gives Armenian January and June distinct abbreviations", () => {
36
+ const abbreviations = abbreviateMonthNames(ARMENIAN_MONTHS);
37
+
38
+ expect(abbreviations[0]).toBe("Հունվ");
39
+ expect(abbreviations[5]).toBe("Հունի");
40
+ expect(abbreviations[6]).toBe("Հուլ");
41
+ expect(new Set(abbreviations).size).toBe(12);
42
+ });
43
+
44
+ it("keeps three letters when they are already unique", () => {
45
+ expect(abbreviateMonthNames(ENGLISH_MONTHS)).toEqual([
46
+ "Jan",
47
+ "Feb",
48
+ "Mar",
49
+ "Apr",
50
+ "May",
51
+ "Jun",
52
+ "Jul",
53
+ "Aug",
54
+ "Sep",
55
+ "Oct",
56
+ "Nov",
57
+ "Dec",
58
+ ]);
59
+ });
60
+
61
+ it("stops at the full name when two names are identical", () => {
62
+ expect(abbreviateMonthNames(["Same", "Same"])).toEqual(["Same", "Same"]);
63
+ });
64
+ });
65
+
66
+ describe("formatDateDayShortMonth", () => {
67
+ it("formats Armenian January and June unambiguously", () => {
68
+ expect(formatDateDayShortMonth(new Date(2026, 0, 15), ARMENIAN_MONTHS)).toBe("15 Հունվ 2026");
69
+ expect(formatDateDayShortMonth(new Date(2026, 5, 15), ARMENIAN_MONTHS)).toBe("15 Հունի 2026");
70
+ expect(formatDateDayShortMonth(new Date(2026, 6, 15), ARMENIAN_MONTHS)).toBe("15 Հուլ 2026");
71
+ });
72
+ });
@@ -76,9 +76,37 @@ export function abbreviateMonthName(monthName: string, letterCount = 3): string
76
76
  return chars.slice(0, end).join("");
77
77
  }
78
78
 
79
- /** "15 Հուլ 2026" — day + abbreviated month + year for range triggers. */
79
+ /**
80
+ * Abbreviates every month name to at least `letterCount` letters, adding letters
81
+ * where two months would otherwise share an abbreviation: Armenian
82
+ * «Հունվար» / «Հունիս» become «Հունվ» / «Հունի» instead of both «Հուն».
83
+ */
84
+ export function abbreviateMonthNames(monthNames: readonly string[], letterCount = 3): string[] {
85
+ return monthNames.map((name, index) => {
86
+ const maxLetters = abbreviatedLetterCount(name);
87
+ let count = letterCount;
88
+ while (
89
+ count < maxLetters &&
90
+ monthNames.some(
91
+ (other, otherIndex) =>
92
+ otherIndex !== index &&
93
+ abbreviateMonthName(other, count) === abbreviateMonthName(name, count),
94
+ )
95
+ ) {
96
+ count += 1;
97
+ }
98
+ return abbreviateMonthName(name, count);
99
+ });
100
+ }
101
+
102
+ /** Letter count of `monthName`, counting the Armenian digraph «ու» as one letter. */
103
+ function abbreviatedLetterCount(monthName: string): number {
104
+ return Array.from(monthName.replace(/ու/g, "u")).length;
105
+ }
106
+
107
+ /** "15 Հուլ 2026" — day + unambiguous abbreviated month + year for range triggers. */
80
108
  export function formatDateDayShortMonth(date: Date, monthNames: string[], letterCount = 3): string {
81
- const month = abbreviateMonthName(monthNames[date.getMonth()] ?? "", letterCount);
109
+ const month = abbreviateMonthNames(monthNames, letterCount)[date.getMonth()] ?? "";
82
110
  return `${date.getDate()} ${month} ${date.getFullYear()}`;
83
111
  }
84
112
 
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getWrappedFocusIndex } from "./focusTrap";
3
+
4
+ describe("getWrappedFocusIndex", () => {
5
+ it("wraps Tab from the last element to the first", () => {
6
+ expect(getWrappedFocusIndex(3, 2, false)).toBe(0);
7
+ });
8
+
9
+ it("wraps Shift+Tab from the first element to the last", () => {
10
+ expect(getWrappedFocusIndex(3, 0, true)).toBe(2);
11
+ });
12
+
13
+ it("leaves Tab between inner elements to the browser", () => {
14
+ expect(getWrappedFocusIndex(3, 1, false)).toBeNull();
15
+ expect(getWrappedFocusIndex(3, 1, true)).toBeNull();
16
+ });
17
+
18
+ it("enters from the dialog container itself in either direction", () => {
19
+ expect(getWrappedFocusIndex(3, -1, false)).toBe(0);
20
+ expect(getWrappedFocusIndex(3, -1, true)).toBe(2);
21
+ });
22
+
23
+ it("has nothing to move to without focusable content", () => {
24
+ expect(getWrappedFocusIndex(0, -1, false)).toBeNull();
25
+ });
26
+ });
@@ -0,0 +1,39 @@
1
+ const TABBABLE_SELECTOR = [
2
+ "a[href]",
3
+ "area[href]",
4
+ "button",
5
+ "input:not([type='hidden'])",
6
+ "select",
7
+ "textarea",
8
+ "iframe",
9
+ "[contenteditable='true']",
10
+ "[tabindex]",
11
+ ].join(",");
12
+
13
+ /** Focusable elements inside `container` that Tab can reach, in DOM order. */
14
+ export function getTabbableElements(container: HTMLElement): HTMLElement[] {
15
+ return Array.from(container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter(
16
+ (element) =>
17
+ element.tabIndex >= 0 &&
18
+ !element.hasAttribute("disabled") &&
19
+ element.getAttribute("aria-hidden") !== "true" &&
20
+ element.getClientRects().length > 0,
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Index to move focus to when Tab would leave the trap, or `null` when the
26
+ * browser's own Tab order already stays inside. `currentIndex` is `-1` when
27
+ * focus is on the container itself (or outside it).
28
+ */
29
+ export function getWrappedFocusIndex(
30
+ count: number,
31
+ currentIndex: number,
32
+ backwards: boolean,
33
+ ): number | null {
34
+ if (count === 0) return null;
35
+ if (currentIndex === -1) return backwards ? count - 1 : 0;
36
+ if (backwards && currentIndex === 0) return count - 1;
37
+ if (!backwards && currentIndex === count - 1) return 0;
38
+ return null;
39
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createOverlayStack,
4
+ type OverlayEventTarget,
5
+ type OverlayKeyboardEvent,
6
+ } from "./overlayStack";
7
+
8
+ type Listener = (event: OverlayKeyboardEvent) => void;
9
+
10
+ /** Minimal `document` stand-in: capture listeners run before bubble listeners. */
11
+ function createFakeDocument() {
12
+ const listeners: Array<{ listener: Listener; capture: boolean }> = [];
13
+
14
+ const target: OverlayEventTarget = {
15
+ addEventListener: (_type, listener, capture) => {
16
+ listeners.push({ listener, capture });
17
+ },
18
+ removeEventListener: (_type, listener, capture) => {
19
+ const index = listeners.findIndex(
20
+ (entry) => entry.listener === listener && entry.capture === capture,
21
+ );
22
+ if (index !== -1) listeners.splice(index, 1);
23
+ },
24
+ };
25
+
26
+ /**
27
+ * `appListener` stands for an app's own `document` keydown listener (bubble
28
+ * phase); `targetListener` for a handler on the focused element, which runs
29
+ * between the capture and bubble phases.
30
+ */
31
+ const pressKey = (key: string, appListener?: Listener, targetListener?: Listener) => {
32
+ let defaultPrevented = false;
33
+ let stopped = false;
34
+ const event: OverlayKeyboardEvent = {
35
+ key,
36
+ get defaultPrevented() {
37
+ return defaultPrevented;
38
+ },
39
+ preventDefault: () => {
40
+ defaultPrevented = true;
41
+ },
42
+ stopImmediatePropagation: () => {
43
+ stopped = true;
44
+ },
45
+ };
46
+ const ordered = [
47
+ ...listeners.filter((entry) => entry.capture).map((entry) => entry.listener),
48
+ ...(targetListener ? [targetListener] : []),
49
+ ...listeners.filter((entry) => !entry.capture).map((entry) => entry.listener),
50
+ ...(appListener ? [appListener] : []),
51
+ ];
52
+ for (const listener of ordered) {
53
+ if (stopped) break;
54
+ listener(event);
55
+ }
56
+ return event;
57
+ };
58
+
59
+ return { target, pressKey, listenerCount: () => listeners.length };
60
+ }
61
+
62
+ describe("createOverlayStack", () => {
63
+ it("closes a popover without closing the modal behind it", () => {
64
+ const fakeDocument = createFakeDocument();
65
+ const stack = createOverlayStack(fakeDocument.target);
66
+ const closeModal = vi.fn();
67
+ const closeSelect = vi.fn();
68
+ const appListener = vi.fn();
69
+
70
+ stack.push({ kind: "overlay", onEscape: closeModal });
71
+ stack.push({ kind: "popover", onEscape: closeSelect });
72
+
73
+ const event = fakeDocument.pressKey("Escape", appListener);
74
+
75
+ expect(closeSelect).toHaveBeenCalledTimes(1);
76
+ expect(closeModal).not.toHaveBeenCalled();
77
+ // An app's own dialog listening on `document` does not see the event either.
78
+ expect(appListener).not.toHaveBeenCalled();
79
+ expect(event.defaultPrevented).toBe(true);
80
+ });
81
+
82
+ it("closes a popover first even when it registered before its overlay", () => {
83
+ // React runs a child's effects before its parent's: a Select that is open on
84
+ // mount inside a Modal registers before the Modal does.
85
+ const fakeDocument = createFakeDocument();
86
+ const stack = createOverlayStack(fakeDocument.target);
87
+ const closeModal = vi.fn();
88
+ const closeSelect = vi.fn();
89
+ const select = { kind: "popover" as const, onEscape: closeSelect };
90
+ const modal = { kind: "overlay" as const, onEscape: closeModal };
91
+
92
+ stack.push(select);
93
+ stack.push(modal);
94
+ fakeDocument.pressKey("Escape");
95
+
96
+ expect(closeSelect).toHaveBeenCalledTimes(1);
97
+ expect(closeModal).not.toHaveBeenCalled();
98
+ expect(stack.isTop(select)).toBe(true);
99
+ expect(stack.isTop(modal)).toBe(true);
100
+ });
101
+
102
+ it("closes the overlay once the popover above it is gone", () => {
103
+ const fakeDocument = createFakeDocument();
104
+ const stack = createOverlayStack(fakeDocument.target);
105
+ const closeModal = vi.fn();
106
+ const closeSelect = vi.fn();
107
+
108
+ stack.push({ kind: "overlay", onEscape: closeModal });
109
+ const removeSelect = stack.push({ kind: "popover", onEscape: closeSelect });
110
+ removeSelect();
111
+
112
+ fakeDocument.pressKey("Escape");
113
+
114
+ expect(closeSelect).not.toHaveBeenCalled();
115
+ expect(closeModal).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ it("closes only the topmost of two stacked overlays", () => {
119
+ const fakeDocument = createFakeDocument();
120
+ const stack = createOverlayStack(fakeDocument.target);
121
+ const closeDrawer = vi.fn();
122
+ const closeModal = vi.fn();
123
+
124
+ stack.push({ kind: "overlay", onEscape: closeDrawer });
125
+ const modal = { kind: "overlay" as const, onEscape: closeModal };
126
+ stack.push(modal);
127
+
128
+ fakeDocument.pressKey("Escape");
129
+
130
+ expect(closeModal).toHaveBeenCalledTimes(1);
131
+ expect(closeDrawer).not.toHaveBeenCalled();
132
+ expect(stack.isTop(modal)).toBe(true);
133
+ });
134
+
135
+ it("leaves an overlay open when content inside it already handled Escape", () => {
136
+ const fakeDocument = createFakeDocument();
137
+ const stack = createOverlayStack(fakeDocument.target);
138
+ const closeModal = vi.fn();
139
+ stack.push({ kind: "overlay", onEscape: closeModal });
140
+
141
+ fakeDocument.pressKey("Escape", undefined, (event) => event.preventDefault());
142
+
143
+ expect(closeModal).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("ignores other keys", () => {
147
+ const fakeDocument = createFakeDocument();
148
+ const stack = createOverlayStack(fakeDocument.target);
149
+ const closeSelect = vi.fn();
150
+ const appListener = vi.fn();
151
+ stack.push({ kind: "popover", onEscape: closeSelect });
152
+
153
+ const event = fakeDocument.pressKey("Enter", appListener);
154
+
155
+ expect(closeSelect).not.toHaveBeenCalled();
156
+ expect(appListener).toHaveBeenCalledTimes(1);
157
+ expect(event.defaultPrevented).toBe(false);
158
+ });
159
+
160
+ it("only listens on the document while a layer is open", () => {
161
+ const fakeDocument = createFakeDocument();
162
+ const stack = createOverlayStack(fakeDocument.target);
163
+
164
+ const removeFirst = stack.push({ kind: "overlay", onEscape: vi.fn() });
165
+ const removeSecond = stack.push({ kind: "popover", onEscape: vi.fn() });
166
+ expect(fakeDocument.listenerCount()).toBe(2);
167
+
168
+ removeFirst();
169
+ removeFirst();
170
+ expect(fakeDocument.listenerCount()).toBe(2);
171
+
172
+ removeSecond();
173
+ expect(fakeDocument.listenerCount()).toBe(0);
174
+ });
175
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Escape handling for stacked web overlays. One Escape closes one layer: an
3
+ * open popover first, otherwise the most recently opened overlay. So closing a
4
+ * Select inside a Modal leaves the Modal open, and a Modal opened over a
5
+ * Drawer closes before the Drawer.
6
+ *
7
+ * - `popover` layers (Select, DatePicker menus) claim Escape in the capture
8
+ * phase and stop it, so neither our overlays nor an app's own `document`
9
+ * listeners close the dialog behind the popover. Popovers always win over
10
+ * overlays: React registers a child popover that is open on mount before
11
+ * its parent Modal, so push order alone is not reliable between the kinds.
12
+ * - `overlay` layers (Modal, Drawer) react once the event bubbled to
13
+ * `document`, and skip events already handled (`defaultPrevented`), so
14
+ * content inside them can keep them open by calling `preventDefault()`.
15
+ */
16
+
17
+ export type OverlayLayerKind = "overlay" | "popover";
18
+
19
+ export interface OverlayLayer {
20
+ readonly kind: OverlayLayerKind;
21
+ onEscape: () => void;
22
+ }
23
+
24
+ export interface OverlayKeyboardEvent {
25
+ readonly key: string;
26
+ readonly defaultPrevented: boolean;
27
+ preventDefault: () => void;
28
+ stopImmediatePropagation: () => void;
29
+ }
30
+
31
+ export interface OverlayEventTarget {
32
+ addEventListener: (
33
+ type: "keydown",
34
+ listener: (event: OverlayKeyboardEvent) => void,
35
+ capture: boolean,
36
+ ) => void;
37
+ removeEventListener: (
38
+ type: "keydown",
39
+ listener: (event: OverlayKeyboardEvent) => void,
40
+ capture: boolean,
41
+ ) => void;
42
+ }
43
+
44
+ export interface OverlayStack {
45
+ /** Adds `layer` on top; returns a function that removes it again. */
46
+ push: (layer: OverlayLayer) => () => void;
47
+ /** Whether `layer` is the most recently opened layer of its kind. */
48
+ isTop: (layer: OverlayLayer) => boolean;
49
+ }
50
+
51
+ export function createOverlayStack(target: OverlayEventTarget): OverlayStack {
52
+ const layers: OverlayLayer[] = [];
53
+ const top = (kind: OverlayLayerKind) => {
54
+ for (let index = layers.length - 1; index >= 0; index -= 1) {
55
+ if (layers[index].kind === kind) return layers[index];
56
+ }
57
+ return undefined;
58
+ };
59
+
60
+ const handleCapture = (event: OverlayKeyboardEvent) => {
61
+ const layer = top("popover");
62
+ if (event.key !== "Escape" || event.defaultPrevented || !layer) return;
63
+ event.preventDefault();
64
+ event.stopImmediatePropagation();
65
+ layer.onEscape();
66
+ };
67
+
68
+ const handleBubble = (event: OverlayKeyboardEvent) => {
69
+ const layer = top("overlay");
70
+ if (event.key !== "Escape" || event.defaultPrevented || !layer) return;
71
+ event.preventDefault();
72
+ layer.onEscape();
73
+ };
74
+
75
+ return {
76
+ push(layer) {
77
+ layers.push(layer);
78
+ if (layers.length === 1) {
79
+ target.addEventListener("keydown", handleCapture, true);
80
+ target.addEventListener("keydown", handleBubble, false);
81
+ }
82
+
83
+ return () => {
84
+ const index = layers.indexOf(layer);
85
+ if (index === -1) return;
86
+ layers.splice(index, 1);
87
+ if (layers.length === 0) {
88
+ target.removeEventListener("keydown", handleCapture, true);
89
+ target.removeEventListener("keydown", handleBubble, false);
90
+ }
91
+ };
92
+ },
93
+ isTop: (layer) => top(layer.kind) === layer,
94
+ };
95
+ }
96
+
97
+ let documentStack: OverlayStack | null = null;
98
+
99
+ /** The shared stack bound to `document`; web only. */
100
+ export function getDocumentOverlayStack(): OverlayStack {
101
+ if (!documentStack) {
102
+ documentStack = createOverlayStack(document as unknown as OverlayEventTarget);
103
+ }
104
+ return documentStack;
105
+ }
@@ -0,0 +1,14 @@
1
+ import { createElement } from "react";
2
+ import { renderToString } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import { useHydrated } from "./useHydrated";
5
+
6
+ function Probe() {
7
+ return createElement("span", null, useHydrated() ? "client" : "server");
8
+ }
9
+
10
+ describe("useHydrated", () => {
11
+ it("is false while rendering on the server, so portals are left out of the SSR HTML", () => {
12
+ expect(renderToString(createElement(Probe))).toBe("<span>server</span>");
13
+ });
14
+ });