@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.
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/THIRD_PARTY_NOTICES.md +253 -0
- package/package.json +50 -3
- package/src/chat/index.ts +9 -0
- package/src/chat/message.tsx +80 -0
- package/src/chat/prompt-input.tsx +121 -0
- package/src/chat/reasoning.tsx +182 -0
- package/src/chat/scroller.tsx +147 -0
- package/src/chat/suggestion.tsx +65 -0
- package/src/chat/tool.tsx +143 -0
- package/src/chat/types.ts +8 -0
- package/src/components/avatar.tsx +35 -0
- package/src/components/badge.tsx +40 -0
- package/src/components/breadcrumb.tsx +100 -0
- package/src/components/button.tsx +56 -0
- package/src/components/card.tsx +75 -0
- package/src/components/checkbox.tsx +25 -0
- package/src/components/color-field.tsx +261 -0
- package/src/components/color-swatch.tsx +52 -0
- package/src/components/context-menu.tsx +249 -0
- package/src/components/dialog.tsx +133 -0
- package/src/components/dropdown-menu.tsx +249 -0
- package/src/components/empty-state.tsx +35 -0
- package/src/components/input.tsx +20 -0
- package/src/components/label.tsx +19 -0
- package/src/components/navigation-menu.tsx +150 -0
- package/src/components/number-field.tsx +432 -0
- package/src/components/popover.tsx +75 -0
- package/src/components/select.tsx +189 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +132 -0
- package/src/components/side-panel.tsx +189 -0
- package/src/components/sidebar.tsx +698 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +71 -0
- package/src/components/snackbar.tsx +143 -0
- package/src/components/status-bar.tsx +45 -0
- package/src/components/tabs.tsx +75 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/toggle-group.tsx +86 -0
- package/src/components/toggle.tsx +43 -0
- package/src/components/toolbar.tsx +304 -0
- package/src/components/tooltip.tsx +54 -0
- package/src/graphics/adsr.ts +78 -0
- package/src/graphics/curve-editor.tsx +567 -0
- package/src/graphics/curve-edits.ts +146 -0
- package/src/graphics/index.ts +16 -0
- package/src/graphics/scale.ts +25 -0
- package/src/graphics/use-canvas.ts +56 -0
- package/src/graphics/use-tokens.ts +35 -0
- package/src/icons.ts +3 -0
- package/src/index.css +199 -0
- package/src/index.ts +35 -0
- package/src/lib/use-mobile.ts +18 -0
- package/src/lib/utils.ts +6 -0
- 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 }
|