@preset.nz/ux-kit 0.1.0 → 0.1.2

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 (60) hide show
  1. package/README.md +31 -42
  2. package/THIRD_PARTY_NOTICES.md +253 -0
  3. package/package.json +13 -13
  4. package/src/chat/index.ts +9 -0
  5. package/src/chat/message.tsx +80 -0
  6. package/src/chat/prompt-input.tsx +121 -0
  7. package/src/chat/reasoning.tsx +182 -0
  8. package/src/chat/scroller.tsx +147 -0
  9. package/src/chat/suggestion.tsx +65 -0
  10. package/src/chat/tool.tsx +143 -0
  11. package/src/chat/types.ts +8 -0
  12. package/src/components/alert-shell.tsx +66 -0
  13. package/src/components/avatar.tsx +1 -1
  14. package/src/components/badge.tsx +6 -6
  15. package/src/components/breadcrumb.tsx +8 -10
  16. package/src/components/button.tsx +1 -1
  17. package/src/components/card.tsx +4 -21
  18. package/src/components/checkbox.tsx +3 -5
  19. package/src/components/color-field.tsx +20 -7
  20. package/src/components/color-swatch.tsx +3 -3
  21. package/src/components/confirm-dialog.tsx +93 -0
  22. package/src/components/context-menu.tsx +16 -13
  23. package/src/components/dialog.tsx +8 -9
  24. package/src/components/download-prompt.tsx +108 -0
  25. package/src/components/dropdown-menu.tsx +16 -13
  26. package/src/components/empty-state.tsx +6 -3
  27. package/src/components/input.tsx +2 -2
  28. package/src/components/label.tsx +3 -2
  29. package/src/components/list.tsx +205 -0
  30. package/src/components/navigation-menu.tsx +17 -10
  31. package/src/components/number-field.tsx +30 -26
  32. package/src/components/popover.tsx +6 -10
  33. package/src/components/progress.tsx +77 -0
  34. package/src/components/select.tsx +16 -28
  35. package/src/components/separator.tsx +2 -6
  36. package/src/components/sheet.tsx +16 -8
  37. package/src/components/side-panel.tsx +31 -8
  38. package/src/components/sidebar.tsx +19 -52
  39. package/src/components/skeleton.tsx +1 -1
  40. package/src/components/slider.tsx +4 -8
  41. package/src/components/snackbar.tsx +39 -36
  42. package/src/components/status-bar.tsx +10 -3
  43. package/src/components/tabs.tsx +5 -6
  44. package/src/components/textarea.tsx +2 -2
  45. package/src/components/toggle-group.tsx +4 -4
  46. package/src/components/toggle.tsx +3 -1
  47. package/src/components/toolbar.tsx +8 -11
  48. package/src/components/tooltip.tsx +1 -1
  49. package/src/graphics/adsr.ts +21 -13
  50. package/src/graphics/curve-editor.tsx +56 -14
  51. package/src/graphics/curve-edits.ts +20 -3
  52. package/src/graphics/index.ts +6 -6
  53. package/src/graphics/scale.ts +4 -1
  54. package/src/graphics/use-canvas.ts +1 -1
  55. package/src/graphics/use-tokens.ts +4 -1
  56. package/src/index.css +9 -1
  57. package/src/index.ts +7 -5
  58. package/src/lib/use-mobile.ts +1 -1
  59. package/src/lib/utils.ts +1 -1
  60. package/src/shadcn-tailwind.css +665 -0
@@ -1,7 +1,7 @@
1
- import * as React from "react"
2
1
  import { mergeProps } from "@base-ui/react/merge-props"
3
2
  import { useRender } from "@base-ui/react/use-render"
4
3
  import { CaretRightIcon, DotsThreeIcon } from "@phosphor-icons/react"
4
+ import type * as React from "react"
5
5
 
6
6
  import { cn } from "../lib/utils"
7
7
 
@@ -15,7 +15,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
15
15
  data-slot="breadcrumb-list"
16
16
  className={cn(
17
17
  "flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
18
- className
18
+ className,
19
19
  )}
20
20
  {...props}
21
21
  />
