@enigmax/primitives 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-KEVZ5XQV.js → chunk-3MGBZOAU.js} +50 -9
- package/dist/{chunk-DSBYVA7V.js → chunk-DZMHY3SU.js} +73 -9
- package/dist/chunk-KVLSOQTI.js +66 -0
- package/dist/chunk-S2FPN5ID.js +115 -0
- package/dist/{chunk-3BQVOOAM.js → chunk-Z3VDE7OA.js} +145 -1
- package/dist/{context-menu-D3FtTn7v.d.ts → clipboard-menu-B_ouitfS.d.ts} +91 -1
- package/dist/color-D_rZ83Oc.d.ts +152 -0
- package/dist/color-T63FLJNH.js +515 -0
- package/dist/{index-DQNnohoo.d.ts → index-qXOkYQCU.d.ts} +56 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/next/index.d.ts +3 -3
- package/dist/next/index.js +6 -4
- package/dist/react/context-menu.d.ts +23 -6
- package/dist/react/context-menu.js +2 -2
- package/dist/react/index.d.ts +5 -5
- package/dist/react/index.js +6 -4
- package/dist/react/input.d.ts +2 -2
- package/dist/react/input.js +2 -1
- package/dist/react-router/index.d.ts +3 -3
- package/dist/react-router/index.js +6 -4
- package/package.json +3 -1
- package/recipes/color/styles.css +163 -0
- package/recipes/context-menu/styles.css +8 -0
- package/registry.json +86 -7
- package/src/core/clipboard-menu.ts +270 -0
- package/src/core/color.ts +248 -0
- package/src/index.ts +32 -0
- package/src/react/context-menu/context.ts +9 -2
- package/src/react/context-menu/index.tsx +14 -0
- package/src/react/context-menu/root.tsx +132 -8
- package/src/react/context-menu/styles.ts +8 -0
- package/src/react/index.ts +32 -0
- package/src/react/input/color-styles.ts +176 -0
- package/src/react/input/color-swatch.tsx +96 -0
- package/src/react/input/color.tsx +527 -0
- package/src/react/input/index.tsx +77 -6
- package/src/react/input/types.ts +63 -3
- package/dist/password-C8lG4Zm9.d.ts +0 -71
- /package/dist/{chunk-3HDEZ2E7.js → chunk-MSOCCQGH.js} +0 -0
|
@@ -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
|
|
27
|
-
* and are imported the moment the type that needs them is used - so a form
|
|
28
|
-
* fields ships none of them, and a page with one password field
|
|
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=
|
|
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
|
-
|
|
376
|
+
ColorLabels, ColorPanelPlacement,
|
|
377
|
+
PasswordOnlyProps, SearchOnlyProps, ColorOnlyProps, PlainOnlyProps
|
|
307
378
|
} from "@/react/input/types";
|