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
@@ -0,0 +1,246 @@
1
+ export type ColorFormat = "hex" | "rgb" | "hsb";
2
+
3
+ export type HsbaColor = {
4
+ h: number;
5
+ s: number;
6
+ b: number;
7
+ a: number;
8
+ };
9
+
10
+ export type RgbaColor = {
11
+ r: number;
12
+ g: number;
13
+ b: number;
14
+ a: number;
15
+ };
16
+
17
+ export type ColorType = string | Color;
18
+
19
+ const HEX = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
20
+ const RGB = /^rgba?\(\s*([0-9.]+%?)\s*[, ]\s*([0-9.]+%?)\s*[, ]\s*([0-9.]+%?)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i;
21
+ const HSL = /^hsla?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)%\s*[, ]\s*([0-9.]+)%(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i;
22
+ const HSB = /^hsba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)%\s*[, ]\s*([0-9.]+)%(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i;
23
+ const HSV = /^hsva?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)%\s*[, ]\s*([0-9.]+)%(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i;
24
+
25
+ export function clamp(n: number, min: number, max: number) {
26
+ return Math.min(max, Math.max(min, n));
27
+ }
28
+
29
+ function round(n: number, digits = 0) {
30
+ const f = 10 ** digits;
31
+ return Math.round(n * f) / f;
32
+ }
33
+
34
+ function wrapHue(h: number) {
35
+ const n = ((h % 360) + 360) % 360;
36
+ return n;
37
+ }
38
+
39
+ function hexByte(n: number) {
40
+ return clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
41
+ }
42
+
43
+ function parseChannel(raw: string, max: number) {
44
+ const t = raw.trim();
45
+ if (t.endsWith("%")) return clamp((parseFloat(t) / 100) * max, 0, max);
46
+ return clamp(parseFloat(t), 0, max);
47
+ }
48
+
49
+ function parseAlpha(raw: string | undefined) {
50
+ if (raw == null || raw === "") return 1;
51
+ const t = raw.trim();
52
+ if (t.endsWith("%")) return clamp(parseFloat(t) / 100, 0, 1);
53
+ const n = parseFloat(t);
54
+ return n > 1 ? clamp(n / 255, 0, 1) : clamp(n, 0, 1);
55
+ }
56
+
57
+ export function hsbaToRgba({ h, s, b, a }: HsbaColor): RgbaColor {
58
+ const hue = wrapHue(h);
59
+ const sat = clamp(s, 0, 1);
60
+ const val = clamp(b, 0, 1);
61
+ const c = val * sat;
62
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
63
+ const m = val - c;
64
+ let r = 0;
65
+ let g = 0;
66
+ let bl = 0;
67
+ if (hue < 60) [r, g, bl] = [c, x, 0];
68
+ else if (hue < 120) [r, g, bl] = [x, c, 0];
69
+ else if (hue < 180) [r, g, bl] = [0, c, x];
70
+ else if (hue < 240) [r, g, bl] = [0, x, c];
71
+ else if (hue < 300) [r, g, bl] = [x, 0, c];
72
+ else [r, g, bl] = [c, 0, x];
73
+ return {
74
+ r: Math.round((r + m) * 255),
75
+ g: Math.round((g + m) * 255),
76
+ b: Math.round((bl + m) * 255),
77
+ a: clamp(a, 0, 1),
78
+ };
79
+ }
80
+
81
+ export function rgbaToHsba({ r, g, b, a }: RgbaColor): HsbaColor {
82
+ const R = clamp(r, 0, 255) / 255;
83
+ const G = clamp(g, 0, 255) / 255;
84
+ const B = clamp(b, 0, 255) / 255;
85
+ const max = Math.max(R, G, B);
86
+ const min = Math.min(R, G, B);
87
+ const d = max - min;
88
+ let h = 0;
89
+ if (d !== 0) {
90
+ if (max === R) h = ((G - B) / d) % 6;
91
+ else if (max === G) h = (B - R) / d + 2;
92
+ else h = (R - G) / d + 4;
93
+ h *= 60;
94
+ if (h < 0) h += 360;
95
+ }
96
+ return { h, s: max === 0 ? 0 : d / max, b: max, a: clamp(a, 0, 1) };
97
+ }
98
+
99
+ function hslToHsba(h: number, s: number, l: number, a: number): HsbaColor {
100
+ const sat = clamp(s, 0, 1);
101
+ const lit = clamp(l, 0, 1);
102
+ const v = lit + sat * Math.min(lit, 1 - lit);
103
+ const sv = v === 0 ? 0 : 2 * (1 - lit / v);
104
+ return { h: wrapHue(h), s: sv, b: v, a: clamp(a, 0, 1) };
105
+ }
106
+
107
+ function expandHex(hex: string) {
108
+ if (hex.length === 3 || hex.length === 4) {
109
+ return hex
110
+ .split("")
111
+ .map((c) => c + c)
112
+ .join("");
113
+ }
114
+ return hex;
115
+ }
116
+
117
+ function parseHex(raw: string): HsbaColor | null {
118
+ const m = HEX.exec(raw.trim());
119
+ if (!m) return null;
120
+ const hex = expandHex(m[1]!.toLowerCase());
121
+ const r = parseInt(hex.slice(0, 2), 16);
122
+ const g = parseInt(hex.slice(2, 4), 16);
123
+ const b = parseInt(hex.slice(4, 6), 16);
124
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
125
+ return rgbaToHsba({ r, g, b, a });
126
+ }
127
+
128
+ function parseCss(raw: string): HsbaColor | null {
129
+ const t = raw.trim();
130
+ if (!t) return null;
131
+ if (t.toLowerCase() === "transparent") return { h: 0, s: 0, b: 0, a: 0 };
132
+ const hex = parseHex(t);
133
+ if (hex) return hex;
134
+ const rgb = RGB.exec(t);
135
+ if (rgb) {
136
+ return rgbaToHsba({
137
+ r: parseChannel(rgb[1]!, 255),
138
+ g: parseChannel(rgb[2]!, 255),
139
+ b: parseChannel(rgb[3]!, 255),
140
+ a: parseAlpha(rgb[4]),
141
+ });
142
+ }
143
+ const hsl = HSL.exec(t);
144
+ if (hsl) return hslToHsba(parseFloat(hsl[1]!), parseFloat(hsl[2]!) / 100, parseFloat(hsl[3]!) / 100, parseAlpha(hsl[4]));
145
+ const hsb = HSB.exec(t) ?? HSV.exec(t);
146
+ if (hsb) {
147
+ return {
148
+ h: wrapHue(parseFloat(hsb[1]!)),
149
+ s: clamp(parseFloat(hsb[2]!) / 100, 0, 1),
150
+ b: clamp(parseFloat(hsb[3]!) / 100, 0, 1),
151
+ a: parseAlpha(hsb[4]),
152
+ };
153
+ }
154
+ return null;
155
+ }
156
+
157
+ export function isColor(value: unknown): value is Color {
158
+ return value instanceof Color;
159
+ }
160
+
161
+ export class Color {
162
+ private readonly meta: HsbaColor;
163
+
164
+ constructor(input?: ColorType | HsbaColor | RgbaColor | null) {
165
+ this.meta = toHsba(input);
166
+ }
167
+
168
+ toHsb(): HsbaColor {
169
+ return { h: this.meta.h, s: this.meta.s, b: this.meta.b, a: this.meta.a };
170
+ }
171
+
172
+ toRgb(): RgbaColor {
173
+ return hsbaToRgba(this.meta);
174
+ }
175
+
176
+ toHex(ignoreAlpha = false): string {
177
+ const { r, g, b, a } = this.toRgb();
178
+ const hex = `${hexByte(r)}${hexByte(g)}${hexByte(b)}`;
179
+ if (ignoreAlpha || a >= 1) return hex;
180
+ return `${hex}${hexByte(a * 255)}`;
181
+ }
182
+
183
+ toHexString(ignoreAlpha = false): string {
184
+ return `#${this.toHex(ignoreAlpha)}`;
185
+ }
186
+
187
+ toRgbString(): string {
188
+ const { r, g, b, a } = this.toRgb();
189
+ if (a < 1) return `rgba(${r}, ${g}, ${b}, ${round(a, 2)})`;
190
+ return `rgb(${r}, ${g}, ${b})`;
191
+ }
192
+
193
+ toHsbString(): string {
194
+ const { h, s, b, a } = this.meta;
195
+ const H = Math.round(wrapHue(h));
196
+ const S = Math.round(s * 100);
197
+ const B = Math.round(b * 100);
198
+ if (a < 1) return `hsba(${H}, ${S}%, ${B}%, ${round(a, 2)})`;
199
+ return `hsb(${H}, ${S}%, ${B}%)`;
200
+ }
201
+
202
+ toCssString(): string {
203
+ return this.toRgbString();
204
+ }
205
+
206
+ toString(format: ColorFormat = "hex"): string {
207
+ if (format === "rgb") return this.toRgbString();
208
+ if (format === "hsb") return this.toHsbString();
209
+ return this.toHexString();
210
+ }
211
+
212
+ static fromHsba(hsba: HsbaColor): Color {
213
+ return new Color(hsba);
214
+ }
215
+ }
216
+
217
+ function isHsbaLike(value: object): value is HsbaColor {
218
+ return "h" in value && "s" in value && "b" in value;
219
+ }
220
+
221
+ function isRgbaLike(value: object): value is RgbaColor {
222
+ return "r" in value && "g" in value && "b" in value;
223
+ }
224
+
225
+ function toHsba(input?: ColorType | HsbaColor | RgbaColor | null): HsbaColor {
226
+ if (input == null || input === "") return { h: 215, s: 0.91, b: 1, a: 1 };
227
+ if (isColor(input)) return input.toHsb();
228
+ if (typeof input === "string") return parseCss(input) ?? { h: 215, s: 0.91, b: 1, a: 1 };
229
+ if (isHsbaLike(input)) {
230
+ return { h: wrapHue(input.h), s: clamp(input.s, 0, 1), b: clamp(input.b, 0, 1), a: clamp(input.a ?? 1, 0, 1) };
231
+ }
232
+ if (isRgbaLike(input)) return rgbaToHsba({ r: input.r, g: input.g, b: input.b, a: input.a ?? 1 });
233
+ return { h: 215, s: 0.91, b: 1, a: 1 };
234
+ }
235
+
236
+ export function parseColor(input?: ColorType | null): Color | null {
237
+ if (input == null || input === "") return null;
238
+ if (isColor(input)) return input;
239
+ if (typeof input === "string") {
240
+ const parsed = parseCss(input);
241
+ return parsed ? Color.fromHsba(parsed) : null;
242
+ }
243
+ return new Color(input);
244
+ }
245
+
246
+ export const DEFAULT_COLOR = new Color("#1677ff");
@@ -4,19 +4,19 @@ import {
4
4
  type ButtonHTMLAttributes,
5
5
  type CSSProperties,
6
6
  type ForwardRefExoticComponent,
7
+ forwardRef,
7
8
  type MouseEvent,
8
9
  type ReactElement,
9
10
  type ReactNode,
10
11
  type RefAttributes,
11
- forwardRef,
12
12
  useEffect,
13
13
  useMemo,
14
14
  useState,
15
15
  } from "react";
16
16
  import type { DateRange } from "react-day-picker";
17
- import { Calendar } from "../calendar/Calendar";
18
- import { Button } from "../button/Button";
19
17
  import { usePopupContainer } from "../app/context";
18
+ import { Button } from "../button/Button";
19
+ import { Calendar } from "../calendar/Calendar";
20
20
  import { cn } from "../lib/cn";
21
21
  import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlStatusClass, getSizeTokens, useControlSize } from "../lib/sizes";
