@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.
- package/README.md +48 -36
- package/dist/Accordion.d.ts +17 -0
- package/dist/Accordion.js +138 -0
- package/dist/Accordion.js.map +1 -0
- package/dist/Alert.d.ts +13 -0
- package/dist/Alert.js +69 -0
- package/dist/Alert.js.map +1 -0
- package/dist/AuthorCard.d.ts +12 -0
- package/dist/AuthorCard.js +57 -0
- package/dist/AuthorCard.js.map +1 -0
- package/dist/Avatar.d.ts +21 -0
- package/dist/Avatar.js +96 -0
- package/dist/Avatar.js.map +1 -0
- package/dist/Badge.d.ts +12 -0
- package/dist/Badge.js +46 -0
- package/dist/Badge.js.map +1 -0
- package/dist/Banner.d.ts +15 -0
- package/dist/Banner.js +68 -0
- package/dist/Banner.js.map +1 -0
- package/dist/Breadcrumb.d.ts +13 -0
- package/dist/Breadcrumb.js +45 -0
- package/dist/Breadcrumb.js.map +1 -0
- package/dist/Button.d.ts +19 -0
- package/dist/Button.js +282 -0
- package/dist/Button.js.map +1 -0
- package/dist/ButtonGroup.d.ts +19 -0
- package/dist/ButtonGroup.js +62 -0
- package/dist/ButtonGroup.js.map +1 -0
- package/dist/Card.d.ts +40 -0
- package/dist/Card.js +175 -0
- package/dist/Card.js.map +1 -0
- package/dist/CardShell.d.ts +18 -0
- package/dist/CardShell.js +58 -0
- package/dist/CardShell.js.map +1 -0
- package/dist/Carousel.d.ts +22 -0
- package/dist/Carousel.js +247 -0
- package/dist/Carousel.js.map +1 -0
- package/dist/Checkbox.d.ts +15 -0
- package/dist/Checkbox.js +84 -0
- package/dist/Checkbox.js.map +1 -0
- package/dist/CloseButton.d.ts +12 -0
- package/dist/CloseButton.js +63 -0
- package/dist/CloseButton.js.map +1 -0
- package/dist/CodeBlock.d.ts +12 -0
- package/dist/CodeBlock.js +221 -0
- package/dist/CodeBlock.js.map +1 -0
- package/dist/ColorPicker.d.ts +24 -0
- package/dist/ColorPicker.js +366 -0
- package/dist/ColorPicker.js.map +1 -0
- package/dist/Combobox.d.ts +34 -0
- package/dist/Combobox.js +255 -0
- package/dist/Combobox.js.map +1 -0
- package/dist/CommandPalette.d.ts +25 -0
- package/dist/CommandPalette.js +304 -0
- package/dist/CommandPalette.js.map +1 -0
- package/dist/ContextMenu.d.ts +16 -0
- package/dist/ContextMenu.js +209 -0
- package/dist/ContextMenu.js.map +1 -0
- package/dist/DataList.d.ts +15 -0
- package/dist/DataList.js +42 -0
- package/dist/DataList.js.map +1 -0
- package/dist/DatePicker.d.ts +65 -0
- package/dist/DatePicker.js +391 -0
- package/dist/DatePicker.js.map +1 -0
- package/dist/DateRangePicker.d.ts +34 -0
- package/dist/DateRangePicker.js +180 -0
- package/dist/DateRangePicker.js.map +1 -0
- package/dist/Drawer.d.ts +17 -0
- package/dist/Drawer.js +132 -0
- package/dist/Drawer.js.map +1 -0
- package/dist/DropdownMenu.d.ts +23 -0
- package/dist/DropdownMenu.js +185 -0
- package/dist/DropdownMenu.js.map +1 -0
- package/dist/EmptyState.d.ts +13 -0
- package/dist/EmptyState.js +55 -0
- package/dist/EmptyState.js.map +1 -0
- package/dist/FieldMessage.d.ts +26 -0
- package/dist/FieldMessage.js +95 -0
- package/dist/FieldMessage.js.map +1 -0
- package/dist/FileUpload.d.ts +32 -0
- package/dist/FileUpload.js +253 -0
- package/dist/FileUpload.js.map +1 -0
- package/dist/Floating.d.ts +32 -0
- package/dist/Floating.js +95 -0
- package/dist/Floating.js.map +1 -0
- package/dist/HoverCard.d.ts +18 -0
- package/dist/HoverCard.js +90 -0
- package/dist/HoverCard.js.map +1 -0
- package/dist/Input.d.ts +17 -0
- package/dist/Input.js +96 -0
- package/dist/Input.js.map +1 -0
- package/dist/Kbd.d.ts +9 -0
- package/dist/Kbd.js +24 -0
- package/dist/Kbd.js.map +1 -0
- package/dist/Modal.d.ts +15 -0
- package/dist/Modal.js +109 -0
- package/dist/Modal.js.map +1 -0
- package/dist/NumberInput.d.ts +23 -0
- package/dist/NumberInput.js +162 -0
- package/dist/NumberInput.js.map +1 -0
- package/dist/OtpInput.d.ts +26 -0
- package/dist/OtpInput.js +173 -0
- package/dist/OtpInput.js.map +1 -0
- package/dist/Pagination.d.ts +11 -0
- package/dist/Pagination.js +148 -0
- package/dist/Pagination.js.map +1 -0
- package/dist/Popover.d.ts +13 -0
- package/dist/Popover.js +98 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Progress.d.ts +12 -0
- package/dist/Progress.js +61 -0
- package/dist/Progress.js.map +1 -0
- package/dist/QuoteCard.d.ts +13 -0
- package/dist/QuoteCard.js +92 -0
- package/dist/QuoteCard.js.map +1 -0
- package/dist/Radio.d.ts +19 -0
- package/dist/Radio.js +161 -0
- package/dist/Radio.js.map +1 -0
- package/dist/RadioCards.d.ts +27 -0
- package/dist/RadioCards.js +205 -0
- package/dist/RadioCards.js.map +1 -0
- package/dist/ReactionBar.d.ts +15 -0
- package/dist/ReactionBar.js +92 -0
- package/dist/ReactionBar.js.map +1 -0
- package/dist/ReadingProgress.d.ts +8 -0
- package/dist/ReadingProgress.js +51 -0
- package/dist/ReadingProgress.js.map +1 -0
- package/dist/ReadingTime.d.ts +9 -0
- package/dist/ReadingTime.js +39 -0
- package/dist/ReadingTime.js.map +1 -0
- package/dist/Select.d.ts +20 -0
- package/dist/Select.js +244 -0
- package/dist/Select.js.map +1 -0
- package/dist/Separator.d.ts +14 -0
- package/dist/Separator.js +33 -0
- package/dist/Separator.js.map +1 -0
- package/dist/ShareButtons.d.ts +10 -0
- package/dist/ShareButtons.js +107 -0
- package/dist/ShareButtons.js.map +1 -0
- package/dist/Sidebar.d.ts +30 -0
- package/dist/Sidebar.js +140 -0
- package/dist/Sidebar.js.map +1 -0
- package/dist/Skeleton.d.ts +15 -0
- package/dist/Skeleton.js +67 -0
- package/dist/Skeleton.js.map +1 -0
- package/dist/Slider.d.ts +27 -0
- package/dist/Slider.js +182 -0
- package/dist/Slider.js.map +1 -0
- package/dist/Spinner.d.ts +12 -0
- package/dist/Spinner.js +24 -0
- package/dist/Spinner.js.map +1 -0
- package/dist/SpotlightCard.d.ts +17 -0
- package/dist/SpotlightCard.js +87 -0
- package/dist/SpotlightCard.js.map +1 -0
- package/dist/Stat.d.ts +19 -0
- package/dist/Stat.js +71 -0
- package/dist/Stat.js.map +1 -0
- package/dist/StatCard.d.ts +24 -0
- package/dist/StatCard.js +212 -0
- package/dist/StatCard.js.map +1 -0
- package/dist/Steps.d.ts +14 -0
- package/dist/Steps.js +94 -0
- package/dist/Steps.js.map +1 -0
- package/dist/Table.d.ts +21 -0
- package/dist/Table.js +93 -0
- package/dist/Table.js.map +1 -0
- package/dist/TableOfContents.d.ts +17 -0
- package/dist/TableOfContents.js +92 -0
- package/dist/TableOfContents.js.map +1 -0
- package/dist/Tabs.d.ts +17 -0
- package/dist/Tabs.js +123 -0
- package/dist/Tabs.js.map +1 -0
- package/dist/TagInput.d.ts +16 -0
- package/dist/TagInput.js +169 -0
- package/dist/TagInput.js.map +1 -0
- package/dist/Textarea.d.ts +13 -0
- package/dist/Textarea.js +74 -0
- package/dist/Textarea.js.map +1 -0
- package/dist/TimePicker.d.ts +28 -0
- package/dist/TimePicker.js +264 -0
- package/dist/TimePicker.js.map +1 -0
- package/dist/Timeline.d.ts +19 -0
- package/dist/Timeline.js +76 -0
- package/dist/Timeline.js.map +1 -0
- package/dist/Toast.d.ts +18 -0
- package/dist/Toast.js +318 -0
- package/dist/Toast.js.map +1 -0
- package/dist/Toggle.d.ts +13 -0
- package/dist/Toggle.js +84 -0
- package/dist/Toggle.js.map +1 -0
- package/dist/Tooltip.d.ts +13 -0
- package/dist/Tooltip.js +138 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/cli.js +172 -5
- package/dist/index.d.ts +68 -631
- package/dist/index.js +67 -5558
- package/dist/index.js.map +1 -1
- package/dist/registry/Accordion.tsx +6 -6
- package/dist/registry/Alert.tsx +13 -10
- package/dist/registry/AuthorCard.tsx +7 -7
- package/dist/registry/Avatar.tsx +5 -5
- package/dist/registry/Badge.tsx +12 -15
- package/dist/registry/Banner.tsx +15 -18
- package/dist/registry/Breadcrumb.tsx +3 -3
- package/dist/registry/Button.tsx +58 -69
- package/dist/registry/ButtonGroup.tsx +9 -9
- package/dist/registry/Card.tsx +15 -15
- package/dist/registry/CardShell.tsx +6 -6
- package/dist/registry/Carousel.tsx +19 -15
- package/dist/registry/Checkbox.tsx +14 -9
- package/dist/registry/CloseButton.tsx +2 -2
- package/dist/registry/CodeBlock.tsx +15 -15
- package/dist/registry/ColorPicker.tsx +404 -0
- package/dist/registry/Combobox.tsx +318 -0
- package/dist/registry/CommandPalette.tsx +31 -24
- package/dist/registry/ContextMenu.tsx +241 -0
- package/dist/registry/DataList.tsx +4 -4
- package/dist/registry/DatePicker.tsx +494 -0
- package/dist/registry/DateRangePicker.tsx +232 -0
- package/dist/registry/Drawer.tsx +17 -15
- package/dist/registry/DropdownMenu.tsx +39 -77
- package/dist/registry/EmptyState.tsx +8 -8
- package/dist/registry/FieldMessage.tsx +123 -0
- package/dist/registry/FileUpload.tsx +318 -0
- package/dist/registry/Floating.tsx +134 -0
- package/dist/registry/HoverCard.tsx +113 -0
- package/dist/registry/Input.tsx +54 -43
- package/dist/registry/Kbd.tsx +4 -5
- package/dist/registry/Modal.tsx +17 -15
- package/dist/registry/NumberInput.tsx +183 -0
- package/dist/registry/OtpInput.tsx +219 -0
- package/dist/registry/Pagination.tsx +15 -18
- package/dist/registry/Popover.tsx +40 -45
- package/dist/registry/Progress.tsx +4 -4
- package/dist/registry/QuoteCard.tsx +7 -7
- package/dist/registry/Radio.tsx +24 -21
- package/dist/registry/RadioCards.tsx +247 -0
- package/dist/registry/ReactionBar.tsx +5 -5
- package/dist/registry/ReadingProgress.tsx +2 -2
- package/dist/registry/ReadingTime.tsx +2 -2
- package/dist/registry/Select.tsx +28 -53
- package/dist/registry/Separator.tsx +52 -0
- package/dist/registry/ShareButtons.tsx +2 -2
- package/dist/registry/Sidebar.tsx +18 -18
- package/dist/registry/Skeleton.tsx +1 -1
- package/dist/registry/Slider.tsx +229 -0
- package/dist/registry/Spinner.tsx +32 -0
- package/dist/registry/SpotlightCard.tsx +6 -6
- package/dist/registry/Stat.tsx +4 -4
- package/dist/registry/StatCard.tsx +19 -19
- package/dist/registry/Steps.tsx +7 -7
- package/dist/registry/Table.tsx +12 -10
- package/dist/registry/TableOfContents.tsx +8 -8
- package/dist/registry/Tabs.tsx +6 -6
- package/dist/registry/TagInput.tsx +9 -9
- package/dist/registry/Textarea.tsx +5 -5
- package/dist/registry/TimePicker.tsx +315 -0
- package/dist/registry/Timeline.tsx +14 -14
- package/dist/registry/Toast.tsx +15 -12
- package/dist/registry/Toggle.tsx +7 -10
- package/dist/registry/Tooltip.tsx +21 -73
- package/dist/registry/styles.css +868 -62
- package/dist/registry/useFloatingPanel.ts +112 -0
- package/dist/styles.css +868 -62
- package/dist/useFloatingPanel.d.ts +24 -0
- package/dist/useFloatingPanel.js +89 -0
- package/dist/useFloatingPanel.js.map +1 -0
- package/dist/useModalBehavior.d.ts +15 -0
- package/dist/useModalBehavior.js +57 -0
- package/dist/useModalBehavior.js.map +1 -0
- 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
|
+
}
|