@ecohouse/ui 0.1.61 → 0.1.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +331 -115
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +332 -117
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Checkbox/Checkbox.tsx +6 -0
- package/src/components/Counter/Counter.test.ts +38 -0
- package/src/components/Counter/Counter.tsx +7 -10
- package/src/components/Counter/Counter.utils.ts +28 -0
- package/src/components/DatePicker/DatePicker.tsx +5 -14
- package/src/components/Drawer/Drawer.tsx +15 -12
- package/src/components/Modal/Modal.tsx +31 -11
- package/src/components/RadioButton/RadioButton.tsx +14 -0
- package/src/components/Select/Select.tsx +16 -8
- package/src/components/Toggle/Toggle.tsx +6 -0
- package/src/components/VerificationCodeInput/VerificationCodeInput.test.ts +74 -12
- package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +29 -24
- package/src/components/VerificationCodeInput/VerificationCodeInput.utils.ts +59 -14
- package/src/index.ts +6 -1
- package/src/utils/dateUtils.test.ts +72 -0
- package/src/utils/dateUtils.ts +30 -2
- package/src/utils/focusTrap.test.ts +26 -0
- package/src/utils/focusTrap.ts +39 -0
- package/src/utils/overlayStack.test.ts +175 -0
- package/src/utils/overlayStack.ts +105 -0
- package/src/utils/useHydrated.test.ts +14 -0
- package/src/utils/useHydrated.ts +14 -0
- package/src/utils/useOverlayLayer.ts +41 -0
- package/src/utils/useWebFocusTrap.ts +68 -0
- package/src/utils/webToggleKey.test.ts +49 -0
- 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 {
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
96
|
-
|
|
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
|
-
(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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?.(
|
|
123
|
+
onValueChange?.(code);
|
|
119
124
|
|
|
120
|
-
if (
|
|
121
|
-
onComplete?.(
|
|
125
|
+
if (isVerificationCodeComplete(nextCells)) {
|
|
126
|
+
onComplete?.(code);
|
|
122
127
|
}
|
|
123
128
|
},
|
|
124
|
-
[
|
|
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 =
|
|
141
|
-
commit(update.
|
|
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" && !
|
|
152
|
-
const update =
|
|
153
|
-
commit(update.
|
|
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={
|
|
231
|
+
value={cells[index] ?? ""}
|
|
227
232
|
/>
|
|
228
233
|
);
|
|
229
234
|
})}
|
|
@@ -2,33 +2,78 @@ 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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
):
|
|
52
|
+
): VerificationCellsUpdate {
|
|
16
53
|
const safeLength = Math.max(1, length);
|
|
17
|
-
const
|
|
18
|
-
const
|
|
54
|
+
const focusedIndex = Math.min(Math.max(0, index), safeLength - 1);
|
|
55
|
+
const cells = Array.from({ length: safeLength }, (_, cellIndex) => {
|
|
56
|
+
const cell = currentCells[cellIndex] ?? "";
|
|
57
|
+
return /^\d$/.test(cell) ? cell : "";
|
|
58
|
+
});
|
|
59
|
+
// A paste that does not fit from the focused cell (typically a whole code pasted while a
|
|
60
|
+
// later cell has focus) is laid out from the first cell instead of being cut to the tail.
|
|
61
|
+
const pastedDigits = input.replace(/\D/g, "");
|
|
62
|
+
const safeIndex =
|
|
63
|
+
pastedDigits.length > 1 && pastedDigits.length > safeLength - focusedIndex ? 0 : focusedIndex;
|
|
19
64
|
const insertedDigits = normalizeVerificationCode(input, safeLength - safeIndex);
|
|
20
65
|
|
|
21
66
|
if (!insertedDigits) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
nextFocus: safeIndex,
|
|
25
|
-
};
|
|
67
|
+
cells[safeIndex] = "";
|
|
68
|
+
return { cells, nextFocus: safeIndex };
|
|
26
69
|
}
|
|
27
70
|
|
|
71
|
+
Array.from(insertedDigits).forEach((digit, offset) => {
|
|
72
|
+
cells[safeIndex + offset] = digit;
|
|
73
|
+
});
|
|
74
|
+
|
|
28
75
|
return {
|
|
29
|
-
|
|
30
|
-
safeIndex + insertedDigits.length,
|
|
31
|
-
)}`.slice(0, safeLength),
|
|
76
|
+
cells,
|
|
32
77
|
nextFocus: Math.min(safeIndex + insertedDigits.length, safeLength - 1),
|
|
33
78
|
};
|
|
34
79
|
}
|
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 {
|
|
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
|
+
});
|
package/src/utils/dateUtils.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 =
|
|
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
|
+
}
|