@@ -33,16 +33,12 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
33
33
  }
34
34
 
35
35
  // Plain anchor by default. Pass `render={<RouterLink to="..." />}` for a router link.
36
- function BreadcrumbLink({
37
- className,
38
- render,
39
- ...props
40
- }: useRender.ComponentProps<"a">) {
36
+ function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) {
41
37
  return useRender({
42
38
  defaultTagName: "a",
43
39
  props: mergeProps<"a">(
44
40
  { className: cn("transition-colors hover:text-foreground", className) },
45
- props
41
+ props,
46
42
  ),
47
43
  render,
48
44
  state: { slot: "breadcrumb-link" },
@@ -51,6 +47,8 @@ function BreadcrumbLink({
51
47
 
52
48
  function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
53
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
54
52
  <span
55
53
  data-slot="breadcrumb-page"
56
54
  role="link"
@@ -93,10 +91,10 @@ function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span"
93
91
 
94
92
  export {
95
93
  Breadcrumb,
96
- BreadcrumbList,
94
+ BreadcrumbEllipsis,
97
95
  BreadcrumbItem,
98
96
  BreadcrumbLink,
97
+ BreadcrumbList,
99
98
  BreadcrumbPage,
100
99
  BreadcrumbSeparator,
101
- BreadcrumbEllipsis,
102
100
  }
@@ -35,7 +35,7 @@ const buttonVariants = cva(
35
35
  variant: "default",
36
36
  size: "default",
37
37
  },
38
- }
38
+ },
39
39
  )
40
40
 
41
41
  function Button({
@@ -1,4 +1,4 @@
1
- import * as React from "react"
1
+ import type * as React from "react"
2
2
 
3
3
  import { cn } from "../lib/utils"
4
4
 
@@ -52,23 +52,14 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52
52
  return (
53
53
  <div
54
54
  data-slot="card-action"
55
- className={cn(
56
- "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
57
- className,
58
- )}
55
+ className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
59
56
  {...props}
60
57
  />
61
58
  )
62
59
  }
63
60
 
64
61
  function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65
- return (
66
- <div
67
- data-slot="card-content"
68
- className={cn("px-4", className)}
69
- {...props}
70
- />
71
- )
62
+ return <div data-slot="card-content" className={cn("px-4", className)} {...props} />
72
63
  }
73
64
 
74
65
  function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -81,12 +72,4 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
81
72
  )
82
73
  }
83
74
 
84
- export {
85
- Card,
86
- CardHeader,
87
- CardFooter,
88
- CardTitle,
89
- CardAction,
90
- CardDescription,
91
- CardContent,
92
- }
75
+ export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
@@ -1,7 +1,6 @@
1
1
  import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
2
-
3
- import { cn } from "../lib/utils"
4
2
  import { CheckIcon } from "@phosphor-icons/react"
3
+ import { cn } from "../lib/utils"
5
4
 
6
5
  function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
7
6
  return (
@@ -9,7 +8,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
9
8
  data-slot="checkbox"
10
9
  className={cn(
11
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",
12
- className
11
+ className,
13
12
  )}
14
13
  {...props}
15
14
  >
@@ -17,8 +16,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
17
16
  data-slot="checkbox-indicator"
18
17
  className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
19
18
  >
20
- <CheckIcon
21
- />
19
+ <CheckIcon />
22
20
  </CheckboxPrimitive.Indicator>
23
21
  </CheckboxPrimitive.Root>
24
22
  )
@@ -35,8 +35,7 @@ function toHex({ r, g, b, a }: Rgba, withAlpha: boolean): string {
35
35
  return `#${p(r)}${p(g)}${p(b)}${withAlpha ? p(a * 255) : ""}`
36
36
  }
37
37
 
38
- interface ColorFieldProps
39
- extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
38
+ interface ColorFieldProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
40
39
  /** #rrggbb or #rrggbbaa. `null` shows the empty chip and a dash. */
41
40
  value: string | null
42
41
  /** Called with a normalised lowercase hex, or null from the clear button. */
