@enigmax/primitives 0.24.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.
@@ -1,4 +1,4 @@
1
- import { writeValue } from './chunk-S7EE57YB.js';
1
+ import { writeValue, ColorSwatch } from './chunk-KVLSOQTI.js';
2
2
  import { INPUT_ICON_PATHS } from './chunk-F25CGNQC.js';
3
3
  import { jsx, jsxs } from 'react/jsx-runtime';
4
4
  import { lazy, forwardRef, useRef, useState, useId, useCallback, useEffect, Suspense } from 'react';
@@ -25,7 +25,7 @@ var COLOR_FIELD_PROPS = {
25
25
  };
26
26
  var PasswordExtras = lazy(() => import('./password-3DRQYAYQ.js').then((module) => ({ default: module.PasswordExtras })));
27
27
  var SearchExtras = lazy(() => import('./search-PBZORZ7P.js').then((module) => ({ default: module.SearchExtras })));
28
- var ColorExtras = lazy(() => import('./color-OPV3BJV6.js').then((module) => ({ default: module.ColorExtras })));
28
+ var ColorExtras = lazy(() => import('./color-T63FLJNH.js').then((module) => ({ default: module.ColorExtras })));
29
29
  var Input = forwardRef(function Input2(props, forwardedRef) {
30
30
  const {
31
31
  reveal,
@@ -71,6 +71,7 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
71
71
  const innerRef = useRef(null);
72
72
  const [element, setElement] = useState(null);
73
73
  const [revealed, setRevealed] = useState(false);
74
+ const [colorWanted, setColorWanted] = useState(false);
74
75
  const describedBy = useId();
75
76
  const isPassword = type === "password";
76
77
  const isSearch = type === "search";
@@ -200,21 +201,37 @@ var Input = forwardRef(function Input2(props, forwardedRef) {
200
201
  "data-score": score ?? void 0,
201
202
  children: [
202
203
  /* @__PURE__ */ jsxs("div", { ...fieldProps, "data-enigma-input-field": "", children: [
203
- isColor && /* @__PURE__ */ jsx(Suspense, { fallback: null, children: /* @__PURE__ */ jsx(
204
- ColorExtras,
204
+ isColor && /* @__PURE__ */ jsx(
205
+ Suspense,
205
206
  {
206
- input: element,
207
- value,
208
- format,
209
- alpha,
210
- swatches,
211
- eyedropper,
212
- placement,
213
- styles,
214
- locked,
215
- labels: colorLabels
207
+ fallback: /* @__PURE__ */ jsx(
208
+ ColorSwatch,
209
+ {
210
+ value,
211
+ locked,
212
+ baseline: styles !== false,
213
+ label: colorLabels?.open ?? "Pick a colour",
214
+ onPress: () => setColorWanted(true)
215
+ }
216
+ ),
217
+ children: /* @__PURE__ */ jsx(
218
+ ColorExtras,
219
+ {
220
+ openOnMount: colorWanted,
221
+ input: element,
222
+ value,
223
+ format,
224
+ alpha,
225
+ swatches,
226
+ eyedropper,
227
+ placement,
228
+ styles,
229
+ locked,
230
+ labels: colorLabels
231
+ }
232
+ )
216
233
  }
217
- ) }),
234
+ ),
218
235
  position === "start" && buttons,
219
236
  /* @__PURE__ */ jsx(
220
237
  "input",
@@ -0,0 +1,66 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+
3
+ // src/react/input/color-swatch.tsx
4
+ var BASELINE = {
5
+ display: "block",
6
+ boxSizing: "border-box",
7
+ width: "1.25rem",
8
+ height: "1.25rem",
9
+ padding: 0,
10
+ background: "none",
11
+ border: "1px solid var(--enigma-color-border, #404040)",
12
+ borderRadius: "0.3125rem",
13
+ overflow: "hidden",
14
+ cursor: "pointer"
15
+ };
16
+ function ColorSwatch({ value, open = false, invalid = false, locked = false, label, anchorRef, buttonRef, onPress, baseline = false, children }) {
17
+ return /* @__PURE__ */ jsxs(
18
+ "span",
19
+ {
20
+ ref: anchorRef,
21
+ "data-enigma-color": "",
22
+ "data-open": open ? "" : void 0,
23
+ style: baseline ? { position: "relative", display: "inline-flex", flex: "none" } : void 0,
24
+ children: [
25
+ /* @__PURE__ */ jsx(
26
+ "button",
27
+ {
28
+ ref: buttonRef,
29
+ type: "button",
30
+ "data-enigma-color-swatch": "",
31
+ "data-enigma-color-checkers": "",
32
+ "data-invalid": invalid ? "" : void 0,
33
+ "aria-haspopup": "dialog",
34
+ "aria-expanded": open,
35
+ "aria-label": label,
36
+ title: label,
37
+ disabled: locked,
38
+ style: baseline ? BASELINE : void 0,
39
+ onClick: onPress,
40
+ children: /* @__PURE__ */ jsx(
41
+ "span",
42
+ {
43
+ "data-enigma-color-fill": "",
44
+ style: {
45
+ background: invalid ? void 0 : value,
46
+ ...baseline ? { display: "block", width: "100%", height: "100%" } : null
47
+ }
48
+ }
49
+ )
50
+ }
51
+ ),
52
+ children
53
+ ]
54
+ }
55
+ );
56
+ }
57
+
58
+ // src/react/input/write-value.ts
59
+ function writeValue(input, next) {
60
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
61
+ if (setter) setter.call(input, next);
62
+ else input.value = next;
63
+ input.dispatchEvent(new Event("input", { bubbles: true }));
64
+ }
65
+
66
+ export { ColorSwatch, writeValue };
@@ -1,7 +1,7 @@
1
1
  import { parseColor, rgbToHsv, colorEquals, hsvToRgb, formatColor, toHex } from './chunk-S2FPN5ID.js';
2
- import { writeValue } from './chunk-S7EE57YB.js';
2
+ import { writeValue, ColorSwatch } from './chunk-KVLSOQTI.js';
3
3
  import { useLayoutEffect, useRef, useState, useMemo, useEffect, useCallback } from 'react';
4
- import { jsxs, jsx } from 'react/jsx-runtime';
4
+ import { jsx, jsxs } from 'react/jsx-runtime';
5
5
 
6
6
  // src/react/input/color-styles.ts
7
7
  var COLOR_STYLES = `
@@ -133,6 +133,26 @@ var COLOR_STYLES = `
133
133
  [data-enigma-color-eyedropper]:hover { color: var(--enigma-color-text); }
134
134
  [data-enigma-color-eyedropper]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 2px; }
135
135
 
136
+ /* The value as text, with the notation beside it. Monospace on purpose: a hex whose digits
137
+ shift width as you drag is a value nobody can read at a glance. */
138
+ [data-enigma-color-value] { display: flex; align-items: center; gap: 0.375rem; }
139
+ [data-enigma-color-format] {
140
+ flex: none; padding: 0.3125rem 0.375rem;
141
+ font: inherit; font-size: 0.625rem; line-height: 1; letter-spacing: 0.06em;
142
+ color: var(--enigma-color-muted); background: none;
143
+ border: 1px solid var(--enigma-color-border); border-radius: 0.25rem; cursor: pointer;
144
+ }
145
+ [data-enigma-color-format]:hover { color: var(--enigma-color-text); }
146
+ [data-enigma-color-input] {
147
+ flex: 1; min-width: 0; padding: 0.25rem 0.375rem;
148
+ font: inherit; font-family: var(--enigma-color-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
149
+ font-size: 0.75rem; line-height: 1.4;
150
+ color: var(--enigma-color-text); background: none;
151
+ border: 1px solid var(--enigma-color-border); border-radius: 0.25rem;
152
+ }
153
+ [data-enigma-color-format]:focus-visible,
154
+ [data-enigma-color-input]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 1px; }
155
+
136
156
  [data-enigma-color-swatches] { display: flex; flex-wrap: wrap; gap: 0.25rem; }
137
157
  [data-enigma-color-preset] {
138
158
  width: 1.125rem; height: 1.125rem; padding: 0;
@@ -144,6 +164,8 @@ var COLOR_STYLES = `
144
164
  var INITIAL = { h: 0, s: 0, v: 0, a: 1 };
145
165
  var STEP = { fine: 0.01, coarse: 0.1, hue: 1, hueCoarse: 10 };
146
166
  var MARGIN = 12;
167
+ var FORMATS = ["hex", "rgb", "hsl"];
168
+ var FORMAT_LABELS = { hex: "HEX", rgb: "RGB", hsl: "HSL" };
147
169
  var injected = false;
148
170
  function injectStyles() {
149
171
  if (injected || typeof document === "undefined") return;
@@ -167,6 +189,7 @@ function ColorExtras({
167
189
  placement = "auto",
168
190
  styles = true,
169
191
  locked = false,
192
+ openOnMount = false,
170
193
  labels
171
194
  }) {
172
195
  useLayoutEffect(() => {
@@ -176,7 +199,7 @@ function ColorExtras({
176
199
  const panelRef = useRef(null);
177
200
  const swatchRef = useRef(null);
178
201
  const areaRef = useRef(null);
179
- const [open, setOpen] = useState(false);
202
+ const [open, setOpen] = useState(openOnMount && !locked);
180
203
  const [side, setSide] = useState(placement === "top" ? "top" : "bottom");
181
204
  const parsed = useMemo(() => parseColor(value), [value]);
182
205
  const [hsv, setHsv] = useState(() => parsed ? rgbToHsv(parsed) : INITIAL);
@@ -305,138 +328,178 @@ function ColorExtras({
305
328
  } catch {
306
329
  }
307
330
  }, [commit, hsv]);
331
+ const [shown, setShown] = useState(format);
332
+ useEffect(() => setShown(format), [format]);
333
+ const [draft, setDraft] = useState(null);
334
+ const readout = useMemo(() => formatColor(rgb, shown, { alpha }), [rgb, shown, alpha]);
335
+ const nextFormat = FORMATS[(FORMATS.indexOf(shown) + 1) % FORMATS.length];
336
+ const typed = useCallback((next) => {
337
+ setDraft(next);
338
+ const colour = parseColor(next);
339
+ if (colour) commit(rgbToHsv(alpha ? colour : { ...colour, a: 1 }, hsv.h));
340
+ }, [commit, alpha, hsv.h]);
308
341
  const css = useMemo(() => formatColor(rgb, "hex", { alpha: true }), [rgb]);
309
342
  const opaque = useMemo(() => toHex({ ...rgb, a: 1 }), [rgb]);
310
343
  const text = labels ?? {};
311
344
  const valueText = `${text.color ?? "Colour"} ${css}`;
312
345
  const panelStyle = { "--enigma-color-hue": Math.round(hsv.h), "--enigma-color-opaque": opaque };
313
- return /* @__PURE__ */ jsxs("span", { ref: anchorRef, "data-enigma-color": "", "data-open": open ? "" : void 0, children: [
314
- /* @__PURE__ */ jsx(
315
- "button",
316
- {
317
- ref: swatchRef,
318
- type: "button",
319
- "data-enigma-color-swatch": "",
320
- "data-enigma-color-checkers": "",
321
- "data-invalid": parsed ? void 0 : "",
322
- "aria-haspopup": "dialog",
323
- "aria-expanded": open,
324
- "aria-label": text.open ?? "Pick a colour",
325
- title: text.open ?? "Pick a colour",
326
- disabled: locked,
327
- onClick: () => open ? close(true) : setOpenState(true),
328
- children: /* @__PURE__ */ jsx("span", { "data-enigma-color-fill": "", style: { background: parsed ? css : void 0 } })
329
- }
330
- ),
331
- open && !locked && /* @__PURE__ */ jsxs(
332
- "div",
333
- {
334
- ref: panelRef,
335
- "data-enigma-color-panel": "",
336
- "data-side": side,
337
- role: "dialog",
338
- "aria-label": text.panel ?? "Colour picker",
339
- style: panelStyle,
340
- onKeyDown: (event) => {
341
- if (event.key !== "Escape") return;
342
- event.stopPropagation();
343
- close(true);
344
- },
345
- children: [
346
- /* @__PURE__ */ jsx(
347
- "div",
348
- {
349
- ref: areaRef,
350
- "data-enigma-color-area": "",
351
- role: "slider",
352
- tabIndex: 0,
353
- "aria-label": text.area ?? "Saturation and brightness",
354
- "aria-valuemin": 0,
355
- "aria-valuemax": 100,
356
- "aria-valuenow": Math.round(hsv.s * 100),
357
- "aria-valuetext": `${Math.round(hsv.s * 100)}% saturation, ${Math.round(hsv.v * 100)}% brightness, ${valueText}`,
358
- onKeyDown: areaKeys,
359
- onPointerDown: (event) => track(event, (x, y) => commit({ ...hsv, s: x, v: 1 - y })),
360
- children: /* @__PURE__ */ jsx("span", { "data-enigma-color-thumb": "", style: { left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` } })
361
- }
362
- ),
363
- /* @__PURE__ */ jsxs("div", { "data-enigma-color-controls": "", children: [
364
- /* @__PURE__ */ jsx("span", { "data-enigma-color-preview": "", "data-enigma-color-checkers": "", children: /* @__PURE__ */ jsx("span", { "data-enigma-color-fill": "", style: { background: css } }) }),
365
- dropper && /* @__PURE__ */ jsx(
366
- "button",
346
+ return /* @__PURE__ */ jsx(
347
+ ColorSwatch,
348
+ {
349
+ anchorRef,
350
+ buttonRef: swatchRef,
351
+ value: css,
352
+ invalid: !parsed,
353
+ open,
354
+ locked,
355
+ label: text.open ?? "Pick a colour",
356
+ onPress: () => open ? close(true) : setOpenState(true),
357
+ children: open && !locked && /* @__PURE__ */ jsxs(
358
+ "div",
359
+ {
360
+ ref: panelRef,
361
+ "data-enigma-color-panel": "",
362
+ "data-side": side,
363
+ role: "dialog",
364
+ "aria-label": text.panel ?? "Colour picker",
365
+ style: panelStyle,
366
+ onKeyDown: (event) => {
367
+ if (event.key !== "Escape") return;
368
+ event.stopPropagation();
369
+ close(true);
370
+ },
371
+ children: [
372
+ /* @__PURE__ */ jsx(
373
+ "div",
367
374
  {
368
- type: "button",
369
- "data-enigma-color-eyedropper": "",
370
- "aria-label": text.eyedropper ?? "Pick a colour from the screen",
371
- title: text.eyedropper ?? "Pick a colour from the screen",
372
- onClick: () => {
373
- void pick();
374
- },
375
- children: /* @__PURE__ */ jsx(EyedropperIcon, {})
375
+ ref: areaRef,
376
+ "data-enigma-color-area": "",
377
+ role: "slider",
378
+ tabIndex: 0,
379
+ "aria-label": text.area ?? "Saturation and brightness",
380
+ "aria-valuemin": 0,
381
+ "aria-valuemax": 100,
382
+ "aria-valuenow": Math.round(hsv.s * 100),
383
+ "aria-valuetext": `${Math.round(hsv.s * 100)}% saturation, ${Math.round(hsv.v * 100)}% brightness, ${valueText}`,
384
+ onKeyDown: areaKeys,
385
+ onPointerDown: (event) => track(event, (x, y) => commit({ ...hsv, s: x, v: 1 - y })),
386
+ children: /* @__PURE__ */ jsx("span", { "data-enigma-color-thumb": "", style: { left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` } })
376
387
  }
377
388
  ),
378
- /* @__PURE__ */ jsxs("div", { "data-enigma-color-rails": "", children: [
389
+ /* @__PURE__ */ jsxs("div", { "data-enigma-color-controls": "", children: [
390
+ /* @__PURE__ */ jsx("span", { "data-enigma-color-preview": "", "data-enigma-color-checkers": "", children: /* @__PURE__ */ jsx("span", { "data-enigma-color-fill": "", style: { background: css } }) }),
391
+ dropper && /* @__PURE__ */ jsx(
392
+ "button",
393
+ {
394
+ type: "button",
395
+ "data-enigma-color-eyedropper": "",
396
+ "aria-label": text.eyedropper ?? "Pick a colour from the screen",
397
+ title: text.eyedropper ?? "Pick a colour from the screen",
398
+ onClick: () => {
399
+ void pick();
400
+ },
401
+ children: /* @__PURE__ */ jsx(EyedropperIcon, {})
402
+ }
403
+ ),
404
+ /* @__PURE__ */ jsxs("div", { "data-enigma-color-rails": "", children: [
405
+ /* @__PURE__ */ jsx(
406
+ "div",
407
+ {
408
+ "data-enigma-color-rail": "hue",
409
+ role: "slider",
410
+ tabIndex: 0,
411
+ "aria-label": text.hue ?? "Hue",
412
+ "aria-valuemin": 0,
413
+ "aria-valuemax": 360,
414
+ "aria-valuenow": Math.round(hsv.h),
415
+ "aria-valuetext": `${Math.round(hsv.h)} degrees`,
416
+ onKeyDown: (event) => railKeys(event, "hue"),
417
+ onPointerDown: (event) => track(event, (x) => commit({ ...hsv, h: x * 360 })),
418
+ children: /* @__PURE__ */ jsx("span", { "data-enigma-color-thumb": "", style: { left: `${hsv.h / 360 * 100}%` } })
419
+ }
420
+ ),
421
+ alpha && /* @__PURE__ */ jsxs(
422
+ "div",
423
+ {
424
+ "data-enigma-color-rail": "alpha",
425
+ "data-enigma-color-checkers": "",
426
+ role: "slider",
427
+ tabIndex: 0,
428
+ "aria-label": text.alpha ?? "Opacity",
429
+ "aria-valuemin": 0,
430
+ "aria-valuemax": 100,
431
+ "aria-valuenow": Math.round(hsv.a * 100),
432
+ "aria-valuetext": `${Math.round(hsv.a * 100)}% opacity`,
433
+ onKeyDown: (event) => railKeys(event, "alpha"),
434
+ onPointerDown: (event) => track(event, (x) => commit({ ...hsv, a: x })),
435
+ children: [
436
+ /* @__PURE__ */ jsx("span", { "data-enigma-color-gradient": "" }),
437
+ /* @__PURE__ */ jsx("span", { "data-enigma-color-thumb": "", style: { left: `${hsv.a * 100}%` } })
438
+ ]
439
+ }
440
+ )
441
+ ] })
442
+ ] }),
443
+ /* @__PURE__ */ jsxs("div", { "data-enigma-color-value": "", children: [
379
444
  /* @__PURE__ */ jsx(
380
- "div",
445
+ "button",
381
446
  {
382
- "data-enigma-color-rail": "hue",
383
- role: "slider",
384
- tabIndex: 0,
385
- "aria-label": text.hue ?? "Hue",
386
- "aria-valuemin": 0,
387
- "aria-valuemax": 360,
388
- "aria-valuenow": Math.round(hsv.h),
389
- "aria-valuetext": `${Math.round(hsv.h)} degrees`,
390
- onKeyDown: (event) => railKeys(event, "hue"),
391
- onPointerDown: (event) => track(event, (x) => commit({ ...hsv, h: x * 360 })),
392
- children: /* @__PURE__ */ jsx("span", { "data-enigma-color-thumb": "", style: { left: `${hsv.h / 360 * 100}%` } })
447
+ type: "button",
448
+ "data-enigma-color-format": "",
449
+ "aria-label": `${text.formatAs ?? "Show as"} ${FORMAT_LABELS[nextFormat]}`,
450
+ title: `${text.formatAs ?? "Show as"} ${FORMAT_LABELS[nextFormat]}`,
451
+ onClick: () => {
452
+ setShown(nextFormat);
453
+ setDraft(null);
454
+ },
455
+ children: FORMAT_LABELS[shown]
393
456
  }
394
457
  ),
395
- alpha && /* @__PURE__ */ jsxs(
396
- "div",
458
+ /* @__PURE__ */ jsx(
459
+ "input",
397
460
  {
398
- "data-enigma-color-rail": "alpha",
399
- "data-enigma-color-checkers": "",
400
- role: "slider",
401
- tabIndex: 0,
402
- "aria-label": text.alpha ?? "Opacity",
403
- "aria-valuemin": 0,
404
- "aria-valuemax": 100,
405
- "aria-valuenow": Math.round(hsv.a * 100),
406
- "aria-valuetext": `${Math.round(hsv.a * 100)}% opacity`,
407
- onKeyDown: (event) => railKeys(event, "alpha"),
408
- onPointerDown: (event) => track(event, (x) => commit({ ...hsv, a: x })),
409
- children: [
410
- /* @__PURE__ */ jsx("span", { "data-enigma-color-gradient": "" }),
411
- /* @__PURE__ */ jsx("span", { "data-enigma-color-thumb": "", style: { left: `${hsv.a * 100}%` } })
412
- ]
461
+ type: "text",
462
+ "data-enigma-color-input": "",
463
+ value: draft ?? readout,
464
+ "aria-label": text.value ?? "Colour value",
465
+ spellCheck: false,
466
+ autoComplete: "off",
467
+ autoCorrect: "off",
468
+ autoCapitalize: "off",
469
+ onChange: (event) => typed(event.target.value),
470
+ onBlur: () => setDraft(null),
471
+ onKeyDown: (event) => {
472
+ if (event.key !== "Enter") return;
473
+ event.preventDefault();
474
+ setDraft(null);
475
+ }
413
476
  }
414
477
  )
415
- ] })
416
- ] }),
417
- swatches && swatches.length > 0 && /* @__PURE__ */ jsx("div", { "data-enigma-color-swatches": "", role: "group", "aria-label": text.swatches ?? "Preset colours", children: swatches.map((preset) => {
418
- const colour = parseColor(preset);
419
- return /* @__PURE__ */ jsx(
420
- "button",
421
- {
422
- type: "button",
423
- "data-enigma-color-preset": "",
424
- "data-enigma-color-checkers": "",
425
- style: { background: preset },
426
- "aria-label": preset,
427
- title: preset,
428
- "aria-pressed": colourEqualsPreset(colour, css),
429
- onClick: () => {
430
- if (colour) commit(rgbToHsv(alpha ? colour : { ...colour, a: 1 }, hsv.h));
431
- }
432
- },
433
- preset
434
- );
435
- }) })
436
- ]
437
- }
438
- )
439
- ] });
478
+ ] }),
479
+ swatches && swatches.length > 0 && /* @__PURE__ */ jsx("div", { "data-enigma-color-swatches": "", role: "group", "aria-label": text.swatches ?? "Preset colours", children: swatches.map((preset) => {
480
+ const colour = parseColor(preset);
481
+ return /* @__PURE__ */ jsx(
482
+ "button",
483
+ {
484
+ type: "button",
485
+ "data-enigma-color-preset": "",
486
+ "data-enigma-color-checkers": "",
487
+ style: { background: preset },
488
+ "aria-label": preset,
489
+ title: preset,
490
+ "aria-pressed": colourEqualsPreset(colour, css),
491
+ onClick: () => {
492
+ if (colour) commit(rgbToHsv(alpha ? colour : { ...colour, a: 1 }, hsv.h));
493
+ }
494
+ },
495
+ preset
496
+ );
497
+ }) })
498
+ ]
499
+ }
500
+ )
501
+ }
502
+ );
440
503
  }
441
504
  function colourEqualsPreset(preset, current) {
442
505
  return colorEquals(preset, parseColor(current));
@@ -145,6 +145,10 @@ interface ColorLabels {
145
145
  alpha?: string;
146
146
  eyedropper?: string;
147
147
  swatches?: string;
148
+ /** The editable readout under the rails. Default "Colour value". */
149
+ value?: string;
150
+ /** The button that cycles the readout between HEX, RGB and HSL. Default "Show as". */
151
+ formatAs?: string;
148
152
  /** The word before the colour in what a screen reader announces. Default "Colour". */
149
153
  color?: string;
150
154
  }
@@ -7,7 +7,7 @@ export { UseSearchResult, useSearch } from '../react/search.js';
7
7
  export { I as InputAction, b as InputIcon, d as InputOptions } from '../input-BwXjFenq.js';
8
8
  export { F as FuseConstructor, a as FuseLike, S as SearchInstance, b as SearchMatch, c as SearchOptions, s as shortenQuery } from '../search-DYgqRp37.js';
9
9
  export { c as ButtonOptions, d as ButtonState } from '../button-CaXaqG_K.js';
10
- export { B as BreachChecker, a as BreachState, b as BreachStatus, C as ColorLabels, c as ColorOnlyProps, d as ColorPanelPlacement, F as FieldAction, I as Input, e as InputBaseProps, f as InputProps, g as InputType, P as PasswordOnlyProps, i as PlainOnlyProps, S as SearchOnlyProps } from '../index-ZPvlf9vs.js';
10
+ export { B as BreachChecker, a as BreachState, b as BreachStatus, C as ColorLabels, c as ColorOnlyProps, d as ColorPanelPlacement, F as FieldAction, I as Input, e as InputBaseProps, f as InputProps, g as InputType, P as PasswordOnlyProps, i as PlainOnlyProps, S as SearchOnlyProps } from '../index-qXOkYQCU.js';
11
11
  export { C as ColorFormat, E as EstimateOptions, F as FormatColorOptions, G as GeneratePasswordOptions, H as Hsl, a as Hsv, b as PasswordScore, c as PasswordStrengthReport, R as Rgb, d as colorEquals, e as estimatePasswordStrength, f as formatColor, g as generatePassword, h as hslToRgb, i as hsvToRgb, p as parseColor, r as rgbToHsl, j as rgbToHsv, t as toHex } from '../color-D_rZ83Oc.js';
12
12
  export { Slot, SlotProps, mergeSlotProps } from '../react/slot.js';
13
13
  export { UseNotificationsResult, createNotificationQueue, defaultQueue, useNotifications } from '../react/notifications.js';
@@ -23,8 +23,8 @@ export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeF
23
23
  import '../chunk-SNYUBXWQ.js';
24
24
  import '../chunk-UOSSNUSC.js';
25
25
  import '../chunk-2QFTRNAZ.js';
26
- export { Input } from '../chunk-QJJ34E4G.js';
27
- import '../chunk-S7EE57YB.js';
26
+ export { Input } from '../chunk-3MGBZOAU.js';
27
+ import '../chunk-KVLSOQTI.js';
28
28
  import '../chunk-F25CGNQC.js';
29
29
  export { PaletteContent, PaletteField, PaletteFooter, PaletteItem, PaletteList, PaletteRoot, PaletteTrigger, SearchPalette, usePaletteContext } from '../chunk-FGBIZDV2.js';
30
30
  export { Select, SelectContent, SelectList, SelectOptionRow, SelectRoot, SelectSearch, SelectTrigger, SelectValue, useSelectContext } from '../chunk-VKL3DEIQ.js';
@@ -7,8 +7,8 @@ export { UseSearchResult, useSearch } from './search.js';
7
7
  export { F as FuseConstructor, a as FuseLike, S as SearchInstance, b as SearchMatch, c as SearchOptions, s as shortenQuery } from '../search-DYgqRp37.js';
8
8
  import { d as ButtonState, c as ButtonOptions } from '../button-CaXaqG_K.js';
9
9
  export { Button, ButtonProps } from './button.js';
10
- import { h as PasswordStrengthClassNames } from '../index-ZPvlf9vs.js';
11
- export { B as BreachChecker, a as BreachState, b as BreachStatus, C as ColorLabels, c as ColorOnlyProps, d as ColorPanelPlacement, F as FieldAction, I as Input, e as InputBaseProps, f as InputProps, g as InputType, P as PasswordOnlyProps, i as PlainOnlyProps, S as SearchOnlyProps } from '../index-ZPvlf9vs.js';
10
+ import { h as PasswordStrengthClassNames } from '../index-qXOkYQCU.js';
11
+ export { B as BreachChecker, a as BreachState, b as BreachStatus, C as ColorLabels, c as ColorOnlyProps, d as ColorPanelPlacement, F as FieldAction, I as Input, e as InputBaseProps, f as InputProps, g as InputType, P as PasswordOnlyProps, i as PlainOnlyProps, S as SearchOnlyProps } from '../index-qXOkYQCU.js';
12
12
  import { c as PasswordStrengthReport } from '../color-D_rZ83Oc.js';
13
13
  export { C as ColorFormat, E as EstimateOptions, F as FormatColorOptions, G as GeneratePasswordOptions, H as Hsl, a as Hsv, b as PasswordScore, R as Rgb, d as colorEquals, e as estimatePasswordStrength, f as formatColor, g as generatePassword, h as hslToRgb, i as hsvToRgb, p as parseColor, r as rgbToHsl, j as rgbToHsv, t as toHex } from '../color-D_rZ83Oc.js';
14
14
  export { Slot, SlotProps, mergeSlotProps } from './slot.js';
@@ -22,8 +22,8 @@ export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeF
22
22
  import '../chunk-SNYUBXWQ.js';
23
23
  import '../chunk-UOSSNUSC.js';
24
24
  import '../chunk-2QFTRNAZ.js';
25
- export { Input } from '../chunk-QJJ34E4G.js';
26
- import '../chunk-S7EE57YB.js';
25
+ export { Input } from '../chunk-3MGBZOAU.js';
26
+ import '../chunk-KVLSOQTI.js';
27
27
  import '../chunk-F25CGNQC.js';
28
28
  export { PaletteContent, PaletteField, PaletteFooter, PaletteItem, PaletteList, PaletteRoot, PaletteTrigger, SearchPalette, usePaletteContext } from '../chunk-FGBIZDV2.js';
29
29
  export { Select, SelectContent, SelectList, SelectOptionRow, SelectRoot, SelectSearch, SelectTrigger, SelectValue, useSelectContext } from '../chunk-VKL3DEIQ.js';
@@ -1,4 +1,4 @@
1
- export { B as BreachChecker, a as BreachState, b as BreachStatus, C as ColorLabels, c as ColorOnlyProps, d as ColorPanelPlacement, F as FieldAction, I as Input, e as InputBaseProps, f as InputProps, g as InputType, P as PasswordOnlyProps, i as PlainOnlyProps, S as SearchOnlyProps } from '../index-ZPvlf9vs.js';
1
+ export { B as BreachChecker, a as BreachState, b as BreachStatus, C as ColorLabels, c as ColorOnlyProps, d as ColorPanelPlacement, F as FieldAction, I as Input, e as InputBaseProps, f as InputProps, g as InputType, P as PasswordOnlyProps, i as PlainOnlyProps, S as SearchOnlyProps } from '../index-qXOkYQCU.js';
2
2
  import 'react';
3
3
  import '../color-D_rZ83Oc.js';
4
4
  import '../search-DYgqRp37.js';
@@ -1,4 +1,4 @@
1
1
  "use client";
2
- export { Input } from '../chunk-QJJ34E4G.js';
3
- import '../chunk-S7EE57YB.js';
2
+ export { Input } from '../chunk-3MGBZOAU.js';
3
+ import '../chunk-KVLSOQTI.js';
4
4
  import '../chunk-F25CGNQC.js';
@@ -7,7 +7,7 @@ export { UseSearchResult, useSearch } from '../react/search.js';
7
7
  export { I as InputAction, b as InputIcon, d as InputOptions } from '../input-BwXjFenq.js';
8
8
  export { F as FuseConstructor, a as FuseLike, S as SearchInstance, b as SearchMatch, c as SearchOptions, s as shortenQuery } from '../search-DYgqRp37.js';
9
9
  export { c as ButtonOptions, d as ButtonState } from '../button-CaXaqG_K.js';
10
- export { B as BreachChecker, a as BreachState, b as BreachStatus, C as ColorLabels, c as ColorOnlyProps, d as ColorPanelPlacement, F as FieldAction, I as Input, e as InputBaseProps, f as InputProps, g as InputType, P as PasswordOnlyProps, i as PlainOnlyProps, S as SearchOnlyProps } from '../index-ZPvlf9vs.js';
10
+ export { B as BreachChecker, a as BreachState, b as BreachStatus, C as ColorLabels, c as ColorOnlyProps, d as ColorPanelPlacement, F as FieldAction, I as Input, e as InputBaseProps, f as InputProps, g as InputType, P as PasswordOnlyProps, i as PlainOnlyProps, S as SearchOnlyProps } from '../index-qXOkYQCU.js';
11
11
  export { C as ColorFormat, E as EstimateOptions, F as FormatColorOptions, G as GeneratePasswordOptions, H as Hsl, a as Hsv, b as PasswordScore, c as PasswordStrengthReport, R as Rgb, d as colorEquals, e as estimatePasswordStrength, f as formatColor, g as generatePassword, h as hslToRgb, i as hsvToRgb, p as parseColor, r as rgbToHsl, j as rgbToHsv, t as toHex } from '../color-D_rZ83Oc.js';
12
12
  export { Slot, SlotProps, mergeSlotProps } from '../react/slot.js';
13
13
  export { UseNotificationsResult, createNotificationQueue, defaultQueue, useNotifications } from '../react/notifications.js';
@@ -23,8 +23,8 @@ export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeF
23
23
  import '../chunk-SNYUBXWQ.js';
24
24
  import '../chunk-UOSSNUSC.js';
25
25
  import '../chunk-2QFTRNAZ.js';
26
- export { Input } from '../chunk-QJJ34E4G.js';
27
- import '../chunk-S7EE57YB.js';
26
+ export { Input } from '../chunk-3MGBZOAU.js';
27
+ import '../chunk-KVLSOQTI.js';
28
28
  import '../chunk-F25CGNQC.js';
29
29
  export { PaletteContent, PaletteField, PaletteFooter, PaletteItem, PaletteList, PaletteRoot, PaletteTrigger, SearchPalette, usePaletteContext } from '../chunk-FGBIZDV2.js';
30
30
  export { Select, SelectContent, SelectList, SelectOptionRow, SelectRoot, SelectSearch, SelectTrigger, SelectValue, useSelectContext } from '../chunk-VKL3DEIQ.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/primitives",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Headless components: the behaviour, the timing and the accessibility, with no styles of their own. Framework-agnostic core plus thin adapters.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -134,6 +134,26 @@
134
134
  [data-enigma-color-eyedropper]:hover { color: var(--enigma-color-text); }
135
135
  [data-enigma-color-eyedropper]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 2px; }
136
136
 
137
+ /* The value as text, with the notation beside it. Monospace on purpose: a hex whose digits
138
+ shift width as you drag is a value nobody can read at a glance. */
139
+ [data-enigma-color-value] { display: flex; align-items: center; gap: 0.375rem; }
140
+ [data-enigma-color-format] {
141
+ flex: none; padding: 0.3125rem 0.375rem;
142
+ font: inherit; font-size: 0.625rem; line-height: 1; letter-spacing: 0.06em;
143
+ color: var(--enigma-color-muted); background: none;
144
+ border: 1px solid var(--enigma-color-border); border-radius: 0.25rem; cursor: pointer;
145
+ }
146
+ [data-enigma-color-format]:hover { color: var(--enigma-color-text); }
147
+ [data-enigma-color-input] {
148
+ flex: 1; min-width: 0; padding: 0.25rem 0.375rem;
149
+ font: inherit; font-family: var(--enigma-color-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
150
+ font-size: 0.75rem; line-height: 1.4;
151
+ color: var(--enigma-color-text); background: none;
152
+ border: 1px solid var(--enigma-color-border); border-radius: 0.25rem;
153
+ }
154
+ [data-enigma-color-format]:focus-visible,
155
+ [data-enigma-color-input]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 1px; }
156
+
137
157
  [data-enigma-color-swatches] { display: flex; flex-wrap: wrap; gap: 0.25rem; }
138
158
  [data-enigma-color-preset] {
139
159
  width: 1.125rem; height: 1.125rem; padding: 0;
package/registry.json CHANGED
@@ -156,7 +156,7 @@
156
156
  {
157
157
  "name": "input",
158
158
  "title": "Input, one field for every type",
159
- "description": "A field you pass props to, typed by its `type`: password gets the reveal toggle, a generator, a strength meter and a breach check; search gets debouncing, ranking and a clear button; color gets a swatch and a picker panel - saturation square, hue and alpha rails, presets and the screen eyedropper - instead of the operating system popup; everything else is a plain field with native props. What each type needs is loaded when that type is used, so a form of text fields ships none of it.",
159
+ "description": "A field you pass props to, typed by its `type`: password gets the reveal toggle, a generator, a strength meter and a breach check; search gets debouncing, ranking and a clear button; color gets a swatch and a picker panel - saturation square, hue and alpha rails, an editable readout that prints the value as hex, rgb() or hsl(), presets and the screen eyedropper - instead of the operating system popup, and the swatch is there from the first frame rather than when the panel's code lands; everything else is a plain field with native props. What each type needs is loaded when that type is used, so a form of text fields ships none of it.",
160
160
  "targets": [
161
161
  "vanilla",
162
162
  "astro",
@@ -279,6 +279,13 @@
279
279
  "react"
280
280
  ]
281
281
  },
282
+ {
283
+ "path": "src/react/input/color-swatch.tsx",
284
+ "dest": "input/color-swatch.tsx",
285
+ "targets": [
286
+ "react"
287
+ ]
288
+ },
282
289
  {
283
290
  "path": "src/react/input/color.tsx",
284
291
  "dest": "input/color.tsx",
@@ -289,7 +296,8 @@
289
296
  "@/core/color": "./color-core",
290
297
  "@/react/input/write-value": "./write-value",
291
298
  "@/react/input/color-styles": "./color-styles",
292
- "@/react/input/types": "./types"
299
+ "@/react/input/types": "./types",
300
+ "@/react/input/color-swatch": "./color-swatch"
293
301
  }
294
302
  },
295
303
  {
@@ -325,7 +333,8 @@
325
333
  "@/react/input/password": "./password",
326
334
  "@/react/input/search": "./search",
327
335
  "@/core/password": "./password-core",
328
- "@/react/input/color": "./color"
336
+ "@/react/input/color": "./color",
337
+ "@/react/input/color-swatch": "./color-swatch"
329
338
  }
330
339
  },
331
340
  {
@@ -146,6 +146,26 @@ export const COLOR_STYLES = `
146
146
  [data-enigma-color-eyedropper]:hover { color: var(--enigma-color-text); }
147
147
  [data-enigma-color-eyedropper]:focus-visible { outline: 2px solid var(--enigma-color-focus); outline-offset: 2px; }
148
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
+
149
169
  [data-enigma-color-swatches] { display: flex; flex-wrap: wrap; gap: 0.25rem; }
150
170
  [data-enigma-color-preset] {
151
171
  width: 1.125rem; height: 1.125rem; padding: 0;
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { writeValue } from "@/react/input/write-value";
4
+ import { ColorSwatch } from "@/react/input/color-swatch";
4
5
  import { COLOR_STYLES } from "@/react/input/color-styles";
5
6
  import type { ColorLabels, ColorPanelPlacement } from "@/react/input/types";
6
7
  import { parseColor, formatColor, rgbToHsv, hsvToRgb, toHex, colorEquals, type Hsv, type ColorFormat } from "@/core/color";
@@ -47,6 +48,10 @@ 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. */
48
49
  const MARGIN = 12;
49
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
+
50
55
  let injected = false;
51
56
 
52
57
  function injectStyles(): void {
@@ -76,6 +81,11 @@ export interface ColorExtrasProps {
76
81
  styles?: boolean;
77
82
  /** The field is disabled or read-only: the swatch is inert and the panel never opens. */
78
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;
79
89
  labels?: ColorLabels;
80
90
  }
81
91
 
@@ -89,6 +99,7 @@ export function ColorExtras({
89
99
  placement = "auto",
90
100
  styles = true,
91
101
  locked = false,
102
+ openOnMount = false,
92
103
  labels
93
104
  }: ColorExtrasProps): ReactNode {
94
105
  // Before paint: a sheet applied after the first frame shows the panel unstyled first.
@@ -99,7 +110,7 @@ export function ColorExtras({
99
110
  const swatchRef = useRef<HTMLButtonElement | null>(null);
100
111
  const areaRef = useRef<HTMLDivElement | null>(null);
101
112
 
102
- const [open, setOpen] = useState(false);
113
+ const [open, setOpen] = useState(openOnMount && !locked);
103
114
  const [side, setSide] = useState<"top" | "bottom">(placement === "top" ? "top" : "bottom");
104
115
 
105
116
  const parsed = useMemo(() => parseColor(value), [value]);
@@ -296,6 +307,33 @@ export function ColorExtras({
296
307
  }
297
308
  }, [commit, hsv]);
298
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
+
299
337
  /* -------- rendering -------- */
300
338
 
301
339
  const css = useMemo(() => formatColor(rgb, "hex", { alpha: true }), [rgb]);
@@ -306,27 +344,16 @@ export function ColorExtras({
306
344
  const panelStyle = { "--enigma-color-hue": Math.round(hsv.h), "--enigma-color-opaque": opaque } as CSSProperties;
307
345
 
308
346
  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
-
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
+ >
330
357
  {open && !locked && (
331
358
  <div
332
359
  ref={panelRef}
@@ -421,6 +448,40 @@ export function ColorExtras({
421
448
  </div>
422
449
  </div>
423
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
+
424
485
  {swatches && swatches.length > 0 && (
425
486
  <div data-enigma-color-swatches="" role="group" aria-label={text.swatches ?? "Preset colours"}>
426
487
  {swatches.map((preset) => {
@@ -445,7 +506,7 @@ export function ColorExtras({
445
506
  )}
446
507
  </div>
447
508
  )}
448
- </span>
509
+ </ColorSwatch>
449
510
  );
450
511
  }
451
512
 
@@ -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";
@@ -96,6 +97,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
96
97
  const innerRef = useRef<HTMLInputElement | null>(null);
97
98
  const [element, setElement] = useState<HTMLInputElement | null>(null);
98
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);
99
108
  const describedBy = useId();
100
109
 
101
110
  const isPassword = type === "password";
@@ -263,12 +272,26 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
263
272
  data-score={score ?? undefined}
264
273
  >
265
274
  <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. */}
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. */}
269
281
  {isColor && (
270
- <Suspense fallback={null}>
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
+ >
271
293
  <ColorExtras
294
+ openOnMount={colorWanted}
272
295
  input={element}
273
296
  value={value}
274
297
  format={format}
@@ -151,6 +151,10 @@ export interface ColorLabels {
151
151
  alpha?: string;
152
152
  eyedropper?: string;
153
153
  swatches?: string;
154
+ /** The editable readout under the rails. Default "Colour value". */
155
+ value?: string;
156
+ /** The button that cycles the readout between HEX, RGB and HSL. Default "Show as". */
157
+ formatAs?: string;
154
158
  /** The word before the colour in what a screen reader announces. Default "Colour". */
155
159
  color?: string;
156
160
  }
@@ -1,9 +0,0 @@
1
- // src/react/input/write-value.ts
2
- function writeValue(input, next) {
3
- const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
4
- if (setter) setter.call(input, next);
5
- else input.value = next;
6
- input.dispatchEvent(new Event("input", { bubbles: true }));
7
- }
8
-
9
- export { writeValue };