@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
@@ -0,0 +1,527 @@
1
+ "use client";
2
+
3
+ import { writeValue } from "@/react/input/write-value";
4
+ import { ColorSwatch } from "@/react/input/color-swatch";
5
+ import { COLOR_STYLES } from "@/react/input/color-styles";
6
+ import type { ColorLabels, ColorPanelPlacement } from "@/react/input/types";
7
+ import { parseColor, formatColor, rgbToHsv, hsvToRgb, toHex, colorEquals, type Hsv, type ColorFormat } from "@/core/color";
8
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
9
+
10
+ /**
11
+ * The colour picker for `<Input type="color">`: the swatch in the field, and the panel it
12
+ * opens - a saturation/brightness square, a hue rail, an optional alpha rail, presets, and
13
+ * the browser's eyedropper where there is one.
14
+ *
15
+ * Its own chunk, imported the moment a colour field is rendered, so a form of text and email
16
+ * fields downloads none of it - the same arrangement the password meter and the search engine
17
+ * are in.
18
+ *
19
+ * WHY NOT THE NATIVE ONE. `<input type="color">` opens a picker drawn by the OPERATING
20
+ * SYSTEM: unstylable, different on every platform, with no presets, no alpha, and a value
21
+ * that can only ever be `#rrggbb`. It also cannot be read or typed into, which is the fastest
22
+ * way to enter a colour anyone already has. So the field stays a TEXT input holding the
23
+ * canonical string - typed, pasted and submitted like any other value - and the swatch opens
24
+ * this panel beside it.
25
+ *
26
+ * The value in the field is the single source of truth, with one exception that is the whole
27
+ * reason this component keeps state: HSV. Hue is undefined at black, at white and at every
28
+ * grey, so recomputing it from the field's RGB resets the rail to red the moment the square
29
+ * is dragged into a corner. `core/color.ts` documents that trap; the state below is what
30
+ * avoids it.
31
+ */
32
+
33
+ /** What the browser's colour dropper resolves to. Typed here because the DOM lib has no entry. */
34
+ interface EyeDropperResult {
35
+ sRGBHex: string;
36
+ }
37
+
38
+ interface EyeDropperApi {
39
+ new(): { open(options?: { signal?: AbortSignal; }): Promise<EyeDropperResult>; };
40
+ }
41
+
42
+ /** Nothing chosen yet: black, and a hue of 0, which is what the rails open on. */
43
+ const INITIAL: Hsv = { h: 0, s: 0, v: 0, a: 1 };
44
+
45
+ /** An arrow key's step, and what Shift multiplies it by. */
46
+ const STEP = { fine: 0.01, coarse: 0.1, hue: 1, hueCoarse: 10 };
47
+
48
+ /** Kept clear of the window edge when deciding which side to open on. */
49
+ const MARGIN = 12;
50
+
51
+ /** The notations the readout cycles through, in the order the button steps over them. */
52
+ const FORMATS: ColorFormat[] = ["hex", "rgb", "hsl"];
53
+ const FORMAT_LABELS: Record<ColorFormat, string> = { hex: "HEX", rgb: "RGB", hsl: "HSL" };
54
+
55
+ let injected = false;
56
+
57
+ function injectStyles(): void {
58
+ if (injected || typeof document === "undefined") return;
59
+ injected = true;
60
+ if (document.querySelector("[data-enigma-color-styles]")) return;
61
+ const element = document.createElement("style");
62
+ element.setAttribute("data-enigma-color-styles", "");
63
+ element.textContent = COLOR_STYLES;
64
+ document.head.prepend(element);
65
+ }
66
+
67
+ function clamp01(value: number): number {
68
+ return value < 0 ? 0 : value > 1 ? 1 : value;
69
+ }
70
+
71
+ export interface ColorExtrasProps {
72
+ /** The field the value lives in. Null for the one render before it mounts. */
73
+ input: HTMLInputElement | null;
74
+ /** What the field currently holds, parseable or not. */
75
+ value: string;
76
+ format?: ColorFormat;
77
+ alpha?: boolean;
78
+ swatches?: readonly string[];
79
+ eyedropper?: boolean;
80
+ placement?: ColorPanelPlacement;
81
+ styles?: boolean;
82
+ /** The field is disabled or read-only: the swatch is inert and the panel never opens. */
83
+ locked?: boolean;
84
+ /**
85
+ * The swatch was already pressed, while this module was still being fetched. Opens the
86
+ * panel on the first render rather than asking for the press a second time.
87
+ */
88
+ openOnMount?: boolean;
89
+ labels?: ColorLabels;
90
+ }
91
+
92
+ export function ColorExtras({
93
+ input,
94
+ value,
95
+ format = "hex",
96
+ alpha = false,
97
+ swatches,
98
+ eyedropper = true,
99
+ placement = "auto",
100
+ styles = true,
101
+ locked = false,
102
+ openOnMount = false,
103
+ labels
104
+ }: ColorExtrasProps): ReactNode {
105
+ // Before paint: a sheet applied after the first frame shows the panel unstyled first.
106
+ useLayoutEffect(() => { if (styles) injectStyles(); }, [styles]);
107
+
108
+ const anchorRef = useRef<HTMLSpanElement | null>(null);
109
+ const panelRef = useRef<HTMLDivElement | null>(null);
110
+ const swatchRef = useRef<HTMLButtonElement | null>(null);
111
+ const areaRef = useRef<HTMLDivElement | null>(null);
112
+
113
+ const [open, setOpen] = useState(openOnMount && !locked);
114
+ const [side, setSide] = useState<"top" | "bottom">(placement === "top" ? "top" : "bottom");
115
+
116
+ const parsed = useMemo(() => parseColor(value), [value]);
117
+ const [hsv, setHsv] = useState<Hsv>(() => (parsed ? rgbToHsv(parsed) : INITIAL));
118
+
119
+ /**
120
+ * The field changed under us - typed, pasted, reset by the form, set by the caller.
121
+ *
122
+ * Only when it says a DIFFERENT colour than the one the controls are already showing: a
123
+ * value this component just wrote round-trips to the same RGB, and re-deriving HSV from
124
+ * it would throw away the hue and saturation the square is standing on. `current.h` is
125
+ * passed through for the greys, where the hue cannot be recovered from the bytes at all.
126
+ */
127
+ useEffect(() => {
128
+ if (!parsed) return;
129
+ setHsv((current) => (colorEquals(hsvToRgb(current), parsed) ? current : rgbToHsv(parsed, current.h)));
130
+ }, [parsed]);
131
+
132
+ const rgb = useMemo(() => hsvToRgb(hsv), [hsv]);
133
+
134
+ const commit = useCallback((next: Hsv) => {
135
+ setHsv(next);
136
+ if (input) writeValue(input, formatColor(hsvToRgb(next), format, { alpha }));
137
+ }, [input, format, alpha]);
138
+
139
+ /**
140
+ * What was typed, put into the canonical form - on BLUR and never before it.
141
+ *
142
+ * `#3b8` and `RGB(59 130 246)` are the same colour as what the picker writes, and storing
143
+ * either alongside the other means two spellings of one value in the database. Rewriting
144
+ * while the caret is still in the field would fight every keystroke, so it waits for the
145
+ * field to be left, which is the rule the validation policy sets for normalizing.
146
+ */
147
+ useEffect(() => {
148
+ if (!input) return;
149
+ const onBlur = (): void => {
150
+ const colour = parseColor(input.value);
151
+ if (!colour) return;
152
+ const canonical = formatColor(colour, format, { alpha });
153
+ if (canonical !== input.value) writeValue(input, canonical);
154
+ };
155
+ input.addEventListener("blur", onBlur);
156
+ return () => input.removeEventListener("blur", onBlur);
157
+ }, [input, format, alpha]);
158
+
159
+ const setOpenState = useCallback((next: boolean) => {
160
+ setOpen((current) => (current === next ? current : next));
161
+ }, []);
162
+
163
+ const close = useCallback((focusSwatch: boolean) => {
164
+ setOpenState(false);
165
+ // Only when focus is still inside the panel that is going away. Taking it back after
166
+ // the visitor has clicked somewhere else would drag them out of what they just chose.
167
+ if (focusSwatch) swatchRef.current?.focus();
168
+ }, [setOpenState]);
169
+
170
+ /* -------- where the panel goes, and what dismisses it -------- */
171
+
172
+ const place = useCallback(() => {
173
+ if (placement !== "auto") return setSide(placement);
174
+ const anchor = anchorRef.current?.getBoundingClientRect();
175
+ const panel = panelRef.current?.getBoundingClientRect();
176
+ if (!anchor || !panel) return;
177
+ const below = window.innerHeight - anchor.bottom;
178
+ // Flipped only when there is genuinely more room the other way: near the bottom of the
179
+ // window an unflipped panel hangs off the screen and its rails cannot be reached.
180
+ setSide(below < panel.height + MARGIN && anchor.top > below ? "top" : "bottom");
181
+ }, [placement]);
182
+
183
+ useLayoutEffect(() => {
184
+ if (!open) return;
185
+ place();
186
+ }, [open, place]);
187
+
188
+ useEffect(() => {
189
+ 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
+ const inside = (target: EventTarget | null): boolean => Boolean(anchorRef.current?.contains(target as Node | null));
195
+ const onPointerDown = (event: globalThis.PointerEvent): void => { if (!inside(event.target)) setOpenState(false); };
196
+ /**
197
+ * Tabbing out closes it. `document.body` is skipped on purpose: pressing the panel's
198
+ * own padding moves focus there, and treating that as leaving would shut the panel
199
+ * every time somebody clicks a gap between two controls.
200
+ */
201
+ const onFocusIn = (event: FocusEvent): void => {
202
+ if (event.target === document.body || inside(event.target)) return;
203
+ setOpenState(false);
204
+ };
205
+ const onResize = (): void => place();
206
+
207
+ document.addEventListener("pointerdown", onPointerDown, true);
208
+ document.addEventListener("focusin", onFocusIn, true);
209
+ window.addEventListener("resize", onResize);
210
+ return () => {
211
+ document.removeEventListener("pointerdown", onPointerDown, true);
212
+ document.removeEventListener("focusin", onFocusIn, true);
213
+ window.removeEventListener("resize", onResize);
214
+ };
215
+ }, [open, place, setOpenState]);
216
+
217
+ /* -------- dragging -------- */
218
+
219
+ /**
220
+ * A press, and everything that follows it until the finger comes up.
221
+ *
222
+ * The pointer is CAPTURED, so a drag that leaves the square keeps steering it instead of
223
+ * stopping at the edge - which is what makes the last 5% of saturation reachable. The
224
+ * marquee's warning about capture does not apply here: it retargets the compatibility
225
+ * mouse events, and there is nothing clickable inside these surfaces to lose them (the
226
+ * handle is `pointer-events: none` for exactly this reason).
227
+ */
228
+ const track = useCallback((event: PointerEvent<HTMLElement>, onMove: (x: number, y: number) => void) => {
229
+ if (event.button !== 0) return;
230
+ const element = event.currentTarget;
231
+ // Stops the press selecting the panel's text on the way, and the drag becoming a
232
+ // browser image drag. Focus is then taken by hand, since preventDefault denies it.
233
+ event.preventDefault();
234
+ element.focus();
235
+
236
+ const apply = (clientX: number, clientY: number): void => {
237
+ const rect = element.getBoundingClientRect();
238
+ onMove(
239
+ rect.width ? clamp01((clientX - rect.left) / rect.width) : 0,
240
+ rect.height ? clamp01((clientY - rect.top) / rect.height) : 0
241
+ );
242
+ };
243
+
244
+ const move = (moved: globalThis.PointerEvent): void => apply(moved.clientX, moved.clientY);
245
+ const stop = (): void => {
246
+ element.removeEventListener("pointermove", move);
247
+ element.removeEventListener("pointerup", stop);
248
+ element.removeEventListener("pointercancel", stop);
249
+ };
250
+ element.addEventListener("pointermove", move);
251
+ element.addEventListener("pointerup", stop);
252
+ element.addEventListener("pointercancel", stop);
253
+ try { element.setPointerCapture(event.pointerId); } catch { /* a pointer already gone */ }
254
+
255
+ apply(event.clientX, event.clientY);
256
+ }, []);
257
+
258
+ /* -------- the keyboard, which is the same control by other means -------- */
259
+
260
+ const areaKeys = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
261
+ const step = event.shiftKey ? STEP.coarse : STEP.fine;
262
+ const moves: Record<string, [number, number]> = {
263
+ ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step]
264
+ };
265
+ const move = moves[event.key];
266
+ if (!move) return;
267
+ event.preventDefault();
268
+ commit({ ...hsv, s: clamp01(hsv.s + move[0]), v: clamp01(hsv.v + move[1]) });
269
+ }, [commit, hsv]);
270
+
271
+ const railKeys = useCallback((event: KeyboardEvent<HTMLDivElement>, kind: "hue" | "alpha") => {
272
+ const step = kind === "hue"
273
+ ? (event.shiftKey ? STEP.hueCoarse : STEP.hue)
274
+ : (event.shiftKey ? STEP.coarse : STEP.fine);
275
+ const max = kind === "hue" ? 360 : 1;
276
+ const at = kind === "hue" ? hsv.h : hsv.a;
277
+
278
+ let next = at;
279
+ if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = at - step;
280
+ else if (event.key === "ArrowRight" || event.key === "ArrowUp") next = at + step;
281
+ else if (event.key === "Home") next = 0;
282
+ else if (event.key === "End") next = max;
283
+ else return;
284
+
285
+ event.preventDefault();
286
+ // The hue rail WRAPS, because the spectrum does: stopping at red on one side and
287
+ // magenta on the other makes half the wheel a long walk back.
288
+ const value = kind === "hue" ? ((next % 360) + 360) % 360 : clamp01(next);
289
+ commit(kind === "hue" ? { ...hsv, h: value } : { ...hsv, a: value });
290
+ }, [commit, hsv]);
291
+
292
+ /* -------- the eyedropper, when the browser has one -------- */
293
+
294
+ const dropper = eyedropper && typeof window !== "undefined" && "EyeDropper" in window;
295
+
296
+ const pick = useCallback(async () => {
297
+ const api = (window as unknown as { EyeDropper?: EyeDropperApi; }).EyeDropper;
298
+ if (!api) return;
299
+ try {
300
+ const result = await new api().open();
301
+ const colour = parseColor(result.sRGBHex);
302
+ // The dropper reads an opaque pixel, so the alpha in hand is kept rather than
303
+ // reset: someone picking a colour for a 40% overlay wants the colour, not the 100%.
304
+ if (colour) commit(rgbToHsv({ ...colour, a: hsv.a }, hsv.h));
305
+ } catch {
306
+ // Dismissed with Escape, or refused. Neither is worth reporting: nothing changed.
307
+ }
308
+ }, [commit, hsv]);
309
+
310
+ /* -------- the readout, which is the value in whichever notation you want to read it -------- */
311
+
312
+ /**
313
+ * Which notation the panel PRINTS the colour in, and only that.
314
+ *
315
+ * `format` is the caller's contract - the spelling their column, their API or their CSS
316
+ * expects - so cycling this button never changes what is written into the field. It
317
+ * changes what you can read out of the panel and paste elsewhere, which is what the
318
+ * browser's own picker offers and what a picker showing no code at all cannot.
319
+ */
320
+ const [shown, setShown] = useState<ColorFormat>(format);
321
+ useEffect(() => setShown(format), [format]);
322
+
323
+ /** What is being typed, while it is being typed. Null means "show the current colour". */
324
+ const [draft, setDraft] = useState<string | null>(null);
325
+
326
+ const readout = useMemo(() => formatColor(rgb, shown, { alpha }), [rgb, shown, alpha]);
327
+ const nextFormat = FORMATS[(FORMATS.indexOf(shown) + 1) % FORMATS.length] as ColorFormat;
328
+
329
+ const typed = useCallback((next: string) => {
330
+ setDraft(next);
331
+ const colour = parseColor(next);
332
+ // Applied as it becomes a colour, so the square and the rails follow a pasted value
333
+ // rather than waiting for a blur. Half-typed text simply is not one yet.
334
+ if (colour) commit(rgbToHsv(alpha ? colour : { ...colour, a: 1 }, hsv.h));
335
+ }, [commit, alpha, hsv.h]);
336
+
337
+ /* -------- rendering -------- */
338
+
339
+ const css = useMemo(() => formatColor(rgb, "hex", { alpha: true }), [rgb]);
340
+ const opaque = useMemo(() => toHex({ ...rgb, a: 1 }), [rgb]);
341
+ const text = labels ?? {};
342
+ const valueText = `${text.color ?? "Colour"} ${css}`;
343
+
344
+ const panelStyle = { "--enigma-color-hue": Math.round(hsv.h), "--enigma-color-opaque": opaque } as CSSProperties;
345
+
346
+ return (
347
+ <ColorSwatch
348
+ anchorRef={anchorRef}
349
+ buttonRef={swatchRef}
350
+ value={css}
351
+ invalid={!parsed}
352
+ open={open}
353
+ locked={locked}
354
+ label={text.open ?? "Pick a colour"}
355
+ onPress={() => (open ? close(true) : setOpenState(true))}
356
+ >
357
+ {open && !locked && (
358
+ <div
359
+ ref={panelRef}
360
+ data-enigma-color-panel=""
361
+ data-side={side}
362
+ role="dialog"
363
+ aria-label={text.panel ?? "Colour picker"}
364
+ style={panelStyle}
365
+ onKeyDown={(event) => {
366
+ if (event.key !== "Escape") return;
367
+ event.stopPropagation();
368
+ close(true);
369
+ }}
370
+ >
371
+ {/*
372
+ There is no ARIA role for a two-dimensional slider, and inventing one
373
+ announces nothing. `slider` with saturation as the number is the closest
374
+ honest fit - the arrows drive both axes and `aria-valuetext` says what
375
+ the number alone cannot, including the colour it lands on.
376
+ */}
377
+ <div
378
+ ref={areaRef}
379
+ data-enigma-color-area=""
380
+ role="slider"
381
+ tabIndex={0}
382
+ aria-label={text.area ?? "Saturation and brightness"}
383
+ aria-valuemin={0}
384
+ aria-valuemax={100}
385
+ aria-valuenow={Math.round(hsv.s * 100)}
386
+ aria-valuetext={`${Math.round(hsv.s * 100)}% saturation, ${Math.round(hsv.v * 100)}% brightness, ${valueText}`}
387
+ onKeyDown={areaKeys}
388
+ onPointerDown={(event) => track(event, (x, y) => commit({ ...hsv, s: x, v: 1 - y }))}
389
+ >
390
+ <span data-enigma-color-thumb="" style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` }} />
391
+ </div>
392
+
393
+ <div data-enigma-color-controls="">
394
+ {/* The chosen colour, over the chequerboard, so an alpha of 40% reads
395
+ as transparent rather than as a paler colour. */}
396
+ <span data-enigma-color-preview="" data-enigma-color-checkers="">
397
+ <span data-enigma-color-fill="" style={{ background: css }} />
398
+ </span>
399
+
400
+ {/* Feature-detected rather than assumed: `EyeDropper` is Chromium-only,
401
+ and a button that throws on Safari is worse than no button. */}
402
+ {dropper && (
403
+ <button
404
+ type="button"
405
+ data-enigma-color-eyedropper=""
406
+ aria-label={text.eyedropper ?? "Pick a colour from the screen"}
407
+ title={text.eyedropper ?? "Pick a colour from the screen"}
408
+ onClick={() => { void pick(); }}
409
+ >
410
+ <EyedropperIcon />
411
+ </button>
412
+ )}
413
+
414
+ <div data-enigma-color-rails="">
415
+ <div
416
+ data-enigma-color-rail="hue"
417
+ role="slider"
418
+ tabIndex={0}
419
+ aria-label={text.hue ?? "Hue"}
420
+ aria-valuemin={0}
421
+ aria-valuemax={360}
422
+ aria-valuenow={Math.round(hsv.h)}
423
+ aria-valuetext={`${Math.round(hsv.h)} degrees`}
424
+ onKeyDown={(event) => railKeys(event, "hue")}
425
+ onPointerDown={(event) => track(event, (x) => commit({ ...hsv, h: x * 360 }))}
426
+ >
427
+ <span data-enigma-color-thumb="" style={{ left: `${(hsv.h / 360) * 100}%` }} />
428
+ </div>
429
+
430
+ {alpha && (
431
+ <div
432
+ data-enigma-color-rail="alpha"
433
+ data-enigma-color-checkers=""
434
+ role="slider"
435
+ tabIndex={0}
436
+ aria-label={text.alpha ?? "Opacity"}
437
+ aria-valuemin={0}
438
+ aria-valuemax={100}
439
+ aria-valuenow={Math.round(hsv.a * 100)}
440
+ aria-valuetext={`${Math.round(hsv.a * 100)}% opacity`}
441
+ onKeyDown={(event) => railKeys(event, "alpha")}
442
+ onPointerDown={(event) => track(event, (x) => commit({ ...hsv, a: x }))}
443
+ >
444
+ <span data-enigma-color-gradient="" />
445
+ <span data-enigma-color-thumb="" style={{ left: `${hsv.a * 100}%` }} />
446
+ </div>
447
+ )}
448
+ </div>
449
+ </div>
450
+
451
+ {/* The colour as text, editable, with the notation next to it. A picker
452
+ that shows no code is one you cannot read a value out of, cannot paste
453
+ one into, and cannot check against the hex somebody sent you. */}
454
+ <div data-enigma-color-value="">
455
+ <button
456
+ type="button"
457
+ data-enigma-color-format=""
458
+ aria-label={`${text.formatAs ?? "Show as"} ${FORMAT_LABELS[nextFormat]}`}
459
+ title={`${text.formatAs ?? "Show as"} ${FORMAT_LABELS[nextFormat]}`}
460
+ onClick={() => { setShown(nextFormat); setDraft(null); }}
461
+ >
462
+ {FORMAT_LABELS[shown]}
463
+ </button>
464
+ <input
465
+ type="text"
466
+ data-enigma-color-input=""
467
+ value={draft ?? readout}
468
+ aria-label={text.value ?? "Colour value"}
469
+ spellCheck={false}
470
+ autoComplete="off"
471
+ autoCorrect="off"
472
+ autoCapitalize="off"
473
+ onChange={(event) => typed(event.target.value)}
474
+ // What was typed goes back to the canonical spelling the moment the
475
+ // field is left, the way the input itself normalizes on blur.
476
+ onBlur={() => setDraft(null)}
477
+ onKeyDown={(event) => {
478
+ if (event.key !== "Enter") return;
479
+ event.preventDefault();
480
+ setDraft(null);
481
+ }}
482
+ />
483
+ </div>
484
+
485
+ {swatches && swatches.length > 0 && (
486
+ <div data-enigma-color-swatches="" role="group" aria-label={text.swatches ?? "Preset colours"}>
487
+ {swatches.map((preset) => {
488
+ const colour = parseColor(preset);
489
+ return (
490
+ <button
491
+ key={preset}
492
+ type="button"
493
+ data-enigma-color-preset=""
494
+ data-enigma-color-checkers=""
495
+ style={{ background: preset }}
496
+ // The value IS the name. A guessed one ("dark blue")
497
+ // would be a label nobody wrote and half of them wrong.
498
+ aria-label={preset}
499
+ title={preset}
500
+ aria-pressed={colourEqualsPreset(colour, css)}
501
+ onClick={() => { if (colour) commit(rgbToHsv(alpha ? colour : { ...colour, a: 1 }, hsv.h)); }}
502
+ />
503
+ );
504
+ })}
505
+ </div>
506
+ )}
507
+ </div>
508
+ )}
509
+ </ColorSwatch>
510
+ );
511
+ }
512
+
513
+ /** Whether a preset is the colour currently chosen, compared as colours and not as strings. */
514
+ function colourEqualsPreset(preset: ReturnType<typeof parseColor>, current: string): boolean {
515
+ return colorEquals(preset, parseColor(current));
516
+ }
517
+
518
+ /** Drawn rather than loaded, like every other icon here: an SVG file would be a request. */
519
+ function EyedropperIcon(): ReactNode {
520
+ return (
521
+ <svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
522
+ <path d="m2 22 1-1h3l9-9" />
523
+ <path d="M3 21v-3l9-9" />
524
+ <path d="m15 6 3-3a2.83 2.83 0 0 1 4 4l-3 3-1-1-4 4-3-3 4-4Z" />
525
+ </svg>
526
+ );
527
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Icon } from "@/react/input/icon";
4
+ import { ColorSwatch } from "@/react/input/color-swatch";
4
5
  import { INPUT_ICON_PATHS } from "@/core/input-icons";
5
6
  import { writeValue } from "@/react/input/write-value";
6
7
  import type { AnyInputProps, InputProps, FieldAction, BreachState } from "@/react/input/types";
@@ -14,6 +15,7 @@ import { forwardRef, lazy, Suspense, useCallback, useEffect, useId, useRef, useS
14
15
  * <Input type="email" required />
15
16
  * <Input type="password" generate strength breach={checkPasswordBreach} />
16
17
  * <Input type="search" items={docs} keys={["title"]} renderResults={...} />
18
+ * <Input type="color" alpha swatches={brand} />
17
19
  * ```
18
20
  *
19
21
  * ONE component keyed on `type`, because that is what HTML is: `type` is an attribute of a
@@ -23,15 +25,31 @@ import { forwardRef, lazy, Suspense, useCallback, useEffect, useId, useRef, useS
23
25
  * PALETTE is a dialog, so it lives in `SearchPalette` rather than behind a prop here.
24
26
  *
25
27
  * WHAT LOADS. The field, its buttons and the reveal are this module and nothing else. The
26
- * password estimator, the breach watcher and the search engine each live in their own chunk
27
- * and are imported the moment the type that needs them is used - so a form of text and email
28
- * fields ships none of them, and a page with one password field does not pay for search.
28
+ * password estimator, the breach watcher, the search engine and the colour picker each live
29
+ * in their own chunk and are imported the moment the type that needs them is used - so a form
30
+ * of text and email fields ships none of them, and a page with one password field pays for
31
+ * neither search nor a colour wheel.
29
32
  * The generator is loaded on the first press of its button, because until then it is a
30
33
  * function nobody has called.
31
34
  */
32
35
 
36
+ /**
37
+ * What a colour field is given that a text field is not.
38
+ *
39
+ * Spread BEFORE `{...rest}`, so every one of them is still the caller's to override. A hex
40
+ * string is not a word, not a saved form value, and not something to capitalize - and an
41
+ * autocomplete menu over a colour field covers the panel that just opened.
42
+ */
43
+ const COLOR_FIELD_PROPS = {
44
+ autoComplete: "off",
45
+ autoCorrect: "off",
46
+ autoCapitalize: "off",
47
+ spellCheck: false
48
+ } as const;
49
+
33
50
  const PasswordExtras = lazy(() => import("@/react/input/password").then((module) => ({ default: module.PasswordExtras })));
34
51
  const SearchExtras = lazy(() => import("@/react/input/search").then((module) => ({ default: module.SearchExtras })));
52
+ const ColorExtras = lazy(() => import("@/react/input/color").then((module) => ({ default: module.ColorExtras })));
35
53
 
36
54
  export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, forwardedRef) {
37
55
  const {
@@ -60,6 +78,13 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
60
78
  renderResults,
61
79
  clearable,
62
80
  clearLabel = "Clear",
81
+ format,
82
+ alpha,
83
+ swatches,
84
+ eyedropper,
85
+ placement,
86
+ styles,
87
+ colorLabels,
63
88
  wrapperProps,
64
89
  fieldProps,
65
90
  classNames,
@@ -72,10 +97,19 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
72
97
  const innerRef = useRef<HTMLInputElement | null>(null);
73
98
  const [element, setElement] = useState<HTMLInputElement | null>(null);
74
99
  const [revealed, setRevealed] = useState(false);
100
+ /**
101
+ * The swatch was pressed before the picker's code arrived.
102
+ *
103
+ * Remembered rather than dropped: the press was a request to open the panel, and a
104
+ * control that ignores one because a chunk was in flight is a control that feels broken
105
+ * for exactly as long as the network is slow.
106
+ */
107
+ const [colorWanted, setColorWanted] = useState(false);
75
108
  const describedBy = useId();
76
109
 
77
110
  const isPassword = type === "password";
78
111
  const isSearch = type === "search";
112
+ const isColor = type === "color";
79
113
  const showReveal = reveal ?? isPassword;
80
114
  const showGenerate = generate !== false && isPassword;
81
115
  const showClear = (clearable ?? isSearch) && isSearch;
@@ -238,8 +272,42 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
238
272
  data-score={score ?? undefined}
239
273
  >
240
274
  <div {...fieldProps} data-enigma-input-field="">
275
+ {/* The swatch is NOT part of the picker's chunk, unlike the meter, which has
276
+ nothing to show until it has been asked a question. A colour field with no
277
+ swatch until a request lands is a field whose only affordance is missing,
278
+ and a press in that window reaches nothing: so the button is here from the
279
+ first frame, and a press before the panel's code arrives opens it as soon
280
+ as it does. */}
281
+ {isColor && (
282
+ <Suspense
283
+ fallback={
284
+ <ColorSwatch
285
+ value={value}
286
+ locked={locked}
287
+ baseline={styles !== false}
288
+ label={colorLabels?.open ?? "Pick a colour"}
289
+ onPress={() => setColorWanted(true)}
290
+ />
291
+ }
292
+ >
293
+ <ColorExtras
294
+ openOnMount={colorWanted}
295
+ input={element}
296
+ value={value}
297
+ format={format}
298
+ alpha={alpha}
299
+ swatches={swatches}
300
+ eyedropper={eyedropper}
301
+ placement={placement}
302
+ styles={styles}
303
+ locked={locked}
304
+ labels={colorLabels}
305
+ />
306
+ </Suspense>
307
+ )}
241
308
  {position === "start" && buttons}
242
309
  <input
310
+ {...(isColor ? COLOR_FIELD_PROPS : null)}
243
311
  {...rest}
244
312
  ref={(node) => {
245
313
  innerRef.current = node;
@@ -248,8 +316,10 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
248
316
  else if (forwardedRef) forwardedRef.current = node;
249
317
  }}
250
318
  // Revealing a password is a type switch, which is what the caret dance
251
- // above exists for.
252
- type={revealed && isPassword ? "text" : type}
319
+ // above exists for. A colour field is a TEXT one in the DOM: the native
320
+ // `type="color"` renders a swatch whose popup is the operating system's -
321
+ // unstylable, alpha-less, and impossible to type or paste a value into.
322
+ type={isColor ? "text" : revealed && isPassword ? "text" : type}
253
323
  onChange={handleChange}
254
324
  data-enigma-input=""
255
325
  aria-describedby={score !== null ? describedBy : rest["aria-describedby"]}
@@ -303,5 +373,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
303
373
  export type {
304
374
  InputProps, InputBaseProps, InputType, FieldAction,
305
375
  BreachChecker, BreachState, BreachStatus,
306
- PasswordOnlyProps, SearchOnlyProps, PlainOnlyProps
376
+ ColorLabels, ColorPanelPlacement,
377
+ PasswordOnlyProps, SearchOnlyProps, ColorOnlyProps, PlainOnlyProps
307
378
  } from "@/react/input/types";