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