@enigmax/primitives 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/{chunk-VKL3DEIQ.js → chunk-6WIGVIQF.js} +58 -37
  2. package/dist/{chunk-O2UJ5ZIX.js → chunk-CB7K6VW4.js} +1 -1
  3. package/dist/{chunk-2RIO4VDV.js → chunk-IQMHDRJF.js} +2 -2
  4. package/dist/{chunk-PVDU6AZU.js → chunk-JPBVKXOL.js} +1 -1
  5. package/dist/{chunk-CPJJ3FPW.js → chunk-RRPWJAIJ.js} +26 -12
  6. package/dist/chunk-RXEO4WPI.js +111 -0
  7. package/dist/{chunk-RMNXVWQ4.js → chunk-YHH5U2NW.js} +1 -1
  8. package/dist/{color-T63FLJNH.js → color-WBNVPFT5.js} +157 -158
  9. package/dist/{menu-52PXC5PL.js → menu-5JZMGNOT.js} +2 -1
  10. package/dist/{menu-YIGMPFUP.js → menu-O3G4LM5O.js} +3 -2
  11. package/dist/next/index.js +6 -5
  12. package/dist/react/context-menu.d.ts +1 -1
  13. package/dist/react/context-menu.js +2 -1
  14. package/dist/react/image.js +1 -1
  15. package/dist/react/index.js +6 -5
  16. package/dist/react/input.js +1 -1
  17. package/dist/react/select.d.ts +6 -1
  18. package/dist/react/select.js +2 -1
  19. package/dist/react/video.js +1 -1
  20. package/dist/react-router/index.js +6 -5
  21. package/dist/viewer-C323RYNZ.js +3 -0
  22. package/package.json +1 -1
  23. package/recipes/color/styles.css +12 -7
  24. package/recipes/context-menu/styles.css +9 -2
  25. package/recipes/select/styles.css +24 -7
  26. package/registry.json +98 -5
  27. package/src/core/floating.ts +71 -0
  28. package/src/react/context-menu/root.tsx +30 -15
  29. package/src/react/context-menu/styles.ts +9 -2
  30. package/src/react/floating.ts +185 -0
  31. package/src/react/input/color-styles.ts +12 -7
  32. package/src/react/input/color.tsx +30 -27
  33. package/src/react/select/context.ts +5 -0
  34. package/src/react/select/root.tsx +33 -21
  35. package/src/react/select/styles.ts +24 -7
  36. package/dist/viewer-N5WLFB24.js +0 -3
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { placeFloating, type FloatingRect, type FloatingSide } from "@/core/floating";
4
+ import { useCallback, useEffect, useLayoutEffect, useState, type DependencyList, type KeyboardEvent, type RefObject } from "react";
5
+
6
+ /**
7
+ * A popup that cannot be clipped: portaled out of the tree and placed in viewport coordinates.
8
+ *
9
+ * Left where it is declared, an absolutely positioned panel belongs to its ancestors: any
10
+ * `overflow: hidden` between it and the page cuts it off, and any stacking context puts it
11
+ * under whatever the page raised - so a select inside a dialog, a card or a scrolling table
12
+ * opens a list that is half there. Every primitive popup that hangs from a trigger (the
13
+ * select, the colour picker) and the context menu go through here, so they escape the same
14
+ * way and sit on the same layer (`--enigma-floating-z`, above the lightbox).
15
+ *
16
+ * The portal goes into `<body>`, except inside a dialog. A native modal `<dialog>` makes
17
+ * everything outside it inert, and a script dialog (anything with `role="dialog"` or
18
+ * `aria-modal`) traps focus and treats a press outside itself as dismissal - a panel in
19
+ * `<body>` would be unclickable, or would close the dialog it was opened from. Inside the
20
+ * dialog it is still `position: fixed`, which escapes every scrolling region in the dialog;
21
+ * only a dialog that is itself a containing block (a `transform`, typically the centring
22
+ * one) can clip it, and then the dialog's box is the boundary the panel flips and shrinks
23
+ * inside, rather than a line it is cut at.
24
+ */
25
+
26
+ /** Where a panel that hangs from `anchor` is portaled to. Null during a server render. */
27
+ export function floatingContainer(anchor: Element | null): HTMLElement | null {
28
+ if (typeof document === "undefined") return null;
29
+ const host = anchor?.closest<HTMLElement>("dialog[open], [popover], [role=dialog], [role=alertdialog], [aria-modal=true]");
30
+ return host ?? document.body;
31
+ }
32
+
33
+ /**
34
+ * The frame a fixed panel lives in: what `left: 0; top: 0` resolves to, and the box it has
35
+ * to stay inside.
36
+ *
37
+ * Measured with a probe rather than assumed, because `fixed` resolves against the viewport
38
+ * only until an ancestor has a `transform`, a `filter` or `contain` - and then it resolves
39
+ * against that ancestor, so viewport coordinates written as-is land offset by wherever the
40
+ * ancestor happens to be.
41
+ */
42
+ export function measureFrame(panel: HTMLElement): { x: number; y: number; boundary: FloatingRect; } {
43
+ const root = document.documentElement;
44
+ const viewport: FloatingRect = { left: 0, top: 0, right: root.clientWidth || window.innerWidth, bottom: root.clientHeight || window.innerHeight };
45
+ const host = panel.parentElement;
46
+ if (!host || host === document.body) return { x: 0, y: 0, boundary: viewport };
47
+
48
+ const probe = document.createElement("div");
49
+ probe.style.cssText = "position:fixed;left:0;top:0;width:0;height:0;visibility:hidden;pointer-events:none";
50
+ host.appendChild(probe);
51
+ const origin = probe.getBoundingClientRect();
52
+ probe.remove();
53
+
54
+ const box = host.getBoundingClientRect();
55
+ // The host IS the containing block when the probe sits on its padding box. Then its own
56
+ // overflow clips the panel, and the room the panel has is the part of the host on screen.
57
+ const isBlock = Math.abs(origin.left - (box.left + host.clientLeft)) < 1 && Math.abs(origin.top - (box.top + host.clientTop)) < 1;
58
+ const style = getComputedStyle(host);
59
+ const clips = style.overflowX !== "visible" || style.overflowY !== "visible";
60
+ const boundary = isBlock && clips
61
+ ? {
62
+ left: Math.max(viewport.left, box.left), top: Math.max(viewport.top, box.top),
63
+ right: Math.min(viewport.right, box.right), bottom: Math.min(viewport.bottom, box.bottom)
64
+ }
65
+ : viewport;
66
+ return { x: origin.left, y: origin.top, boundary };
67
+ }
68
+
69
+ export interface UseFloatingOptions {
70
+ /** Whether the panel is up. Listeners exist only while it is. */
71
+ open: boolean;
72
+ /** What the panel hangs from. */
73
+ anchorRef: RefObject<HTMLElement | null>;
74
+ /** The panel itself, which has to be rendered into the returned container. */
75
+ panelRef: RefObject<HTMLElement | null>;
76
+ gap?: number;
77
+ margin?: number;
78
+ side?: FloatingSide | "auto";
79
+ /** Things that change the panel's size, re-placed in the same frame rather than the next. */
80
+ deps?: DependencyList;
81
+ }
82
+
83
+ /**
84
+ * Places a portaled panel against its anchor and keeps it there.
85
+ *
86
+ * Returns the element to portal into - null until the first client effect, so a server
87
+ * render and the hydration render agree on rendering no panel at all.
88
+ *
89
+ * The placement is written onto the element rather than kept in state: it lands in the same
90
+ * layout pass the panel mounts in, before paint and before any effect of the panel's own
91
+ * children. That order matters - the select's search field focuses itself in an effect, and a
92
+ * panel still waiting for a state round-trip to be placed would be taking focus off-screen.
93
+ * `--enigma-anchor-width` is the trigger's width, which is what a list's `min-width` needs now
94
+ * that `100%` would be a percentage of the window.
95
+ */
96
+ export function useFloating({ open, anchorRef, panelRef, gap = 4, margin = 8, side = "auto", deps = [] }: UseFloatingOptions): HTMLElement | null {
97
+ const [container, setContainer] = useState<HTMLElement | null>(null);
98
+
99
+ useLayoutEffect(() => {
100
+ if (open) setContainer(floatingContainer(anchorRef.current));
101
+ }, [open, anchorRef]);
102
+
103
+ const place = useCallback(() => {
104
+ const anchor = anchorRef.current;
105
+ const panel = panelRef.current;
106
+ if (!anchor || !panel) return;
107
+ const rect = anchor.getBoundingClientRect();
108
+ panel.style.setProperty("--enigma-anchor-width", `${rect.width}px`);
109
+ // Measured at its natural height: the cap from the last placement would otherwise be
110
+ // read back as the panel's size, and a panel that shrank once could never grow again.
111
+ panel.style.maxHeight = "";
112
+ const frame = measureFrame(panel);
113
+ const placed = placeFloating(rect, { width: panel.offsetWidth, height: panel.offsetHeight }, { boundary: frame.boundary, margin, gap, side });
114
+ panel.style.left = `${placed.left - frame.x}px`;
115
+ panel.style.top = `${placed.top - frame.y}px`;
116
+ panel.style.maxHeight = `${placed.maxHeight}px`;
117
+ panel.setAttribute("data-side", placed.side);
118
+ }, [anchorRef, panelRef, margin, gap, side]);
119
+
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
121
+ useLayoutEffect(() => { if (open) place(); }, [open, container, place, ...deps]);
122
+
123
+ /**
124
+ * Kept against its anchor while anything moves. Fixed coordinates do not follow a scroll,
125
+ * so every scroll anywhere (captured - a container's scroll does not bubble) and every
126
+ * resize re-places it, one frame at a time. A ResizeObserver covers what no event reports:
127
+ * the trigger growing a row of tags, the list filtering down to three rows.
128
+ */
129
+ useEffect(() => {
130
+ if (!open || !container) return;
131
+ let frame = 0;
132
+ const schedule = (): void => {
133
+ if (frame) return;
134
+ frame = requestAnimationFrame(() => { frame = 0; place(); });
135
+ };
136
+ const onScroll = (event: Event): void => {
137
+ // The panel's own list scrolling moves nothing.
138
+ if (panelRef.current?.contains(event.target as Node | null)) return;
139
+ schedule();
140
+ };
141
+ document.addEventListener("scroll", onScroll, true);
142
+ window.addEventListener("resize", schedule);
143
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
144
+ if (anchorRef.current) observer?.observe(anchorRef.current);
145
+ if (panelRef.current) observer?.observe(panelRef.current);
146
+ return () => {
147
+ cancelAnimationFrame(frame);
148
+ document.removeEventListener("scroll", onScroll, true);
149
+ window.removeEventListener("resize", schedule);
150
+ observer?.disconnect();
151
+ };
152
+ }, [open, container, place, anchorRef, panelRef]);
153
+
154
+ return container;
155
+ }
156
+
157
+ const TABBABLE = "a[href], button:not(:disabled), input:not(:disabled):not([type=hidden]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex='-1'])";
158
+
159
+ /**
160
+ * Tab out of a portaled panel, landing where it would have if the panel were still next to
161
+ * its trigger.
162
+ *
163
+ * In the tree, the panel came straight after the trigger, so Tab off its last control went to
164
+ * whatever follows the trigger and Shift+Tab off its first went back to the trigger. In
165
+ * `<body>` the panel is at the end of the page, and the same keys would send focus to the
166
+ * browser's own chrome or the last link on the page. So: Shift+Tab off the first control
167
+ * focuses the trigger itself, and Tab off the last focuses the trigger and lets the key carry
168
+ * on from there, which is the element after it.
169
+ *
170
+ * Returns whether the key left the panel.
171
+ */
172
+ export function tabOut(event: KeyboardEvent, panel: HTMLElement | null, trigger: HTMLElement | null): boolean {
173
+ if (event.key !== "Tab" || !panel || !trigger) return false;
174
+ const stops = Array.from(panel.querySelectorAll<HTMLElement>(TABBABLE));
175
+ const at = stops.indexOf(document.activeElement as HTMLElement);
176
+ if (event.shiftKey) {
177
+ if (at > 0) return false;
178
+ event.preventDefault();
179
+ trigger.focus();
180
+ return true;
181
+ }
182
+ if (at !== -1 && at < stops.length - 1) return false;
183
+ trigger.focus();
184
+ return true;
185
+ }
@@ -36,6 +36,11 @@ export const COLOR_STYLES = `
36
36
  on, which is why only one colour is a property. */
37
37
  --enigma-color-checker: rgba(255, 255, 255, 0.22);
38
38
  --enigma-color-checker-size: 0.5rem;
39
+
40
+ /* The layer every portaled popup shares, declared with the same value by the select and
41
+ the context menu: above the lightbox (9999), because a popup is opened FROM whatever
42
+ is on top. Not in the light-scheme block, or half the readers get no z-index. */
43
+ --enigma-floating-z: 10000;
39
44
  }
40
45
 
41
46
  @media (prefers-color-scheme: light) {
@@ -52,9 +57,7 @@ export const COLOR_STYLES = `
52
57
  }
