@enigmax/primitives 0.23.0 → 0.25.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-KEVZ5XQV.js → chunk-3MGBZOAU.js} +50 -9
- package/dist/{chunk-DSBYVA7V.js → chunk-DZMHY3SU.js} +73 -9
- package/dist/chunk-KVLSOQTI.js +66 -0
- package/dist/chunk-S2FPN5ID.js +115 -0
- package/dist/{chunk-3BQVOOAM.js → chunk-Z3VDE7OA.js} +145 -1
- package/dist/{context-menu-D3FtTn7v.d.ts → clipboard-menu-B_ouitfS.d.ts} +91 -1
- package/dist/color-D_rZ83Oc.d.ts +152 -0
- package/dist/color-T63FLJNH.js +515 -0
- package/dist/{index-DQNnohoo.d.ts → index-qXOkYQCU.d.ts} +56 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/next/index.d.ts +3 -3
- package/dist/next/index.js +6 -4
- package/dist/react/context-menu.d.ts +23 -6
- package/dist/react/context-menu.js +2 -2
- package/dist/react/index.d.ts +5 -5
- package/dist/react/index.js +6 -4
- package/dist/react/input.d.ts +2 -2
- package/dist/react/input.js +2 -1
- package/dist/react-router/index.d.ts +3 -3
- package/dist/react-router/index.js +6 -4
- package/package.json +3 -1
- package/recipes/color/styles.css +163 -0
- package/recipes/context-menu/styles.css +8 -0
- package/registry.json +86 -7
- package/src/core/clipboard-menu.ts +270 -0
- package/src/core/color.ts +248 -0
- package/src/index.ts +32 -0
- package/src/react/context-menu/context.ts +9 -2
- package/src/react/context-menu/index.tsx +14 -0
- package/src/react/context-menu/root.tsx +132 -8
- package/src/react/context-menu/styles.ts +8 -0
- package/src/react/index.ts +32 -0
- package/src/react/input/color-styles.ts +176 -0
- package/src/react/input/color-swatch.tsx +96 -0
- package/src/react/input/color.tsx +527 -0
- package/src/react/input/index.tsx +77 -6
- package/src/react/input/types.ts +63 -3
- package/dist/password-C8lG4Zm9.d.ts +0 -71
- /package/dist/{chunk-3HDEZ2E7.js → chunk-MSOCCQGH.js} +0 -0
|
@@ -6,6 +6,10 @@ import { shortenQuery } from "@/core/search";
|
|
|
6
6
|
import { shortcutTokens } from "@/core/keys";
|
|
7
7
|
import { CONTEXT_MENU_STYLES } from "@/react/context-menu/styles";
|
|
8
8
|
import { ContextMenuContext, useContextMenuContext, type ContextMenuItem, type ContextMenuNode } from "@/react/context-menu/context";
|
|
9
|
+
import {
|
|
10
|
+
CLIPBOARD_PREFIX, clipboardEntries, clipboardAction, clipboardHasText, inspectClipboardTarget, performClipboardAction,
|
|
11
|
+
type ClipboardMenuOptions, type ClipboardTarget
|
|
12
|
+
} from "@/core/clipboard-menu";
|
|
9
13
|
import {
|
|
10
14
|
useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState,
|
|
11
15
|
type ComponentPropsWithoutRef, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode
|
|
@@ -37,6 +41,41 @@ import {
|
|
|
37
41
|
* that opened it. Rendered into `<body>`, it is subject to none of them.
|
|
38
42
|
*/
|
|
39
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The three glyphs the clipboard rows carry.
|
|
46
|
+
*
|
|
47
|
+
* Drawn here rather than imported: a menu whose caller gives every row an icon and whose
|
|
48
|
+
* built-in rows have none reads as three broken rows, and an icon file would be a request.
|
|
49
|
+
* Stroked with `currentColor` at 1em, so they inherit the row's colour and size.
|
|
50
|
+
*/
|
|
51
|
+
const CLIPBOARD_ICON = {
|
|
52
|
+
viewBox: "0 0 24 24", width: "1em", height: "1em", fill: "none",
|
|
53
|
+
stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round",
|
|
54
|
+
"aria-hidden": true
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
const CLIPBOARD_ICONS = {
|
|
58
|
+
copy: (
|
|
59
|
+
<svg {...CLIPBOARD_ICON}>
|
|
60
|
+
<rect x="9" y="9" width="12" height="12" rx="2" />
|
|
61
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
62
|
+
</svg>
|
|
63
|
+
),
|
|
64
|
+
cut: (
|
|
65
|
+
<svg {...CLIPBOARD_ICON}>
|
|
66
|
+
<circle cx="6" cy="6" r="3" />
|
|
67
|
+
<circle cx="6" cy="18" r="3" />
|
|
68
|
+
<path d="M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12" />
|
|
69
|
+
</svg>
|
|
70
|
+
),
|
|
71
|
+
paste: (
|
|
72
|
+
<svg {...CLIPBOARD_ICON}>
|
|
73
|
+
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
|
74
|
+
<rect x="8" y="2" width="8" height="4" rx="1" />
|
|
75
|
+
</svg>
|
|
76
|
+
)
|
|
77
|
+
};
|
|
78
|
+
|
|
40
79
|
let injected = false;
|
|
41
80
|
|
|
42
81
|
/**
|
|
@@ -97,6 +136,16 @@ export interface ContextMenuRootProps {
|
|
|
97
136
|
onOpenChange?: (open: boolean) => void;
|
|
98
137
|
/** Nothing opens, and the press is left to the browser. For a disabled row or a read-only view. */
|
|
99
138
|
disabled?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Copy, Cut and Paste, built from whatever was right-clicked. ON by default, and `false`
|
|
141
|
+
* turns them off; an object turns off one of the three, renames them, or both.
|
|
142
|
+
*
|
|
143
|
+
* On by default because the browser's own menu has them and this one replaces it - a
|
|
144
|
+
* custom menu over a text field that cannot copy is a loss the visitor discovers rather
|
|
145
|
+
* than one anybody decided on. They appear only where they mean something: Copy over a
|
|
146
|
+
* selection, Cut over a selection in something writable, Paste in anything writable.
|
|
147
|
+
*/
|
|
148
|
+
clipboard?: boolean | ClipboardMenuOptions;
|
|
100
149
|
/** Fuse.js's constructor, for fuzzy filtering. Omit it for the built-in matcher. */
|
|
101
150
|
fuse?: ContextMenuOptions["fuse"];
|
|
102
151
|
fuseOptions?: Record<string, unknown>;
|
|
@@ -132,6 +181,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
|
|
|
132
181
|
loadingLabel = "Loading...",
|
|
133
182
|
emptyLabel = "Nothing here.",
|
|
134
183
|
styles = true,
|
|
184
|
+
clipboard = true,
|
|
135
185
|
children
|
|
136
186
|
} = props;
|
|
137
187
|
|
|
@@ -163,7 +213,14 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
|
|
|
163
213
|
matcher,
|
|
164
214
|
searchKeys,
|
|
165
215
|
cacheMs,
|
|
166
|
-
onSelect: (item, path) =>
|
|
216
|
+
onSelect: (item, path) => {
|
|
217
|
+
// Ours are performed here and STILL reported: the caller may want to log the
|
|
218
|
+
// copy, or undo it, and a row that reaches onSelect for every other item but
|
|
219
|
+
// these three would be a hole nobody expects.
|
|
220
|
+
const action = clipboardAction(item.id);
|
|
221
|
+
if (action) void performClipboardAction(action, clipboardTarget.current);
|
|
222
|
+
latest.current.onSelect?.(item as ContextMenuItem, path);
|
|
223
|
+
},
|
|
167
224
|
onOpenChange: (open) => latest.current.onOpenChange?.(open)
|
|
168
225
|
// Built once: rebuilding it would drop the open branch and the highlight on every
|
|
169
226
|
// render. Every option below is pushed in through update().
|
|
@@ -217,9 +274,41 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
|
|
|
217
274
|
const currentItems = useRef(items);
|
|
218
275
|
currentItems.current = items;
|
|
219
276
|
|
|
277
|
+
/* -------- the clipboard rows, and what they were built over -------- */
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* What the menu was opened over, and the rows that came out of it.
|
|
281
|
+
*
|
|
282
|
+
* Refs rather than state, and read at open time rather than derived: the selection and the
|
|
283
|
+
* caret belong to the DOM at the instant of the press, and a render later they are gone -
|
|
284
|
+
* choosing a row moves focus into the panel. Keeping them here also means the merge below
|
|
285
|
+
* never re-runs the inspection, so what is performed is what the rows were built from.
|
|
286
|
+
*/
|
|
287
|
+
const clipboardTarget = useRef<ClipboardTarget>(inspectClipboardTarget(null));
|
|
288
|
+
const clipboardRows = useRef<ContextMenuEntry[]>([]);
|
|
289
|
+
|
|
290
|
+
const clipboardOptions = useMemo<ClipboardMenuOptions | null>(() => {
|
|
291
|
+
if (clipboard === false) return null;
|
|
292
|
+
return { icons: CLIPBOARD_ICONS, ...(clipboard === true ? {} : clipboard) };
|
|
293
|
+
}, [clipboard]);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The caller's rows with ours in front of them.
|
|
297
|
+
*
|
|
298
|
+
* In front because that is where every desktop menu puts them, and separated because they
|
|
299
|
+
* are about the SELECTION rather than about the thing the menu was opened on - two blocks
|
|
300
|
+
* with a rule between them, not one list where Copy sits next to Delete.
|
|
301
|
+
*/
|
|
302
|
+
const withClipboard = useCallback((rows: readonly ContextMenuNode[]): readonly ContextMenuEntry[] => {
|
|
303
|
+
const clip = clipboardRows.current;
|
|
304
|
+
if (clip.length === 0) return rows as readonly ContextMenuEntry[];
|
|
305
|
+
if (rows.length === 0) return clip;
|
|
306
|
+
return [...clip, { type: "separator", id: `${CLIPBOARD_PREFIX}rule` }, ...(rows as readonly ContextMenuEntry[])];
|
|
307
|
+
}, []);
|
|
308
|
+
|
|
220
309
|
useEffect(() => {
|
|
221
|
-
instance.update({ items: currentItems.current
|
|
222
|
-
}, [instance, signature, title]);
|
|
310
|
+
instance.update({ items: withClipboard(currentItems.current), title });
|
|
311
|
+
}, [instance, signature, title, withClipboard]);
|
|
223
312
|
|
|
224
313
|
const cancelClose = useCallback(() => { window.clearTimeout(closing.current); }, []);
|
|
225
314
|
|
|
@@ -230,11 +319,40 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
|
|
|
230
319
|
|
|
231
320
|
useEffect(() => cancelClose, [cancelClose]);
|
|
232
321
|
|
|
233
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Open at a point, over `target` - whatever the press actually landed on.
|
|
324
|
+
*
|
|
325
|
+
* The rows are pushed in SYNCHRONOUSLY, before the menu opens: state would arrive a render
|
|
326
|
+
* too late and the first paint of the menu would be the one without them.
|
|
327
|
+
*/
|
|
328
|
+
const open = useCallback((point: ContextMenuPoint, target?: EventTarget | null): boolean => {
|
|
234
329
|
if (disabled) return false;
|
|
235
330
|
cancelClose();
|
|
331
|
+
|
|
332
|
+
if (clipboardOptions) {
|
|
333
|
+
clipboardTarget.current = inspectClipboardTarget(target ?? null);
|
|
334
|
+
clipboardRows.current = clipboardEntries(clipboardTarget.current, clipboardOptions);
|
|
335
|
+
instance.update({ items: withClipboard(currentItems.current) });
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* And then, where the browser will say so without a prompt, whether there is
|
|
339
|
+
* anything to paste. It answers a microtask later at best, so Paste opens enabled
|
|
340
|
+
* and greys out if the clipboard turns out to be empty - the other way round would
|
|
341
|
+
* flash a disabled row on every menu that opens over a field.
|
|
342
|
+
*/
|
|
343
|
+
if (clipboardRows.current.some((entry) => isAction(entry) && clipboardAction(entry.id) === "paste")) {
|
|
344
|
+
void clipboardHasText().then((has) => {
|
|
345
|
+
if (has !== false || !instance.state.open) return;
|
|
346
|
+
clipboardRows.current = clipboardRows.current.map((entry) => (
|
|
347
|
+
isAction(entry) && clipboardAction(entry.id) === "paste" ? { ...entry, disabled: true } : entry
|
|
348
|
+
));
|
|
349
|
+
instance.update({ items: withClipboard(currentItems.current) });
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
236
354
|
return instance.open(point);
|
|
237
|
-
}, [instance, disabled, cancelClose]);
|
|
355
|
+
}, [instance, disabled, cancelClose, clipboardOptions, withClipboard]);
|
|
238
356
|
|
|
239
357
|
const close = useCallback(() => {
|
|
240
358
|
cancelClose();
|
|
@@ -409,14 +527,18 @@ export function ContextMenuTrigger({ asChild = false, longPress = true, children
|
|
|
409
527
|
if (event.defaultPrevented) return;
|
|
410
528
|
// The default is prevented only when a menu actually opened. With nothing to
|
|
411
529
|
// show, the browser's own menu is better than none - and than an empty box.
|
|
412
|
-
|
|
530
|
+
// The event TARGET, not the trigger: the clipboard rows are built from the
|
|
531
|
+
// field or the selection the press actually landed on, which is usually a
|
|
532
|
+
// descendant of the area this menu covers.
|
|
533
|
+
if (menu.open({ x: event.clientX, y: event.clientY }, event.target)) event.preventDefault();
|
|
413
534
|
}}
|
|
414
535
|
onPointerDown={(event: PointerEvent<HTMLDivElement>) => {
|
|
415
536
|
props.onPointerDown?.(event);
|
|
416
537
|
if (event.defaultPrevented || !longPress || event.pointerType !== "touch") return;
|
|
417
538
|
const { clientX: x, clientY: y } = event;
|
|
418
539
|
cancelPress();
|
|
419
|
-
|
|
540
|
+
const target = event.target;
|
|
541
|
+
press.current = { x, y, timer: window.setTimeout(() => { press.current = null; menu.open({ x, y }, target); }, LONG_PRESS_MS) };
|
|
420
542
|
}}
|
|
421
543
|
onPointerMove={(event: PointerEvent<HTMLDivElement>) => {
|
|
422
544
|
props.onPointerMove?.(event);
|
|
@@ -437,7 +559,9 @@ export function ContextMenuTrigger({ asChild = false, longPress = true, children
|
|
|
437
559
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
438
560
|
// At the element's own corner, inset a little: a menu opened by key has no
|
|
439
561
|
// pointer to appear under, and the corner is where every platform puts it.
|
|
440
|
-
|
|
562
|
+
// Opened by key, so what it is over is wherever the caret is - which is what
|
|
563
|
+
// a keyboard visitor means by "here".
|
|
564
|
+
if (menu.open({ x: rect.left + 8, y: rect.top + 8 }, document.activeElement)) event.preventDefault();
|
|
441
565
|
}}
|
|
442
566
|
>
|
|
443
567
|
{children}
|
|
@@ -66,6 +66,14 @@ export const CONTEXT_MENU_STYLES = `
|
|
|
66
66
|
visibility: hidden;
|
|
67
67
|
}
|
|
68
68
|
[data-enigma-menu-panel][data-placed] { visibility: visible; }
|
|
69
|
+
/* No focus ring around the panel. It takes focus so the keyboard has somewhere to be, not
|
|
70
|
+
because it is a control: what MOVES is the highlighted row, drawn by [data-active] and
|
|
71
|
+
announced through aria-activedescendant. A ring around the whole menu says the menu is the
|
|
72
|
+
thing focused, which is not what is happening - and it appears the moment you press an
|
|
73
|
+
arrow, over a surface that was already showing you where you are. Nothing is lost by
|
|
74
|
+
removing it, because nothing inside is reachable by Tab: the menu owns that key. */
|
|
75
|
+
[data-enigma-menu-panel]:focus,
|
|
76
|
+
[data-enigma-menu-panel]:focus-visible { outline: none; }
|
|
69
77
|
@keyframes enigma-menu-in {
|
|
70
78
|
from { opacity: 0; transform: scale(0.98); }
|
|
71
79
|
to { opacity: 1; transform: none; }
|
package/src/react/index.ts
CHANGED
|
@@ -19,13 +19,33 @@ export {
|
|
|
19
19
|
type BreachChecker,
|
|
20
20
|
type BreachState,
|
|
21
21
|
type BreachStatus,
|
|
22
|
+
type ColorLabels,
|
|
23
|
+
type ColorPanelPlacement,
|
|
22
24
|
type PasswordOnlyProps,
|
|
23
25
|
type SearchOnlyProps,
|
|
26
|
+
type ColorOnlyProps,
|
|
24
27
|
type PlainOnlyProps
|
|
25
28
|
} from "@/react/input/types";
|
|
26
29
|
// The meter as a standalone component, for a form that renders it somewhere else. Re-exported
|
|
27
30
|
// from the chunk rather than through `Input`, so importing only `Input` leaves it behind.
|
|
28
31
|
export { PasswordStrength, type PasswordStrengthProps } from "@/react/input/password";
|
|
32
|
+
// The colour maths, for a form that has to read or write the value the picker produced. The
|
|
33
|
+
// picker itself arrives through `<Input type="color">`, so nothing here reaches into its chunk.
|
|
34
|
+
export {
|
|
35
|
+
parseColor,
|
|
36
|
+
formatColor,
|
|
37
|
+
toHex,
|
|
38
|
+
rgbToHsv,
|
|
39
|
+
hsvToRgb,
|
|
40
|
+
rgbToHsl,
|
|
41
|
+
hslToRgb,
|
|
42
|
+
colorEquals,
|
|
43
|
+
type Rgb,
|
|
44
|
+
type Hsv,
|
|
45
|
+
type Hsl,
|
|
46
|
+
type ColorFormat,
|
|
47
|
+
type FormatColorOptions
|
|
48
|
+
} from "@/core/color";
|
|
29
49
|
export { Slot, mergeSlotProps, type SlotProps } from "@/react/slot";
|
|
30
50
|
export {
|
|
31
51
|
generatePassword,
|
|
@@ -127,6 +147,18 @@ export {
|
|
|
127
147
|
type ContextMenuNode,
|
|
128
148
|
type ContextMenuContextValue
|
|
129
149
|
} from "@/react/context-menu";
|
|
150
|
+
export {
|
|
151
|
+
CLIPBOARD_PREFIX,
|
|
152
|
+
clipboardEntries,
|
|
153
|
+
clipboardAction,
|
|
154
|
+
clipboardHasText,
|
|
155
|
+
inspectClipboardTarget,
|
|
156
|
+
performClipboardAction,
|
|
157
|
+
type ClipboardAction,
|
|
158
|
+
type ClipboardTarget,
|
|
159
|
+
type ClipboardMenuOptions,
|
|
160
|
+
type ClipboardMenuLabels
|
|
161
|
+
} from "@/core/clipboard-menu";
|
|
130
162
|
export {
|
|
131
163
|
createContextMenu,
|
|
132
164
|
isAction,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The colour picker's baseline look.
|
|
3
|
+
*
|
|
4
|
+
* The rest of `<Input>` ships no styles, and this one does, for the reason the select's sheet
|
|
5
|
+
* gives: an unstyled POPUP is not a plain-looking control, it is transparent boxes lying on
|
|
6
|
+
* top of the page. A saturation square with no size is nothing at all - there is no useful
|
|
7
|
+
* "unstyled" version of a gradient someone drags a handle across - so the panel arrives with
|
|
8
|
+
* a look, and `styles={false}` takes it away for anyone who wants to draw their own.
|
|
9
|
+
*
|
|
10
|
+
* A string rather than a stylesheet because the picker injects it. `scripts/sync-recipes.mjs`
|
|
11
|
+
* generates `recipes/color/styles.css` from here and CI fails when the two drift, so this
|
|
12
|
+
* module is the source and the `.css` is the copy for anyone who prefers an import.
|
|
13
|
+
*
|
|
14
|
+
* Everything below is a custom property or an attribute selector: override the properties on
|
|
15
|
+
* `:root` for a different-looking picker without writing a single selector. The sheet is
|
|
16
|
+
* PREPENDED to `<head>`, so anything the document already has wins on source order at equal
|
|
17
|
+
* specificity.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const COLOR_STYLES = `
|
|
21
|
+
:root {
|
|
22
|
+
--enigma-color-panel-bg: #1c1c1c;
|
|
23
|
+
--enigma-color-panel-border: #333333;
|
|
24
|
+
--enigma-color-panel-radius: 0.625rem;
|
|
25
|
+
--enigma-color-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
|
26
|
+
--enigma-color-panel-width: 13.5rem;
|
|
27
|
+
--enigma-color-text: #f5f5f5;
|
|
28
|
+
--enigma-color-muted: #a3a3a3;
|
|
29
|
+
--enigma-color-border: #404040;
|
|
30
|
+
--enigma-color-focus: #a3a3a3;
|
|
31
|
+
--enigma-color-area-height: 8rem;
|
|
32
|
+
--enigma-color-rail-height: 0.625rem;
|
|
33
|
+
--enigma-color-thumb: #ffffff;
|
|
34
|
+
--enigma-color-thumb-ring: rgba(0, 0, 0, 0.55);
|
|
35
|
+
/* The light square of the transparency chequerboard. The dark one is the surface it sits
|
|
36
|
+
on, which is why only one colour is a property. */
|
|
37
|
+
--enigma-color-checker: rgba(255, 255, 255, 0.22);
|
|
38
|
+
--enigma-color-checker-size: 0.5rem;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@media (prefers-color-scheme: light) {
|
|
42
|
+
:root {
|
|
43
|
+
--enigma-color-panel-bg: #ffffff;
|
|
44
|
+
--enigma-color-panel-border: #e5e5e5;
|
|
45
|
+
--enigma-color-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
|
46
|
+
--enigma-color-text: #171717;
|
|
47
|
+
--enigma-color-muted: #737373;
|
|
48
|
+
--enigma-color-border: #d4d4d4;
|
|
49
|
+
--enigma-color-focus: #737373;
|
|
50
|
+
--enigma-color-thumb-ring: rgba(0, 0, 0, 0.35);
|
|
51
|
+
--enigma-color-checker: rgba(0, 0, 0, 0.14);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
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. */
|
|
58
|
+
[data-enigma-color] { position: relative; display: inline-flex; flex: none; }
|
|
59
|
+
|
|
60
|
+
[data-enigma-color-swatch] {
|
|
61
|
+
display: block; width: 1.25rem; height: 1.25rem; padding: 0;
|
|
62
|
+
background: none; border: 1px solid var(--enigma-color-border); border-radius: 0.3125rem;
|
|
63
|
+
cursor: pointer; overflow: hidden;
|
|
64
|
+
}
|
|
65
|
+
[data-enigma-color-swatch]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 2px; }
|
|
66
|
+
[data-enigma-color-swatch]:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
67
|
+
|
|
68
|
+
/* The chequerboard, so a colour with alpha reads as transparent rather than as a lighter
|
|
69
|
+
colour - which is the whole difference between #ff000080 and #ff8080. */
|
|
70
|
+
[data-enigma-color-checkers] {
|
|
71
|
+
background-image:
|
|
72
|
+
conic-gradient(var(--enigma-color-checker) 0 25%, transparent 0 50%, var(--enigma-color-checker) 0 75%, transparent 0);
|
|
73
|
+
background-size: var(--enigma-color-checker-size) var(--enigma-color-checker-size);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
[data-enigma-color-fill] { display: block; width: 100%; height: 100%; }
|
|
77
|
+
/* An unparseable field has no colour to show, so the swatch shows the chequerboard alone
|
|
78
|
+
rather than the last good colour: a stale swatch says the value is fine when it is not. */
|
|
79
|
+
[data-enigma-color-swatch][data-invalid] [data-enigma-color-fill] { background: none; }
|
|
80
|
+
|
|
81
|
+
[data-enigma-color-panel] {
|
|
82
|
+
position: absolute; z-index: 60; top: calc(100% + 0.375rem); left: 0;
|
|
83
|
+
display: grid; gap: 0.625rem;
|
|
84
|
+
box-sizing: border-box; width: var(--enigma-color-panel-width); padding: 0.625rem;
|
|
85
|
+
color: var(--enigma-color-text); background: var(--enigma-color-panel-bg);
|
|
86
|
+
border: 1px solid var(--enigma-color-panel-border);
|
|
87
|
+
border-radius: var(--enigma-color-panel-radius);
|
|
88
|
+
box-shadow: var(--enigma-color-panel-shadow);
|
|
89
|
+
}
|
|
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
|
+
|
|
94
|
+
[data-enigma-color-area] {
|
|
95
|
+
position: relative; height: var(--enigma-color-area-height);
|
|
96
|
+
border-radius: 0.375rem; cursor: crosshair;
|
|
97
|
+
/* The drag IS the control here, so the page must not scroll under it. The marquee's
|
|
98
|
+
warning about touch-action is the opposite case: a full-width row still has to let the
|
|
99
|
+
page move. */
|
|
100
|
+
touch-action: none;
|
|
101
|
+
background:
|
|
102
|
+
linear-gradient(to top, #000000, rgba(0, 0, 0, 0)),
|
|
103
|
+
linear-gradient(to right, #ffffff, rgba(255, 255, 255, 0)),
|
|
104
|
+
hsl(var(--enigma-color-hue, 0), 100%, 50%);
|
|
105
|
+
}
|
|
106
|
+
[data-enigma-color-area]:focus-visible,
|
|
107
|
+
[data-enigma-color-rail]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 2px; }
|
|
108
|
+
|
|
109
|
+
[data-enigma-color-controls] { display: flex; align-items: center; gap: 0.5rem; }
|
|
110
|
+
[data-enigma-color-rails] { display: grid; gap: 0.5rem; flex: 1; min-width: 0; }
|
|
111
|
+
|
|
112
|
+
[data-enigma-color-rail] {
|
|
113
|
+
position: relative; height: var(--enigma-color-rail-height);
|
|
114
|
+
border-radius: 999px; cursor: pointer; touch-action: none;
|
|
115
|
+
}
|
|
116
|
+
[data-enigma-color-rail="hue"] {
|
|
117
|
+
background: linear-gradient(to right, #ff0000, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000);
|
|
118
|
+
}
|
|
119
|
+
[data-enigma-color-rail="alpha"] { background-image: none; }
|
|
120
|
+
[data-enigma-color-rail="alpha"] [data-enigma-color-gradient] {
|
|
121
|
+
position: absolute; inset: 0; border-radius: inherit;
|
|
122
|
+
background: linear-gradient(to right, transparent, var(--enigma-color-opaque, #000000));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* One handle for the square and the rails. It must never take the pointer: a press that
|
|
126
|
+
lands on the handle instead of the surface under it would start no drag at all. */
|
|
127
|
+
[data-enigma-color-thumb] {
|
|
128
|
+
position: absolute; pointer-events: none;
|
|
129
|
+
width: 0.75rem; height: 0.75rem; margin: -0.375rem 0 0 -0.375rem;
|
|
130
|
+
border: 2px solid var(--enigma-color-thumb); border-radius: 999px;
|
|
131
|
+
box-shadow: 0 0 0 1px var(--enigma-color-thumb-ring), inset 0 0 0 1px var(--enigma-color-thumb-ring);
|
|
132
|
+
}
|
|
133
|
+
[data-enigma-color-rail] [data-enigma-color-thumb] { top: 50%; }
|
|
134
|
+
|
|
135
|
+
[data-enigma-color-preview] {
|
|
136
|
+
position: relative; flex: none; width: 1.75rem; height: 1.75rem;
|
|
137
|
+
border: 1px solid var(--enigma-color-border); border-radius: 999px; overflow: hidden;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
[data-enigma-color-eyedropper] {
|
|
141
|
+
display: grid; place-items: center; flex: none;
|
|
142
|
+
width: 1.75rem; height: 1.75rem; padding: 0;
|
|
143
|
+
color: var(--enigma-color-muted); background: none;
|
|
144
|
+
border: 1px solid var(--enigma-color-border); border-radius: 0.375rem; cursor: pointer;
|
|
145
|
+
}
|
|
146
|
+
[data-enigma-color-eyedropper]:hover { color: var(--enigma-color-text); }
|
|
147
|
+
[data-enigma-color-eyedropper]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 2px; }
|
|
148
|
+
|
|
149
|
+
/* The value as text, with the notation beside it. Monospace on purpose: a hex whose digits
|
|
150
|
+
shift width as you drag is a value nobody can read at a glance. */
|
|
151
|
+
[data-enigma-color-value] { display: flex; align-items: center; gap: 0.375rem; }
|
|
152
|
+
[data-enigma-color-format] {
|
|
153
|
+
flex: none; padding: 0.3125rem 0.375rem;
|
|
154
|
+
font: inherit; font-size: 0.625rem; line-height: 1; letter-spacing: 0.06em;
|
|
155
|
+
color: var(--enigma-color-muted); background: none;
|
|
156
|
+
border: 1px solid var(--enigma-color-border); border-radius: 0.25rem; cursor: pointer;
|
|
157
|
+
}
|
|
158
|
+
[data-enigma-color-format]:hover { color: var(--enigma-color-text); }
|
|
159
|
+
[data-enigma-color-input] {
|
|
160
|
+
flex: 1; min-width: 0; padding: 0.25rem 0.375rem;
|
|
161
|
+
font: inherit; font-family: var(--enigma-color-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
162
|
+
font-size: 0.75rem; line-height: 1.4;
|
|
163
|
+
color: var(--enigma-color-text); background: none;
|
|
164
|
+
border: 1px solid var(--enigma-color-border); border-radius: 0.25rem;
|
|
165
|
+
}
|
|
166
|
+
[data-enigma-color-format]:focus-visible,
|
|
167
|
+
[data-enigma-color-input]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 1px; }
|
|
168
|
+
|
|
169
|
+
[data-enigma-color-swatches] { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
|
170
|
+
[data-enigma-color-preset] {
|
|
171
|
+
width: 1.125rem; height: 1.125rem; padding: 0;
|
|
172
|
+
border: 1px solid var(--enigma-color-border); border-radius: 0.25rem; cursor: pointer;
|
|
173
|
+
}
|
|
174
|
+
[data-enigma-color-preset]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 2px; }
|
|
175
|
+
[data-enigma-color-preset][aria-pressed="true"] { box-shadow: 0 0 0 2px var(--enigma-color-focus); }
|
|
176
|
+
`;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, ReactNode, Ref } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The swatch in the field, and the box the panel hangs from.
|
|
7
|
+
*
|
|
8
|
+
* Its own module, and deliberately NOT part of the picker's chunk. The picker is imported on
|
|
9
|
+
* demand, so a swatch that lived in there would not exist for as long as that request takes -
|
|
10
|
+
* the field renders as a bare text box, the affordance appears late and shifts the row, and a
|
|
11
|
+
* press in that window reaches nothing at all. On a fast connection that is a blink; on a
|
|
12
|
+
* cold cache it is the second or two somebody spends clicking a swatch that is not there yet.
|
|
13
|
+
*
|
|
14
|
+
* So the swatch ships with the field and the panel arrives behind it. It costs the markup
|
|
15
|
+
* below in the base chunk and no colour maths at all: the fill is handed to the browser, which
|
|
16
|
+
* is the only parser that already knows every notation a value could be written in.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Enough of a look to be a swatch, for the frames before the panel's stylesheet exists.
|
|
21
|
+
*
|
|
22
|
+
* The sheet ships with the picker, so the swatch rendered ahead of it would otherwise be a
|
|
23
|
+
* default browser button for as long as that request takes - which trades a missing control
|
|
24
|
+
* for an ugly one. Written against the same custom properties, so a project that themed the
|
|
25
|
+
* picker themes this too, and dropped entirely the moment the real swatch replaces it.
|
|
26
|
+
*/
|
|
27
|
+
const BASELINE: CSSProperties = {
|
|
28
|
+
display: "block",
|
|
29
|
+
boxSizing: "border-box",
|
|
30
|
+
width: "1.25rem",
|
|
31
|
+
height: "1.25rem",
|
|
32
|
+
padding: 0,
|
|
33
|
+
background: "none",
|
|
34
|
+
border: "1px solid var(--enigma-color-border, #404040)",
|
|
35
|
+
borderRadius: "0.3125rem",
|
|
36
|
+
overflow: "hidden",
|
|
37
|
+
cursor: "pointer"
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface ColorSwatchProps {
|
|
41
|
+
/** The value to paint, exactly as the field holds it. Anything unparseable paints nothing. */
|
|
42
|
+
value?: string;
|
|
43
|
+
/** Whether the panel is up, for `aria-expanded`. */
|
|
44
|
+
open?: boolean;
|
|
45
|
+
/** The value is not a colour: the swatch shows the chequerboard rather than a stale fill. */
|
|
46
|
+
invalid?: boolean;
|
|
47
|
+
/** The field is disabled or read-only. */
|
|
48
|
+
locked?: boolean;
|
|
49
|
+
label: string;
|
|
50
|
+
anchorRef?: Ref<HTMLSpanElement>;
|
|
51
|
+
buttonRef?: Ref<HTMLButtonElement>;
|
|
52
|
+
onPress?: () => void;
|
|
53
|
+
/** The picker's stylesheet has not arrived yet, so carry the few declarations it needs. */
|
|
54
|
+
baseline?: boolean;
|
|
55
|
+
/** The panel, once its code is here. */
|
|
56
|
+
children?: ReactNode;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ColorSwatch({ value, open = false, invalid = false, locked = false, label, anchorRef, buttonRef, onPress, baseline = false, children }: ColorSwatchProps): ReactNode {
|
|
60
|
+
return (
|
|
61
|
+
<span
|
|
62
|
+
ref={anchorRef}
|
|
63
|
+
data-enigma-color=""
|
|
64
|
+
data-open={open ? "" : undefined}
|
|
65
|
+
style={baseline ? { position: "relative", display: "inline-flex", flex: "none" } : undefined}
|
|
66
|
+
>
|
|
67
|
+
<button
|
|
68
|
+
ref={buttonRef}
|
|
69
|
+
// Never a bare `<button>`: inside a form it would default to submit, so opening
|
|
70
|
+
// the picker would post the half-filled form. The same trap the reveal has.
|
|
71
|
+
type="button"
|
|
72
|
+
data-enigma-color-swatch=""
|
|
73
|
+
data-enigma-color-checkers=""
|
|
74
|
+
data-invalid={invalid ? "" : undefined}
|
|
75
|
+
aria-haspopup="dialog"
|
|
76
|
+
aria-expanded={open}
|
|
77
|
+
aria-label={label}
|
|
78
|
+
title={label}
|
|
79
|
+
disabled={locked}
|
|
80
|
+
style={baseline ? BASELINE : undefined}
|
|
81
|
+
onClick={onPress}
|
|
82
|
+
>
|
|
83
|
+
{/* The browser parses the value for us: an unparseable string simply applies
|
|
84
|
+
nothing, which is why the swatch needs no colour maths to fall back on. */}
|
|
85
|
+
<span
|
|
86
|
+
data-enigma-color-fill=""
|
|
87
|
+
style={{
|
|
88
|
+
background: invalid ? undefined : value,
|
|
89
|
+
...(baseline ? { display: "block", width: "100%", height: "100%" } : null)
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
</button>
|
|
93
|
+
{children}
|
|
94
|
+
</span>
|
|
95
|
+
);
|
|
96
|
+
}
|