@preset.nz/ux-kit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -2
  3. package/package.json +51 -3
  4. package/src/components/avatar.tsx +35 -0
  5. package/src/components/badge.tsx +42 -0
  6. package/src/components/breadcrumb.tsx +102 -0
  7. package/src/components/button.tsx +56 -0
  8. package/src/components/card.tsx +92 -0
  9. package/src/components/checkbox.tsx +27 -0
  10. package/src/components/color-field.tsx +248 -0
  11. package/src/components/color-swatch.tsx +52 -0
  12. package/src/components/context-menu.tsx +249 -0
  13. package/src/components/dialog.tsx +137 -0
  14. package/src/components/dropdown-menu.tsx +249 -0
  15. package/src/components/empty-state.tsx +32 -0
  16. package/src/components/input.tsx +20 -0
  17. package/src/components/label.tsx +18 -0
  18. package/src/components/navigation-menu.tsx +144 -0
  19. package/src/components/number-field.tsx +428 -0
  20. package/src/components/popover.tsx +82 -0
  21. package/src/components/select.tsx +201 -0
  22. package/src/components/separator.tsx +25 -0
  23. package/src/components/sheet.tsx +124 -0
  24. package/src/components/side-panel.tsx +166 -0
  25. package/src/components/sidebar.tsx +735 -0
  26. package/src/components/skeleton.tsx +14 -0
  27. package/src/components/slider.tsx +75 -0
  28. package/src/components/snackbar.tsx +141 -0
  29. package/src/components/status-bar.tsx +38 -0
  30. package/src/components/tabs.tsx +78 -0
  31. package/src/components/textarea.tsx +18 -0
  32. package/src/components/toggle-group.tsx +86 -0
  33. package/src/components/toggle.tsx +43 -0
  34. package/src/components/toolbar.tsx +307 -0
  35. package/src/components/tooltip.tsx +54 -0
  36. package/src/graphics/adsr.ts +70 -0
  37. package/src/graphics/curve-editor.tsx +525 -0
  38. package/src/graphics/curve-edits.ts +129 -0
  39. package/src/graphics/index.ts +16 -0
  40. package/src/graphics/scale.ts +22 -0
  41. package/src/graphics/use-canvas.ts +56 -0
  42. package/src/graphics/use-tokens.ts +32 -0
  43. package/src/icons.ts +3 -0
  44. package/src/index.css +199 -0
  45. package/src/index.ts +37 -0
  46. package/src/lib/use-mobile.ts +18 -0
  47. package/src/lib/utils.ts +6 -0
