devnonla-ui 0.2.0 → 0.3.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/package.json +1 -1
- package/src/colorpicker/ColorPicker.tsx +694 -30
- package/src/colorpicker/color.ts +246 -0
- package/src/form/variants/FieldColor.tsx +16 -51
- package/src/index.ts +5 -2
- package/src/splitter/Splitter.tsx +330 -0
- package/src/splitter/sizes.ts +67 -0
- package/src/styles.css +15 -0
|
@@ -1,45 +1,709 @@
|
|
|
1
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
2
|
+
import {
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
type FC,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
type PointerEvent as ReactPointerEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
isValidElement,
|
|
9
|
+
useEffect,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { usePopupContainer } from "../app/context";
|
|
1
14
|
import { cn } from "../lib/cn";
|
|
15
|
+
import { type PopperPlacement, placementToRadix } from "../lib/placement";
|
|
16
|
+
import {
|
|
17
|
+
type ControlSize,
|
|
18
|
+
controlFieldFocusBorder,
|
|
19
|
+
controlFieldSurface,
|
|
20
|
+
controlFieldTransition,
|
|
21
|
+
controlHeightVar,
|
|
22
|
+
controlRadiusVar,
|
|
23
|
+
controlStatusClass,
|
|
24
|
+
getSizeTokens,
|
|
25
|
+
useControlSize,
|
|
26
|
+
} from "../lib/sizes";
|
|
27
|
+
import { glassOverlayClass } from "../lib/surface";
|
|
28
|
+
import {
|
|
29
|
+
Color,
|
|
30
|
+
DEFAULT_COLOR,
|
|
31
|
+
clamp,
|
|
32
|
+
hsbaToRgba,
|
|
33
|
+
parseColor,
|
|
34
|
+
type ColorFormat,
|
|
35
|
+
type ColorType,
|
|
36
|
+
type HsbaColor,
|
|
37
|
+
} from "./color";
|
|
38
|
+
|
|
39
|
+
export { Color, DEFAULT_COLOR, parseColor };
|
|
40
|
+
export type { ColorFormat, ColorType, HsbaColor };
|
|
41
|
+
|
|
42
|
+
export type PresetColorType = {
|
|
43
|
+
label: ReactNode;
|
|
44
|
+
defaultOpen?: boolean;
|
|
45
|
+
key?: React.Key;
|
|
46
|
+
colors: ColorType[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ColorPickerSemanticSlot = "root" | "body" | "content" | "description" | "popup";
|
|
50
|
+
|
|
51
|
+
export type ColorPickerPanelRender = (
|
|
52
|
+
panel: ReactNode,
|
|
53
|
+
extra: { components: { Picker: FC; Presets: FC } },
|
|
54
|
+
) => ReactNode;
|
|
2
55
|
|
|
3
56
|
export type ColorPickerProps = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
57
|
+
value?: ColorType | null;
|
|
58
|
+
defaultValue?: ColorType | null;
|
|
59
|
+
defaultFormat?: ColorFormat;
|
|
60
|
+
format?: ColorFormat;
|
|
61
|
+
allowClear?: boolean;
|
|
62
|
+
arrow?: boolean | { pointAtCenter?: boolean };
|
|
63
|
+
children?: ReactNode;
|
|
8
64
|
className?: string;
|
|
65
|
+
style?: CSSProperties;
|
|
66
|
+
classNames?: Partial<Record<ColorPickerSemanticSlot, string>>;
|
|
67
|
+
styles?: Partial<Record<ColorPickerSemanticSlot, CSSProperties>>;
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
disabledAlpha?: boolean;
|
|
70
|
+
disabledFormat?: boolean;
|
|
71
|
+
destroyOnHidden?: boolean;
|
|
72
|
+
open?: boolean;
|
|
73
|
+
onOpenChange?: (open: boolean) => void;
|
|
74
|
+
placement?: PopperPlacement;
|
|
75
|
+
presets?: PresetColorType[];
|
|
76
|
+
panelRender?: ColorPickerPanelRender;
|
|
77
|
+
showText?: boolean | ((color: Color) => ReactNode);
|
|
78
|
+
size?: ControlSize;
|
|
79
|
+
status?: "error" | "warning";
|
|
80
|
+
trigger?: "click" | "hover";
|
|
81
|
+
onChange?: (value: Color, css: string) => void;
|
|
82
|
+
onChangeComplete?: (value: Color) => void;
|
|
83
|
+
onFormatChange?: (format: ColorFormat) => void;
|
|
84
|
+
onClear?: () => void;
|
|
85
|
+
popupClassName?: string;
|
|
86
|
+
getPopupContainer?: () => HTMLElement;
|
|
9
87
|
};
|
|
10
88
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const activeClass = "ring-2 ring-ring ring-offset-2 ring-offset-background";
|
|
89
|
+
const HUE_GRADIENT = "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)";
|
|
90
|
+
const FORMATS: ColorFormat[] = ["hex", "rgb", "hsb"];
|
|
14
91
|
|
|
15
|
-
|
|
16
|
-
|
|
92
|
+
function hueFill(h: number) {
|
|
93
|
+
return `hsl(${((h % 360) + 360) % 360} 100% 50%)`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toColor(input?: ColorType | null, fallback = DEFAULT_COLOR): Color {
|
|
97
|
+
return parseColor(input) ?? fallback;
|
|
98
|
+
}
|
|
17
99
|
|
|
100
|
+
function Handle({ x, y }: { x: number; y: number }) {
|
|
18
101
|
return (
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
102
|
+
<span
|
|
103
|
+
className="pointer-events-none absolute size-3 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.25)]"
|
|
104
|
+
style={{ left: `${x * 100}%`, top: `${y * 100}%`, transform: "translate(-50%, -50%)" }}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function useDrag(
|
|
110
|
+
onMove: (nx: number, ny: number) => void,
|
|
111
|
+
onEnd?: () => void,
|
|
112
|
+
) {
|
|
113
|
+
const nodeRef = useRef<HTMLDivElement | null>(null);
|
|
114
|
+
const moveRef = useRef(onMove);
|
|
115
|
+
const endRef = useRef(onEnd);
|
|
116
|
+
moveRef.current = onMove;
|
|
117
|
+
endRef.current = onEnd;
|
|
118
|
+
|
|
119
|
+
const apply = (clientX: number, clientY: number) => {
|
|
120
|
+
const el = nodeRef.current;
|
|
121
|
+
if (!el) return;
|
|
122
|
+
const rect = el.getBoundingClientRect();
|
|
123
|
+
const nx = rect.width ? clamp((clientX - rect.left) / rect.width, 0, 1) : 0;
|
|
124
|
+
const ny = rect.height ? clamp((clientY - rect.top) / rect.height, 0, 1) : 0;
|
|
125
|
+
moveRef.current(nx, ny);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
129
|
+
if (e.button !== 0) return;
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
132
|
+
apply(e.clientX, e.clientY);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
136
|
+
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
|
137
|
+
apply(e.clientX, e.clientY);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
141
|
+
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
|
142
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
143
|
+
endRef.current?.();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return { nodeRef, onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function Palette({ hsba, onChange, onComplete }: { hsba: HsbaColor; onChange: (next: HsbaColor) => void; onComplete: () => void }) {
|
|
150
|
+
const drag = useDrag((nx, ny) => onChange({ ...hsba, s: nx, b: 1 - ny }), onComplete);
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
ref={drag.nodeRef}
|
|
154
|
+
role="slider"
|
|
155
|
+
aria-label="Saturation and brightness"
|
|
156
|
+
aria-valuemin={0}
|
|
157
|
+
aria-valuemax={100}
|
|
158
|
+
aria-valuenow={Math.round(hsba.s * 100)}
|
|
159
|
+
aria-valuetext={`Saturation ${Math.round(hsba.s * 100)}%, brightness ${Math.round(hsba.b * 100)}%`}
|
|
160
|
+
tabIndex={0}
|
|
161
|
+
className="relative h-40 w-full cursor-crosshair touch-none overflow-hidden rounded-md"
|
|
162
|
+
style={{
|
|
163
|
+
backgroundImage: `linear-gradient(to bottom, transparent, #000), linear-gradient(to right, #fff, ${hueFill(hsba.h)})`,
|
|
164
|
+
}}
|
|
165
|
+
onPointerDown={drag.onPointerDown}
|
|
166
|
+
onPointerMove={drag.onPointerMove}
|
|
167
|
+
onPointerUp={drag.onPointerUp}
|
|
168
|
+
onPointerCancel={drag.onPointerCancel}
|
|
169
|
+
onKeyDown={(e) => {
|
|
170
|
+
const step = e.shiftKey ? 0.1 : 0.02;
|
|
171
|
+
if (e.key === "ArrowLeft") onChange({ ...hsba, s: clamp(hsba.s - step, 0, 1) });
|
|
172
|
+
else if (e.key === "ArrowRight") onChange({ ...hsba, s: clamp(hsba.s + step, 0, 1) });
|
|
173
|
+
else if (e.key === "ArrowUp") onChange({ ...hsba, b: clamp(hsba.b + step, 0, 1) });
|
|
174
|
+
else if (e.key === "ArrowDown") onChange({ ...hsba, b: clamp(hsba.b - step, 0, 1) });
|
|
175
|
+
else return;
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<Handle x={hsba.s} y={1 - hsba.b} />
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function Slider({
|
|
185
|
+
value,
|
|
186
|
+
ariaLabel,
|
|
187
|
+
background,
|
|
188
|
+
onChange,
|
|
189
|
+
onComplete,
|
|
190
|
+
}: {
|
|
191
|
+
value: number;
|
|
192
|
+
ariaLabel: string;
|
|
193
|
+
background: string;
|
|
194
|
+
onChange: (n: number) => void;
|
|
195
|
+
onComplete: () => void;
|
|
196
|
+
}) {
|
|
197
|
+
const drag = useDrag((nx) => onChange(nx), onComplete);
|
|
198
|
+
return (
|
|
199
|
+
<div
|
|
200
|
+
ref={drag.nodeRef}
|
|
201
|
+
role="slider"
|
|
202
|
+
aria-label={ariaLabel}
|
|
203
|
+
aria-valuemin={0}
|
|
204
|
+
aria-valuemax={100}
|
|
205
|
+
aria-valuenow={Math.round(value * 100)}
|
|
206
|
+
tabIndex={0}
|
|
207
|
+
className="relative h-2 w-full cursor-pointer touch-none rounded-full"
|
|
208
|
+
style={{ background }}
|
|
209
|
+
onPointerDown={drag.onPointerDown}
|
|
210
|
+
onPointerMove={drag.onPointerMove}
|
|
211
|
+
onPointerUp={drag.onPointerUp}
|
|
212
|
+
onPointerCancel={drag.onPointerCancel}
|
|
213
|
+
onKeyDown={(e) => {
|
|
214
|
+
const step = e.shiftKey ? 0.1 : 0.02;
|
|
215
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowDown") onChange(clamp(value - step, 0, 1));
|
|
216
|
+
else if (e.key === "ArrowRight" || e.key === "ArrowUp") onChange(clamp(value + step, 0, 1));
|
|
217
|
+
else return;
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<Handle x={value} y={0.5} />
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function MiniInput({
|
|
227
|
+
value,
|
|
228
|
+
ariaLabel,
|
|
229
|
+
onCommit,
|
|
230
|
+
className,
|
|
231
|
+
}: {
|
|
232
|
+
value: string;
|
|
233
|
+
ariaLabel: string;
|
|
234
|
+
onCommit: (next: string) => void;
|
|
235
|
+
className?: string;
|
|
236
|
+
}) {
|
|
237
|
+
const [text, setText] = useState(value);
|
|
238
|
+
const [focused, setFocused] = useState(false);
|
|
239
|
+
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (!focused) setText(value);
|
|
242
|
+
}, [value, focused]);
|
|
243
|
+
|
|
244
|
+
const commit = () => onCommit(text);
|
|
245
|
+
|
|
246
|
+
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
247
|
+
if (e.key === "Enter") {
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
commit();
|
|
250
|
+
e.currentTarget.blur();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<input
|
|
256
|
+
aria-label={ariaLabel}
|
|
257
|
+
value={text}
|
|
258
|
+
onChange={(e) => setText(e.target.value)}
|
|
259
|
+
onFocus={() => setFocused(true)}
|
|
260
|
+
onBlur={() => {
|
|
261
|
+
setFocused(false);
|
|
262
|
+
commit();
|
|
263
|
+
}}
|
|
264
|
+
onKeyDown={onKeyDown}
|
|
265
|
+
className={cn(
|
|
266
|
+
"min-w-0 flex-1 text-center tabular-nums text-xs",
|
|
267
|
+
controlFieldSurface,
|
|
268
|
+
controlFieldTransition,
|
|
269
|
+
controlFieldFocusBorder,
|
|
270
|
+
"h-6 px-1 focus-visible:outline-none",
|
|
271
|
+
className,
|
|
272
|
+
)}
|
|
273
|
+
style={{ borderRadius: controlRadiusVar("small") }}
|
|
274
|
+
/>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function channel(raw: string, fallback: number, min: number, max: number) {
|
|
279
|
+
const n = Number(raw);
|
|
280
|
+
return Number.isFinite(n) ? clamp(n, min, max) : fallback;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function FormatFields({
|
|
284
|
+
color,
|
|
285
|
+
format,
|
|
286
|
+
disabledAlpha,
|
|
287
|
+
onCommit,
|
|
288
|
+
}: {
|
|
289
|
+
color: Color;
|
|
290
|
+
format: ColorFormat;
|
|
291
|
+
disabledAlpha: boolean;
|
|
292
|
+
onCommit: (next: Color) => void;
|
|
293
|
+
}) {
|
|
294
|
+
const hsba = color.toHsb();
|
|
295
|
+
const rgba = color.toRgb();
|
|
296
|
+
|
|
297
|
+
if (format === "hex") {
|
|
298
|
+
return (
|
|
299
|
+
<MiniInput
|
|
300
|
+
ariaLabel="Hex"
|
|
301
|
+
value={color.toHexString(disabledAlpha).replace("#", "").toUpperCase()}
|
|
302
|
+
onCommit={(raw) => {
|
|
303
|
+
const next = parseColor(raw.startsWith("#") ? raw : `#${raw}`);
|
|
304
|
+
if (next) onCommit(disabledAlpha ? Color.fromHsba({ ...next.toHsb(), a: 1 }) : next);
|
|
305
|
+
}}
|
|
306
|
+
className="font-mono"
|
|
307
|
+
/>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (format === "rgb") {
|
|
312
|
+
return (
|
|
313
|
+
<>
|
|
314
|
+
<MiniInput ariaLabel="R" value={String(rgba.r)} onCommit={(raw) => onCommit(Color.fromHsba({ ...rgbaToKeepHue(hsba, { ...rgba, r: channel(raw, rgba.r, 0, 255) }) }))} />
|
|
315
|
+
<MiniInput ariaLabel="G" value={String(rgba.g)} onCommit={(raw) => onCommit(Color.fromHsba({ ...rgbaToKeepHue(hsba, { ...rgba, g: channel(raw, rgba.g, 0, 255) }) }))} />
|
|
316
|
+
<MiniInput ariaLabel="B" value={String(rgba.b)} onCommit={(raw) => onCommit(Color.fromHsba({ ...rgbaToKeepHue(hsba, { ...rgba, b: channel(raw, rgba.b, 0, 255) }) }))} />
|
|
317
|
+
{disabledAlpha ? null : (
|
|
318
|
+
<MiniInput
|
|
319
|
+
ariaLabel="Alpha"
|
|
320
|
+
value={String(Math.round(hsba.a * 100))}
|
|
321
|
+
onCommit={(raw) => onCommit(Color.fromHsba({ ...hsba, a: channel(raw, hsba.a * 100, 0, 100) / 100 }))}
|
|
30
322
|
/>
|
|
31
|
-
)
|
|
32
|
-
|
|
323
|
+
)}
|
|
324
|
+
</>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<>
|
|
330
|
+
<MiniInput ariaLabel="Hue" value={String(Math.round(hsba.h))} onCommit={(raw) => onCommit(Color.fromHsba({ ...hsba, h: channel(raw, hsba.h, 0, 360) }))} />
|
|
331
|
+
<MiniInput ariaLabel="Saturation" value={String(Math.round(hsba.s * 100))} onCommit={(raw) => onCommit(Color.fromHsba({ ...hsba, s: channel(raw, hsba.s * 100, 0, 100) / 100 }))} />
|
|
332
|
+
<MiniInput ariaLabel="Brightness" value={String(Math.round(hsba.b * 100))} onCommit={(raw) => onCommit(Color.fromHsba({ ...hsba, b: channel(raw, hsba.b * 100, 0, 100) / 100 }))} />
|
|
333
|
+
{disabledAlpha ? null : (
|
|
334
|
+
<MiniInput
|
|
335
|
+
ariaLabel="Alpha"
|
|
336
|
+
value={String(Math.round(hsba.a * 100))}
|
|
337
|
+
onCommit={(raw) => onCommit(Color.fromHsba({ ...hsba, a: channel(raw, hsba.a * 100, 0, 100) / 100 }))}
|
|
338
|
+
/>
|
|
339
|
+
)}
|
|
340
|
+
</>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function rgbaToKeepHue(prev: HsbaColor, rgba: { r: number; g: number; b: number; a?: number }): HsbaColor {
|
|
345
|
+
const next = new Color({ r: rgba.r, g: rgba.g, b: rgba.b, a: rgba.a ?? prev.a }).toHsb();
|
|
346
|
+
if (next.s === 0) next.h = prev.h;
|
|
347
|
+
return next;
|
|
348
|
+
}
|
|
33
349
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
350
|
+
function PresetGroup({
|
|
351
|
+
group,
|
|
352
|
+
current,
|
|
353
|
+
onPick,
|
|
354
|
+
}: {
|
|
355
|
+
group: PresetColorType;
|
|
356
|
+
current: Color | null;
|
|
357
|
+
onPick: (color: Color) => void;
|
|
358
|
+
}) {
|
|
359
|
+
const [open, setOpen] = useState(group.defaultOpen !== false);
|
|
360
|
+
const currentHex = current?.toHexString().toLowerCase();
|
|
361
|
+
return (
|
|
362
|
+
<div>
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
className="mb-1.5 flex w-full cursor-pointer items-center justify-between border-0 bg-transparent p-0 text-xs text-muted-foreground hover:text-foreground"
|
|
366
|
+
onClick={() => setOpen((v) => !v)}
|
|
38
367
|
>
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
368
|
+
<span>{group.label}</span>
|
|
369
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden className={cn("opacity-60 transition-transform", open && "rotate-180")}>
|
|
370
|
+
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
371
|
+
</svg>
|
|
372
|
+
</button>
|
|
373
|
+
{open ? (
|
|
374
|
+
<div className="flex flex-wrap gap-1.5">
|
|
375
|
+
{group.colors.map((item) => {
|
|
376
|
+
const color = toColor(item);
|
|
377
|
+
const hex = color.toHexString().toLowerCase();
|
|
378
|
+
const token = typeof item === "string" ? item : color.toCssString();
|
|
379
|
+
const active = currentHex === hex;
|
|
380
|
+
return (
|
|
381
|
+
<button
|
|
382
|
+
key={`${String(group.key ?? "")}:${token}`}
|
|
383
|
+
type="button"
|
|
384
|
+
title={color.toHexString()}
|
|
385
|
+
aria-label={color.toHexString()}
|
|
386
|
+
className={cn(
|
|
387
|
+
"nonla-color-checkered size-5 cursor-pointer overflow-hidden rounded-sm border border-glass-border",
|
|
388
|
+
active && "ring-2 ring-brand ring-offset-1 ring-offset-background",
|
|
389
|
+
)}
|
|
390
|
+
onClick={() => onPick(color)}
|
|
391
|
+
>
|
|
392
|
+
<span className="block size-full" style={{ backgroundColor: color.toRgbString() }} />
|
|
393
|
+
</button>
|
|
394
|
+
);
|
|
395
|
+
})}
|
|
396
|
+
</div>
|
|
397
|
+
) : null}
|
|
43
398
|
</div>
|
|
44
399
|
);
|
|
45
400
|
}
|
|
401
|
+
|
|
402
|
+
function ColorBlock({
|
|
403
|
+
color,
|
|
404
|
+
cleared,
|
|
405
|
+
className,
|
|
406
|
+
style,
|
|
407
|
+
}: {
|
|
408
|
+
color: Color;
|
|
409
|
+
cleared?: boolean;
|
|
410
|
+
className?: string;
|
|
411
|
+
style?: CSSProperties;
|
|
412
|
+
}) {
|
|
413
|
+
return (
|
|
414
|
+
<span className={cn("nonla-color-checkered relative block overflow-hidden", className)} style={style}>
|
|
415
|
+
<span className="absolute inset-0" style={{ backgroundColor: cleared ? "transparent" : color.toRgbString() }} />
|
|
416
|
+
{cleared ? <span className="nonla-color-cleared absolute inset-0" /> : null}
|
|
417
|
+
</span>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function ClearIcon() {
|
|
422
|
+
return (
|
|
423
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
424
|
+
<circle cx="6" cy="6" r="4.25" stroke="currentColor" strokeWidth="1.25" />
|
|
425
|
+
<path d="M3.2 3.2L8.8 8.8" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
|
426
|
+
</svg>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function ColorPicker({
|
|
431
|
+
value,
|
|
432
|
+
defaultValue,
|
|
433
|
+
defaultFormat = "hex",
|
|
434
|
+
format: formatProp,
|
|
435
|
+
allowClear = false,
|
|
436
|
+
arrow = false,
|
|
437
|
+
children,
|
|
438
|
+
className,
|
|
439
|
+
style,
|
|
440
|
+
classNames,
|
|
441
|
+
styles,
|
|
442
|
+
disabled,
|
|
443
|
+
disabledAlpha,
|
|
444
|
+
disabledFormat,
|
|
445
|
+
destroyOnHidden,
|
|
446
|
+
open: openProp,
|
|
447
|
+
onOpenChange,
|
|
448
|
+
placement = "bottomLeft",
|
|
449
|
+
presets,
|
|
450
|
+
panelRender,
|
|
451
|
+
showText,
|
|
452
|
+
size,
|
|
453
|
+
status,
|
|
454
|
+
trigger = "click",
|
|
455
|
+
onChange,
|
|
456
|
+
onChangeComplete,
|
|
457
|
+
onFormatChange,
|
|
458
|
+
onClear,
|
|
459
|
+
popupClassName,
|
|
460
|
+
getPopupContainer,
|
|
461
|
+
}: ColorPickerProps) {
|
|
462
|
+
const controlled = value !== undefined;
|
|
463
|
+
const [inner, setInner] = useState<Color>(() => toColor(defaultValue));
|
|
464
|
+
const [clearedInner, setClearedInner] = useState(() => defaultValue == null || defaultValue === "");
|
|
465
|
+
const committed = controlled ? parseColor(value) : inner;
|
|
466
|
+
const cleared = controlled ? value == null || value === "" : clearedInner;
|
|
467
|
+
const [draft, setDraft] = useState<Color | null>(null);
|
|
468
|
+
const current = draft ?? committed ?? DEFAULT_COLOR;
|
|
469
|
+
const triggerColor = controlled && !onChange ? (committed ?? DEFAULT_COLOR) : current;
|
|
470
|
+
const hsba = (disabledAlpha ? Color.fromHsba({ ...current.toHsb(), a: 1 }) : current).toHsb();
|
|
471
|
+
const panelColor = Color.fromHsba(hsba);
|
|
472
|
+
const liveRef = useRef(panelColor);
|
|
473
|
+
liveRef.current = panelColor;
|
|
474
|
+
|
|
475
|
+
const formatControlled = formatProp !== undefined;
|
|
476
|
+
const [innerFormat, setInnerFormat] = useState<ColorFormat>(defaultFormat);
|
|
477
|
+
const format = formatProp ?? innerFormat;
|
|
478
|
+
|
|
479
|
+
const [innerOpen, setInnerOpen] = useState(false);
|
|
480
|
+
const open = openProp ?? innerOpen;
|
|
481
|
+
const hover = trigger === "hover";
|
|
482
|
+
const enterTimer = useRef(0);
|
|
483
|
+
const leaveTimer = useRef(0);
|
|
484
|
+
const { side, align } = placementToRadix(placement);
|
|
485
|
+
const portal = usePopupContainer(getPopupContainer);
|
|
486
|
+
const resolvedSize = useControlSize(size);
|
|
487
|
+
const tok = getSizeTokens(resolvedSize);
|
|
488
|
+
const showArrow = Boolean(arrow);
|
|
489
|
+
|
|
490
|
+
useEffect(() => () => {
|
|
491
|
+
window.clearTimeout(enterTimer.current);
|
|
492
|
+
window.clearTimeout(leaveTimer.current);
|
|
493
|
+
}, []);
|
|
494
|
+
|
|
495
|
+
const setOpen = (next: boolean) => {
|
|
496
|
+
if (disabled) return;
|
|
497
|
+
if (openProp === undefined) setInnerOpen(next);
|
|
498
|
+
onOpenChange?.(next);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const emit = (next: Color, complete: boolean) => {
|
|
502
|
+
const color = disabledAlpha ? Color.fromHsba({ ...next.toHsb(), a: 1 }) : next;
|
|
503
|
+
if (!controlled) setInner(color);
|
|
504
|
+
setClearedInner(false);
|
|
505
|
+
setDraft(complete ? null : color);
|
|
506
|
+
onChange?.(color, color.toCssString());
|
|
507
|
+
if (complete) onChangeComplete?.(color);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const setFormat = (next: ColorFormat) => {
|
|
511
|
+
if (!formatControlled) setInnerFormat(next);
|
|
512
|
+
onFormatChange?.(next);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const cycleFormat = () => {
|
|
516
|
+
const i = FORMATS.indexOf(format);
|
|
517
|
+
setFormat(FORMATS[(i + 1) % FORMATS.length]!);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const onEnter = () => {
|
|
521
|
+
if (!hover || disabled) return;
|
|
522
|
+
window.clearTimeout(leaveTimer.current);
|
|
523
|
+
enterTimer.current = window.setTimeout(() => setOpen(true), 100);
|
|
524
|
+
};
|
|
525
|
+
const onLeave = () => {
|
|
526
|
+
if (!hover) return;
|
|
527
|
+
window.clearTimeout(enterTimer.current);
|
|
528
|
+
leaveTimer.current = window.setTimeout(() => setOpen(false), 100);
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const clear = (e?: { preventDefault(): void; stopPropagation(): void }) => {
|
|
532
|
+
e?.preventDefault();
|
|
533
|
+
e?.stopPropagation();
|
|
534
|
+
if (!controlled) {
|
|
535
|
+
setInner(DEFAULT_COLOR);
|
|
536
|
+
setClearedInner(true);
|
|
537
|
+
}
|
|
538
|
+
setDraft(null);
|
|
539
|
+
onClear?.();
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const pick = (next: Color) => emit(next, true);
|
|
543
|
+
const rgb = hsbaToRgba(hsba);
|
|
544
|
+
|
|
545
|
+
const pickerBody = (
|
|
546
|
+
<div className="flex flex-col gap-2">
|
|
547
|
+
<Palette hsba={hsba} onChange={(next) => emit(Color.fromHsba(next), false)} onComplete={() => emit(liveRef.current, true)} />
|
|
548
|
+
<div className="flex items-center gap-2">
|
|
549
|
+
<ColorBlock color={panelColor} className="size-8 shrink-0 rounded-md" />
|
|
550
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
551
|
+
<Slider
|
|
552
|
+
ariaLabel="Hue"
|
|
553
|
+
value={(((hsba.h % 360) + 360) % 360) / 360}
|
|
554
|
+
background={HUE_GRADIENT}
|
|
555
|
+
onChange={(n) => emit(Color.fromHsba({ ...hsba, h: n * 360 }), false)}
|
|
556
|
+
onComplete={() => emit(liveRef.current, true)}
|
|
557
|
+
/>
|
|
558
|
+
{disabledAlpha ? null : (
|
|
559
|
+
<div className="relative">
|
|
560
|
+
<span className="nonla-color-checkered pointer-events-none absolute inset-0 rounded-full" />
|
|
561
|
+
<Slider
|
|
562
|
+
ariaLabel="Alpha"
|
|
563
|
+
value={hsba.a}
|
|
564
|
+
background={`linear-gradient(to right, rgba(${rgb.r},${rgb.g},${rgb.b},0), rgb(${rgb.r},${rgb.g},${rgb.b}))`}
|
|
565
|
+
onChange={(n) => emit(Color.fromHsba({ ...hsba, a: n }), false)}
|
|
566
|
+
onComplete={() => emit(liveRef.current, true)}
|
|
567
|
+
/>
|
|
568
|
+
</div>
|
|
569
|
+
)}
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const presetsBody = presets?.length ? (
|
|
576
|
+
<div className="flex flex-col gap-2 border-t border-border pt-2">
|
|
577
|
+
{presets.map((group, i) => (
|
|
578
|
+
<PresetGroup key={group.key ?? i} group={group} current={cleared ? null : panelColor} onPick={pick} />
|
|
579
|
+
))}
|
|
580
|
+
</div>
|
|
581
|
+
) : null;
|
|
582
|
+
|
|
583
|
+
const Picker: FC = () => pickerBody;
|
|
584
|
+
const Presets: FC = () => presetsBody;
|
|
585
|
+
|
|
586
|
+
const panel = (
|
|
587
|
+
<div className="flex w-[234px] flex-col gap-2">
|
|
588
|
+
{pickerBody}
|
|
589
|
+
<div className="flex items-center gap-1.5">
|
|
590
|
+
{disabledFormat ? (
|
|
591
|
+
<span className="w-11 shrink-0 text-xs text-muted-foreground">{format.toUpperCase()}</span>
|
|
592
|
+
) : (
|
|
593
|
+
<button
|
|
594
|
+
type="button"
|
|
595
|
+
className="inline-flex h-6 w-11 shrink-0 cursor-pointer items-center justify-between rounded-md border-0 bg-transparent px-0.5 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
|
596
|
+
onClick={cycleFormat}
|
|
597
|
+
>
|
|
598
|
+
{format.toUpperCase()}
|
|
599
|
+
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden>
|
|
600
|
+
<path d="M1.5 3L4 5.5L6.5 3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
|
601
|
+
</svg>
|
|
602
|
+
</button>
|
|
603
|
+
)}
|
|
604
|
+
<FormatFields color={panelColor} format={format} disabledAlpha={Boolean(disabledAlpha)} onCommit={(next) => emit(next, true)} />
|
|
605
|
+
{allowClear ? (
|
|
606
|
+
<button
|
|
607
|
+
type="button"
|
|
608
|
+
aria-label="Clear"
|
|
609
|
+
className="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-foreground/6 hover:text-foreground"
|
|
610
|
+
onClick={() => clear()}
|
|
611
|
+
>
|
|
612
|
+
<ClearIcon />
|
|
613
|
+
</button>
|
|
614
|
+
) : null}
|
|
615
|
+
</div>
|
|
616
|
+
{presetsBody}
|
|
617
|
+
</div>
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
const rendered = panelRender ? panelRender(panel, { components: { Picker, Presets } }) : panel;
|
|
621
|
+
const textNode =
|
|
622
|
+
showText && !cleared
|
|
623
|
+
? typeof showText === "function"
|
|
624
|
+
? showText(triggerColor)
|
|
625
|
+
: triggerColor.toString(format)
|
|
626
|
+
: null;
|
|
627
|
+
|
|
628
|
+
const pad = 2;
|
|
629
|
+
const defaultTrigger = (
|
|
630
|
+
<button
|
|
631
|
+
type="button"
|
|
632
|
+
disabled={disabled}
|
|
633
|
+
aria-label="Color picker"
|
|
634
|
+
aria-expanded={open}
|
|
635
|
+
className={cn(
|
|
636
|
+
"box-border inline-flex cursor-pointer items-center justify-center leading-none",
|
|
637
|
+
textNode != null ? "gap-2" : "aspect-square",
|
|
638
|
+
controlFieldSurface,
|
|
639
|
+
"shadow-none",
|
|
640
|
+
controlFieldTransition,
|
|
641
|
+
controlFieldFocusBorder,
|
|
642
|
+
"focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-45",
|
|
643
|
+
controlStatusClass(status),
|
|
644
|
+
classNames?.root,
|
|
645
|
+
className,
|
|
646
|
+
)}
|
|
647
|
+
style={{
|
|
648
|
+
height: controlHeightVar(resolvedSize),
|
|
649
|
+
minHeight: controlHeightVar(resolvedSize),
|
|
650
|
+
width: textNode != null ? undefined : controlHeightVar(resolvedSize),
|
|
651
|
+
paddingTop: pad,
|
|
652
|
+
paddingBottom: pad,
|
|
653
|
+
paddingLeft: pad,
|
|
654
|
+
paddingRight: textNode != null ? tok.paddingInline : pad,
|
|
655
|
+
borderRadius: controlRadiusVar(resolvedSize),
|
|
656
|
+
...styles?.root,
|
|
657
|
+
...style,
|
|
658
|
+
}}
|
|
659
|
+
>
|
|
660
|
+
<span
|
|
661
|
+
className={cn("block size-full min-h-0 min-w-0 shrink-0 overflow-hidden", classNames?.body)}
|
|
662
|
+
style={{
|
|
663
|
+
width: textNode != null ? "auto" : "100%",
|
|
664
|
+
height: "100%",
|
|
665
|
+
aspectRatio: "1",
|
|
666
|
+
borderRadius: `max(4px, calc(${controlRadiusVar(resolvedSize)} - ${pad}px))`,
|
|
667
|
+
...styles?.body,
|
|
668
|
+
}}
|
|
669
|
+
>
|
|
670
|
+
<ColorBlock color={triggerColor} cleared={cleared} className={cn("size-full", classNames?.content)} style={styles?.content} />
|
|
671
|
+
</span>
|
|
672
|
+
{textNode != null ? (
|
|
673
|
+
<span className={cn("min-w-0 flex-1 truncate font-mono text-foreground", classNames?.description)} style={{ fontSize: tok.fontSize, ...styles?.description }}>
|
|
674
|
+
{textNode}
|
|
675
|
+
</span>
|
|
676
|
+
) : null}
|
|
677
|
+
</button>
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
return (
|
|
681
|
+
<PopoverPrimitive.Root
|
|
682
|
+
open={open}
|
|
683
|
+
onOpenChange={(v) => {
|
|
684
|
+
if (hover && v) return;
|
|
685
|
+
setOpen(v);
|
|
686
|
+
}}
|
|
687
|
+
>
|
|
688
|
+
<PopoverPrimitive.Trigger asChild onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
|
689
|
+
{children == null ? defaultTrigger : isValidElement(children) ? children : <span className="inline-flex">{children}</span>}
|
|
690
|
+
</PopoverPrimitive.Trigger>
|
|
691
|
+
<PopoverPrimitive.Portal container={portal()}>
|
|
692
|
+
<PopoverPrimitive.Content
|
|
693
|
+
side={side}
|
|
694
|
+
align={align}
|
|
695
|
+
sideOffset={showArrow ? 8 : 6}
|
|
696
|
+
forceMount={destroyOnHidden === false ? true : undefined}
|
|
697
|
+
onMouseEnter={onEnter}
|
|
698
|
+
onMouseLeave={onLeave}
|
|
699
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
700
|
+
className={cn("w-auto p-3", glassOverlayClass, classNames?.popup, popupClassName)}
|
|
701
|
+
style={styles?.popup}
|
|
702
|
+
>
|
|
703
|
+
{rendered}
|
|
704
|
+
{showArrow ? <PopoverPrimitive.Arrow width={12} height={6} className="fill-glass drop-shadow-[0_1px_0_var(--glass-border)]" /> : null}
|
|
705
|
+
</PopoverPrimitive.Content>
|
|
706
|
+
</PopoverPrimitive.Portal>
|
|
707
|
+
</PopoverPrimitive.Root>
|
|
708
|
+
);
|
|
709
|
+
}
|