22
22
  import { glassOverlayClass } from "../lib/surface";
@@ -66,27 +66,22 @@ export function DesktopBarDivider({ className }: { className?: string }) {
66
66
  return <span className={cn("mx-2 h-4 w-px bg-ink-line", className)} aria-hidden />;
67
67
  }
68
68
 
69
- export function DesktopHeader({
70
- logo,
71
- leading,
72
- trailing,
73
- profile,
74
- }: {
75
- logo?: ReactNode;
76
- leading?: ReactNode;
77
- trailing?: ReactNode;
78
- profile?: ReactNode;
79
- }) {
69
+ export type DesktopHeaderProps = {
70
+ left?: ReactNode;
71
+ right?: ReactNode;
72
+ className?: string;
73
+ };
74
+
75
+ export function DesktopHeader({ left, right, className }: DesktopHeaderProps) {
80
76
  return (
81
- <header className="nonla-header-layer fixed inset-x-0 top-0 flex h-desktop-bar items-center justify-between gap-3 border-0 bg-glass-bar px-3 backdrop-blur-lg">
82
- <div className="flex min-w-0 items-center">
83
- {logo}
84
- {leading}
85
- </div>
86
- <div className="flex min-w-0 shrink-0 items-center">
87
- {trailing}
88
- {profile}
89
- </div>
77
+ <header
78
+ className={cn(
79
+ "nonla-header-layer fixed inset-x-0 top-0 flex h-desktop-bar items-center justify-between gap-3 border-0 bg-glass-bar px-3 backdrop-blur-lg",
80
+ className,
81
+ )}
82
+ >
83
+ <div className="flex min-w-0 items-center">{left}</div>
84
+ <div className="flex min-w-0 shrink-0 items-center">{right}</div>
90
85
  </header>
91
86
  );
92
87
  }
@@ -1,4 +1,4 @@
1
- import { type ReactNode, forwardRef } from "react";
1
+ import { forwardRef, type ReactNode } from "react";
2
2
  import { FluentIcon } from "../icon/FluentIcon";
3
3
 
4
4
  const iconButtonClass =
@@ -1,8 +1,8 @@
1
- import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1
+ import { createContext, type MouseEvent as ReactMouseEvent, type ReactNode, type PointerEvent as ReactPointerEvent, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { cn } from "../lib/cn";
4
- import { OverlayScroll, type OverlayScrollVisibility } from "../scroll/OverlayScroll";
5
4
  import { glassSurfaceClass } from "../lib/surface";
5
+ import { OverlayScroll, type OverlayScrollVisibility } from "../scroll/OverlayScroll";
6
6
 
7
7
  type Phase = "open" | "leaving";
8
8
  type Size = { w: number; h: number };
@@ -37,6 +37,10 @@ function isEditableTarget(target: EventTarget | null) {
37
37
  return Boolean(target.closest(".monaco-editor"));
38
38
  }
39
39
 
40
+ function hasOpenDialog() {
41
+ return Boolean(document.querySelector('[role="dialog"][data-state="open"], .nonla-modal-content'));
42
+ }
43
+
40
44
  function originFromActiveIcon(overlay: HTMLElement, frame: HTMLElement) {
41
45
  const source = document.querySelector('[aria-current="true"]');
42
46
  if (!(source instanceof HTMLElement)) return "50% 50%";
@@ -190,33 +194,33 @@ function TrafficLight({
190
194
  }
191
195
 
192
196
  type WindowHeaderSlot = {
193
- slot: HTMLElement | null;
194
- setCustom: (on: boolean) => void;
197
+ left: HTMLElement | null;
198
+ right: HTMLElement | null;
195
199
  };
196
200
 
197
201
  const WindowHeaderSlotContext = createContext<WindowHeaderSlot | null>(null);
198
202
 
199
- export function WindowHeader({ children }: { children: ReactNode }) {
203
+ export type WindowHeaderProps = {
204
+ left?: ReactNode;
205
+ right?: ReactNode;
206
+ };
207
+
208
+ /** Injects into the window title bar from a child. Prefer `DesktopWindow` `left` / `right` when the parent can pass them. */
209
+ export function WindowHeader({ left, right }: WindowHeaderProps) {
200
210
  const ctx = useContext(WindowHeaderSlotContext);
201
- useLayoutEffect(() => {
202
- if (!ctx) return;
203
- ctx.setCustom(true);
204
- return () => ctx.setCustom(false);
205
- }, [ctx]);
206
- if (!ctx?.slot) return null;
207
- return createPortal(children, ctx.slot);
211
+ if (!ctx) return null;
212
+ return (
213
+ <>
214
+ {ctx.left && left != null ? createPortal(left, ctx.left) : null}
215
+ {ctx.right && right != null ? createPortal(right, ctx.right) : null}
216
+ </>
217
+ );
208
218
  }
209
219
 
210
- export function DesktopWindow({
211
- title,
212
- expanded,
213
- onClose,
214
- onToggleExpand,
215
- scroll = true,
216
- scrollbar = "hover",
217
- children,
218
- }: {
220
+ export type DesktopWindowProps = {
219
221
  title?: ReactNode;
222
+ left?: ReactNode;
223
+ right?: ReactNode;
220
224
  expanded: boolean;
221
225
  onClose: () => void;
222
226
  onToggleExpand: () => void;
@@ -224,7 +228,19 @@ export function DesktopWindow({
224
228
  scroll?: boolean;
225
229
  scrollbar?: OverlayScrollVisibility;
226
230
  children: ReactNode;
227
- }) {
231
+ };
232
+
233
+ export function DesktopWindow({
234
+ title,
235
+ left,
236
+ right,
237
+ expanded,
238
+ onClose,
239
+ onToggleExpand,
240
+ scroll = true,
241
+ scrollbar = "hover",
242
+ children,
243
+ }: DesktopWindowProps) {
228
244
  const overlayRef = useRef<HTMLDivElement>(null);
229
245
  const frameRef = useRef<HTMLElement>(null);
230
246
  const closedRef = useRef(false);
@@ -253,9 +269,9 @@ export function DesktopWindow({
253
269
  w: window.innerWidth,
254
270
  h: Math.max(0, window.innerHeight - 42),
255
271
  }));
256
- const [headerSlot, setHeaderSlot] = useState<HTMLDivElement | null>(null);
257
- const [customHeader, setCustomHeader] = useState(false);
258
- const headerChrome = useMemo(() => ({ slot: headerSlot, setCustom: setCustomHeader }), [headerSlot]);
272
+ const [leftSlot, setLeftSlot] = useState<HTMLDivElement | null>(null);
273
+ const [rightSlot, setRightSlot] = useState<HTMLDivElement | null>(null);
274
+ const headerChrome = useMemo(() => ({ left: leftSlot, right: rightSlot }), [leftSlot, rightSlot]);
259
275
  const requestCloseRef = useRef<() => void>(() => {});
260
276
  const viewRef = useRef(view);
261
277
  const offsetRef = useRef(offset);
@@ -325,7 +341,7 @@ export function DesktopWindow({
325
341
 
326
342
  useEffect(() => {
327
343
  const onKey = (e: KeyboardEvent) => {
328
- if (e.key !== "Escape" || isEditableTarget(e.target)) return;
344
+ if (e.key !== "Escape" || e.defaultPrevented || isEditableTarget(e.target) || hasOpenDialog()) return;
329
345
  e.preventDefault();
330
346
  requestCloseRef.current();
331
347
  };
@@ -475,10 +491,8 @@ export function DesktopWindow({
475
491
  className={cn("absolute flex flex-col rounded-xl pointer-events-auto transform-gpu", glassSurfaceClass, "nonla-window-glass", leaving && "pointer-events-none")}
476
492
  >
477
493
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
478
- {/* biome-ignore lint/a11y/noStaticElementInteractions: title bar is a pointer drag surface */}
479
494
  <header
480
495
  onPointerDown={startDrag}
481
- onDoubleClick={onTitleBarDoubleClick}
482
496
  className="flex h-8 shrink-0 cursor-default items-center gap-4 border-0 border-b border-solid border-ink-line px-3 select-none touch-none"
483
497
  >
484
498
  <div className="group/traffic flex shrink-0 items-center gap-2">
@@ -490,8 +504,18 @@ export function DesktopWindow({
490
504
  </TrafficLight>
491
505
  </div>
492
506
  <div className="flex min-w-0 flex-1 items-center">
493
- {customHeader ? null : typeof title === "string" ? <span className="min-w-0 truncate text-xs font-semibold leading-none text-foreground/90">{title}</span> : title}
494
- <div ref={setHeaderSlot} className="contents" />
507
+ <div className="flex min-w-0 items-center">
508
+ {left}
509
+ <div ref={setLeftSlot} className="contents" />
510
+ </div>
511
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: middle strip double-clicks to expand */}
512
+ <div onDoubleClick={onTitleBarDoubleClick} className="flex min-w-8 flex-1 items-center self-stretch px-2">
513
+ {typeof title === "string" ? <span className="min-w-0 truncate text-xs font-semibold leading-none text-foreground/90">{title}</span> : title}
514
+ </div>
515
+ <div className="flex shrink-0 items-center">
516
+ {right}
517
+ <div ref={setRightSlot} className="contents" />
518
+ </div>
495
519
  </div>
496
520
  </header>
497
521
 
package/src/form/Form.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef } from "react";
2
- import { FormProvider, type FieldValues, type UseFormReturn, useWatch } from "react-hook-form";
2
+ import { type FieldValues, FormProvider, type UseFormReturn, useWatch } from "react-hook-form";
3
3
  import { cn } from "../lib/cn";
4
4
  import { FormExtraContext, type FormFetcher } from "./common/context";
5
5
  import { fieldNameOf } from "./common/rules";
@@ -1,5 +1,5 @@
1
- import { useState, type ComponentType } from "react";
2
- import { Controller, type Control, type FieldValues } from "react-hook-form";
1
+ import { type ComponentType, useState } from "react";
2
+ import { type Control, Controller, type FieldValues } from "react-hook-form";
3
3
  import { cn } from "../lib/cn";
4
4
  import { EFormItemType } from "./common/enum";
5
5
  import { fieldNameOf, hydrateRules, isRuleRequired } from "./common/rules";
package/src/form/index.ts CHANGED
@@ -1,35 +1,34 @@
1
1
  /** Schema-driven form (react-hook-form). Prefer this name over layout `Form`. */
2
- export { Form as SchemaForm } from "./Form";
3
- export type { FormProps as SchemaFormProps } from "./Form";
4
- /** @deprecated use SchemaForm — kept so existing demo imports keep typechecking during migrate */
5
- export { Form as FormSchema } from "./Form";
6
- export { FormItem } from "./FormItem";
7
- export type { FormItemProps } from "./FormItem";
8
- export { EFormItemType } from "./common/enum";
9
- export { hydrateRules, fieldNameOf, isRuleRequired } from "./common/rules";
2
+
10
3
  export type { FormFetcher } from "./common/context";
4
+ export { EFormItemType } from "./common/enum";
5
+ export { fieldNameOf, hydrateRules, isRuleRequired } from "./common/rules";
11
6
  export type {
12
- TForm,
13
- TFormItemProps,
14
- TFormRule,
15
- TRuleValueMessage,
16
- ISelectItemProps,
17
7
  IFormItemHelpProps,
8
+ ISelectItemProps,
9
+ TForm,
10
+ TFormItemCheckbox,
11
+ TFormItemColor,
12
+ TFormItemCustom,
13
+ TFormItemDateTime,
14
+ TFormItemHidden,
18
15
  TFormItemInput,
19
- TFormItemTextarea,
20
16
  TFormItemNumber,
17
+ TFormItemObject,
18
+ TFormItemProps,
19
+ TFormItemRadio,
20
+ TFormItemRenderCtx,
21
+ TFormItemRepeater,
21
22
  TFormItemSelect,
22
23
  TFormItemSelectMultiple,
23
24
  TFormItemSelectRemote,
24
- TFormItemDateTime,
25
- TFormItemTime,
26
- TFormItemRepeater,
27
- TFormItemObject,
28
- TFormItemRadio,
29
- TFormItemColor,
30
- TFormItemHidden,
31
25
  TFormItemSwitch,
32
- TFormItemCheckbox,
33
- TFormItemCustom,
34
- TFormItemRenderCtx,
26
+ TFormItemTextarea,
27
+ TFormItemTime,
28
+ TFormRule,
29
+ TRuleValueMessage,
35
30
  } from "./common/types";
31
+ export type { FormProps as SchemaFormProps } from "./Form";
32
+ export { Form as SchemaForm, Form as FormSchema } from "./Form";
33
+ export type { FormItemProps } from "./FormItem";
34
+ export { FormItem } from "./FormItem";
@@ -1,7 +1,6 @@
1
- import { useState } from "react";
2
1
  import type { ControllerRenderProps, FieldValues } from "react-hook-form";
3
- import { Input } from "../../input/Input";
4
- import { Popover } from "../../popover/Popover";
2
+ import { ColorPicker, type PresetColorType } from "../../colorpicker/ColorPicker";
3
+ import type { ControlSize } from "../../lib/sizes";
5
4
 
6
5
  type Props = {
7
6
  field: ControllerRenderProps<FieldValues, string>;
@@ -9,55 +8,21 @@ type Props = {
9
8
  status?: "error" | "warning";
10
9
  };
11
10
 
12
- const FALLBACK = "#000000";
13
-
14
11
  export function FieldColor({ field, options, status }: Props) {
15
- const raw = typeof field.value === "string" ? field.value : field.value ? String(field.value) : "";
16
- const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw) ? raw : FALLBACK;
17
- const [open, setOpen] = useState(false);
18
-
12
+ const opts = options ?? {};
13
+ const raw = typeof field.value === "string" ? field.value : field.value != null ? String(field.value) : "";
19
14
  return (
20
- <div className="flex items-center gap-2">
21
- <Popover
22
- open={open}
23
- onOpenChange={setOpen}
24
- trigger="click"
25
- placement="bottom"
26
- content={
27
- <div className="flex flex-col gap-2 p-1 min-w-45">
28
- <input
29
- type="color"
30
- value={hex}
31
- onChange={(e) => field.onChange(e.target.value)}
32
- className="h-24 w-full cursor-pointer border-0 bg-transparent p-0"
33
- aria-label="Pick color"
34
- />
35
- <Input
36
- value={raw || hex}
37
- onChange={(e) => field.onChange(e.target.value)}
38
- placeholder="#000000"
39
- status={status}
40
- className="font-mono text-sm"
41
- {...(options as object)}
42
- />
43
- </div>
44
- }
45
- >
46
- <button
47
- type="button"
48
- className="size-9 shrink-0 cursor-pointer rounded border border-input p-0"
49
- style={{ backgroundColor: raw || hex }}
50
- aria-label="Color picker"
51
- />
52
- </Popover>
53
- <Input
54
- className="flex-1 font-mono text-sm"
55
- value={raw}
56
- onChange={(e) => field.onChange(e.target.value)}
57
- onBlur={field.onBlur}
58
- placeholder="#000000"
59
- status={status}
60
- />
61
- </div>
15
+ <ColorPicker
16
+ value={raw || null}
17
+ onChange={(color) => field.onChange(color.toHexString())}
18
+ onClear={() => field.onChange("")}
19
+ showText
20
+ allowClear={opts.allowClear !== false}
21
+ disabled={opts.disabled as boolean | undefined}
22
+ disabledAlpha={opts.disabledAlpha as boolean | undefined}
23
+ size={opts.size as ControlSize | undefined}
24
+ status={status}
25
+ presets={opts.presets as PresetColorType[] | undefined}
26
+ />
62
27
  );
63
28
  }