@preset.nz/ux-kit 0.0.0-stage → 0.1.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -2
  3. package/THIRD_PARTY_NOTICES.md +253 -0
  4. package/package.json +50 -3
  5. package/src/chat/index.ts +9 -0
  6. package/src/chat/message.tsx +80 -0
  7. package/src/chat/prompt-input.tsx +121 -0
  8. package/src/chat/reasoning.tsx +182 -0
  9. package/src/chat/scroller.tsx +147 -0
  10. package/src/chat/suggestion.tsx +65 -0
  11. package/src/chat/tool.tsx +143 -0
  12. package/src/chat/types.ts +8 -0
  13. package/src/components/avatar.tsx +35 -0
  14. package/src/components/badge.tsx +40 -0
  15. package/src/components/breadcrumb.tsx +100 -0
  16. package/src/components/button.tsx +56 -0
  17. package/src/components/card.tsx +75 -0
  18. package/src/components/checkbox.tsx +25 -0
  19. package/src/components/color-field.tsx +261 -0
  20. package/src/components/color-swatch.tsx +52 -0
  21. package/src/components/context-menu.tsx +249 -0
  22. package/src/components/dialog.tsx +133 -0
  23. package/src/components/dropdown-menu.tsx +249 -0
  24. package/src/components/empty-state.tsx +35 -0
  25. package/src/components/input.tsx +20 -0
  26. package/src/components/label.tsx +19 -0
  27. package/src/components/navigation-menu.tsx +150 -0
  28. package/src/components/number-field.tsx +432 -0
  29. package/src/components/popover.tsx +75 -0
  30. package/src/components/select.tsx +189 -0
  31. package/src/components/separator.tsx +21 -0
  32. package/src/components/sheet.tsx +132 -0
  33. package/src/components/side-panel.tsx +189 -0
  34. package/src/components/sidebar.tsx +698 -0
  35. package/src/components/skeleton.tsx +14 -0
  36. package/src/components/slider.tsx +71 -0
  37. package/src/components/snackbar.tsx +143 -0
  38. package/src/components/status-bar.tsx +45 -0
  39. package/src/components/tabs.tsx +75 -0
  40. package/src/components/textarea.tsx +18 -0
  41. package/src/components/toggle-group.tsx +86 -0
  42. package/src/components/toggle.tsx +43 -0
  43. package/src/components/toolbar.tsx +304 -0
  44. package/src/components/tooltip.tsx +54 -0
  45. package/src/graphics/adsr.ts +78 -0
  46. package/src/graphics/curve-editor.tsx +567 -0
  47. package/src/graphics/curve-edits.ts +146 -0
  48. package/src/graphics/index.ts +16 -0
  49. package/src/graphics/scale.ts +25 -0
  50. package/src/graphics/use-canvas.ts +56 -0
  51. package/src/graphics/use-tokens.ts +35 -0
  52. package/src/icons.ts +3 -0
  53. package/src/index.css +199 -0
  54. package/src/index.ts +35 -0
  55. package/src/lib/use-mobile.ts +18 -0
  56. package/src/lib/utils.ts +6 -0
  57. package/src/shadcn-tailwind.css +665 -0