@@ -109,7 +108,9 @@ function ColorField({
109
108
  const pct = Number(alphaDraft)
110
109
  setAlphaDraft(null)
111
110
  if (!Number.isFinite(pct)) return
112
- emit({ ...(rgba ?? parseHex(fallback)!), a: Math.min(100, Math.max(0, pct)) / 100 })
111
+ const base = rgba ?? parseHex(fallback)
112
+ if (!base) return
113
+ emit({ ...base, a: Math.min(100, Math.max(0, pct)) / 100 })
113
114
  }
114
115
 
115
116
  const pick = (hex: string, session = false) => {
@@ -126,7 +127,10 @@ function ColorField({
126
127
  return (
127
128
  <div data-slot="color-field" className={cn("flex flex-col gap-1", className)} {...props}>
128
129
  {label ? (
129
- <label htmlFor={inputId} className="text-[11px] font-medium tracking-wide text-muted-foreground">
130
+ <label
131
+ htmlFor={inputId}
132
+ className="text-[11px] font-medium tracking-wide text-muted-foreground"
133
+ >
130
134
  {label}
131
135
  </label>
132
136
  ) : null}
@@ -143,7 +147,11 @@ function ColorField({
143
147
  <input
144
148
  type="color"
145
149
  aria-label={`${swatchLabel} picker`}
146
- value={rgba ? toHex(rgba, false) : toHex(parseHex(fallback) ?? { r: 0, g: 0, b: 0, a: 1 }, false)}
150
+ value={
151
+ rgba
152
+ ? toHex(rgba, false)
153
+ : toHex(parseHex(fallback) ?? { r: 0, g: 0, b: 0, a: 1 }, false)
154
+ }
147
155
  onChange={(e) => pick(e.target.value, true)}
148
156
  onFocus={onPickStart}
149
157
  onBlur={() => {
@@ -163,6 +171,7 @@ function ColorField({
163
171
  </PopoverTrigger>
164
172
  <PopoverContent align="start" className="w-auto gap-2 p-2">
165
173
  {presets?.length || clearable ? (
174
+ // biome-ignore lint/a11y/useSemanticElements: a div keeps the swatch grid layout; role="group" labels it
166
175
  <div className="grid grid-cols-7 gap-1" role="group" aria-label="Presets">
167
176
  {presets?.map((c) => (
168
177
  <button
@@ -191,7 +200,11 @@ function ColorField({
191
200
  <input
192
201
  type="color"
193
202
  aria-label={`${swatchLabel} picker`}
194
- value={rgba ? toHex(rgba, false) : toHex(parseHex(fallback) ?? { r: 0, g: 0, b: 0, a: 1 }, false)}
203
+ value={
204
+ rgba
205
+ ? toHex(rgba, false)
206
+ : toHex(parseHex(fallback) ?? { r: 0, g: 0, b: 0, a: 1 }, false)
207
+ }
195
208
  onChange={(e) => pick(e.target.value, true)}
196
209
  onFocus={onPickStart}
197
210
  onBlur={() => {
@@ -244,5 +257,5 @@ function ColorField({
244
257
  )
245
258
  }
246
259
 
247
- export { ColorField, parseHex, toHex }
248
260
  export type { ColorFieldProps }
261
+ export { ColorField, parseHex, toHex }
@@ -1,4 +1,4 @@
1
- import * as React from "react"
1
+ import type * as React from "react"
2
2
 
3
3
  import { cn } from "../lib/utils"
4
4
 
@@ -38,7 +38,7 @@ function ColorSwatch({
38
38
  size === "lg" && "size-6",
39
39
  shape === "round" ? "rounded-full" : "rounded-xs",
40
40
  !color && "border-dashed border-muted-foreground/60",
41
- className
41
+ className,
42
42
  )}
43
43
  style={color ? { background: CHECKER, ...style } : style}
44
44
  {...props}
@@ -48,5 +48,5 @@ function ColorSwatch({
48
48
  )
49
49
  }
50
50
 
51
- export { ColorSwatch }
52
51
  export type { ColorSwatchProps }
52
+ export { ColorSwatch }
@@ -0,0 +1,93 @@
1
+ import * as React from "react"
2
+ import { AlertShell } from "./alert-shell"
3
+ import { Button } from "./button"
4
+
5
+ // ConfirmDialog: ask before something that can't be taken back. Controlled
6
+ // only; the caller owns `open`. It is answered, not dismissed: no close "x",
7
+ // and a click outside does nothing, so Cancel and Escape are the ways out.
8
+
9
+ interface ConfirmDialogProps {
10
+ open: boolean
11
+ onOpenChange: (open: boolean) => void
12
+ title: string
13
+ /** The body: text, or a node (a list of apps, a size). */
14
+ children?: React.ReactNode
15
+ /** What the action is called: "Remove", never "OK". */
16
+ confirmLabel: string
17
+ /** Defaults to "Cancel". */
18
+ cancelLabel?: string
19
+ /** Confirm uses the destructive Button variant and initial focus is Cancel. */
20
+ destructive?: boolean
21
+ /** While a returned promise is pending the dialog stays open; a rejection keeps it open. */
22
+ onConfirm: () => void | Promise<void>
23
+ }
24
+
25
+ function ConfirmDialog({
26
+ open,
27
+ onOpenChange,
28
+ title,
29
+ children,
30
+ confirmLabel,
31
+ cancelLabel = "Cancel",
32
+ destructive = false,
33
+ onConfirm,
34
+ }: ConfirmDialogProps) {
35
+ const [pending, setPending] = React.useState(false)
36
+ const cancelRef = React.useRef<HTMLButtonElement>(null)
37
+ const confirmRef = React.useRef<HTMLButtonElement>(null)
38
+
39
+ // Nothing closes the dialog while the action runs.
40
+ const handleOpenChange = (next: boolean) => {
41
+ if (pending && !next) return
42
+ onOpenChange(next)
43
+ }
44
+
45
+ const handleConfirm = async () => {
46
+ if (pending) return
47
+ setPending(true)
48
+ try {
49
+ await onConfirm()
50
+ } catch {
51
+ // The caller shows its own failure; the dialog stays open.
52
+ setPending(false)
53
+ return
54
+ }
55
+ setPending(false)
56
+ onOpenChange(false)
57
+ }
58
+
59
+ return (
60
+ <AlertShell
61
+ open={open}
62
+ onDismiss={() => handleOpenChange(false)}
63
+ title={title}
64
+ initialFocus={destructive ? cancelRef : confirmRef}
65
+ footer={
66
+ <>
67
+ <Button
68
+ ref={cancelRef}
69
+ variant="outline"
70
+ disabled={pending}
71
+ onClick={() => handleOpenChange(false)}
72
+ >
73
+ {cancelLabel}
74
+ </Button>
75
+ <Button
76
+ ref={confirmRef}
77
+ variant={destructive ? "destructive" : "default"}
78
+ disabled={pending}
79
+ focusableWhenDisabled
80
+ onClick={handleConfirm}
81
+ >
82
+ {confirmLabel}
83
+ </Button>
84
+ </>
85
+ }
86
+ >
87
+ {children}
88
+ </AlertShell>
89
+ )
90
+ }
91
+
92
+ export type { ConfirmDialogProps }
93
+ export { ConfirmDialog }
@@ -1,9 +1,12 @@
1
- import * as React from "react"
2
1
  import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
3
2
  import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react"
3
+ import type * as React from "react"
4
4
 
5
5
  import { cn } from "../lib/utils"
6
6
 
7
+ // Base UI: `ContextMenuLabel` is Base UI's `GroupLabel`, so it must sit inside a `ContextMenuGroup`.
8
+ // Same shape as DropdownMenu.
9
+
7
10
  function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
8
11
  return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
9
12
  }
@@ -44,7 +47,7 @@ function ContextMenuContent({
44
47
  data-slot="context-menu-content"
45
48
  className={cn(
46
49
  "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
50
+ className,
48
51
  )}
49
52
  {...props}
50
53
  />
@@ -68,7 +71,7 @@ function ContextMenuLabel({
68
71
  data-inset={inset}
69
72
  className={cn(
70
73
  "px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
71
- className
74
+ className,
72
75
  )}
73
76
  {...props}
74
77
  />
@@ -91,7 +94,7 @@ function ContextMenuItem({
91
94
  data-variant={variant}
92
95
  className={cn(
93
96
  "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
97
+ className,
95
98
  )}
96
99
  {...props}
97
100
  />
@@ -114,7 +117,7 @@ function ContextMenuSubTrigger({
114
117
  data-inset={inset}
115
118
  className={cn(
116
119
  "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
120
+ className,
118
121
  )}
119
122
  {...props}
120
123
  >
@@ -157,7 +160,7 @@ function ContextMenuCheckboxItem({
157
160
  data-inset={inset}
158
161
  className={cn(
159
162
  "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
163
+ className,
161
164
  )}
162
165
  {...props}
163
166
  >
@@ -190,7 +193,7 @@ function ContextMenuRadioItem({
190
193
  data-inset={inset}
191
194
  className={cn(
192
195
  "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
196
+ className,
194
197
  )}
195
198
  {...props}
196
199
  >
@@ -223,7 +226,7 @@ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span
223
226
  data-slot="context-menu-shortcut"
224
227
  className={cn(
225
228
  "ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
226
- className
229
+ className,
227
230
  )}
228
231
  {...props}
229
232
  />
@@ -232,18 +235,18 @@ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span
232
235
 
233
236
  export {
234
237
  ContextMenu,
235
- ContextMenuPortal,
236
- ContextMenuTrigger,
238
+ ContextMenuCheckboxItem,
237
239
  ContextMenuContent,
238
240
  ContextMenuGroup,
239
- ContextMenuLabel,
240
241
  ContextMenuItem,
241
- ContextMenuCheckboxItem,
242
+ ContextMenuLabel,
243
+ ContextMenuPortal,
242
244
  ContextMenuRadioGroup,
243
245
  ContextMenuRadioItem,
244
246
  ContextMenuSeparator,
245
247
  ContextMenuShortcut,
246
248
  ContextMenuSub,
247
- ContextMenuSubTrigger,
248
249
  ContextMenuSubContent,
250
+ ContextMenuSubTrigger,
251
+ ContextMenuTrigger,
249
252
  }
@@ -1,10 +1,13 @@
1
- import * as React from "react"
2
1
  import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
3
2
  import { XIcon } from "@phosphor-icons/react"
3
+ import type * as React from "react"
4
4
 
5
5
  import { cn } from "../lib/utils"
6
6
  import { Button } from "./button"
7
7
 
8
+ // Dialog: Base UI Dialog in the kit's theme. Triggers and closers take
9
+ // `render={<Button />}` where Radix used `asChild`.
10
+
8
11
  function Dialog({ ...props }: DialogPrimitive.Root.Props) {
9
12
  return <DialogPrimitive.Root data-slot="dialog" {...props} />
10
13
  }
@@ -27,7 +30,7 @@ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props)
27
30
  data-slot="dialog-overlay"
28
31
  className={cn(
29
32
  "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
33
+ className,
31
34
  )}
32
35
  {...props}
33
36
  />
@@ -49,7 +52,7 @@ function DialogContent({
49
52
  data-slot="dialog-content"
50
53
  className={cn(
51
54
  "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
55
+ className,
53
56
  )}
54
57
  {...props}
55
58
  >
@@ -70,11 +73,7 @@ function DialogContent({
70
73
 
71
74
  function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
72
75
  return (
73
- <div
74
- data-slot="dialog-header"
75
- className={cn("flex flex-col gap-2", className)}
76
- {...props}
77
- />
76
+ <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
78
77
  )
79
78
  }
80
79
 
@@ -116,7 +115,7 @@ function DialogDescription({ className, ...props }: DialogPrimitive.Description.
116
115
  data-slot="dialog-description"
117
116
  className={cn(
118
117
  "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
119
- className
118
+ className,
120
119
  )}
121
120
  {...props}
122
121
  />
@@ -0,0 +1,108 @@
1
+ import * as React from "react"
2
+ import { AlertShell } from "./alert-shell"
3
+ import { Button } from "./button"
4
+ import { formatBytes, Progress } from "./progress"
5
+
6
+ // DownloadPrompt: one dialog for a download, in three states the caller drives:
7
+ // ask first, show progress, offer Retry. It owns no timer and no network; the
8
+ // caller sets `state` to null when the download is done. Escape always means
9
+ // "stop": Not now, Cancel, Close.
10
+
11
+ type DownloadPromptProps = {
12
+ /** `null` is closed. */
13
+ state: "ask" | "downloading" | "failed" | null
14
+ /** "Smart Select needs a model". */
15
+ title: string
16
+ /** What is being downloaded: "MobileSAM". */
17
+ name: string
18
+ /** Formatted by the caller; an omitted fact is left out of the line. */
19
+ facts: { size?: string; licence?: string; host?: string }
20
+ /** Bytes, while downloading. Without it the bar is indeterminate. */
21
+ progress?: { done: number; total: number }
22
+ /** One line, when failed. */
23
+ reason?: string
24
+ /** ask: Download. */
25
+ onDownload: () => void
26
+ /** ask: Not now. failed: Close. */
27
+ onDismiss: () => void
28
+ /** downloading: Cancel. */
29
+ onCancel: () => void
30
+ /** failed: Retry. */
31
+ onRetry: () => void
32
+ }
33
+
34
+ function DownloadPrompt(props: DownloadPromptProps) {
35
+ // Keep the last open props so the closing animation shows the old content, not a blank dialog.
36
+ const last = React.useRef<DownloadPromptProps & { state: "ask" | "downloading" | "failed" }>(null)
37
+ if (props.state !== null) last.current = { ...props, state: props.state }
38
+ const shown = last.current
39
+ const focusRef = React.useRef<HTMLButtonElement>(null)
40
+
41
+ // initialFocus only acts on open. A state change swaps the buttons out from under the focus,
42
+ // so move it to the new state's default button.
43
+ React.useEffect(() => {
44
+ if (props.state !== null) focusRef.current?.focus()
45
+ }, [props.state])
46
+
47
+ if (shown === null) return null
48
+ const { state, title, name, facts, progress, reason } = shown
49
+ // Callbacks come from the live props: the stored copy may be a render old.
50
+ const live = props
51
+ const onEscape = state === "downloading" ? live.onCancel : live.onDismiss
52
+
53
+ const factLine = [facts.size, facts.licence, facts.host ? `from ${facts.host}` : undefined]
54
+ .filter(Boolean)
55
+ .join(" · ")
56
+
57
+ return (
58
+ <AlertShell
59
+ open={props.state !== null}
60
+ onDismiss={onEscape}
61
+ title={title}
62
+ initialFocus={focusRef}
63
+ footer={
64
+ state === "ask" ? (
65
+ <>
66
+ <Button variant="outline" onClick={live.onDismiss}>
67
+ Not now
68
+ </Button>
69
+ <Button ref={focusRef} onClick={live.onDownload}>
70
+ Download
71
+ </Button>
72
+ </>
73
+ ) : state === "downloading" ? (
74
+ <Button ref={focusRef} variant="outline" onClick={live.onCancel}>
75
+ Cancel
76
+ </Button>
77
+ ) : (
78
+ <>
79
+ <Button variant="outline" onClick={live.onDismiss}>
80
+ Close
81
+ </Button>
82
+ <Button ref={focusRef} onClick={live.onRetry}>
83
+ Retry
84
+ </Button>
85
+ </>
86
+ )
87
+ }
88
+ >
89
+ <p className="font-medium text-foreground">{name}</p>
90
+ {state === "ask" && factLine ? <p className="mt-1">{factLine}</p> : null}
91
+ {state === "downloading" ? (
92
+ <Progress
93
+ className="mt-3"
94
+ label={name}
95
+ value={progress ? progress.done : null}
96
+ max={progress?.total}
97
+ detail={
98
+ progress ? `${formatBytes(progress.done)} / ${formatBytes(progress.total)}` : undefined
99
+ }
100
+ />
101
+ ) : null}
102
+ {state === "failed" && reason ? <p className="mt-1">{reason}</p> : null}
103
+ </AlertShell>
104
+ )
105
+ }
106
+
107
+ export type { DownloadPromptProps }
108
+ export { DownloadPrompt }