@enigmax/primitives 0.26.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 (58) hide show
  1. package/dist/{chunk-VKL3DEIQ.js → chunk-6WIGVIQF.js} +58 -37
  2. package/dist/{chunk-ISXB5RUH.js → chunk-CB7K6VW4.js} +10 -3
  3. package/dist/{chunk-QIIV2QSV.js → chunk-HUUINHPR.js} +28 -1
  4. package/dist/{chunk-4SZ5N7BA.js → chunk-IQMHDRJF.js} +2 -2
  5. package/dist/{chunk-RUEQ3TGP.js → chunk-JLZDS6CV.js} +23 -1
  6. package/dist/{chunk-JJVZYW5C.js → chunk-JPBVKXOL.js} +10 -3
  7. package/dist/{chunk-DZMHY3SU.js → chunk-RRPWJAIJ.js} +41 -17
  8. package/dist/chunk-RXEO4WPI.js +111 -0
  9. package/dist/{chunk-RMNXVWQ4.js → chunk-YHH5U2NW.js} +1 -1
  10. package/dist/{chunk-Z3VDE7OA.js → chunk-YQV5XZIA.js} +2 -2
  11. package/dist/{clipboard-menu-BvXJCSFN.d.ts → clipboard-menu-eemzEBqB.d.ts} +1 -1
  12. package/dist/{color-T63FLJNH.js → color-WBNVPFT5.js} +157 -158
  13. package/dist/{context-BfjYGPnH.d.ts → context-DR1dpO8b.d.ts} +1 -1
  14. package/dist/{context-menu-D3FtTn7v.d.ts → context-menu-qqpGBkny.d.ts} +6 -0
  15. package/dist/index.d.ts +29 -3
  16. package/dist/index.js +3 -3
  17. package/dist/{menu-PNQRTRNV.js → menu-5JZMGNOT.js} +3 -2
  18. package/dist/{menu-MSQSE6U5.js → menu-O3G4LM5O.js} +9 -4
  19. package/dist/next/index.d.ts +3 -3
  20. package/dist/next/index.js +8 -7
  21. package/dist/react/context-menu.d.ts +13 -7
  22. package/dist/react/context-menu.js +3 -2
  23. package/dist/react/image.d.ts +5 -2
  24. package/dist/react/image.js +1 -1
  25. package/dist/react/index.d.ts +3 -3
  26. package/dist/react/index.js +8 -7
  27. package/dist/react/input.js +1 -1
  28. package/dist/react/select.d.ts +6 -1
  29. package/dist/react/select.js +2 -1
  30. package/dist/react/video.d.ts +9 -3
  31. package/dist/react/video.js +2 -2
  32. package/dist/react-router/index.d.ts +3 -3
  33. package/dist/react-router/index.js +8 -7
  34. package/dist/viewer-C323RYNZ.js +3 -0
  35. package/package.json +1 -1
  36. package/recipes/color/styles.css +12 -7
  37. package/recipes/context-menu/styles.css +15 -1
  38. package/recipes/select/styles.css +24 -7
  39. package/registry.json +119 -10
  40. package/src/core/context-menu.ts +22 -3
  41. package/src/core/floating.ts +71 -0
  42. package/src/core/image-viewer.ts +47 -0
  43. package/src/core/player.ts +42 -0
  44. package/src/index.ts +3 -0
  45. package/src/react/context-menu/root.tsx +57 -20
  46. package/src/react/context-menu/styles.ts +15 -1
  47. package/src/react/floating.ts +185 -0
  48. package/src/react/image/icons.tsx +4 -0
  49. package/src/react/image/menu.tsx +5 -1
  50. package/src/react/image/types.ts +3 -0
  51. package/src/react/input/color-styles.ts +12 -7
  52. package/src/react/input/color.tsx +30 -27
  53. package/src/react/select/context.ts +5 -0
  54. package/src/react/select/root.tsx +33 -21
  55. package/src/react/select/styles.ts +24 -7
  56. package/src/react/video/index.tsx +8 -1
  57. package/src/react/video/types.ts +7 -1
  58. package/dist/viewer-73LSE6RR.js +0 -3
