@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.
- package/dist/{chunk-VKL3DEIQ.js → chunk-6WIGVIQF.js} +58 -37
- package/dist/{chunk-O2UJ5ZIX.js → chunk-CB7K6VW4.js} +1 -1
- package/dist/{chunk-2RIO4VDV.js → chunk-IQMHDRJF.js} +2 -2
- package/dist/{chunk-PVDU6AZU.js → chunk-JPBVKXOL.js} +1 -1
- package/dist/{chunk-CPJJ3FPW.js → chunk-RRPWJAIJ.js} +26 -12
- package/dist/chunk-RXEO4WPI.js +111 -0
- package/dist/{chunk-RMNXVWQ4.js → chunk-YHH5U2NW.js} +1 -1
- package/dist/{color-T63FLJNH.js → color-WBNVPFT5.js} +157 -158
- package/dist/{menu-52PXC5PL.js → menu-5JZMGNOT.js} +2 -1
- package/dist/{menu-YIGMPFUP.js → menu-O3G4LM5O.js} +3 -2
- package/dist/next/index.js +6 -5
- package/dist/react/context-menu.d.ts +1 -1
- package/dist/react/context-menu.js +2 -1
- package/dist/react/image.js +1 -1
- package/dist/react/index.js +6 -5
- package/dist/react/input.js +1 -1
- package/dist/react/select.d.ts +6 -1
- package/dist/react/select.js +2 -1
- package/dist/react/video.js +1 -1
- package/dist/react-router/index.js +6 -5
- package/dist/viewer-C323RYNZ.js +3 -0
- package/package.json +1 -1
- package/recipes/color/styles.css +12 -7
- package/recipes/context-menu/styles.css +9 -2
- package/recipes/select/styles.css +24 -7
- package/registry.json +98 -5
- package/src/core/floating.ts +71 -0
- package/src/react/context-menu/root.tsx +30 -15
- package/src/react/context-menu/styles.ts +9 -2
- package/src/react/floating.ts +185 -0
- package/src/react/input/color-styles.ts +12 -7
- package/src/react/input/color.tsx +30 -27
- package/src/react/select/context.ts +5 -0
- package/src/react/select/root.tsx +33 -21
- package/src/react/select/styles.ts +24 -7
- 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
|
|
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:
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}, [
|
|
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
|
-
|
|
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,
|
|
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
|
-
{
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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={
|
|
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:
|
|
142
|
-
|
|
143
|
-
|
|
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
|
}
|
package/dist/viewer-N5WLFB24.js
DELETED