@@ -0,0 +1,100 @@
1
+ import { mergeProps } from "@base-ui/react/merge-props"
2
+ import { useRender } from "@base-ui/react/use-render"
3
+ import { CaretRightIcon, DotsThreeIcon } from "@phosphor-icons/react"
4
+ import type * as React from "react"
5
+
6
+ import { cn } from "../lib/utils"
7
+
8
+ function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
9
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
10
+ }
11
+
12
+ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
13
+ return (
14
+ <ol
15
+ data-slot="breadcrumb-list"
16
+ className={cn(
17
+ "flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
18
+ className,
19
+ )}
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
26
+ return (
27
+ <li
28
+ data-slot="breadcrumb-item"
29
+ className={cn("inline-flex items-center gap-1.5", className)}
30
+ {...props}
31
+ />
32
+ )
33
+ }
34
+
35
+ // Plain anchor by default. Pass `render={<RouterLink to="..." />}` for a router link.
36
+ function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) {
37
+ return useRender({
38
+ defaultTagName: "a",
39
+ props: mergeProps<"a">(
40
+ { className: cn("transition-colors hover:text-foreground", className) },
41
+ props,
42
+ ),
43
+ render,
44
+ state: { slot: "breadcrumb-link" },
45
+ })
46
+ }
47
+
48
+ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
49
+ return (
50
+ // biome-ignore lint/a11y/useFocusableInteractive: the current page is a disabled link (WAI-ARIA breadcrumb pattern), so it is not focusable
51
+ // biome-ignore lint/a11y/useSemanticElements: a disabled link has no native element; role="link" with aria-disabled is the breadcrumb pattern
52
+ <span
53
+ data-slot="breadcrumb-page"
54
+ role="link"
55
+ aria-disabled="true"
56
+ aria-current="page"
57
+ className={cn("font-normal text-foreground", className)}
58
+ {...props}
59
+ />
60
+ )
61
+ }
62
+
63
+ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
64
+ return (
65
+ <li
66
+ data-slot="breadcrumb-separator"
67
+ role="presentation"
68
+ aria-hidden="true"
69
+ className={cn("[&>svg]:size-3.5", className)}
70
+ {...props}
71
+ >
72
+ {children ?? <CaretRightIcon />}
73
+ </li>
74
+ )
75
+ }
76
+
77
+ function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
78
+ return (
79
+ <span
80
+ data-slot="breadcrumb-ellipsis"
81
+ role="presentation"
82
+ aria-hidden="true"
83
+ className={cn("flex size-9 items-center justify-center", className)}
84
+ {...props}
85
+ >
86
+ <DotsThreeIcon className="size-4" />
87
+ <span className="sr-only">More</span>
88
+ </span>
89
+ )
90
+ }
91
+
92
+ export {
93
+ Breadcrumb,
94
+ BreadcrumbEllipsis,
95
+ BreadcrumbItem,
96
+ BreadcrumbLink,
97
+ BreadcrumbList,
98
+ BreadcrumbPage,
99
+ BreadcrumbSeparator,
100
+ }
@@ -0,0 +1,56 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "../lib/utils"
5
+
6
+ const buttonVariants = cva(
7
+ "group/button inline-flex shrink-0 items-center justify-center rounded-sm border border-transparent bg-clip-padding font-heading text-xs font-medium tracking-wide uppercase whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
12
+ outline:
13
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
14
+ secondary:
15
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
16
+ ghost:
17
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
18
+ destructive:
19
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default:
24
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
25
+ xs: "h-6 gap-1 rounded-sm px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-7 gap-1 rounded-sm px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
27
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
28
+ icon: "size-8",
29
+ "icon-xs": "size-6 rounded-sm [&_svg:not([class*='size-'])]:size-3",
30
+ "icon-sm": "size-7 rounded-sm",
31
+ "icon-lg": "size-9",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: "default",
36
+ size: "default",
37
+ },
38
+ },
39
+ )
40
+
41
+ function Button({
42
+ className,
43
+ variant = "default",
44
+ size = "default",
45
+ ...props
46
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
47
+ return (
48
+ <ButtonPrimitive
49
+ data-slot="button"
50
+ className={cn(buttonVariants({ variant, size, className }))}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ export { Button, buttonVariants }
@@ -0,0 +1,75 @@
1
+ import type * as React from "react"
2
+
3
+ import { cn } from "../lib/utils"
4
+
5
+ function Card({ className, ...props }: React.ComponentProps<"div">) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-4 shadow-sm",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19
+ return (
20
+ <div
21
+ data-slot="card-header"
22
+ className={cn(
23
+ "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4",
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32
+ return (
33
+ <div
34
+ data-slot="card-title"
35
+ className={cn("leading-none font-semibold", className)}
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42
+ return (
43
+ <div
44
+ data-slot="card-description"
45
+ className={cn("text-muted-foreground text-sm", className)}
46
+ {...props}
47
+ />
48
+ )
49
+ }
50
+
51
+ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52
+ return (
53
+ <div
54
+ data-slot="card-action"
55
+ className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
56
+ {...props}
57
+ />
58
+ )
59
+ }
60
+
61
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
62
+ return <div data-slot="card-content" className={cn("px-4", className)} {...props} />
63
+ }
64
+
65
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
66
+ return (
67
+ <div
68
+ data-slot="card-footer"
69
+ className={cn("flex items-center px-4 [.border-t]:pt-4", className)}
70
+ {...props}
71
+ />
72
+ )
73
+ }
74
+
75
+ export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
@@ -0,0 +1,25 @@
1
+ import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
2
+ import { CheckIcon } from "@phosphor-icons/react"
3
+ import { cn } from "../lib/utils"
4
+
5
+ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
6
+ return (
7
+ <CheckboxPrimitive.Root
8
+ data-slot="checkbox"
9
+ className={cn(
10
+ "peer relative flex size-4 shrink-0 items-center justify-center rounded-none border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
11
+ className,
12
+ )}
13
+ {...props}
14
+ >
15
+ <CheckboxPrimitive.Indicator
16
+ data-slot="checkbox-indicator"
17
+ className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
18
+ >
19
+ <CheckIcon />
20
+ </CheckboxPrimitive.Indicator>
21
+ </CheckboxPrimitive.Root>
22
+ )
23
+ }
24
+
25
+ export { Checkbox }
@@ -0,0 +1,261 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../lib/utils"
4
+ import { ColorSwatch } from "./color-swatch"
5
+ import { Input } from "./input"
6
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover"
7
+
8
+ // ColorField: swatch, hex text, optional alpha, preset grid, native picker.
9
+ // Union of M&T's ColorField (presets, hex input), Oblique's colour renderers
10
+ // (native picker, read-only hex, one picker session = one undo step via
11
+ // onPickStart/onPickEnd, optional colour), and Strata's swatch popover
12
+ // (clearable, no-colour chip). Values are hex strings: #rrggbb, or #rrggbbaa
13
+ // when `alpha` is set. The palette engine (presets and derivation) is app
14
+ // domain data and stays in the app; it feeds `presets` and nothing more.
15
+
16
+ interface Rgba {
17
+ r: number
18
+ g: number
19
+ b: number
20
+ a: number
21
+ }
22
+
23
+ /** Parses #rgb, #rgba, #rrggbb or #rrggbbaa (leading # optional). Alpha is 0 to 1. */
24
+ function parseHex(input: string): Rgba | null {
25
+ let h = input.trim().replace(/^#/, "")
26
+ if (!/^[0-9a-f]+$/i.test(h)) return null
27
+ if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join("")
28
+ if (h.length !== 6 && h.length !== 8) return null
29
+ const n = (i: number) => parseInt(h.slice(i, i + 2), 16)
30
+ return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) / 255 : 1 }
31
+ }
32
+
33
+ function toHex({ r, g, b, a }: Rgba, withAlpha: boolean): string {
34
+ const p = (v: number) => Math.round(v).toString(16).padStart(2, "0")
35
+ return `#${p(r)}${p(g)}${p(b)}${withAlpha ? p(a * 255) : ""}`
36
+ }
37
+
38
+ interface ColorFieldProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
39
+ /** #rrggbb or #rrggbbaa. `null` shows the empty chip and a dash. */
40
+ value: string | null
41
+ /** Called with a normalised lowercase hex, or null from the clear button. */
42
+ onChange?: (value: string | null) => void
43
+ label?: string
44
+ /** Shows an alpha percentage field; output is #rrggbbaa. Default false. */
45
+ alpha?: boolean
46
+ /** Preset colours as hex. Omit for none. */
47
+ presets?: string[]
48
+ /** Adds a "no colour" chip to the popover that calls `onChange(null)`. */
49
+ clearable?: boolean
50
+ /** Shows the colour and hex but cannot change them. Also the state when `onChange` is missing. */
51
+ readOnly?: boolean
52
+ disabled?: boolean
53
+ /** The colour a null value falls back to for the native picker. Default #000000. */
54
+ fallback?: string
55
+ /** The native picker opened or closed: the app's undo transaction boundary. */
56
+ onPickStart?: () => void
57
+ onPickEnd?: () => void
58
+ /** Extra controls after the fields, e.g. an eyedropper button. */
59
+ trailing?: React.ReactNode
60
+ }
61
+
62
+ function ColorField({
63
+ value: valueProp,
64
+ onChange,
65
+ label,
66
+ alpha = false,
67
+ presets,
68
+ clearable = false,
69
+ readOnly,
70
+ disabled,
71
+ fallback = "#000000",
72
+ onPickStart,
73
+ onPickEnd,
74
+ trailing,
75
+ id,
76
+ className,
77
+ ...props
78
+ }: ColorFieldProps) {
79
+ const generatedId = React.useId()
80
+ const inputId = id ?? generatedId
81
+ const locked = !!readOnly || !onChange
82
+ // During a picker session the field shows only the colour picked so far and ignores incoming
83
+ // `value` props (an app that round-trips each change would fight the picker); on the picker
84
+ // closing it re-syncs to the prop.
85
+ const [live, setLive] = React.useState<string | null>(null)
86
+ const value = live ?? valueProp
87
+ const rgba = value ? parseHex(value) : null
88
+ const shown = value ? (rgba ? toHex(rgba, alpha) : value) : ""
89
+
90
+ const [draft, setDraft] = React.useState<string | null>(null)
91
+ const [alphaDraft, setAlphaDraft] = React.useState<string | null>(null)
92
+
93
+ const emit = (next: Rgba) => onChange?.(toHex(next, alpha))
94
+
95
+ const commitHex = () => {
96
+ if (draft === null) return
97
+ const parsed = parseHex(draft)
98
+ setDraft(null)
99
+ if (!parsed) return // invalid text reverts to the current value
100
+ // An entry without alpha digits keeps the existing alpha.
101
+ const digits = draft.trim().replace(/^#/, "").length
102
+ const hasAlpha = digits === 4 || digits === 8
103
+ emit(alpha && rgba && !hasAlpha ? { ...parsed, a: rgba.a } : parsed)
104
+ }
105
+
106
+ const commitAlpha = () => {
107
+ if (alphaDraft === null) return
108
+ const pct = Number(alphaDraft)
109
+ setAlphaDraft(null)
110
+ if (!Number.isFinite(pct)) return
111
+ const base = rgba ?? parseHex(fallback)
112
+ if (!base) return
113
+ emit({ ...base, a: Math.min(100, Math.max(0, pct)) / 100 })
114
+ }
115
+
116
+ const pick = (hex: string, session = false) => {
117
+ const parsed = parseHex(hex)
118
+ if (!parsed) return
119
+ if (session) setLive(toHex(parsed, false))
120
+ // Presets and the native picker carry no alpha of their own unless 8-digit.
121
+ emit(hex.replace(/^#/, "").length === 8 || !rgba ? parsed : { ...parsed, a: rgba.a })
122
+ }
123
+
124
+ const swatchLabel = label ? `${label} colour` : "Colour"
125
+ const chip = <ColorSwatch color={value} size="lg" className="size-7" />
126
+
127
+ return (
128
+ <div data-slot="color-field" className={cn("flex flex-col gap-1", className)} {...props}>
129
+ {label ? (
130
+ <label
131
+ htmlFor={inputId}
132
+ className="text-[11px] font-medium tracking-wide text-muted-foreground"
133
+ >
134
+ {label}
135
+ </label>
136
+ ) : null}
137
+ <div className={cn("flex items-center gap-2", disabled && "opacity-50")}>
138
+ {locked || disabled ? (
139
+ chip
140
+ ) : !presets?.length && !clearable ? (
141
+ // Nothing to offer but the picker: the swatch opens it directly.
142
+ <label
143
+ className="relative shrink-0 cursor-pointer rounded-sm focus-within:ring-1 focus-within:ring-ring"
144
+ title={swatchLabel}
145
+ >
146
+ {chip}
147
+ <input
148
+ type="color"
149
+ aria-label={`${swatchLabel} picker`}
150
+ value={
151
+ rgba
152
+ ? toHex(rgba, false)
153
+ : toHex(parseHex(fallback) ?? { r: 0, g: 0, b: 0, a: 1 }, false)
154
+ }
155
+ onChange={(e) => pick(e.target.value, true)}
156
+ onFocus={onPickStart}
157
+ onBlur={() => {
158
+ setLive(null)
159
+ onPickEnd?.()
160
+ }}
161
+ className="absolute inset-0 size-full cursor-pointer opacity-0"
162
+ />
163
+ </label>
164
+ ) : (
165
+ <Popover>
166
+ <PopoverTrigger
167
+ aria-label={swatchLabel}
168
+ className="shrink-0 rounded-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
169
+ >
170
+ {chip}
171
+ </PopoverTrigger>
172
+ <PopoverContent align="start" className="w-auto gap-2 p-2">
173
+ {presets?.length || clearable ? (
174
+ // biome-ignore lint/a11y/useSemanticElements: a div keeps the swatch grid layout; role="group" labels it
175
+ <div className="grid grid-cols-7 gap-1" role="group" aria-label="Presets">
176
+ {presets?.map((c) => (
177
+ <button
178
+ key={c}
179
+ type="button"
180
+ aria-label={c}
181
+ aria-pressed={value?.toLowerCase() === c.toLowerCase()}
182
+ onClick={() => pick(c)}
183
+ className="rounded-xs outline-none hover:scale-110 focus-visible:ring-1 focus-visible:ring-ring aria-pressed:ring-2 aria-pressed:ring-foreground/40"
184
+ >
185
+ <ColorSwatch color={c} size="lg" />
186
+ </button>
187
+ ))}
188
+ {clearable ? (
189
+ <button
190
+ type="button"
191
+ aria-label="No colour"
192
+ onClick={() => onChange?.(null)}
193
+ className="rounded-xs outline-none hover:scale-110 focus-visible:ring-1 focus-visible:ring-ring"
194
+ >
195
+ <ColorSwatch color={null} size="lg" />
196
+ </button>
197
+ ) : null}
198
+ </div>
199
+ ) : null}
200
+ <input
201
+ type="color"
202
+ aria-label={`${swatchLabel} picker`}
203
+ value={
204
+ rgba
205
+ ? toHex(rgba, false)
206
+ : toHex(parseHex(fallback) ?? { r: 0, g: 0, b: 0, a: 1 }, false)
207
+ }
208
+ onChange={(e) => pick(e.target.value, true)}
209
+ onFocus={onPickStart}
210
+ onBlur={() => {
211
+ setLive(null)
212
+ onPickEnd?.()
213
+ }}
214
+ className="h-8 w-full cursor-pointer rounded-xs border border-border bg-transparent"
215
+ />
216
+ </PopoverContent>
217
+ </Popover>
218
+ )}
219
+ <Input
220
+ id={inputId}
221
+ value={draft ?? (value ? shown : "")}
222
+ placeholder="—"
223
+ readOnly={locked}
224
+ disabled={disabled}
225
+ spellCheck={false}
226
+ aria-label={label ? `${label} hex` : "Hex"}
227
+ onChange={(e) => setDraft(e.target.value)}
228
+ onBlur={commitHex}
229
+ onKeyDown={(e) => {
230
+ if (e.key === "Enter") commitHex()
231
+ if (e.key === "Escape") setDraft(null)
232
+ }}
233
+ className="flex-1 font-mono"
234
+ />
235
+ {alpha ? (
236
+ <div className="flex w-16 shrink-0 items-center gap-1 text-xs text-muted-foreground">
237
+ <Input
238
+ inputMode="numeric"
239
+ value={alphaDraft ?? (rgba ? String(Math.round(rgba.a * 100)) : "")}
240
+ readOnly={locked}
241
+ disabled={disabled}
242
+ aria-label={label ? `${label} alpha` : "Alpha"}
243
+ onChange={(e) => setAlphaDraft(e.target.value)}
244
+ onBlur={commitAlpha}
245
+ onKeyDown={(e) => {
246
+ if (e.key === "Enter") commitAlpha()
247
+ if (e.key === "Escape") setAlphaDraft(null)
248
+ }}
249
+ className="text-right tabular-nums"
250
+ />
251
+ %
252
+ </div>
253
+ ) : null}
254
+ {trailing}
255
+ </div>
256
+ </div>
257
+ )
258
+ }
259
+
260
+ export type { ColorFieldProps }
261
+ export { ColorField, parseHex, toHex }
@@ -0,0 +1,52 @@
1
+ import type * as React from "react"
2
+
3
+ import { cn } from "../lib/utils"
4
+
5
+ // A colour chip. Any CSS colour string works; `null` draws the dashed "no
6
+ // colour" chip (Strata's colour label, Oblique's disabled optional colour).
7
+ // A checkerboard sits under the colour so transparency reads as transparency.
8
+
9
+ const CHECKER =
10
+ "conic-gradient(var(--muted) 25%, transparent 0 50%, var(--muted) 0 75%, transparent 0) 0 0 / 8px 8px"
11
+
12
+ interface ColorSwatchProps extends Omit<React.ComponentProps<"span">, "color"> {
13
+ /** A CSS colour (#rrggbbaa, rgb(), a variable), or null for none. */
14
+ color?: string | null
15
+ /** `sm` 12px, `md` 16px, `lg` 24px. */
16
+ size?: "sm" | "md" | "lg"
17
+ shape?: "square" | "round"
18
+ }
19
+
20
+ function ColorSwatch({
21
+ color,
22
+ size = "md",
23
+ shape = "square",
24
+ className,
25
+ style,
26
+ ...props
27
+ }: ColorSwatchProps) {
28
+ return (
29
+ <span
30
+ data-slot="color-swatch"
31
+ data-empty={color ? undefined : ""}
32
+ role={props["aria-label"] ? "img" : undefined}
33
+ aria-hidden={props["aria-label"] ? undefined : true}
34
+ className={cn(
35
+ "relative inline-block shrink-0 overflow-hidden border border-border",
36
+ size === "sm" && "size-3",
37
+ size === "md" && "size-4",
38
+ size === "lg" && "size-6",
39
+ shape === "round" ? "rounded-full" : "rounded-xs",
40
+ !color && "border-dashed border-muted-foreground/60",
41
+ className,
42
+ )}
43
+ style={color ? { background: CHECKER, ...style } : style}
44
+ {...props}
45
+ >
46
+ {color ? <span className="absolute inset-0" style={{ backgroundColor: color }} /> : null}
47
+ </span>
48
+ )
49
+ }
50
+
51
+ export type { ColorSwatchProps }
52
+ export { ColorSwatch }