@enigmax/primitives 0.22.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.
- package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
- package/dist/chunk-4VUHQFAT.js +130 -0
- package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
- package/dist/chunk-DZMHY3SU.js +777 -0
- package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
- package/dist/chunk-N6PDHMAX.js +213 -0
- package/dist/chunk-NONGREXC.js +248 -0
- package/dist/{chunk-R4ZAEE7V.js → chunk-QJJ34E4G.js} +34 -10
- package/dist/chunk-S2FPN5ID.js +115 -0
- package/dist/chunk-S7EE57YB.js +9 -0
- package/dist/chunk-VKL3DEIQ.js +804 -0
- package/dist/chunk-WSQC3PCC.js +466 -0
- package/dist/chunk-Z3VDE7OA.js +532 -0
- package/dist/clipboard-menu-B_ouitfS.d.ts +264 -0
- package/dist/color-D_rZ83Oc.d.ts +152 -0
- package/dist/color-OPV3BJV6.js +452 -0
- package/dist/{index-BHpOZncw.d.ts → index-ZPvlf9vs.d.ts} +53 -5
- package/dist/index.d.ts +6 -2
- package/dist/index.js +9 -4
- package/dist/keys-D2zJs1uB.d.ts +100 -0
- package/dist/next/index.d.ts +10 -3
- package/dist/next/index.js +22 -13
- package/dist/react/context-menu.d.ts +219 -0
- package/dist/react/context-menu.js +6 -0
- package/dist/react/index.d.ts +12 -5
- package/dist/react/index.js +21 -12
- package/dist/react/input.d.ts +3 -3
- package/dist/react/input.js +2 -1
- package/dist/react/palette.d.ts +1 -1
- package/dist/react/palette.js +3 -3
- package/dist/react/search.d.ts +1 -1
- package/dist/react/search.js +2 -2
- package/dist/react/select.d.ts +217 -0
- package/dist/react/select.js +7 -0
- package/dist/react/selection.d.ts +104 -0
- package/dist/react/selection.js +4 -0
- package/dist/react-router/index.d.ts +10 -3
- package/dist/react-router/index.js +22 -13
- package/dist/search/index.d.ts +2 -2
- package/dist/search/index.js +1 -1
- package/dist/{search-BD9-5O5U.d.ts → search-DYgqRp37.d.ts} +13 -1
- package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
- package/dist/select-ClSy-J1f.d.ts +100 -0
- package/dist/selection-B_pmzHpy.d.ts +150 -0
- package/package.json +23 -2
- package/recipes/color/styles.css +143 -0
- package/recipes/context-menu/styles.css +185 -0
- package/recipes/input/styles.css +9 -0
- package/recipes/palette/styles.css +3 -0
- package/recipes/search/tailwind.tsx +3 -2
- package/recipes/select/styles.css +230 -0
- package/recipes/toast/styles.css +1 -1
- package/registry.json +432 -5
- package/src/core/clipboard-menu.ts +270 -0
- package/src/core/color.ts +248 -0
- package/src/core/context-menu.ts +694 -0
- package/src/core/keys.ts +264 -0
- package/src/core/search.ts +19 -0
- package/src/core/select.ts +404 -0
- package/src/core/selection.ts +648 -0
- package/src/index.ts +92 -1
- package/src/react/context-menu/context.ts +64 -0
- package/src/react/context-menu/index.tsx +108 -0
- package/src/react/context-menu/root.tsx +970 -0
- package/src/react/context-menu/styles.ts +194 -0
- package/src/react/index.ts +100 -1
- package/src/react/input/color-styles.ts +156 -0
- package/src/react/input/color.tsx +466 -0
- package/src/react/input/index.tsx +54 -6
- package/src/react/input/types.ts +59 -3
- package/src/react/palette/root.tsx +2 -2
- package/src/react/select/context.ts +56 -0
- package/src/react/select/index.tsx +96 -0
- package/src/react/select/root.tsx +839 -0
- package/src/react/select/styles.ts +238 -0
- package/src/react/selection/index.tsx +115 -0
- package/src/react/selection/use-selection.ts +260 -0
- package/dist/password-C8lG4Zm9.d.ts +0 -71
- /package/dist/{chunk-U3V4EHOB.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
|
|
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
|
|
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=
|
|
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
|
-
|
|
353
|
+
ColorLabels, ColorPanelPlacement,
|
|
354
|
+
PasswordOnlyProps, SearchOnlyProps, ColorOnlyProps, PlainOnlyProps
|
|
307
355
|
} from "@/react/input/types";
|
package/src/react/input/types.ts
CHANGED
|
@@ -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
|
|
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; };
|