devnonla-ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -1
  3. package/src/app/App.tsx +4 -4
  4. package/src/button/Button.tsx +1 -1
  5. package/src/button/ButtonCopy.tsx +11 -21
  6. package/src/calendar/Calendar.tsx +1 -1
  7. package/src/chat/AgentPanel.tsx +3 -3
  8. package/src/chat/index.ts +35 -51
  9. package/src/chat/message-ui/ChatError.tsx +2 -4
  10. package/src/chat/message-ui/ChatInput.tsx +5 -9
  11. package/src/chat/message-ui/ChatThinking.tsx +2 -3
  12. package/src/chat/message-ui/ChatUserMessage.tsx +4 -5
  13. package/src/chat/message-ui/ChatWelcome.tsx +3 -8
  14. package/src/chat/message-ui/MermaidBlock.tsx +7 -47
  15. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +8 -11
  16. package/src/chat/tool-ui/BackgroundTasksBar.tsx +13 -14
  17. package/src/chat/tool-ui/CallAgentToolUI.tsx +10 -4
  18. package/src/chat/tool-ui/ChatToolCall.tsx +11 -17
  19. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +2 -2
  20. package/src/chat/tool-ui/ReadSkillToolUI.tsx +3 -3
  21. package/src/chat/tool-ui/RunJsToolUI.tsx +2 -2
  22. package/src/chat/tool-ui/ToolUiTrailing.tsx +3 -7
  23. package/src/chat/tool-ui/WebFetchToolUI.tsx +22 -13
  24. package/src/checkbox/Checkbox.tsx +1 -1
  25. package/src/codeblock/CodeBlock.tsx +17 -19
  26. package/src/colorpicker/ColorPicker.tsx +694 -30
  27. package/src/colorpicker/color.ts +246 -0
  28. package/src/datepicker/DatePicker.tsx +3 -3
  29. package/src/desktop/DesktopHeader.tsx +15 -20
  30. package/src/desktop/DesktopIcon.tsx +1 -1
  31. package/src/desktop/DesktopWindow.tsx +54 -30
  32. package/src/form/Form.tsx +1 -1
  33. package/src/form/FormItem.tsx +2 -2
  34. package/src/form/index.ts +23 -24
  35. package/src/form/variants/FieldColor.tsx +16 -51
  36. package/src/index.ts +167 -196
  37. package/src/input/Input.tsx +14 -12
  38. package/src/input/SearchInput.tsx +5 -2
  39. package/src/lib/sizes.ts +1 -1
  40. package/src/modal/Modal.tsx +3 -0
  41. package/src/pagination/Pagination.tsx +528 -51
  42. package/src/scroll/OverlayScroll.tsx +58 -37
  43. package/src/select/Select.tsx +2 -2
  44. package/src/spin/Spin.tsx +14 -17
  45. package/src/splitter/Splitter.tsx +330 -0
  46. package/src/splitter/sizes.ts +67 -0
  47. package/src/styles.css +28 -3
  48. package/src/switch/Switch.tsx +1 -1
  49. package/src/table/Table.tsx +43 -18
  50. package/src/timepicker/TimePicker.tsx +2 -2
@@ -1,45 +1,709 @@
1
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
2
+ import {
3
+ type CSSProperties,
4
+ type FC,
5
+ isValidElement,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ type PointerEvent as ReactPointerEvent,
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
+ type ColorFormat,
31
+ type ColorType,
32
+ clamp,
33
+ DEFAULT_COLOR,
34
+ type HsbaColor,
35
+ hsbaToRgba,
36
+ parseColor,
37
+ } from "./color";
38
+
39
+ export type { ColorFormat, ColorType, HsbaColor };
40
+ export { Color, DEFAULT_COLOR, parseColor };
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
- presets: readonly string[];
5
- value: string;
6
- onChange: (color: string) => void;
7
- size?: number;
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 swatchClass =
12
- "rounded-md cursor-pointer transition-all duration-150 hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
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
- export function ColorPicker({ presets, value, onChange, size = 28, className }: ColorPickerProps) {
16
- const isCustom = !presets.includes(value);
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
- <div className={cn("flex items-center gap-1.5 flex-wrap", className)}>
20
- {presets.map((preset) => {
21
- const isActive = value === preset;
22
- return (
23
- <button
24
- key={preset}
25
- type="button"
26
- onClick={() => onChange(preset)}
27
- className={cn(swatchClass, isActive && activeClass)}
28
- style={{ width: size, height: size, backgroundColor: preset }}
29
- title={preset}
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
- <label
35
- className={cn("relative overflow-hidden", swatchClass, isCustom && activeClass)}
36
- style={{ width: size, height: size }}
37
- title="Custom color"
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
- <div className="absolute inset-0" style={{ background: "conic-gradient(#e88080, #e8c870, #80d4a0, #70c8d8, #7aaee8, #a888e8, #e88080)" }} />
40
- {isCustom ? <div className="absolute inset-1 rounded-sm" style={{ backgroundColor: value }} /> : null}
41
- <input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
42
- </label>
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
+ }