@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.
Files changed (40) hide show
  1. package/dist/{chunk-KEVZ5XQV.js → chunk-3MGBZOAU.js} +50 -9
  2. package/dist/{chunk-DSBYVA7V.js → chunk-DZMHY3SU.js} +73 -9
  3. package/dist/chunk-KVLSOQTI.js +66 -0
  4. package/dist/chunk-S2FPN5ID.js +115 -0
  5. package/dist/{chunk-3BQVOOAM.js → chunk-Z3VDE7OA.js} +145 -1
  6. package/dist/{context-menu-D3FtTn7v.d.ts → clipboard-menu-B_ouitfS.d.ts} +91 -1
  7. package/dist/color-D_rZ83Oc.d.ts +152 -0
  8. package/dist/color-T63FLJNH.js +515 -0
  9. package/dist/{index-DQNnohoo.d.ts → index-qXOkYQCU.d.ts} +56 -4
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +2 -1
  12. package/dist/next/index.d.ts +3 -3
  13. package/dist/next/index.js +6 -4
  14. package/dist/react/context-menu.d.ts +23 -6
  15. package/dist/react/context-menu.js +2 -2
  16. package/dist/react/index.d.ts +5 -5
  17. package/dist/react/index.js +6 -4
  18. package/dist/react/input.d.ts +2 -2
  19. package/dist/react/input.js +2 -1
  20. package/dist/react-router/index.d.ts +3 -3
  21. package/dist/react-router/index.js +6 -4
  22. package/package.json +3 -1
  23. package/recipes/color/styles.css +163 -0
  24. package/recipes/context-menu/styles.css +8 -0
  25. package/registry.json +86 -7
  26. package/src/core/clipboard-menu.ts +270 -0
  27. package/src/core/color.ts +248 -0
  28. package/src/index.ts +32 -0
  29. package/src/react/context-menu/context.ts +9 -2
  30. package/src/react/context-menu/index.tsx +14 -0
  31. package/src/react/context-menu/root.tsx +132 -8
  32. package/src/react/context-menu/styles.ts +8 -0
  33. package/src/react/index.ts +32 -0
  34. package/src/react/input/color-styles.ts +176 -0
  35. package/src/react/input/color-swatch.tsx +96 -0
  36. package/src/react/input/color.tsx +527 -0
  37. package/src/react/input/index.tsx +77 -6
  38. package/src/react/input/types.ts +63 -3
  39. package/dist/password-C8lG4Zm9.d.ts +0 -71
  40. /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) => latest.current.onSelect?.(item as ContextMenuItem, 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 as readonly ContextMenuEntry[], title });
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
- const open = useCallback((point: ContextMenuPoint): boolean => {
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
- if (menu.open({ x: event.clientX, y: event.clientY })) event.preventDefault();
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
- press.current = { x, y, timer: window.setTimeout(() => { press.current = null; menu.open({ x, y }); }, LONG_PRESS_MS) };
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
- if (menu.open({ x: rect.left + 8, y: rect.top + 8 })) event.preventDefault();
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; }
@@ -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
+ }