@@ -4,7 +4,9 @@ import { Slot } from "@/react/slot";
4
4
  import { createPortal } from "react-dom";
5
5
  import { shortenQuery } from "@/core/search";
6
6
  import { shortcutTokens } from "@/core/keys";
7
+ import { placeFloating } from "@/core/floating";
7
8
  import { CONTEXT_MENU_STYLES } from "@/react/context-menu/styles";
9
+ import { floatingContainer, measureFrame } from "@/react/floating";
8
10
  import { ContextMenuContext, useContextMenuContext, type ContextMenuItem, type ContextMenuNode } from "@/react/context-menu/context";
9
11
  import {
10
12
  CLIPBOARD_PREFIX, clipboardEntries, clipboardAction, clipboardHasText, inspectClipboardTarget, performClipboardAction,
@@ -38,7 +40,9 @@ import {
38
40
  * that is what a pointer event reports. Left in the tree it inherits any ancestor's
39
41
  * `overflow: hidden`, any `transform` (which makes `fixed` resolve against that ancestor
40
42
  * rather than the window) and any stacking context - so the menu ends up clipped by the row
41
- * that opened it. Rendered into `<body>`, it is subject to none of them.
43
+ * that opened it. Rendered into `<body>`, it is subject to none of them. Inside a dialog it
44
+ * goes into the dialog instead, which a modal `<dialog>` makes the only live part of the
45
+ * page - the select and the colour picker share the rule, in `react/floating.ts`.
42
46
  */
43
47
 
44
48
  /**
@@ -146,6 +150,12 @@ export interface ContextMenuRootProps {
146
150
  * selection, Cut over a selection in something writable, Paste in anything writable.
147
151
  */
148
152
  clipboard?: boolean | ClipboardMenuOptions;
153
+ /**
154
+ * A filter field over the rows. `"auto"` (the default) grows one only once there are
155
+ * enough rows to be worth filtering; `true` asks for one whatever the count - which is
156
+ * what a menu whose rows are fetched wants - and `false` never draws one.
157
+ */
158
+ searchable?: boolean | "auto";
149
159
  /** Fuse.js's constructor, for fuzzy filtering. Omit it for the built-in matcher. */
150
160
  fuse?: ContextMenuOptions["fuse"];
151
161
  fuseOptions?: Record<string, unknown>;
@@ -169,6 +179,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
169
179
  const {
170
180
  items,
171
181
  title,
182
+ searchable,
172
183
  disabled = false,
173
184
  fuse,
174
185
  fuseOptions,
@@ -208,6 +219,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
208
219
  const instance = useMemo<ContextMenuInstance>(() => createContextMenu({
209
220
  items: items as readonly ContextMenuEntry[],
210
221
  title,
222
+ searchable,
211
223
  fuse,
212
224
  fuseOptions,
213
225
  matcher,
@@ -307,8 +319,8 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
307
319
  }, []);
308
320
 
309
321
  useEffect(() => {
310
- instance.update({ items: withClipboard(currentItems.current), title });
311
- }, [instance, signature, title, withClipboard]);
322
+ instance.update({ items: withClipboard(currentItems.current), title, searchable });
323
+ }, [instance, signature, title, searchable, withClipboard]);
312
324
 
313
325
  const cancelClose = useCallback(() => { window.clearTimeout(closing.current); }, []);
314
326
 
@@ -597,7 +609,7 @@ export function ContextMenuContent({ chunk = 40, portal = true, ...props }: Cont
597
609
 
598
610
  if (!portal) return panels;
599
611
  if (!mounted || typeof document === "undefined") return null;
600
- return createPortal(panels, document.body);
612
+ return createPortal(panels, floatingContainer(menu.triggerRef.current) ?? document.body);
601
613
  }
602
614
 
603
615
  export interface ContextMenuPanelProps extends ComponentPropsWithoutRef<"div"> {
@@ -626,23 +638,31 @@ export function ContextMenuPanel({ level, chunk = 40, ...props }: ContextMenuPan
626
638
  useLayoutEffect(() => {
627
639
  const panel = ref.current;
628
640
  if (!panel) return;
629
- // Measured with the placement cleared, so a panel that shrank is not measured against
630
- // the width it had while it was longer.
641
+ // Measured with the cap cleared, so a panel that was shortened to fit last time is
642
+ // measured at its real height and not at the room it had then.
643
+ panel.style.maxHeight = "";
631
644
  const width = panel.offsetWidth;
632
645
  const height = panel.offsetHeight;
633
- const vw = window.innerWidth;
634
- const vh = window.innerHeight;
646
+ // The viewport, or the dialog it is portaled into when that dialog is what `fixed`
647
+ // resolves against - and the offset between the two, which every coordinate pays.
648
+ const frame = measureFrame(panel);
649
+ const { boundary } = frame;
635
650
 
636
651
  let left: number;
637
652
  let top: number;
638
653
 
639
654
  if (level === 0) {
640
- left = point?.x ?? MARGIN;
641
- top = point?.y ?? MARGIN;
642
- // Flipped rather than clamped: a menu whose left edge is dragged back to fit
643
- // would sit UNDER the pointer, and the first row would be chosen by the release.
644
- if (left + width > vw - MARGIN) left = Math.max(MARGIN, left - width);
645
- if (top + height > vh - MARGIN) top = Math.max(MARGIN, top - height);
655
+ // The pointer is the anchor: a box with no size. Flipped rather than clamped, by
656
+ // the shared placement - a menu whose left edge is dragged back to fit would sit
657
+ // UNDER the pointer, and the first row would be chosen by the release.
658
+ const x = point?.x ?? MARGIN;
659
+ const y = point?.y ?? MARGIN;
660
+ const placedAt = placeFloating({ left: x, right: x, top: y, bottom: y }, { width, height }, { boundary, margin: MARGIN });
661
+ left = placedAt.left;
662
+ top = placedAt.top;
663
+ // Capped to the room on the side it opened, with the list scrolling inside: in a
664
+ // short window the alternative is rows below the edge that nothing can reach.
665
+ panel.style.maxHeight = `${placedAt.maxHeight}px`;
646
666
  } else {
647
667
  const parent = document.getElementById(menu.panelId(level - 1))?.getBoundingClientRect();
648
668
  const row = document.getElementById(menu.itemId(level - 1, menu.state.levels[level - 1]?.active ?? -1))?.getBoundingClientRect();
@@ -650,12 +670,15 @@ export function ContextMenuPanel({ level, chunk = 40, ...props }: ContextMenuPan
650
670
  // Overlapped by a couple of pixels on purpose: a gap between a row and its
651
671
  // submenu is a strip of page that closes the branch when the pointer crosses it.
652
672
  left = anchor.right - 2;
653
- if (left + width > vw - MARGIN) left = Math.max(MARGIN, anchor.left - width + 2);
673
+ if (left + width > boundary.right - MARGIN) left = Math.max(boundary.left + MARGIN, anchor.left - width + 2);
654
674
  top = (row?.top ?? anchor.top) - 4;
655
- if (top + height > vh - MARGIN) top = Math.max(MARGIN, vh - MARGIN - height);
675
+ if (top + height > boundary.bottom - MARGIN) top = Math.max(boundary.top + MARGIN, boundary.bottom - MARGIN - height);
656
676
  }
657
677
 
658
- setPlaced({ left: Math.max(MARGIN, Math.round(left)), top: Math.max(MARGIN, Math.round(top)) });
678
+ setPlaced({
679
+ left: Math.max(boundary.left + MARGIN, Math.round(left)) - frame.x,
680
+ top: Math.max(boundary.top + MARGIN, Math.round(top)) - frame.y
681
+ });
659
682
  // Re-placed whenever the panel's own size can have changed: a filter that shortens the
660
683
  // list, a fetched submenu that arrived, another chunk of a long one.
661
684
  }, [level, point?.x, point?.y, rows, loading, menu, menu.state.levels.length]);
@@ -883,6 +906,18 @@ export function ContextMenuRow({ level, index, entry, children, ...props }: Cont
883
906
  return <p {...props} data-enigma-menu-label="" aria-hidden="true">{entry.label}</p>;
884
907
  }
885
908
 
909
+ /**
910
+ * Whether the level RESERVES the tick column, which is not the same as this row having a
911
+ * tick.
912
+ *
913
+ * A checkable row carries a check and, usually, an icon; its neighbours carry only the
914
+ * icon. Drawn per row, the ticked one's label therefore sat a column further right than
915
+ * everyone else's and the menu read as broken. Every desktop menu reserves the column for
916
+ * the whole level instead, so this asks the LEVEL. Read from `entries` rather than from
917
+ * what is visible: filtering a menu must not shift its rows sideways.
918
+ */
919
+ const reserveCheck = state?.entries.some((entry) => isAction(entry) && (entry as ContextMenuItem).checked !== undefined) ?? false;
920
+
886
921
  const checkable = item.checked !== undefined;
887
922
  // A checkable row inside a group is a radio: choosing one is choosing INSTEAD of its
888
923
  // siblings, and a screen reader announces the two differently.
@@ -942,16 +977,18 @@ export function ContextMenuRow({ level, index, entry, children, ...props }: Cont
942
977
  menu.instance.select(level, index);
943
978
  }}
944
979
  >
945
- {children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} />}
980
+ {children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} reserveCheck={reserveCheck} />}
946
981
  </div>
947
982
  );
948
983
  }
