@lotics/ui 11.7.3 → 11.7.4

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/docs/catalog.md CHANGED
@@ -676,10 +676,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
676
676
  own scroll, like `OptionList`): the anchored floating surface. **NON-MODAL**: the anchored
677
677
  popover has NO blocking overlay — the rest of the page stays interactive, and clicking another
678
678
  control both dismisses this popover AND activates that control in one click; clicking outside,
679
- scrolling an ancestor, or Escape dismisses. Only the `small` (bottom-sheet) presentation is
680
- modal (scrim). `PopoverContent` already insets its body 12px put content directly in it,
681
- NEVER add your own padding `View` (that double-pads); title/actions go in `PopoverHeader` /
682
- `PopoverFooter`.
679
+ scrolling an ancestor, or Escape dismisses. Interactions inside a layer the popover opened
680
+ from within (a nested popover, an `Alert` confirm, a full-screen Modal like the file preview)
681
+ never dismiss it clicks, scrolls, and Escape belong to that layer until it closes. Only the
682
+ `small` (bottom-sheet) presentation is modal (scrim). `PopoverContent` already insets its
683
+ body 12px — put content directly in it, NEVER add your own padding `View` (that double-pads);
684
+ title/actions go in `PopoverHeader` / `PopoverFooter`.
683
685
  - **`popover_nav`** — `usePopoverNav` + `PopoverScreen` + `PopoverNavHeader` — the popover's
684
686
  built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes,
685
687
  `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.7.3",
3
+ "version": "11.7.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -115,6 +115,7 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
115
115
  // additionally can't hold filename + every action inline (the close button gets
116
116
  // pushed off-screen), so the remaining actions collapse into a ⋯ menu.
117
117
  const { small } = useScreenSize();
118
+ const locale = useLoticsLocale();
118
119
 
119
120
  const close = useCallback(() => onIndexChange(null), [onIndexChange]);
120
121
  const next = useCallback(() => {
@@ -154,7 +155,7 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
154
155
  const file = files[activeIndex];
155
156
  if (!file) return null;
156
157
 
157
- const l = { ...useLoticsLocale().gallery, ...labels };
158
+ const l = { ...locale.gallery, ...labels };
158
159
  const isImage = isImageMimeType(file.mimeType);
159
160
  const total = files.length;
160
161
  const rotated = rot.rotationFor(file.id) !== 0;
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 INSIDE
325
- // a deeper (nested) popover keeps this one open — a Select opened inside a filter
326
- // popover must not dismiss the filter. The bottom sheet (`small`) is modal and
327
- // dismisses via its scrim, so it opts out here.
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
- const el = node instanceof Element ? node : node.parentElement;
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. Capture catches nested scrollers.
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
- if (e.target instanceof Node && popoverRef.current?.contains(e.target)) return;
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
+ }