53
58
  }
54
59
 
55
- /* The anchor the panel hangs from. Inline so it sits in the field's own row, and the
56
- positioning context so the panel needs no portal and no scroll listener: it moves with
57
- the field because it is INSIDE it. */
60
+ /* The anchor the panel is placed against. Inline so it sits in the field's own row. */
58
61
  [data-enigma-color] { position: relative; display: inline-flex; flex: none; }
59
62
 
60
63
  [data-enigma-color-swatch] {
@@ -78,8 +81,13 @@ export const COLOR_STYLES = `
78
81
  rather than the last good colour: a stale swatch says the value is fine when it is not. */
79
82
  [data-enigma-color-swatch][data-invalid] [data-enigma-color-fill] { background: none; }
80
83
 
84
+ /* Portaled and placed in viewport coordinates against the swatch (see react/floating.ts),
85
+ so a dialog or a card with overflow: hidden cannot cut it off. left, top, max-height and
86
+ data-side are written by the placement: below, flipped above when there is more room
87
+ there, and capped to the room it has - scrolling inside rather than off the screen. */
81
88
  [data-enigma-color-panel] {
82
- position: absolute; z-index: 60; top: calc(100% + 0.375rem); left: 0;
89
+ position: fixed; z-index: var(--enigma-floating-z);
90
+ overflow-y: auto; overscroll-behavior: contain;
83
91
  display: grid; gap: 0.625rem;
84
92
  box-sizing: border-box; width: var(--enigma-color-panel-width); padding: 0.625rem;
85
93
  color: var(--enigma-color-text); background: var(--enigma-color-panel-bg);
@@ -87,9 +95,6 @@ export const COLOR_STYLES = `
87
95
  border-radius: var(--enigma-color-panel-radius);
88
96
  box-shadow: var(--enigma-color-panel-shadow);
89
97
  }
