@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.
- package/dist/{chunk-VKL3DEIQ.js → chunk-6WIGVIQF.js} +58 -37
- package/dist/{chunk-ISXB5RUH.js → chunk-CB7K6VW4.js} +10 -3
- package/dist/{chunk-QIIV2QSV.js → chunk-HUUINHPR.js} +28 -1
- package/dist/{chunk-4SZ5N7BA.js → chunk-IQMHDRJF.js} +2 -2
- package/dist/{chunk-RUEQ3TGP.js → chunk-JLZDS6CV.js} +23 -1
- package/dist/{chunk-JJVZYW5C.js → chunk-JPBVKXOL.js} +10 -3
- package/dist/{chunk-DZMHY3SU.js → chunk-RRPWJAIJ.js} +41 -17
- package/dist/chunk-RXEO4WPI.js +111 -0
- package/dist/{chunk-RMNXVWQ4.js → chunk-YHH5U2NW.js} +1 -1
- package/dist/{chunk-Z3VDE7OA.js → chunk-YQV5XZIA.js} +2 -2
- package/dist/{clipboard-menu-BvXJCSFN.d.ts → clipboard-menu-eemzEBqB.d.ts} +1 -1
- package/dist/{color-T63FLJNH.js → color-WBNVPFT5.js} +157 -158
- package/dist/{context-BfjYGPnH.d.ts → context-DR1dpO8b.d.ts} +1 -1
- package/dist/{context-menu-D3FtTn7v.d.ts → context-menu-qqpGBkny.d.ts} +6 -0
- package/dist/index.d.ts +29 -3
- package/dist/index.js +3 -3
- package/dist/{menu-PNQRTRNV.js → menu-5JZMGNOT.js} +3 -2
- package/dist/{menu-MSQSE6U5.js → menu-O3G4LM5O.js} +9 -4
- package/dist/next/index.d.ts +3 -3
- package/dist/next/index.js +8 -7
- package/dist/react/context-menu.d.ts +13 -7
- package/dist/react/context-menu.js +3 -2
- package/dist/react/image.d.ts +5 -2
- package/dist/react/image.js +1 -1
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js +8 -7
- 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.d.ts +9 -3
- package/dist/react/video.js +2 -2
- package/dist/react-router/index.d.ts +3 -3
- package/dist/react-router/index.js +8 -7
- 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 +15 -1
- package/recipes/select/styles.css +24 -7
- package/registry.json +119 -10
- package/src/core/context-menu.ts +22 -3
- package/src/core/floating.ts +71 -0
- package/src/core/image-viewer.ts +47 -0
- package/src/core/player.ts +42 -0
- package/src/index.ts +3 -0
- package/src/react/context-menu/root.tsx +57 -20
- package/src/react/context-menu/styles.ts +15 -1
- package/src/react/floating.ts +185 -0
- package/src/react/image/icons.tsx +4 -0
- package/src/react/image/menu.tsx +5 -1
- package/src/react/image/types.ts +3 -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/src/react/video/index.tsx +8 -1
- package/src/react/video/types.ts +7 -1
- package/dist/viewer-73LSE6RR.js +0 -3
|
@@ -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
|
}
|
|
@@ -531,7 +531,14 @@ export function Video(props: VideoProps): ReactNode {
|
|
|
531
531
|
playsInline={rest.playsInline ?? true}
|
|
532
532
|
onClick={() => { if (clickToPlay) toggle(); }}
|
|
533
533
|
>
|
|
534
|
-
{typeof src !== "string" && sources.map((source) =>
|
|
534
|
+
{typeof src !== "string" && sources.map((source) => (
|
|
535
|
+
// The type is what lets the browser SKIP a file it cannot play without
|
|
536
|
+
// fetching it, which is the only reason to list several. Read from the
|
|
537
|
+
// extension when it was not given, and left off when the extension means
|
|
538
|
+
// nothing - a wrong type is worse than none, because the source is then
|
|
539
|
+
// skipped and the video does not play at all.
|
|
540
|
+
<source key={source.src} src={source.src} type={source.type ?? player.sourceType(source.src)} />
|
|
541
|
+
))}
|
|
535
542
|
{tracks?.map((track) => (
|
|
536
543
|
<track
|
|
537
544
|
key={track.src}
|
package/src/react/video/types.ts
CHANGED
|
@@ -12,7 +12,13 @@ import type { ContextMenuItem, ContextMenuNode } from "@/react/context-menu/cont
|
|
|
12
12
|
|
|
13
13
|
export interface VideoSource {
|
|
14
14
|
src: string;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* `video/mp4`, `video/webm`. The browser picks the first one it can play.
|
|
17
|
+
*
|
|
18
|
+
* Inferred from the file extension when it is left out, and left off entirely when the
|
|
19
|
+
* extension means nothing - the browser then fetches and sniffs, which always works. A
|
|
20
|
+
* WRONG type is the expensive mistake: the source is skipped without being tried.
|
|
21
|
+
*/
|
|
16
22
|
type?: string;
|
|
17
23
|
}
|
|
18
24
|
|
package/dist/viewer-73LSE6RR.js
DELETED