949
984
 
950
985
  /** The default row: what a desktop menu draws, in the order it draws it. */
951
- function ContextMenuRowContent({ item, submenu }: { item: ContextMenuItem; submenu: boolean; }): ReactNode {
986
+ function ContextMenuRowContent({ item, submenu, reserveCheck }: { item: ContextMenuItem; submenu: boolean; reserveCheck: boolean; }): ReactNode {
952
987
  return (
953
988
  <>
954
- {item.checked !== undefined && <span data-enigma-menu-check="" aria-hidden="true" />}
989
+ {/* Empty when this row is not checkable and something else in the level is: the
990
+ column is the level's, not the row's. */}
991
+ {(item.checked !== undefined || reserveCheck) && <span data-enigma-menu-check="" aria-hidden="true" />}
955
992
  {item.icon ? <span data-enigma-menu-icon="">{item.icon}</span> : null}
956
993
  <span data-enigma-menu-item-text="">
957
994
  <span data-enigma-menu-item-label="">{item.label}</span>
@@ -29,6 +29,16 @@ export const CONTEXT_MENU_STYLES = `
29
29
  --enigma-menu-accent: #fbbf24;
30
30
  --enigma-menu-danger: #f87171;
31
31
  --enigma-menu-danger-bg: rgba(248, 113, 113, 0.12);
32
+
33
+ /* Above the lightbox, which sits at 9999, because a menu is opened FROM whatever is on
34
+ top: the image viewer's own three-dot menu was portalled to the same body and painted
35
+ underneath it, so its rows could be neither seen nor pressed. A menu is the topmost
36
+ transient surface on a page, and this is the number that says so. It belongs here and
37
+ not in the light-scheme block, or half the readers get an invalid z-index.
38
+ --enigma-floating-z is the layer the select and the colour picker share, declared with
39
+ the same value in their sheets, so overriding it once moves every popup together. */
40
+ --enigma-floating-z: 10000;
41
+ --enigma-menu-z: var(--enigma-floating-z);
32
42
  }
33
43
 
34
44
  @media (prefers-color-scheme: light) {
@@ -49,7 +59,10 @@ export const CONTEXT_MENU_STYLES = `
49
59
  and what a menu opened at the pointer needs. Fixed rather than absolute so a scrolling
50
60
  ancestor cannot drag it away from the place it was opened. */
51
61
  [data-enigma-menu-panel] {
52
- position: fixed; z-index: 60;
62
+ position: fixed; z-index: var(--enigma-menu-z);
63
+ /* A column, so the max-height the placement writes in a short window shrinks the LIST,
64
+ which scrolls, rather than letting rows spill past the panel's edge. */
65
+ display: flex; flex-direction: column;
53
66
  box-sizing: border-box;
54
67
  min-width: var(--enigma-menu-min-width);
55
68
  max-width: min(22rem, calc(100vw - 1rem));
@@ -104,6 +117,7 @@ export const CONTEXT_MENU_STYLES = `
104
117
 
105
118
  [data-enigma-menu-list] {
106
119
  max-height: var(--enigma-menu-max-height);
120
+ min-height: 0; flex: 0 1 auto;
107
121
  overflow-y: auto; overscroll-behavior: contain;
108
122
  }
109
123
 
@@ -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
+ }
@@ -53,3 +53,7 @@ export function Dots(): ReactNode {
53
53
  export function Download(): ReactNode {
54
54
  return <Glyph><path d="M12 3v12M7 11l5 5 5-5M4 20h16" /></Glyph>;
55
55
  }
56
+
57
+ export function NewTab(): ReactNode {
58
+ return <Glyph><path d="M14 4h6v6" /><path d="M20 4 11 13" /><path d="M18 14v5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 19V7.5A1.5 1.5 0 0 1 5.5 6H10" /></Glyph>;
59
+ }
@@ -3,7 +3,7 @@
3
3
  import * as icons from "@/react/image/icons";
4
4
  import { useRef, type ReactNode } from "react";
5
5
  import { MenuButton } from "@/react/image/viewer";
6
- import { downloadFile } from "@/core/image-viewer";
6
+ import { downloadFile, openInNewTab } from "@/core/image-viewer";
7
7
  import type { ImageItem, ImageLabels, ImageMenuOptions } from "@/react/image/types";
8
8
  import { ContextMenu, useContextMenuContext, type ContextMenuNode } from "@/react/context-menu";
9
9
 
@@ -31,6 +31,9 @@ export function ImageMenu({ item, index, options, labels }: ImageMenuProps): Rea
31
31
  if (options.download !== false) {
32
32
  rows.push({ id: "download", label: labels.download ?? "Download", icon: <icons.Download /> });
33
33
  }
34
+ if (options.newTab !== false) {
35
+ rows.push({ id: "new-tab", label: labels.newTab ?? "Open in a new tab", icon: <icons.NewTab /> });
36
+ }
34
37
  if (options.items?.length) {
35
38
  if (rows.length > 0) rows.push({ type: "separator" });
36
39
  rows.push(...options.items);
@@ -44,6 +47,7 @@ export function ImageMenu({ item, index, options, labels }: ImageMenuProps): Rea
44
47
  clipboard={false}
45
48
  onSelect={(row) => {
46
49
  if (row.id === "download") void downloadFile(item.download ?? item.src, item.filename);
50
+ if (row.id === "new-tab") openInNewTab(item.src);
47
51
  options.onSelect?.(row.id, item, index);
48
52
  }}
49
53
  >
@@ -39,6 +39,8 @@ export interface ZoomOptions {
39
39
  export interface ImageMenuOptions {
40
40
  /** The download row. On whenever the menu is. */
41
41
  download?: boolean;
42
+ /** The row that opens the picture on its own, in another tab. On whenever the menu is. */
43
+ newTab?: boolean;
42
44
  /** Rows of your own, after the built-in ones. */
43
45
  items?: readonly ContextMenuNode[];
44
46
  onSelect?: (id: string, item: ImageItem, index: number) => void;
@@ -55,6 +57,7 @@ export interface ImageLabels {
55
57
  next?: string;
56
58
  menu?: string;
57
59
  download?: string;
60
+ newTab?: string;
58
61
  discard?: string;
59
62
  thumbnails?: string;
60
63
  /** The frame itself, announced as the dialog's name. Default "Image viewer". */
@@ -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. */