@lotics/ui 11.7.3 → 11.8.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.
- package/AGENTS.md +1 -1
- package/docs/catalog.md +22 -7
- package/docs/data_entry.md +25 -2
- package/docs/templates.md +41 -0
- package/package.json +1 -1
- package/src/date_field.tsx +26 -0
- package/src/date_picker.tsx +2 -0
- package/src/date_picker_value.test.ts +37 -0
- package/src/date_picker_value.ts +30 -0
- package/src/date_segments.test.ts +45 -0
- package/src/date_segments.ts +11 -0
- package/src/date_segments_field.tsx +58 -5
- package/src/file_gallery_modal.tsx +2 -1
- package/src/inline_date_picker.tsx +201 -67
- package/src/inline_edit.tsx +43 -3
- package/src/inline_focus.test.ts +39 -0
- package/src/inline_focus.ts +40 -0
- package/src/interaction_modality.ts +38 -0
- package/src/locale.tsx +2 -2
- package/src/popover.tsx +41 -11
- package/src/popover_layers.test.ts +113 -0
- package/src/popover_layers.ts +57 -0
- package/src/use_focus_ring.ts +3 -26
package/src/popover.tsx
CHANGED
|
@@ -13,6 +13,11 @@ import { IconButton } from "./icon_button";
|
|
|
13
13
|
import { Portal } from "./portal";
|
|
14
14
|
import { Divider } from "./divider";
|
|
15
15
|
import { useOverlayScope } from "./overlay_scope";
|
|
16
|
+
import {
|
|
17
|
+
hasModalLayerAbove,
|
|
18
|
+
isInLayerAbove,
|
|
19
|
+
snapshotOpenModalLayers,
|
|
20
|
+
} from "./popover_layers";
|
|
16
21
|
import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
|
|
17
22
|
|
|
18
23
|
export type PopoverSide = "top" | "right" | "bottom" | "left";
|
|
@@ -216,6 +221,11 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
216
221
|
// hover/focus — so positioning cannot rely on `triggerRef.current` still being
|
|
217
222
|
// live when it runs.
|
|
218
223
|
const triggerRectRef = useRef<DOMRect | null>(null);
|
|
224
|
+
// Modal layers ([aria-modal]) open at the moment this popover opened. Any
|
|
225
|
+
// aria-modal element that appears LATER was opened from within this popover
|
|
226
|
+
// (a file-preview Modal, an Alert confirm) and stacks ABOVE it — interactions
|
|
227
|
+
// inside it must not dismiss this popover. See popover_layers.ts.
|
|
228
|
+
const modalsAtOpenRef = useRef<ReadonlySet<Element> | null>(null);
|
|
219
229
|
|
|
220
230
|
const handleClose = useCallback(() => {
|
|
221
231
|
if (!open) return;
|
|
@@ -232,6 +242,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
232
242
|
if (triggerRef.current) {
|
|
233
243
|
triggerRectRef.current = triggerRef.current.getBoundingClientRect();
|
|
234
244
|
}
|
|
245
|
+
modalsAtOpenRef.current = snapshotOpenModalLayers(document);
|
|
235
246
|
}, [open, small, triggerRef]);
|
|
236
247
|
|
|
237
248
|
// Focus management: when the popover opens, remember what had focus and move
|
|
@@ -301,6 +312,12 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
301
312
|
const handleEscape = (e: KeyboardEvent) => {
|
|
302
313
|
if (e.key !== "Escape") return;
|
|
303
314
|
|
|
315
|
+
// A modal opened from within (a file preview, an Alert) is stacked above
|
|
316
|
+
// this popover: RN-web's own document keyup listener closes the topmost
|
|
317
|
+
// modal. Stopping the event here would instead close this popover and
|
|
318
|
+
// unmount that modal with it — so yield while any such layer is open.
|
|
319
|
+
if (hasModalLayerAbove(document, modalsAtOpenRef.current)) return;
|
|
320
|
+
|
|
304
321
|
const allPopovers = document.querySelectorAll("[data-popover]");
|
|
305
322
|
const lastPopover = allPopovers[allPopovers.length - 1];
|
|
306
323
|
if (popoverRef.current !== lastPopover) return;
|
|
@@ -321,10 +338,11 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
321
338
|
// this popover's content AND its trigger. We listen on `click` (not pointerdown)
|
|
322
339
|
// in the CAPTURE phase so that clicking ANOTHER field's trigger completes that
|
|
323
340
|
// trigger's own press — the old popover dismisses AND the new one opens in a
|
|
324
|
-
// single click, instead of the press being cancelled mid-gesture. A click
|
|
325
|
-
// a
|
|
326
|
-
// popover
|
|
327
|
-
//
|
|
341
|
+
// single click, instead of the press being cancelled mid-gesture. A click inside
|
|
342
|
+
// a layer stacked ABOVE this popover keeps it open — a Select nested inside a
|
|
343
|
+
// filter popover, or a file-preview Modal / Alert opened from within — those are
|
|
344
|
+
// interactions WITH the popover's own flow, not a move away from it. The bottom
|
|
345
|
+
// sheet (`small`) is modal and dismisses via its scrim, so it opts out here.
|
|
328
346
|
useEffect(() => {
|
|
329
347
|
if (!open || small) return;
|
|
330
348
|
const onOutsideClick = (e: MouseEvent) => {
|
|
@@ -332,12 +350,8 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
332
350
|
if (!node) return;
|
|
333
351
|
if (popoverRef.current?.contains(node)) return; // inside my content
|
|
334
352
|
if (triggerRef.current?.contains(node)) return; // on my trigger — its own press toggles
|
|
335
|
-
// Keep open when the click is inside a DEEPER popover (e.g. a Select opened
|
|
336
|
-
// inside this one). `data-popover-level` is the same nesting depth as z-index.
|
|
337
353
|
const myLevel = Number(popoverRef.current?.getAttribute("data-popover-level") ?? "0");
|
|
338
|
-
|
|
339
|
-
const hit = el?.closest("[data-popover]");
|
|
340
|
-
if (hit && Number(hit.getAttribute("data-popover-level") ?? "0") > myLevel) return; // deeper popover
|
|
354
|
+
if (isInLayerAbove(node, myLevel, modalsAtOpenRef.current)) return;
|
|
341
355
|
onOpenChange(false);
|
|
342
356
|
};
|
|
343
357
|
document.addEventListener("click", onOutsideClick, true);
|
|
@@ -476,6 +490,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
476
490
|
setPosition((previous) => (previous === null ? previous : null));
|
|
477
491
|
setIsBottomSheetShown((previous) => (previous ? false : previous));
|
|
478
492
|
triggerRectRef.current = null;
|
|
493
|
+
modalsAtOpenRef.current = null;
|
|
479
494
|
return;
|
|
480
495
|
}
|
|
481
496
|
|
|
@@ -511,9 +526,14 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
511
526
|
// page or any ancestor is a "moved on" signal, so we DISMISS (as native
|
|
512
527
|
// <select> and most menu systems do) rather than chase the trigger and risk a
|
|
513
528
|
// panel detached from an off-screen anchor. Scrolling the popover's OWN content
|
|
514
|
-
// (a long option list) must NOT close it
|
|
529
|
+
// (a long option list) must NOT close it, and neither must a scroll inside a
|
|
530
|
+
// layer stacked above it (a nested popover's list, a file-preview Modal opened
|
|
531
|
+
// from within) — the anchor didn't move. Capture catches nested scrollers.
|
|
515
532
|
const onScroll = (e: Event) => {
|
|
516
|
-
|
|
533
|
+
const node = e.target instanceof Node ? e.target : null;
|
|
534
|
+
if (node && popoverRef.current?.contains(node)) return;
|
|
535
|
+
const myLevel = Number(popoverRef.current?.getAttribute("data-popover-level") ?? "0");
|
|
536
|
+
if (node && isInLayerAbove(node, myLevel, modalsAtOpenRef.current)) return;
|
|
517
537
|
onOpenChange(false);
|
|
518
538
|
};
|
|
519
539
|
window.addEventListener("scroll", onScroll, true);
|
|
@@ -626,6 +646,16 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
626
646
|
}),
|
|
627
647
|
}}
|
|
628
648
|
onClick={(e) => e.stopPropagation()}
|
|
649
|
+
// React synthetic events bubble through portals via the REACT tree, so a
|
|
650
|
+
// keydown inside this popover — or inside a Modal/Alert opened from it,
|
|
651
|
+
// which is a React child even though it portals to document.body — would
|
|
652
|
+
// reach the TRIGGER's ancestors (e.g. a grid cell's Escape-cancels-edit
|
|
653
|
+
// onKeyDown) and let a lower layer act on a higher layer's keys, on
|
|
654
|
+
// keydown, before any keyup layering logic runs. Keyboard sibling of the
|
|
655
|
+
// click curtain above. Keyup deliberately keeps flowing: RN-web Modal
|
|
656
|
+
// closes on a document-level keyup listener, and this popover's own
|
|
657
|
+
// Escape handling is a document capture keyup — neither must be starved.
|
|
658
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
629
659
|
>
|
|
630
660
|
{small && (
|
|
631
661
|
<View
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, test, beforeEach } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
hasModalLayerAbove,
|
|
5
|
+
isInLayerAbove,
|
|
6
|
+
snapshotOpenModalLayers,
|
|
7
|
+
} from "./popover_layers";
|
|
8
|
+
|
|
9
|
+
function addModal(): HTMLElement {
|
|
10
|
+
const modal = document.createElement("div");
|
|
11
|
+
modal.setAttribute("aria-modal", "true");
|
|
12
|
+
const inner = document.createElement("button");
|
|
13
|
+
modal.appendChild(inner);
|
|
14
|
+
document.body.appendChild(modal);
|
|
15
|
+
return modal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function addPopover(level: number): HTMLElement {
|
|
19
|
+
const popover = document.createElement("div");
|
|
20
|
+
popover.setAttribute("data-popover", "true");
|
|
21
|
+
popover.setAttribute("data-popover-level", String(level));
|
|
22
|
+
const inner = document.createElement("button");
|
|
23
|
+
popover.appendChild(inner);
|
|
24
|
+
document.body.appendChild(popover);
|
|
25
|
+
return popover;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("isInLayerAbove", () => {
|
|
33
|
+
test("plain page node is not above", () => {
|
|
34
|
+
const el = document.createElement("div");
|
|
35
|
+
document.body.appendChild(el);
|
|
36
|
+
expect(isInLayerAbove(el, 0, new Set())).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("document itself (page scroll target) is not above", () => {
|
|
40
|
+
expect(isInLayerAbove(document, 0, new Set())).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("text node resolves through its parent element", () => {
|
|
44
|
+
const modal = addModal();
|
|
45
|
+
const text = document.createTextNode("hello");
|
|
46
|
+
modal.appendChild(text);
|
|
47
|
+
expect(isInLayerAbove(text, 0, new Set())).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("node inside a deeper nested popover is above", () => {
|
|
51
|
+
const nested = addPopover(1);
|
|
52
|
+
expect(isInLayerAbove(nested.firstChild as Node, 0, new Set())).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("node inside a same-level popover is not above", () => {
|
|
56
|
+
const sibling = addPopover(0);
|
|
57
|
+
expect(isInLayerAbove(sibling.firstChild as Node, 0, new Set())).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("node inside a modal opened AFTER the popover is above (file preview)", () => {
|
|
61
|
+
const snapshot = snapshotOpenModalLayers(document);
|
|
62
|
+
const preview = addModal();
|
|
63
|
+
expect(isInLayerAbove(preview.firstChild as Node, 0, snapshot)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("node inside the modal the popover LIVES IN is not above (drawer scroll dismisses)", () => {
|
|
67
|
+
const drawer = addModal();
|
|
68
|
+
const snapshot = snapshotOpenModalLayers(document);
|
|
69
|
+
expect(isInLayerAbove(drawer.firstChild as Node, 0, snapshot)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("stacked modals: only the one opened after the popover is above", () => {
|
|
73
|
+
const drawer = addModal();
|
|
74
|
+
const snapshot = snapshotOpenModalLayers(document);
|
|
75
|
+
const preview = addModal();
|
|
76
|
+
expect(isInLayerAbove(drawer.firstChild as Node, 0, snapshot)).toBe(false);
|
|
77
|
+
expect(isInLayerAbove(preview.firstChild as Node, 0, snapshot)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("null snapshot treats any modal ancestor as above", () => {
|
|
81
|
+
const modal = addModal();
|
|
82
|
+
expect(isInLayerAbove(modal.firstChild as Node, 0, null)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("hasModalLayerAbove", () => {
|
|
87
|
+
test("false with no modals", () => {
|
|
88
|
+
expect(hasModalLayerAbove(document, new Set())).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("false while only pre-existing modals are open (popover in a drawer)", () => {
|
|
92
|
+
addModal();
|
|
93
|
+
const snapshot = snapshotOpenModalLayers(document);
|
|
94
|
+
expect(hasModalLayerAbove(document, snapshot)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("true while a modal opened after the popover is open, false after it closes", () => {
|
|
98
|
+
addModal();
|
|
99
|
+
const snapshot = snapshotOpenModalLayers(document);
|
|
100
|
+
const preview = addModal();
|
|
101
|
+
expect(hasModalLayerAbove(document, snapshot)).toBe(true);
|
|
102
|
+
preview.remove();
|
|
103
|
+
expect(hasModalLayerAbove(document, snapshot)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a reopened modal is a NEW element and counts as above", () => {
|
|
107
|
+
const snapshot = snapshotOpenModalLayers(document);
|
|
108
|
+
const first = addModal();
|
|
109
|
+
first.remove();
|
|
110
|
+
addModal();
|
|
111
|
+
expect(hasModalLayerAbove(document, snapshot)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Layer-aware "outside" test for the non-modal anchored Popover.
|
|
2
|
+
//
|
|
3
|
+
// The popover dismisses on ambient signals — outside clicks, page scrolls,
|
|
4
|
+
// Escape. "Outside" cannot be a DOM-containment test alone: overlays the
|
|
5
|
+
// popover opens FROM WITHIN (a file-preview Modal, an Alert confirm, a nested
|
|
6
|
+
// popover) are React children but DOM SIBLINGS — they portal to document.body
|
|
7
|
+
// or a PortalHost — so containment misreads interactions inside them as "the
|
|
8
|
+
// user moved on" and dismisses the popover, unmounting the very overlay being
|
|
9
|
+
// used. Per WAI-ARIA, aria-modal content blocks interaction with everything
|
|
10
|
+
// beneath it, so an event inside such a layer says nothing about the layers
|
|
11
|
+
// underneath.
|
|
12
|
+
//
|
|
13
|
+
// A modal layer is ABOVE the popover exactly when it appeared AFTER the
|
|
14
|
+
// popover opened — it can only have been opened from within. Anything already
|
|
15
|
+
// open at that moment (the Dialog/Drawer the popover lives in) is part of the
|
|
16
|
+
// page: scrolling a drawer body moves the popover's anchor and must still
|
|
17
|
+
// dismiss. Callers snapshot the open modal set at open time and pass it in.
|
|
18
|
+
|
|
19
|
+
export function snapshotOpenModalLayers(doc: Document): ReadonlySet<Element> {
|
|
20
|
+
return new Set(doc.querySelectorAll('[aria-modal="true"]'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when `node` sits inside an overlay stacked above the popover: a deeper
|
|
25
|
+
* nested popover, or a modal layer that opened after the popover did.
|
|
26
|
+
* `modalsAtOpen` null (no snapshot yet) is treated as "nothing was open".
|
|
27
|
+
*/
|
|
28
|
+
export function isInLayerAbove(
|
|
29
|
+
node: Node,
|
|
30
|
+
popoverLevel: number,
|
|
31
|
+
modalsAtOpen: ReadonlySet<Element> | null,
|
|
32
|
+
): boolean {
|
|
33
|
+
const el = node instanceof Element ? node : node.parentElement;
|
|
34
|
+
if (!el) return false;
|
|
35
|
+
const popoverHit = el.closest("[data-popover]");
|
|
36
|
+
if (popoverHit && Number(popoverHit.getAttribute("data-popover-level") ?? "0") > popoverLevel) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
const modalHit = el.closest('[aria-modal="true"]');
|
|
40
|
+
return modalHit !== null && !(modalsAtOpen?.has(modalHit) ?? false);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True while any modal layer opened after the popover is still open. The
|
|
45
|
+
* popover's Escape handler yields then: RN-web's own document keyup listener
|
|
46
|
+
* closes the topmost modal, and stopping the event at the popover would
|
|
47
|
+
* instead close the popover and unmount that modal with it.
|
|
48
|
+
*/
|
|
49
|
+
export function hasModalLayerAbove(
|
|
50
|
+
doc: Document,
|
|
51
|
+
modalsAtOpen: ReadonlySet<Element> | null,
|
|
52
|
+
): boolean {
|
|
53
|
+
for (const modal of doc.querySelectorAll('[aria-modal="true"]')) {
|
|
54
|
+
if (!(modalsAtOpen?.has(modal) ?? false)) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
package/src/use_focus_ring.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useState } from "react";
|
|
2
|
+
import { ensureModalityListeners, getInteractionModality } from "./interaction_modality";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Combine two optional event handlers into one. Either may be undefined.
|
|
@@ -17,31 +18,6 @@ export function composeHandler<E>(
|
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
// `:focus-visible` is a MODALITY heuristic: a control shows its ring on keyboard
|
|
21
|
-
// focus but not pointer focus. The kit no longer carries a global `:focus-visible`
|
|
22
|
-
// CSS rule, so every control paints its own ring — and needs that same signal. One
|
|
23
|
-
// focus event carries no modality, so it can only be read at the document level:
|
|
24
|
-
// a single module-level tracker records the last interaction. Web-only — on native
|
|
25
|
-
// / SSR there is no `document`, so it stays "pointer" and the ring is keyboard-gated
|
|
26
|
-
// off (text-like inputs opt back in with `always`).
|
|
27
|
-
type Modality = "keyboard" | "pointer";
|
|
28
|
-
let lastModality: Modality = "pointer";
|
|
29
|
-
let listenersInstalled = false;
|
|
30
|
-
|
|
31
|
-
function ensureModalityListeners(): void {
|
|
32
|
-
if (listenersInstalled) return;
|
|
33
|
-
if (typeof document === "undefined") return;
|
|
34
|
-
listenersInstalled = true;
|
|
35
|
-
// Capture phase so the modality is recorded BEFORE any control's focus handler
|
|
36
|
-
// runs. Installed once for the app's lifetime (the browser's own `:focus-visible`
|
|
37
|
-
// heuristic listens the same way) — per-mount add/remove would be the bug.
|
|
38
|
-
const opts = { capture: true, passive: true } as const;
|
|
39
|
-
document.addEventListener("keydown", () => { lastModality = "keyboard"; }, opts);
|
|
40
|
-
document.addEventListener("pointerdown", () => { lastModality = "pointer"; }, opts);
|
|
41
|
-
document.addEventListener("mousedown", () => { lastModality = "pointer"; }, opts);
|
|
42
|
-
document.addEventListener("touchstart", () => { lastModality = "pointer"; }, opts);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
21
|
export interface UseFocusRingOptions {
|
|
46
22
|
/**
|
|
47
23
|
* Ring on ANY focus, not just keyboard focus. For text-like inputs, which the
|
|
@@ -69,12 +45,13 @@ export interface FocusRingState {
|
|
|
69
45
|
* Replaces the kit's removed global `:focus-visible` CSS rule: each interactive
|
|
70
46
|
* primitive calls this and renders `FOCUS_RING` (`control_surface.ts`) when
|
|
71
47
|
* `focusVisible`. App authors do the same for their own raw focusable elements.
|
|
48
|
+
* The keyboard-vs-pointer signal lives in `interaction_modality.ts`.
|
|
72
49
|
*/
|
|
73
50
|
export function useFocusRing(options?: UseFocusRingOptions): FocusRingState {
|
|
74
51
|
ensureModalityListeners();
|
|
75
52
|
const [focused, setFocused] = useState(false);
|
|
76
53
|
const onFocus = useCallback(() => setFocused(true), []);
|
|
77
54
|
const onBlur = useCallback(() => setFocused(false), []);
|
|
78
|
-
const focusVisible = focused && (options?.always === true ||
|
|
55
|
+
const focusVisible = focused && (options?.always === true || getInteractionModality() === "keyboard");
|
|
79
56
|
return { focusVisible, focused, focusProps: { onFocus, onBlur } };
|
|
80
57
|
}
|