@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
|
@@ -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
|
|
630
|
-
//
|
|
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
|
-
|
|
634
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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 >
|
|
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 >
|
|
675
|
+
if (top + height > boundary.bottom - MARGIN) top = Math.max(boundary.top + MARGIN, boundary.bottom - MARGIN - height);
|
|
656
676
|
}
|
|
657
677
|
|
|
658
|
-
setPlaced({
|
|
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
|
-
{
|
|
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:
|
|
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
|
+
}
|
package/src/react/image/menu.tsx
CHANGED
|
@@ -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
|
>
|
package/src/react/image/types.ts
CHANGED
|
@@ -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
|
|
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. */
|