@@ -0,0 +1,248 @@
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
39
+ extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
40
+ /** #rrggbb or #rrggbbaa. `null` shows the empty chip and a dash. */
41
+ value: string | null
42
+ /** Called with a normalised lowercase hex, or null from the clear button. */
43
+ onChange?: (value: string | null) => void
44
+ label?: string
45
+ /** Shows an alpha percentage field; output is #rrggbbaa. Default false. */
46
+ alpha?: boolean
47
+ /** Preset colours as hex. Omit for none. */
48
+ presets?: string[]
49
+ /** Adds a "no colour" chip to the popover that calls `onChange(null)`. */
50
+ clearable?: boolean
51
+ /** Shows the colour and hex but cannot change them. Also the state when `onChange` is missing. */
52
+ readOnly?: boolean
53
+ disabled?: boolean
54
+ /** The colour a null value falls back to for the native picker. Default #000000. */
55
+ fallback?: string
56
+ /** The native picker opened or closed: the app's undo transaction boundary. */
57
+ onPickStart?: () => void
58
+ onPickEnd?: () => void
59
+ /** Extra controls after the fields, e.g. an eyedropper button. */
60
+ trailing?: React.ReactNode
61
+ }
62
+
63
+ function ColorField({
64
+ value: valueProp,
65
+ onChange,
66
+ label,
67
+ alpha = false,
68
+ presets,
69
+ clearable = false,
70
+ readOnly,
71
+ disabled,
72
+ fallback = "#000000",
73
+ onPickStart,
74
+ onPickEnd,
75
+ trailing,
76
+ id,
77
+ className,
78
+ ...props
79
+ }: ColorFieldProps) {
80
+ const generatedId = React.useId()
81
+ const inputId = id ?? generatedId
82
+ const locked = !!readOnly || !onChange
83
+ // During a picker session the field shows only the colour picked so far and ignores incoming
84
+ // `value` props (an app that round-trips each change would fight the picker); on the picker
85
+ // closing it re-syncs to the prop.
86
+ const [live, setLive] = React.useState<string | null>(null)
87
+ const value = live ?? valueProp
88
+ const rgba = value ? parseHex(value) : null
89
+ const shown = value ? (rgba ? toHex(rgba, alpha) : value) : ""
90
+
91
+ const [draft, setDraft] = React.useState<string | null>(null)
92
+ const [alphaDraft, setAlphaDraft] = React.useState<string | null>(null)
93
+
94
+ const emit = (next: Rgba) => onChange?.(toHex(next, alpha))
95
+
96
+ const commitHex = () => {
97
+ if (draft === null) return
98
+ const parsed = parseHex(draft)
99
+ setDraft(null)
100
+ if (!parsed) return // invalid text reverts to the current value
101
+ // An entry without alpha digits keeps the existing alpha.
102
+ const digits = draft.trim().replace(/^#/, "").length
103
+ const hasAlpha = digits === 4 || digits === 8
104
+ emit(alpha && rgba && !hasAlpha ? { ...parsed, a: rgba.a } : parsed)
105
+ }
106
+
107
+ const commitAlpha = () => {
108
+ if (alphaDraft === null) return
109
+ const pct = Number(alphaDraft)
110
+ setAlphaDraft(null)
111
+ if (!Number.isFinite(pct)) return
112
+ emit({ ...(rgba ?? parseHex(fallback)!), a: Math.min(100, Math.max(0, pct)) / 100 })
113
+ }
114
+
115
+ const pick = (hex: string, session = false) => {
116
+ const parsed = parseHex(hex)
117
+ if (!parsed) return
118
+ if (session) setLive(toHex(parsed, false))
119
+ // Presets and the native picker carry no alpha of their own unless 8-digit.
120
+ emit(hex.replace(/^#/, "").length === 8 || !rgba ? parsed : { ...parsed, a: rgba.a })
121
+ }
122
+
123
+ const swatchLabel = label ? `${label} colour` : "Colour"
124
+ const chip = <ColorSwatch color={value} size="lg" className="size-7" />
125
+
126
+ return (
127
+ <div data-slot="color-field" className={cn("flex flex-col gap-1", className)} {...props}>
128
+ {label ? (
129
+ <label htmlFor={inputId} className="text-[11px] font-medium tracking-wide text-muted-foreground">
130
+ {label}
131
+ </label>
132
+ ) : null}
133
+ <div className={cn("flex items-center gap-2", disabled && "opacity-50")}>
134
+ {locked || disabled ? (
135
+ chip
136
+ ) : !presets?.length && !clearable ? (
137
+ // Nothing to offer but the picker: the swatch opens it directly.
138
+ <label
139
+ className="relative shrink-0 cursor-pointer rounded-sm focus-within:ring-1 focus-within:ring-ring"
140
+ title={swatchLabel}
141
+ >
142
+ {chip}
143
+ <input
144
+ type="color"
145
+ aria-label={`${swatchLabel} picker`}
146
+ value={rgba ? toHex(rgba, false) : toHex(parseHex(fallback) ?? { r: 0, g: 0, b: 0, a: 1 }, false)}
147
+ onChange={(e) => pick(e.target.value, true)}
148
+ onFocus={onPickStart}
149
+ onBlur={() => {
150
+ setLive(null)
151
+ onPickEnd?.()
152
+ }}
153
+ className="absolute inset-0 size-full cursor-pointer opacity-0"
154
+ />
155
+ </label>
156
+ ) : (
157
+ <Popover>
158
+ <PopoverTrigger
159
+ aria-label={swatchLabel}
160
+ className="shrink-0 rounded-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
161
+ >
162
+ {chip}
163
+ </PopoverTrigger>
164
+ <PopoverContent align="start" className="w-auto gap-2 p-2">
165
+ {presets?.length || clearable ? (
166
+ <div className="grid grid-cols-7 gap-1" role="group" aria-label="Presets">
167
+ {presets?.map((c) => (
168
+ <button
169
+ key={c}
170
+ type="button"
171
+ aria-label={c}
172
+ aria-pressed={value?.toLowerCase() === c.toLowerCase()}
173
+ onClick={() => pick(c)}
174
+ 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"
175
+ >
176
+ <ColorSwatch color={c} size="lg" />
177
+ </button>
178
+ ))}
179
+ {clearable ? (
180
+ <button
181
+ type="button"
182
+ aria-label="No colour"
183
+ onClick={() => onChange?.(null)}
184
+ className="rounded-xs outline-none hover:scale-110 focus-visible:ring-1 focus-visible:ring-ring"
185
+ >
186
+ <ColorSwatch color={null} size="lg" />
187
+ </button>
188
+ ) : null}
189
+ </div>
190
+ ) : null}
191
+ <input
192
+ type="color"
193
+ aria-label={`${swatchLabel} picker`}
194
+ value={rgba ? toHex(rgba, false) : toHex(parseHex(fallback) ?? { r: 0, g: 0, b: 0, a: 1 }, false)}
195
+ onChange={(e) => pick(e.target.value, true)}
196
+ onFocus={onPickStart}
197
+ onBlur={() => {
198
+ setLive(null)
199
+ onPickEnd?.()
200
+ }}
201
+ className="h-8 w-full cursor-pointer rounded-xs border border-border bg-transparent"
202
+ />
203
+ </PopoverContent>
204
+ </Popover>
205
+ )}
206
+ <Input
207
+ id={inputId}
208
+ value={draft ?? (value ? shown : "")}
209
+ placeholder="—"
210
+ readOnly={locked}
211
+ disabled={disabled}
212
+ spellCheck={false}
213
+ aria-label={label ? `${label} hex` : "Hex"}
214
+ onChange={(e) => setDraft(e.target.value)}
215
+ onBlur={commitHex}
216
+ onKeyDown={(e) => {
217
+ if (e.key === "Enter") commitHex()
218
+ if (e.key === "Escape") setDraft(null)
219
+ }}
220
+ className="flex-1 font-mono"
221
+ />
222
+ {alpha ? (
223
+ <div className="flex w-16 shrink-0 items-center gap-1 text-xs text-muted-foreground">
224
+ <Input
225
+ inputMode="numeric"
226
+ value={alphaDraft ?? (rgba ? String(Math.round(rgba.a * 100)) : "")}
227
+ readOnly={locked}
228
+ disabled={disabled}
229
+ aria-label={label ? `${label} alpha` : "Alpha"}
230
+ onChange={(e) => setAlphaDraft(e.target.value)}
231
+ onBlur={commitAlpha}
232
+ onKeyDown={(e) => {
233
+ if (e.key === "Enter") commitAlpha()
234
+ if (e.key === "Escape") setAlphaDraft(null)
235
+ }}
236
+ className="text-right tabular-nums"
237
+ />
238
+ %
239
+ </div>
240
+ ) : null}
241
+ {trailing}
242
+ </div>
243
+ </div>
244
+ )
245
+ }
246
+
247
+ export { ColorField, parseHex, toHex }
248
+ export type { ColorFieldProps }
@@ -0,0 +1,52 @@
1
+ import * 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 { ColorSwatch }
52
+ export type { ColorSwatchProps }
@@ -0,0 +1,249 @@
1
+ import * as React from "react"
2
+ import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
3
+ import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react"
4
+
5
+ import { cn } from "../lib/utils"
6
+
7
+ function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
8
+ return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
9
+ }
10
+
11
+ function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
12
+ return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
13
+ }
14
+
15
+ function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
16
+ return (
17
+ <ContextMenuPrimitive.Trigger
18
+ data-slot="context-menu-trigger"
19
+ className={cn("select-none", className)}
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ function ContextMenuContent({
26
+ align = "start",
27
+ alignOffset = 0,
28
+ side = "bottom",
29
+ sideOffset = 4,
30
+ className,
31
+ ...props
32
+ }: ContextMenuPrimitive.Popup.Props &
33
+ Pick<ContextMenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
34
+ return (
35
+ <ContextMenuPrimitive.Portal>
36
+ <ContextMenuPrimitive.Positioner
37
+ className="isolate z-50 outline-none"
38
+ align={align}
39
+ alignOffset={alignOffset}
40
+ side={side}
41
+ sideOffset={sideOffset}
42
+ >
43
+ <ContextMenuPrimitive.Popup
44
+ data-slot="context-menu-content"
45
+ className={cn(
46
+ "z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
47
+ className
48
+ )}
49
+ {...props}
50
+ />
51
+ </ContextMenuPrimitive.Positioner>
52
+ </ContextMenuPrimitive.Portal>
53
+ )
54
+ }
55
+
56
+ function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
57
+ return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
58
+ }
59
+
60
+ function ContextMenuLabel({
61
+ className,
62
+ inset,
63
+ ...props
64
+ }: ContextMenuPrimitive.GroupLabel.Props & { inset?: boolean }) {
65
+ return (
66
+ <ContextMenuPrimitive.GroupLabel
67
+ data-slot="context-menu-label"
68
+ data-inset={inset}
69
+ className={cn(
70
+ "px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
71
+ className
72
+ )}
73
+ {...props}
74
+ />
75
+ )
76
+ }
77
+
78
+ function ContextMenuItem({
79
+ className,
80
+ inset,
81
+ variant = "default",
82
+ ...props
83
+ }: ContextMenuPrimitive.Item.Props & {
84
+ inset?: boolean
85
+ variant?: "default" | "destructive"
86
+ }) {
87
+ return (
88
+ <ContextMenuPrimitive.Item
89
+ data-slot="context-menu-item"
90
+ data-inset={inset}
91
+ data-variant={variant}
92
+ className={cn(
93
+ "group/context-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
94
+ className
95
+ )}
96
+ {...props}
97
+ />
98
+ )
99
+ }
100
+
101
+ function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
102
+ return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
103
+ }
104
+
105
+ function ContextMenuSubTrigger({
106
+ className,
107
+ inset,
108
+ children,
109
+ ...props
110
+ }: ContextMenuPrimitive.SubmenuTrigger.Props & { inset?: boolean }) {
111
+ return (
112
+ <ContextMenuPrimitive.SubmenuTrigger
113
+ data-slot="context-menu-sub-trigger"
114
+ data-inset={inset}
115
+ className={cn(
116
+ "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:pl-8 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
117
+ className
118
+ )}
119
+ {...props}
120
+ >
121
+ {children}
122
+ <CaretRightIcon className="ml-auto" />
123
+ </ContextMenuPrimitive.SubmenuTrigger>
124
+ )
125
+ }
126
+
127
+ function ContextMenuSubContent({
128
+ align = "start",
129
+ alignOffset = -3,
130
+ side = "right",
131
+ sideOffset = 0,
132
+ className,
133
+ ...props
134
+ }: React.ComponentProps<typeof ContextMenuContent>) {
135
+ return (
136
+ <ContextMenuContent
137
+ data-slot="context-menu-sub-content"
138
+ className={cn("min-w-32 shadow-lg", className)}
139
+ align={align}
140
+ alignOffset={alignOffset}
141
+ side={side}
142
+ sideOffset={sideOffset}
143
+ {...props}
144
+ />
145
+ )
146
+ }
147
+
148
+ function ContextMenuCheckboxItem({
149
+ className,
150
+ children,
151
+ inset,
152
+ ...props
153
+ }: ContextMenuPrimitive.CheckboxItem.Props & { inset?: boolean }) {
154
+ return (
155
+ <ContextMenuPrimitive.CheckboxItem
156
+ data-slot="context-menu-checkbox-item"
157
+ data-inset={inset}
158
+ className={cn(
159
+ "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
160
+ className
161
+ )}
162
+ {...props}
163
+ >
164
+ <span
165
+ className="pointer-events-none absolute right-2 flex items-center justify-center"
166
+ data-slot="context-menu-checkbox-item-indicator"
167
+ >
168
+ <ContextMenuPrimitive.CheckboxItemIndicator>
169
+ <CheckIcon />
170
+ </ContextMenuPrimitive.CheckboxItemIndicator>
171
+ </span>
172
+ {children}
173
+ </ContextMenuPrimitive.CheckboxItem>
174
+ )
175
+ }
176
+
177
+ function ContextMenuRadioGroup({ ...props }: ContextMenuPrimitive.RadioGroup.Props) {
178
+ return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
179
+ }
180
+
181
+ function ContextMenuRadioItem({
182
+ className,
183
+ children,
184
+ inset,
185
+ ...props
186
+ }: ContextMenuPrimitive.RadioItem.Props & { inset?: boolean }) {
187
+ return (
188
+ <ContextMenuPrimitive.RadioItem
189
+ data-slot="context-menu-radio-item"
190
+ data-inset={inset}
191
+ className={cn(
192
+ "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
193
+ className
194
+ )}
195
+ {...props}
196
+ >
197
+ <span
198
+ className="pointer-events-none absolute right-2 flex items-center justify-center"
199
+ data-slot="context-menu-radio-item-indicator"
200
+ >
201
+ <ContextMenuPrimitive.RadioItemIndicator>
202
+ <CheckIcon />
203
+ </ContextMenuPrimitive.RadioItemIndicator>
204
+ </span>
205
+ {children}
206
+ </ContextMenuPrimitive.RadioItem>
207
+ )
208
+ }
209
+
210
+ function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
211
+ return (
212
+ <ContextMenuPrimitive.Separator
213
+ data-slot="context-menu-separator"
214
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
215
+ {...props}
216
+ />
217
+ )
218
+ }
219
+
220
+ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
221
+ return (
222
+ <span
223
+ data-slot="context-menu-shortcut"
224
+ className={cn(
225
+ "ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
226
+ className
227
+ )}
228
+ {...props}
229
+ />
230
+ )
231
+ }
232
+
233
+ export {
234
+ ContextMenu,
235
+ ContextMenuPortal,
236
+ ContextMenuTrigger,
237
+ ContextMenuContent,
238
+ ContextMenuGroup,
239
+ ContextMenuLabel,
240
+ ContextMenuItem,
241
+ ContextMenuCheckboxItem,
242
+ ContextMenuRadioGroup,
243
+ ContextMenuRadioItem,
244
+ ContextMenuSeparator,
245
+ ContextMenuShortcut,
246
+ ContextMenuSub,
247
+ ContextMenuSubTrigger,
248
+ ContextMenuSubContent,
249
+ }
@@ -0,0 +1,137 @@
1
+ import * as React from "react"
2
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
3
+ import { XIcon } from "@phosphor-icons/react"
4
+
5
+ import { cn } from "../lib/utils"
6
+ import { Button } from "./button"
7
+
8
+ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
9
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
10
+ }
11
+
12
+ function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
13
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
14
+ }
15
+
16
+ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
17
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
18
+ }
19
+
20
+ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
21
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
22
+ }
23
+
24
+ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
25
+ return (
26
+ <DialogPrimitive.Backdrop
27
+ data-slot="dialog-overlay"
28
+ className={cn(
29
+ "fixed inset-0 isolate z-50 bg-foreground/15 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
30
+ className
31
+ )}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ function DialogContent({
38
+ className,
39
+ children,
40
+ showCloseButton = true,
41
+ ...props
42
+ }: DialogPrimitive.Popup.Props & {
43
+ showCloseButton?: boolean
44
+ }) {
45
+ return (
46
+ <DialogPortal>
47
+ <DialogOverlay />
48
+ <DialogPrimitive.Popup
49
+ data-slot="dialog-content"
50
+ className={cn(
51
+ "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
52
+ className
53
+ )}
54
+ {...props}
55
+ >
56
+ {children}
57
+ {showCloseButton && (
58
+ <DialogPrimitive.Close
59
+ data-slot="dialog-close"
60
+ render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
61
+ >
62
+ <XIcon />
63
+ <span className="sr-only">Close</span>
64
+ </DialogPrimitive.Close>
65
+ )}
66
+ </DialogPrimitive.Popup>
67
+ </DialogPortal>
68
+ )
69
+ }
70
+
71
+ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
72
+ return (
73
+ <div
74
+ data-slot="dialog-header"
75
+ className={cn("flex flex-col gap-2", className)}
76
+ {...props}
77
+ />
78
+ )
79
+ }
80
+
81
+ function DialogFooter({
82
+ className,
83
+ showCloseButton = false,
84
+ children,
85
+ ...props
86
+ }: React.ComponentProps<"div"> & {
87
+ showCloseButton?: boolean
88
+ }) {
89
+ return (
90
+ <div
91
+ data-slot="dialog-footer"
92
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
93
+ {...props}
94
+ >
95
+ {children}
96
+ {showCloseButton && (
97
+ <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
98
+ )}
99
+ </div>
100
+ )
101
+ }
102
+
103
+ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
104
+ return (
105
+ <DialogPrimitive.Title
106
+ data-slot="dialog-title"
107
+ className={cn("font-heading leading-none font-medium", className)}
108
+ {...props}
109
+ />
110
+ )
111
+ }
112
+
113
+ function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
114
+ return (
115
+ <DialogPrimitive.Description
116
+ data-slot="dialog-description"
117
+ className={cn(
118
+ "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
119
+ className
120
+ )}
121
+ {...props}
122
+ />
123
+ )
124
+ }
125
+
126
+ export {
127
+ Dialog,
128
+ DialogClose,
129
+ DialogContent,
130
+ DialogDescription,
131
+ DialogFooter,
132
+ DialogHeader,
133
+ DialogOverlay,
134
+ DialogPortal,
135
+ DialogTitle,
136
+ DialogTrigger,
137
+ }