@blurrow/ui 0.2.0 → 0.2.1

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 (271) hide show
  1. package/README.md +48 -36
  2. package/dist/Accordion.d.ts +17 -0
  3. package/dist/Accordion.js +138 -0
  4. package/dist/Accordion.js.map +1 -0
  5. package/dist/Alert.d.ts +13 -0
  6. package/dist/Alert.js +69 -0
  7. package/dist/Alert.js.map +1 -0
  8. package/dist/AuthorCard.d.ts +12 -0
  9. package/dist/AuthorCard.js +57 -0
  10. package/dist/AuthorCard.js.map +1 -0
  11. package/dist/Avatar.d.ts +21 -0
  12. package/dist/Avatar.js +96 -0
  13. package/dist/Avatar.js.map +1 -0
  14. package/dist/Badge.d.ts +12 -0
  15. package/dist/Badge.js +46 -0
  16. package/dist/Badge.js.map +1 -0
  17. package/dist/Banner.d.ts +15 -0
  18. package/dist/Banner.js +68 -0
  19. package/dist/Banner.js.map +1 -0
  20. package/dist/Breadcrumb.d.ts +13 -0
  21. package/dist/Breadcrumb.js +45 -0
  22. package/dist/Breadcrumb.js.map +1 -0
  23. package/dist/Button.d.ts +19 -0
  24. package/dist/Button.js +282 -0
  25. package/dist/Button.js.map +1 -0
  26. package/dist/ButtonGroup.d.ts +19 -0
  27. package/dist/ButtonGroup.js +62 -0
  28. package/dist/ButtonGroup.js.map +1 -0
  29. package/dist/Card.d.ts +40 -0
  30. package/dist/Card.js +175 -0
  31. package/dist/Card.js.map +1 -0
  32. package/dist/CardShell.d.ts +18 -0
  33. package/dist/CardShell.js +58 -0
  34. package/dist/CardShell.js.map +1 -0
  35. package/dist/Carousel.d.ts +22 -0
  36. package/dist/Carousel.js +247 -0
  37. package/dist/Carousel.js.map +1 -0
  38. package/dist/Checkbox.d.ts +15 -0
  39. package/dist/Checkbox.js +84 -0
  40. package/dist/Checkbox.js.map +1 -0
  41. package/dist/CloseButton.d.ts +12 -0
  42. package/dist/CloseButton.js +63 -0
  43. package/dist/CloseButton.js.map +1 -0
  44. package/dist/CodeBlock.d.ts +12 -0
  45. package/dist/CodeBlock.js +221 -0
  46. package/dist/CodeBlock.js.map +1 -0
  47. package/dist/ColorPicker.d.ts +24 -0
  48. package/dist/ColorPicker.js +366 -0
  49. package/dist/ColorPicker.js.map +1 -0
  50. package/dist/Combobox.d.ts +34 -0
  51. package/dist/Combobox.js +255 -0
  52. package/dist/Combobox.js.map +1 -0
  53. package/dist/CommandPalette.d.ts +25 -0
  54. package/dist/CommandPalette.js +304 -0
  55. package/dist/CommandPalette.js.map +1 -0
  56. package/dist/ContextMenu.d.ts +16 -0
  57. package/dist/ContextMenu.js +209 -0
  58. package/dist/ContextMenu.js.map +1 -0
  59. package/dist/DataList.d.ts +15 -0
  60. package/dist/DataList.js +42 -0
  61. package/dist/DataList.js.map +1 -0
  62. package/dist/DatePicker.d.ts +65 -0
  63. package/dist/DatePicker.js +391 -0
  64. package/dist/DatePicker.js.map +1 -0
  65. package/dist/DateRangePicker.d.ts +34 -0
  66. package/dist/DateRangePicker.js +180 -0
  67. package/dist/DateRangePicker.js.map +1 -0
  68. package/dist/Drawer.d.ts +17 -0
  69. package/dist/Drawer.js +132 -0
  70. package/dist/Drawer.js.map +1 -0
  71. package/dist/DropdownMenu.d.ts +23 -0
  72. package/dist/DropdownMenu.js +185 -0
  73. package/dist/DropdownMenu.js.map +1 -0
  74. package/dist/EmptyState.d.ts +13 -0
  75. package/dist/EmptyState.js +55 -0
  76. package/dist/EmptyState.js.map +1 -0
  77. package/dist/FieldMessage.d.ts +26 -0
  78. package/dist/FieldMessage.js +95 -0
  79. package/dist/FieldMessage.js.map +1 -0
  80. package/dist/FileUpload.d.ts +32 -0
  81. package/dist/FileUpload.js +253 -0
  82. package/dist/FileUpload.js.map +1 -0
  83. package/dist/Floating.d.ts +32 -0
  84. package/dist/Floating.js +95 -0
  85. package/dist/Floating.js.map +1 -0
  86. package/dist/HoverCard.d.ts +18 -0
  87. package/dist/HoverCard.js +90 -0
  88. package/dist/HoverCard.js.map +1 -0
  89. package/dist/Input.d.ts +17 -0
  90. package/dist/Input.js +96 -0
  91. package/dist/Input.js.map +1 -0
  92. package/dist/Kbd.d.ts +9 -0
  93. package/dist/Kbd.js +24 -0
  94. package/dist/Kbd.js.map +1 -0
  95. package/dist/Modal.d.ts +15 -0
  96. package/dist/Modal.js +109 -0
  97. package/dist/Modal.js.map +1 -0
  98. package/dist/NumberInput.d.ts +23 -0
  99. package/dist/NumberInput.js +162 -0
  100. package/dist/NumberInput.js.map +1 -0
  101. package/dist/OtpInput.d.ts +26 -0
  102. package/dist/OtpInput.js +173 -0
  103. package/dist/OtpInput.js.map +1 -0
  104. package/dist/Pagination.d.ts +11 -0
  105. package/dist/Pagination.js +148 -0
  106. package/dist/Pagination.js.map +1 -0
  107. package/dist/Popover.d.ts +13 -0
  108. package/dist/Popover.js +98 -0
  109. package/dist/Popover.js.map +1 -0
  110. package/dist/Progress.d.ts +12 -0
  111. package/dist/Progress.js +61 -0
  112. package/dist/Progress.js.map +1 -0
  113. package/dist/QuoteCard.d.ts +13 -0
  114. package/dist/QuoteCard.js +92 -0
  115. package/dist/QuoteCard.js.map +1 -0
  116. package/dist/Radio.d.ts +19 -0
  117. package/dist/Radio.js +161 -0
  118. package/dist/Radio.js.map +1 -0
  119. package/dist/RadioCards.d.ts +27 -0
  120. package/dist/RadioCards.js +205 -0
  121. package/dist/RadioCards.js.map +1 -0
  122. package/dist/ReactionBar.d.ts +15 -0
  123. package/dist/ReactionBar.js +92 -0
  124. package/dist/ReactionBar.js.map +1 -0
  125. package/dist/ReadingProgress.d.ts +8 -0
  126. package/dist/ReadingProgress.js +51 -0
  127. package/dist/ReadingProgress.js.map +1 -0
  128. package/dist/ReadingTime.d.ts +9 -0
  129. package/dist/ReadingTime.js +39 -0
  130. package/dist/ReadingTime.js.map +1 -0
  131. package/dist/Select.d.ts +20 -0
  132. package/dist/Select.js +244 -0
  133. package/dist/Select.js.map +1 -0
  134. package/dist/Separator.d.ts +14 -0
  135. package/dist/Separator.js +33 -0
  136. package/dist/Separator.js.map +1 -0
  137. package/dist/ShareButtons.d.ts +10 -0
  138. package/dist/ShareButtons.js +107 -0
  139. package/dist/ShareButtons.js.map +1 -0
  140. package/dist/Sidebar.d.ts +30 -0
  141. package/dist/Sidebar.js +140 -0
  142. package/dist/Sidebar.js.map +1 -0
  143. package/dist/Skeleton.d.ts +15 -0
  144. package/dist/Skeleton.js +67 -0
  145. package/dist/Skeleton.js.map +1 -0
  146. package/dist/Slider.d.ts +27 -0
  147. package/dist/Slider.js +182 -0
  148. package/dist/Slider.js.map +1 -0
  149. package/dist/Spinner.d.ts +12 -0
  150. package/dist/Spinner.js +24 -0
  151. package/dist/Spinner.js.map +1 -0
  152. package/dist/SpotlightCard.d.ts +17 -0
  153. package/dist/SpotlightCard.js +87 -0
  154. package/dist/SpotlightCard.js.map +1 -0
  155. package/dist/Stat.d.ts +19 -0
  156. package/dist/Stat.js +71 -0
  157. package/dist/Stat.js.map +1 -0
  158. package/dist/StatCard.d.ts +24 -0
  159. package/dist/StatCard.js +212 -0
  160. package/dist/StatCard.js.map +1 -0
  161. package/dist/Steps.d.ts +14 -0
  162. package/dist/Steps.js +94 -0
  163. package/dist/Steps.js.map +1 -0
  164. package/dist/Table.d.ts +21 -0
  165. package/dist/Table.js +93 -0
  166. package/dist/Table.js.map +1 -0
  167. package/dist/TableOfContents.d.ts +17 -0
  168. package/dist/TableOfContents.js +92 -0
  169. package/dist/TableOfContents.js.map +1 -0
  170. package/dist/Tabs.d.ts +17 -0
  171. package/dist/Tabs.js +123 -0
  172. package/dist/Tabs.js.map +1 -0
  173. package/dist/TagInput.d.ts +16 -0
  174. package/dist/TagInput.js +169 -0
  175. package/dist/TagInput.js.map +1 -0
  176. package/dist/Textarea.d.ts +13 -0
  177. package/dist/Textarea.js +74 -0
  178. package/dist/Textarea.js.map +1 -0
  179. package/dist/TimePicker.d.ts +28 -0
  180. package/dist/TimePicker.js +264 -0
  181. package/dist/TimePicker.js.map +1 -0
  182. package/dist/Timeline.d.ts +19 -0
  183. package/dist/Timeline.js +76 -0
  184. package/dist/Timeline.js.map +1 -0
  185. package/dist/Toast.d.ts +18 -0
  186. package/dist/Toast.js +318 -0
  187. package/dist/Toast.js.map +1 -0
  188. package/dist/Toggle.d.ts +13 -0
  189. package/dist/Toggle.js +84 -0
  190. package/dist/Toggle.js.map +1 -0
  191. package/dist/Tooltip.d.ts +13 -0
  192. package/dist/Tooltip.js +138 -0
  193. package/dist/Tooltip.js.map +1 -0
  194. package/dist/cli.js +172 -5
  195. package/dist/index.d.ts +68 -631
  196. package/dist/index.js +67 -5558
  197. package/dist/index.js.map +1 -1
  198. package/dist/registry/Accordion.tsx +6 -6
  199. package/dist/registry/Alert.tsx +13 -10
  200. package/dist/registry/AuthorCard.tsx +7 -7
  201. package/dist/registry/Avatar.tsx +5 -5
  202. package/dist/registry/Badge.tsx +12 -15
  203. package/dist/registry/Banner.tsx +15 -18
  204. package/dist/registry/Breadcrumb.tsx +3 -3
  205. package/dist/registry/Button.tsx +58 -69
  206. package/dist/registry/ButtonGroup.tsx +9 -9
  207. package/dist/registry/Card.tsx +15 -15
  208. package/dist/registry/CardShell.tsx +6 -6
  209. package/dist/registry/Carousel.tsx +19 -15
  210. package/dist/registry/Checkbox.tsx +14 -9
  211. package/dist/registry/CloseButton.tsx +2 -2
  212. package/dist/registry/CodeBlock.tsx +15 -15
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +31 -24
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +4 -4
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +17 -15
  221. package/dist/registry/DropdownMenu.tsx +39 -77
  222. package/dist/registry/EmptyState.tsx +8 -8
  223. package/dist/registry/FieldMessage.tsx +123 -0
  224. package/dist/registry/FileUpload.tsx +318 -0
  225. package/dist/registry/Floating.tsx +134 -0
  226. package/dist/registry/HoverCard.tsx +113 -0
  227. package/dist/registry/Input.tsx +54 -43
  228. package/dist/registry/Kbd.tsx +4 -5
  229. package/dist/registry/Modal.tsx +17 -15
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +15 -18
  233. package/dist/registry/Popover.tsx +40 -45
  234. package/dist/registry/Progress.tsx +4 -4
  235. package/dist/registry/QuoteCard.tsx +7 -7
  236. package/dist/registry/Radio.tsx +24 -21
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +5 -5
  239. package/dist/registry/ReadingProgress.tsx +2 -2
  240. package/dist/registry/ReadingTime.tsx +2 -2
  241. package/dist/registry/Select.tsx +28 -53
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +2 -2
  244. package/dist/registry/Sidebar.tsx +18 -18
  245. package/dist/registry/Skeleton.tsx +1 -1
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +6 -6
  249. package/dist/registry/Stat.tsx +4 -4
  250. package/dist/registry/StatCard.tsx +19 -19
  251. package/dist/registry/Steps.tsx +7 -7
  252. package/dist/registry/Table.tsx +12 -10
  253. package/dist/registry/TableOfContents.tsx +8 -8
  254. package/dist/registry/Tabs.tsx +6 -6
  255. package/dist/registry/TagInput.tsx +9 -9
  256. package/dist/registry/Textarea.tsx +5 -5
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +14 -14
  259. package/dist/registry/Toast.tsx +15 -12
  260. package/dist/registry/Toggle.tsx +7 -10
  261. package/dist/registry/Tooltip.tsx +21 -73
  262. package/dist/registry/styles.css +868 -62
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/styles.css +868 -62
  265. package/dist/useFloatingPanel.d.ts +24 -0
  266. package/dist/useFloatingPanel.js +89 -0
  267. package/dist/useFloatingPanel.js.map +1 -0
  268. package/dist/useModalBehavior.d.ts +15 -0
  269. package/dist/useModalBehavior.js +57 -0
  270. package/dist/useModalBehavior.js.map +1 -0
  271. package/package.json +1 -1