90
- /* Flipped when the field sits near the bottom of the window. Without it the panel opens off
91
- the screen and the controls are unreachable, which is the same defect the select fixes. */
92
- [data-enigma-color-panel][data-side="top"] { top: auto; bottom: calc(100% + 0.375rem); }
93
98
 
94
99
  [data-enigma-color-area] {
95
100
  position: relative; height: var(--enigma-color-area-height);
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { createPortal } from "react-dom";
4
+ import { tabOut, useFloating } from "@/react/floating";
3
5
  import { writeValue } from "@/react/input/write-value";
4
6
  import { ColorSwatch } from "@/react/input/color-swatch";
5
7
  import { COLOR_STYLES } from "@/react/input/color-styles";
@@ -48,6 +50,9 @@ const STEP = { fine: 0.01, coarse: 0.1, hue: 1, hueCoarse: 10 };
48
50
  /** Kept clear of the window edge when deciding which side to open on. */
49
51
  const MARGIN = 12;
50
52
 
53
+ /** Between the swatch and the panel: the 0.375rem the sheet used to put there. */
54
+ const GAP = 6;
55
+
51
56
  /** The notations the readout cycles through, in the order the button steps over them. */
52
57
  const FORMATS: ColorFormat[] = ["hex", "rgb", "hsl"];
53
58
  const FORMAT_LABELS: Record<ColorFormat, string> = { hex: "HEX", rgb: "RGB", hsl: "HSL" };
@@ -111,7 +116,6 @@ export function ColorExtras({
111
116
  const areaRef = useRef<HTMLDivElement | null>(null);
112
117
 
113
118
  const [open, setOpen] = useState(openOnMount && !locked);
114
- const [side, setSide] = useState<"top" | "bottom">(placement === "top" ? "top" : "bottom");
115
119
 
116
120
  const parsed = useMemo(() => parseColor(value), [value]);
117
121
  const [hsv, setHsv] = useState<Hsv>(() => (parsed ? rgbToHsv(parsed) : INITIAL));
@@ -169,29 +173,28 @@ export function ColorExtras({
169
173
 
170
174
  /* -------- where the panel goes, and what dismisses it -------- */
171
175
 
172
- const place = useCallback(() => {
173
- if (placement !== "auto") return setSide(placement);
174
- const anchor = anchorRef.current?.getBoundingClientRect();
175
- const panel = panelRef.current?.getBoundingClientRect();
176
- if (!anchor || !panel) return;
177
- const below = window.innerHeight - anchor.bottom;
178
- // Flipped only when there is genuinely more room the other way: near the bottom of the
179
- // window an unflipped panel hangs off the screen and its rails cannot be reached.
180
- setSide(below < panel.height + MARGIN && anchor.top > below ? "top" : "bottom");
181
- }, [placement]);
182
-
183
- useLayoutEffect(() => {
184
- if (!open) return;
185
- place();
186
- }, [open, place]);
176
+ /**
177
+ * Portaled and placed against the swatch rather than hung from it: a colour field inside a
178
+ * dialog or a card with `overflow: hidden` would otherwise open a picker cut in half. The
179
+ * side is measured unless `placement` insists - below, flipped above when there is more
180
+ * room there - and capped to the room it has. See `react/floating.ts`.
181
+ */
182
+ const showing = open && !locked;
183
+ const container = useFloating({ open: showing, anchorRef, panelRef, gap: GAP, margin: MARGIN, side: placement });
184
+
185
+ // Focus goes into the panel, because the press that opened it was on a control the
186
+ // keyboard has to be able to keep using. The square is what the arrows drive. Keyed on the
187
+ // container as well: the panel only exists once there is somewhere to portal it.
188
+ useEffect(() => {
189
+ if (showing && container) areaRef.current?.focus();
190
+ }, [showing, container]);
187
191
 
188
192
  useEffect(() => {
189
193
  if (!open) return;
190
- // Focus goes into the panel, because the press that opened it was on a control the
191
- // keyboard has to be able to keep using. The square is what the arrows drive.
192
- areaRef.current?.focus();
193
194
 
194
- const inside = (target: EventTarget | null): boolean => Boolean(anchorRef.current?.contains(target as Node | null));
195
+ // The swatch or the panel: the panel is portaled, so it is not inside the anchor.
196
+ const inside = (target: EventTarget | null): boolean =>
197
+ Boolean(anchorRef.current?.contains(target as Node | null) || panelRef.current?.contains(target as Node | null));
195
198
  const onPointerDown = (event: globalThis.PointerEvent): void => { if (!inside(event.target)) setOpenState(false); };
196
199
  /**
197
200
  * Tabbing out closes it. `document.body` is skipped on purpose: pressing the panel's
@@ -202,17 +205,14 @@ export function ColorExtras({
202
205
  if (event.target === document.body || inside(event.target)) return;
203
206
  setOpenState(false);
204
207
  };
205
- const onResize = (): void => place();
206
208
 
207
209
  document.addEventListener("pointerdown", onPointerDown, true);
208
210
  document.addEventListener("focusin", onFocusIn, true);
209
- window.addEventListener("resize", onResize);
210
211
  return () => {
211
212
  document.removeEventListener("pointerdown", onPointerDown, true);
212
213
  document.removeEventListener("focusin", onFocusIn, true);
213
- window.removeEventListener("resize", onResize);
214
214
  };
215
- }, [open, place, setOpenState]);
215
+ }, [open, setOpenState]);
216
216
 
217
217
  /* -------- dragging -------- */
218
218
 
@@ -354,15 +354,17 @@ export function ColorExtras({
354
354
  label={text.open ?? "Pick a colour"}
355
355
  onPress={() => (open ? close(true) : setOpenState(true))}
356
356
  >
357
- {open && !locked && (
357
+ {showing && container && createPortal(
358
358
  <div
359
359
  ref={panelRef}
360
360
  data-enigma-color-panel=""
361
- data-side={side}
362
361
  role="dialog"
363
362
  aria-label={text.panel ?? "Colour picker"}
364
363
  style={panelStyle}
365
364
  onKeyDown={(event) => {
365
+ // Off either end of the panel, Tab lands beside the swatch, where the
366
+ // panel used to sit - not at the end of the page it is portaled to.
367
+ if (tabOut(event, panelRef.current, swatchRef.current)) return;
366
368
  if (event.key !== "Escape") return;
367
369
  event.stopPropagation();
368
370
  close(true);
@@ -504,7 +506,8 @@ export function ColorExtras({
504
506
  })}
505
507
  </div>
506
508
  )}
507
- </div>
509
+ </div>,
510
+ container
508
511
  )}
509
512
  </ColorSwatch>
510
513
  );
@@ -36,6 +36,11 @@ export interface SelectContextValue {
36
36
  optionId: (index: number) => string;
37
37
  triggerRef: RefObject<HTMLButtonElement | null>;
38
38
  fieldRef: RefObject<HTMLInputElement | null>;
39
+ /**
40
+ * The panel. Portaled out of the root, so "inside the select" is the root OR this - a
41
+ * press or a focus check against the root alone would call every option outside.
42
+ */
43
+ contentRef: RefObject<HTMLDivElement | null>;
39
44
  /** Close and put focus back where it came from - the trigger, always. */
40
45
  close: () => void;
41
46
  /** The keys the list answers to, shared by the trigger and the search field. */
@@ -1,9 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { Slot } from "@/react/slot";
4
+ import { createPortal } from "react-dom";
4
5
  import { groupRows } from "@/core/palette";
5
6
  import { shortenQuery } from "@/core/search";
6
7
  import { SELECT_STYLES } from "@/react/select/styles";
8
+ import { tabOut, useFloating } from "@/react/floating";
7
9
  import { SelectContext, useSelectContext, type SelectItem } from "@/react/select/context";
8
10
  import { createSelect, type SelectInstance, type SelectMoveKey, type SelectOptions, type SelectState } from "@/core/select";
9
11
  import {
@@ -174,6 +176,9 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
174
176
  const triggerRef = useRef<HTMLButtonElement | null>(null);
175
177
  const fieldRef = useRef<HTMLInputElement | null>(null);
176
178
  const rootRef = useRef<HTMLDivElement | null>(null);
179
+ const contentRef = useRef<HTMLDivElement | null>(null);
180
+ /** The root or the portaled panel: both are the select, only one is its DOM subtree. */
181
+ const owns = useCallback((node: Node | null) => Boolean(node && (rootRef.current?.contains(node) || contentRef.current?.contains(node))), []);
177
182
 
178
183
  // Kept in a ref so the instance - built once - always calls the CURRENT props rather
179
184
  // than the ones it closed over on the first render.
@@ -304,10 +309,10 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
304
309
  // Whatever closed it was inside the panel - a row, or Enter in the search field -
305
310
  // and the panel is about to unmount with the focus still in it. Focus goes back to
306
311
  // the trigger rather than to the body, exactly as Escape does it.
307
- const held = rootRef.current?.contains(document.activeElement);
312
+ const held = owns(document.activeElement);
308
313
  setOpen(false);
309
314
  if (held) triggerRef.current?.focus();
310
- }, [state.open, open, setOpen]);
315
+ }, [state.open, open, setOpen, owns]);
311
316
 
312
317
  const close = useCallback(() => {
313
318
  setOpen(false);
@@ -321,12 +326,12 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
321
326
  useEffect(() => {
322
327
  if (!open) return;
323
328
  const onPointerDown = (event: PointerEvent) => {
324
- if (rootRef.current?.contains(event.target as Node)) return;
329
+ if (owns(event.target as Node)) return;
325
330
  setOpen(false);
326
331
  };
327
332
  document.addEventListener("pointerdown", onPointerDown, true);
328
333
  return () => document.removeEventListener("pointerdown", onPointerDown, true);
329
- }, [open, setOpen]);
334
+ }, [open, setOpen, owns]);
330
335
 
331
336
  const onListKeyDown = useCallback((event: KeyboardEvent) => {
332
337
  const move = MOVE_KEYS[event.key];
@@ -351,6 +356,9 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
351
356
  return;
352
357
  }
353
358
  if (event.key === "Tab" && open) {
359
+ // The search field is in the portaled panel, at the end of the page: off it, Tab
360
+ // goes where it would have from beside the trigger, not to the browser chrome.
361
+ if (contentRef.current?.contains(event.target as Node)) tabOut(event, contentRef.current, triggerRef.current);
354
362
  setOpen(false);
355
363
  return;
356
364
  }
@@ -399,6 +407,7 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
399
407
  optionId: (index: number) => `${id}-option-${index}`,
400
408
  triggerRef,
401
409
  fieldRef,
410
+ contentRef,
402
411
  close,
403
412
  onListKeyDown
404
413
  }), [instance, state, setOpen, disabled, clearable, multiple, isSearchable, isEmpty, loading, emptyLabel, loadingLabel, ids, id, close, onListKeyDown]);
@@ -614,9 +623,7 @@ export interface SelectContentProps extends ComponentPropsWithoutRef<"div"> {
614
623
 
615
624
  export function SelectContent({ closeDuration = 120, children, ...props }: SelectContentProps): ReactNode {
616
625
  const select = useSelectContext("Select.Content");
617
- const ref = useRef<HTMLDivElement | null>(null);
618
626
  const [mounted, setMounted] = useState(select.state.open);
619
- const [side, setSide] = useState<"top" | "bottom">("bottom");
620
627
 
621
628
  useEffect(() => {
622
629
  if (select.state.open) { setMounted(true); return; }
@@ -625,33 +632,38 @@ export function SelectContent({ closeDuration = 120, children, ...props }: Selec
625
632
  return () => clearTimeout(timer);
626
633
  }, [select.state.open, closeDuration]);
627
634
 
628
- // Which way it opens is measured, not assumed: a select near the bottom of the window
629
- // opens upwards, or its list is off the screen and unreachable.
630
- useLayoutEffect(() => {
631
- if (!select.state.open || !ref.current) return;
632
- const trigger = select.triggerRef.current?.getBoundingClientRect();
633
- if (!trigger) return;
634
- const height = ref.current.offsetHeight;
635
- const below = window.innerHeight - trigger.bottom;
636
- setSide(below < height && trigger.top > below ? "top" : "bottom");
637
- }, [select.state.open, select.state.visible.length, select.triggerRef]);
635
+ /**
636
+ * Portaled and placed against the trigger, never left in the tree.
637
+ *
638
+ * An absolute panel inside the root is clipped by any `overflow: hidden` ancestor and
639
+ * stacked under any dialog - which is where a select usually sits. So it goes to `<body>`
640
+ * (or into the dialog it was opened from - see `react/floating.ts`), measured against the
641
+ * trigger: below it, flipped above when there is more room there, and capped to the room
642
+ * it has with the list scrolling inside, because a list off the screen is unreachable.
643
+ */
644
+ const container = useFloating({
645
+ open: mounted && select.state.open,
646
+ anchorRef: select.triggerRef,
647
+ panelRef: select.contentRef,
648
+ deps: [select.state.visible.length]
649
+ });
638
650
 
639
- if (!mounted) return null;
651
+ if (!mounted || !container) return null;
640
652
 
641
- return (
653
+ return createPortal(
642
654
  <div
643
655
  {...props}
644
- ref={ref}
656
+ ref={select.contentRef}
645
657
  data-enigma-select-content=""
646
658
  data-state={select.state.open ? "open" : "closed"}
647
- data-side={side}
648
659
  onKeyDown={(event) => {
649
660
  props.onKeyDown?.(event);
650
661
  if (!event.defaultPrevented) select.onListKeyDown(event);
651
662
  }}
652
663
  >
653
664
  {children}
654
- </div>
665
+ </div>,
666
+ container
655
667
  );
656
668
  }
657
669
 
@@ -27,12 +27,17 @@ export const SELECT_STYLES = `
27
27
  --enigma-select-panel-border: #333333;
28
28
  --enigma-select-panel-radius: 0.625rem;
29
29
  --enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
30
- --enigma-select-panel-width: 100%;
31
30
  --enigma-select-list-height: 15rem;
32
31
 
33
32
  --enigma-select-active-bg: #2a2a2a;
34
33
  --enigma-select-accent: #fbbf24;
35
34
  --enigma-select-tag-bg: #2a2a2a;
35
+
36
+ /* The layer every portaled popup shares: above the lightbox (9999), because a popup is
37
+ opened FROM whatever is on top. The select, the colour picker and the context menu
38
+ each declare it with the same value, so overriding it once moves all three. Here and
39
+ not in the light-scheme block, or half the readers get an invalid z-index. */
40
+ --enigma-floating-z: 10000;
36
41
  }
37
42
 
38
43
  @media (prefers-color-scheme: light) {
@@ -137,11 +142,24 @@ export const SELECT_STYLES = `
137
142
  [data-enigma-select-trigger][data-clearable]:focus [data-enigma-select-caret] { opacity: 0; }
138
143
  [data-enigma-select-clear]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }
139
144
 
145
+ /* Portaled to <body> and placed in viewport coordinates against the trigger (see
146
+ react/floating.ts), so no ancestor's overflow can clip it and no dialog can stack over it.
147
+ left, top, max-height and data-side are written by the placement: below the trigger,
148
+ flipped above when there is more room there, and capped to the room it has. The column
149
+ is what makes the cap shrink the LIST, which scrolls, instead of cutting off the panel. */
140
150
  [data-enigma-select-content] {
141
- position: absolute; z-index: 50;
142
- top: calc(100% + 0.25rem); left: 0;
143
- min-width: var(--enigma-select-panel-width); box-sizing: border-box;
151
+ position: fixed; z-index: var(--enigma-floating-z);
152
+ display: flex; flex-direction: column;
153
+ /* As wide as the trigger, measured - 100% would now be a percentage of the window - and
154
+ wider only for content that cannot shrink. min-content is what keeps the rest at the
155
+ trigger's width: fixed, the panel's room is the whole window, and a long label or a
156
+ long "Nothing matches" would otherwise widen it to max-width. --enigma-select-panel-width
157
+ overrides the trigger's width. */
158
+ width: min-content;
159
+ min-width: var(--enigma-select-panel-width, var(--enigma-anchor-width, auto)); box-sizing: border-box;
144
160
  padding: 0.25rem;
161
+ color: var(--enigma-select-text);
162
+ font-size: var(--enigma-select-font-size);
145
163
  background: var(--enigma-select-panel-bg);
146
164
  border: 1px solid var(--enigma-select-panel-border);
147
165
  border-radius: var(--enigma-select-panel-radius);
@@ -152,9 +170,6 @@ export const SELECT_STYLES = `
152
170
  trigger wider than this, which is what it is for. */
153
171
  max-width: min(28rem, calc(100vw - 2rem));
154
172
  }
155
- /* Opening upwards is not a variant, it is the same panel measured against the window: near
156
- the bottom of the screen the list would otherwise be unreachable. */
157
- [data-enigma-select-content][data-side="top"] { top: auto; bottom: calc(100% + 0.25rem); }
158
173
  [data-enigma-select-content][data-state="closed"] { animation: enigma-select-out 120ms ease-in forwards; pointer-events: none; }
159
174
 
160
175
  @keyframes enigma-select-in {
@@ -186,6 +201,8 @@ export const SELECT_STYLES = `
186
201
 
187
202
  [data-enigma-select-list] {
188
203
  max-height: var(--enigma-select-list-height);
204
+ /* Allowed below its content, so the panel's placement cap shrinks it and it scrolls. */
205
+ min-height: 0; flex: 0 1 auto;
189
206
  margin-top: 0.25rem;
190
207
  overflow-y: auto; overscroll-behavior: contain;
191
208
  }
@@ -1,3 +0,0 @@
1
- export { ImageViewer, MenuButton } from './chunk-O2UJ5ZIX.js';
2
- import './chunk-HUUINHPR.js';
3
- import './chunk-EYZ366LP.js';