@@ -0,0 +1,404 @@
1
+ "use client";
2
+
3
+ import React, { useId, useRef, useState } from "react";
4
+ import { Portal } from "./Floating";
5
+ import { FieldLabel, FieldMessage } from "./FieldMessage";
6
+ import { useFloatingPanel } from "./useFloatingPanel";
7
+
8
+ /* ── Colour maths ─────────────────────────────────────────────────── */
9
+ type HSV = { h: number; s: number; v: number };
10
+
11
+ const clamp = (n: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, n));
12
+
13
+ /** Accepts #rgb, #rrggbb, with or without the hash. Returns #rrggbb or null */
14
+ export function normalizeHex(input: string): string | null {
15
+ const s = input.trim().replace(/^#/, "").toLowerCase();
16
+ if (/^[0-9a-f]{3}$/.test(s)) return "#" + s.split("").map((c) => c + c).join("");
17
+ if (/^[0-9a-f]{6}$/.test(s)) return "#" + s;
18
+ return null;
19
+ }
20
+
21
+ function hexToHsv(hex: string): HSV {
22
+ const n = parseInt(hex.slice(1), 16);
23
+ const r = ((n >> 16) & 255) / 255;
24
+ const g = ((n >> 8) & 255) / 255;
25
+ const b = (n & 255) / 255;
26
+ const max = Math.max(r, g, b);
27
+ const d = max - Math.min(r, g, b);
28
+ let h = 0;
29
+ if (d) {
30
+ if (max === r) h = ((g - b) / d) % 6;
31
+ else if (max === g) h = (b - r) / d + 2;
32
+ else h = (r - g) / d + 4;
33
+ h = (h * 60 + 360) % 360;
34
+ }
35
+ return { h, s: max ? d / max : 0, v: max };
36
+ }
37
+
38
+ function hsvToHex({ h, s, v }: HSV): string {
39
+ const f = (n: number) => {
40
+ const k = (n + h / 60) % 6;
41
+ return Math.round((v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255);
42
+ };
43
+ return "#" + [f(5), f(3), f(1)].map((x) => x.toString(16).padStart(2, "0")).join("");
44
+ }
45
+
46
+ const DEFAULT_SWATCHES = [
47
+ "#171717", "#737373", "#dc2626", "#ea580c", "#ca8a04",
48
+ "#16a34a", "#0891b2", "#2563eb", "#7c3aed", "#db2777",
49
+ ];
50
+
51
+ /* ── The panel: area, hue strip, hex field, swatches ──────────────── */
52
+ interface ColorPanelProps {
53
+ value: string;
54
+ onChange: (hex: string) => void;
55
+ swatches?: string[];
56
+ /** Focus the saturation knob as soon as the panel mounts */
57
+ autoFocus?: boolean;
58
+ }
59
+
60
+ function ColorPanel({ value, onChange, swatches = DEFAULT_SWATCHES, autoFocus = false }: ColorPanelProps) {
61
+ // Hue and saturation are kept apart from the hex, so dragging through
62
+ // grey or black doesn't forget which colour you were on
63
+ const [hsv, setHsv] = useState<HSV>(() => hexToHsv(value));
64
+ const [draft, setDraft] = useState(value);
65
+
66
+ // A colour set from outside (not one we just sent up) resets the panel
67
+ const [seen, setSeen] = useState(value);
68
+ if (value !== seen) {
69
+ setSeen(value);
70
+ if (value !== hsvToHex(hsv)) {
71
+ setHsv(hexToHsv(value));
72
+ setDraft(value);
73
+ }
74
+ }
75
+
76
+ const update = (next: HSV) => {
77
+ setHsv(next);
78
+ const hex = hsvToHex(next);
79
+ setDraft(hex);
80
+ if (hex !== value) onChange(hex);
81
+ };
82
+
83
+ const pickHex = (hex: string) => {
84
+ setHsv(hexToHsv(hex));
85
+ setDraft(hex);
86
+ if (hex !== value) onChange(hex);
87
+ };
88
+
89
+ return (
90
+ <div className="flex w-[236px] flex-col gap-3">
91
+ <SaturationArea hsv={hsv} onChange={update} autoFocus={autoFocus} />
92
+ <HueStrip hue={hsv.h} onChange={(h) => update({ ...hsv, h })} />
93
+
94
+ <div className="flex items-center gap-2">
95
+ <span
96
+ aria-hidden
97
+ className="h-9 w-9 shrink-0 rounded-[10px]"
98
+ style={{ background: hsvToHex(hsv), boxShadow: "var(--tile-shadow), inset 0 0 0 1px var(--hairline)" }}
99
+ />
100
+ <div className="relative flex-1">
101
+ <span className="pointer-events-none absolute left-3 top-1/2 z-20 -translate-y-1/2 text-[12.5px] text-ink-faint">#</span>
102
+ <input
103
+ aria-label="hex colour"
104
+ value={draft.replace(/^#/, "")}
105
+ maxLength={7}
106
+ spellCheck={false}
107
+ autoComplete="off"
108
+ onChange={(e) => {
109
+ setDraft("#" + e.target.value.replace(/^#/, ""));
110
+ const hex = normalizeHex(e.target.value);
111
+ if (hex && e.target.value.replace(/^#/, "").length === 6) pickHex(hex);
112
+ }}
113
+ onBlur={() => {
114
+ const hex = normalizeHex(draft);
115
+ if (hex) pickHex(hex);
116
+ else setDraft(hsvToHex(hsv));
117
+ }}
118
+ onKeyDown={(e) => {
119
+ if (e.key === "Enter") {
120
+ e.preventDefault();
121
+ (e.target as HTMLInputElement).blur();
122
+ }
123
+ }}
124
+ className="field relative z-10 w-full rounded-[10px] py-2 pl-6 pr-3 font-mono text-[12.5px] uppercase text-ink caret-ink"
125
+ />
126
+ </div>
127
+ </div>
128
+
129
+ {swatches.length > 0 && (
130
+ <div role="group" aria-label="swatches" className="grid grid-cols-10 gap-1.5 border-t border-line-strong pt-3">
131
+ {swatches.map((s) => {
132
+ const hex = normalizeHex(s) ?? s;
133
+ const on = hex === hsvToHex(hsv);
134
+ return (
135
+ <button
136
+ key={s}
137
+ type="button"
138
+ aria-label={hex}
139
+ aria-pressed={on}
140
+ onClick={() => pickHex(hex)}
141
+ className="relative aspect-square cursor-pointer rounded-full transition-transform duration-200 ease-[var(--ease-spring)] hover:scale-115 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
142
+ style={{
143
+ background: hex,
144
+ boxShadow: on
145
+ ? "0 0 0 2px var(--panel-face), 0 0 0 3.5px var(--ink)"
146
+ : "inset 0 0 0 1px var(--hairline), inset 0 1px 1px rgba(255,255,255,0.3), var(--dot-shadow)",
147
+ }}
148
+ />
149
+ );
150
+ })}
151
+ </div>
152
+ )}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ /* Drag anywhere on an element and get back where you are, 0…1 on each axis */
158
+ function useDrag(onMove: (x: number, y: number) => void) {
159
+ const ref = useRef<HTMLDivElement>(null);
160
+ const [dragging, setDragging] = useState(false);
161
+ const at = (e: React.PointerEvent) => {
162
+ const r = ref.current!.getBoundingClientRect();
163
+ onMove(clamp((e.clientX - r.left) / r.width), clamp((e.clientY - r.top) / r.height));
164
+ };
165
+ const handlers = {
166
+ onPointerDown: (e: React.PointerEvent) => {
167
+ if (e.button !== 0) return;
168
+ e.preventDefault();
169
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
170
+ (e.currentTarget.querySelector("[data-thumb]") as HTMLElement | null)?.focus();
171
+ setDragging(true);
172
+ at(e);
173
+ },
174
+ onPointerMove: (e: React.PointerEvent) => dragging && at(e),
175
+ onPointerUp: () => setDragging(false),
176
+ onPointerCancel: () => setDragging(false),
177
+ };
178
+ return { ref, dragging, handlers };
179
+ }
180
+
181
+ /* A raised white ring that rides on the area and the strip */
182
+ const thumbStyle = (color: string, dragging: boolean): React.CSSProperties => ({
183
+ background: color,
184
+ boxShadow:
185
+ "0 0 0 2.5px #fff, 0 0 0 3px rgba(17,17,17,0.1), 0 2px 6px rgba(17,17,17,0.3), inset 0 0 0 1px rgba(17,17,17,0.1)",
186
+ transform: `translate(-50%, -50%) scale(${dragging ? 1.15 : 1})`,
187
+ transition: "transform 200ms var(--ease-spring)",
188
+ });
189
+
190
+ function SaturationArea({ hsv, onChange, autoFocus }: { hsv: HSV; onChange: (hsv: HSV) => void; autoFocus?: boolean }) {
191
+ const { ref, dragging, handlers } = useDrag((x, y) => onChange({ ...hsv, s: x, v: 1 - y }));
192
+ const onKeyDown = (e: React.KeyboardEvent) => {
193
+ const d = e.shiftKey ? 0.1 : 0.01;
194
+ const moves: Record<string, Partial<HSV>> = {
195
+ ArrowLeft: { s: clamp(hsv.s - d) },
196
+ ArrowRight: { s: clamp(hsv.s + d) },
197
+ ArrowUp: { v: clamp(hsv.v + d) },
198
+ ArrowDown: { v: clamp(hsv.v - d) },
199
+ };
200
+ if (moves[e.key]) {
201
+ e.preventDefault();
202
+ onChange({ ...hsv, ...moves[e.key] });
203
+ }
204
+ };
205
+ return (
206
+ <div
207
+ ref={ref}
208
+ {...handlers}
209
+ className="relative h-[148px] cursor-crosshair touch-none rounded-[10px]"
210
+ style={{
211
+ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hsv.h} 100% 50%))`,
212
+ /* Cut into the face like a field: shade inside, lit lip below */
213
+ boxShadow: "inset 0 0 0 1px var(--hairline), inset 0 1px 3px rgba(0,0,0,0.25)",
214
+ }}
215
+ >
216
+ <span
217
+ data-thumb
218
+ ref={autoFocus ? (el) => el?.focus({ preventScroll: true }) : undefined}
219
+ role="slider"
220
+ tabIndex={0}
221
+ aria-label="saturation and brightness"
222
+ aria-valuetext={`saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
223
+ aria-valuenow={Math.round(hsv.s * 100)}
224
+ aria-valuemin={0}
225
+ aria-valuemax={100}
226
+ onKeyDown={onKeyDown}
227
+ className="absolute h-3.5 w-3.5 rounded-full focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-ink"
228
+ style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, ...thumbStyle(hsvToHex(hsv), dragging) }}
229
+ />
230
+ </div>
231
+ );
232
+ }
233
+
234
+ function HueStrip({ hue, onChange }: { hue: number; onChange: (h: number) => void }) {
235
+ const { ref, dragging, handlers } = useDrag((x) => onChange(Math.min(359.9, x * 360)));
236
+ const onKeyDown = (e: React.KeyboardEvent) => {
237
+ const d = e.shiftKey ? 10 : 1;
238
+ if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); onChange((hue - d + 360) % 360); }
239
+ if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); onChange((hue + d) % 360); }
240
+ };
241
+ return (
242
+ <div
243
+ ref={ref}
244
+ {...handlers}
245
+ className="relative mx-[7px] h-3 cursor-pointer touch-none"
246
+ >
247
+ <span
248
+ aria-hidden
249
+ className="absolute -inset-x-[7px] inset-y-0 rounded-full"
250
+ style={{
251
+ background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
252
+ boxShadow: "inset 0 0 0 1px var(--hairline), inset 0 1px 2px rgba(0,0,0,0.3)",
253
+ }}
254
+ />
255
+ <span
256
+ data-thumb
257
+ role="slider"
258
+ tabIndex={0}
259
+ aria-label="hue"
260
+ aria-valuenow={Math.round(hue)}
261
+ aria-valuemin={0}
262
+ aria-valuemax={360}
263
+ onKeyDown={onKeyDown}
264
+ className="absolute top-1/2 h-3.5 w-3.5 rounded-full focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-ink"
265
+ style={{ left: `${(hue / 360) * 100}%`, ...thumbStyle(`hsl(${hue} 100% 50%)`, dragging) }}
266
+ />
267
+ </div>
268
+ );
269
+ }
270
+
271
+ /* ── ColorPicker ──────────────────────────────────────────────────── */
272
+ interface ColorPickerProps {
273
+ /** A hex colour, like "#2563eb" */
274
+ value?: string;
275
+ defaultValue?: string;
276
+ onChange?: (hex: string) => void;
277
+ label?: string;
278
+ hint?: string;
279
+ /** Preset colours under the picker; pass [] to hide them */
280
+ swatches?: string[];
281
+ /** Render the panel straight onto the page instead of behind a field */
282
+ inline?: boolean;
283
+ /** Submit the colour with a form */
284
+ name?: string;
285
+ disabled?: boolean;
286
+ fullWidth?: boolean;
287
+ className?: string;
288
+ }
289
+
290
+ /* Closed, it's a sunken field holding a small raised chip of the colour.
291
+ Open, a floating panel with a saturation area cut into its face, a hue
292
+ strip, a hex field and a row of swatches. */
293
+ export function ColorPicker({
294
+ value,
295
+ defaultValue = "#2563eb",
296
+ onChange,
297
+ label,
298
+ hint,
299
+ swatches,
300
+ inline = false,
301
+ name,
302
+ disabled = false,
303
+ fullWidth = false,
304
+ className = "",
305
+ }: ColorPickerProps) {
306
+ const uid = useId();
307
+ const ref = useRef<HTMLDivElement>(null);
308
+ const buttonRef = useRef<HTMLButtonElement>(null);
309
+ const [internal, setInternal] = useState(normalizeHex(defaultValue) ?? "#000000");
310
+ const color = (value !== undefined ? normalizeHex(value) : null) ?? internal;
311
+ const panel = useFloatingPanel(ref, { onEscape: () => buttonRef.current?.focus() });
312
+ const { setFloating, panelStyle } = panel;
313
+
314
+ const set = (hex: string) => {
315
+ setInternal(hex);
316
+ onChange?.(hex);
317
+ };
318
+
319
+ const labelEl = label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>;
320
+ const hintEl = <FieldMessage hint={hint} />;
321
+ const hidden = name && <input type="hidden" name={name} value={color} />;
322
+
323
+ if (inline) {
324
+ return (
325
+ <div role="group" aria-labelledby={label ? `${uid}-label` : undefined} className={["flex flex-col gap-1.5", className].join(" ")}>
326
+ {labelEl}
327
+ <div className="w-fit rounded-[16px] p-1.5" style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}>
328
+ <div className="rounded-[11px] bg-panel-face p-3" style={{ boxShadow: "var(--panel-face-shadow)" }}>
329
+ <ColorPanel value={color} onChange={set} swatches={swatches} />
330
+ </div>
331
+ </div>
332
+ {hintEl}
333
+ {hidden}
334
+ </div>
335
+ );
336
+ }
337
+
338
+ return (
339
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
340
+ {labelEl}
341
+ <div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-48", className].join(" ")}>
342
+ <button
343
+ ref={buttonRef}
344
+ type="button"
345
+ disabled={disabled}
346
+ onClick={panel.toggle}
347
+ aria-haspopup="dialog"
348
+ aria-expanded={panel.open}
349
+ aria-controls={panel.open ? `${uid}-panel` : undefined}
350
+ aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
351
+ data-open={panel.open || undefined}
352
+ className="field group/color relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-2.5 pl-2.5 pr-4 text-left focus:outline-none disabled:opacity-40 disabled:pointer-events-none"
353
+ >
354
+ <span
355
+ aria-hidden
356
+ className="h-6 w-6 shrink-0 rounded-[7px] transition-transform duration-300 ease-[var(--ease-spring)] group-hover/color:scale-105"
357
+ style={{
358
+ background: color,
359
+ boxShadow:
360
+ "var(--key-shadow), inset 0 1px 1px rgba(255,255,255,0.3), inset 0 0 0 1px var(--hairline)",
361
+ }}
362
+ />
363
+ <span id={`${uid}-value`} className="flex-1 font-mono text-[13px] uppercase tracking-[0.02em] text-ink">
364
+ {color}
365
+ </span>
366
+ <svg
367
+ aria-hidden
368
+ width="14"
369
+ height="14"
370
+ fill="none"
371
+ stroke="currentColor"
372
+ strokeWidth="2"
373
+ viewBox="0 0 24 24"
374
+ className={[
375
+ "shrink-0 transition-[transform,color] duration-300 ease-[var(--ease-spring)]",
376
+ panel.open ? "rotate-180 text-ink" : "text-ink-faint group-hover/color:text-ink-label",
377
+ ].join(" ")}
378
+ >
379
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
380
+ </svg>
381
+ </button>
382
+
383
+ {hidden}
384
+ </div>
385
+ {panel.mounted && (
386
+ <Portal>
387
+ <div
388
+ ref={setFloating}
389
+ id={`${uid}-panel`}
390
+ role="dialog"
391
+ aria-label={label ? `choose ${label}` : "choose a colour"}
392
+ className="rounded-[16px] p-1.5"
393
+ style={panelStyle}
394
+ >
395
+ <div className="rounded-[11px] bg-panel-face p-3" style={{ boxShadow: "var(--panel-face-shadow)" }}>
396
+ <ColorPanel value={color} onChange={set} swatches={swatches} autoFocus />
397
+ </div>
398
+ </div>
399
+ </Portal>
400
+ )}
401
+ {hintEl}
402
+ </div>
403
+ );
404
+ }