@preset.nz/ux-kit 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,44 +15,7 @@ Like [facets](https://github.com/preset-nz/facets), the kit ships unbuilt TypeSc
15
15
 
16
16
  ## Components
17
17
 
18
- Union of the `src/components/ui` folders in Strata, Oblique, Shard and Map & Territory (Fault has none). Where several apps had the same component, the richest variant was taken. All are exported from the package index.
19
-
20
- | Component | In kit | Came from | Notes |
21
- |---|---|---|---|
22
- | button | yes | Strata, Shard, M&T | Base UI Button |
23
- | checkbox | yes | Strata, Oblique, Shard | |
24
- | input | yes | all four | |
25
- | label | yes | Strata, Oblique, Shard | |
26
- | select | yes | Strata, Oblique, Shard | |
27
- | separator | yes | all four | |
28
- | dialog | yes | Oblique, Shard, M&T | Shard's themed variant. `render={<Button />}` replaces `asChild` |
29
- | sheet | yes | M&T | Base UI Dialog docked to an edge (`side`) |
30
- | popover | yes | Oblique | `PopoverAnchor` dropped: Base UI anchors through the Positioner's `anchor` prop |
31
- | tooltip | yes | Oblique, M&T | `TooltipProvider delay` replaces `delayDuration` |
32
- | tabs | yes | Oblique | `TabsTrigger` / `TabsContent` wrap Base UI `Tab` / `Panel`. Orientation uses `data-[orientation=...]` |
33
- | toggle | yes | Oblique | `data-pressed` replaces `data-[state=on]` |
34
- | toggle-group | yes | Oblique | Base UI `value` is always an array and `multiple` replaces `type="single" \| "multiple"` |
35
- | slider | yes | Oblique, Shard, M&T | Takes a number or an array; one thumb per value |
36
- | dropdown-menu | yes | Shard, M&T | M&T's fuller variant. `DropdownMenuLabel` is Base UI's `GroupLabel`, so it must sit inside a `DropdownMenuGroup` |
37
- | context-menu | yes | Shard | Same shape as dropdown-menu, same `Label` caveat |
38
- | navigation-menu | yes | M&T | Always the shared-viewport form. Radix's `NavigationMenuIndicator` and the `viewport={false}` mode are gone |
39
- | snackbar | yes | Strata, Oblique | The kit's one toast, on Base UI Toast. `useSnackbar().show({ message, kind, description, action, persistent, timeout })` returns an id and `dismiss(id?)` closes one or all; kinds are `plain`, `success`, `warning`, `error`. Mount `SnackbarProvider` once and a `SnackbarViewport` inside it. `notify(opts)` and `dismissNotification(id?)` do the same from outside React |
40
- | avatar | yes | M&T | Base UI Avatar |
41
- | badge | yes | M&T | Plain, `render` prop replaces `asChild` |
42
- | breadcrumb | yes | M&T | Plain `<a>`, no `next/link`. Use `render` for a router link |
43
- | card | yes | M&T | Plain |
44
- | skeleton | yes | M&T | Plain |
45
- | textarea | yes | M&T | Plain, restyled to match Input |
46
- | sidebar | yes | M&T | Plain, 700 lines. `asChild` became `render`. `useIsMobile` moved into the kit. Untested beyond the `collapsible="none"` form in the playground |
47
- | toolbar | yes | Oblique, M&T | Base UI Toolbar (roving focus). `ToolbarItems` takes `groups` of `ToolbarItemSpec` (id, label, icon, shortcut, enabled, disabledReason, pressed, menu) and `onCommand(id)`; Oblique's `ToolButtonSpec` table with M&T's disabled reason. Disabled items stay focusable so the tooltip can say why. `ToolbarButton`, `ToolbarGroup`, `ToolbarSeparator`, `ToolbarSpacer` compose by hand. Shard's header buttons are inline JSX and Fault's are not a toolbar, so neither contributed |
48
- | color-field | yes | M&T, Oblique, Strata | Swatch, hex text (commits on blur or Enter, bad input reverts), optional alpha (`#rrggbbaa`), preset grid in a Popover, native picker with `onPickStart`/`onPickEnd` for undo transactions (Oblique), `clearable` and null value (Strata, Oblique's optional colour), read-only, `trailing` slot for an eyedropper |
49
- | number-field | yes | Base UI, Oblique | Base UI NumberField with the label as the ScrubArea (drag it; click it to type). Shift is coarse (x10), Alt/Option fine (x0.1), as Oblique's InputNumber, and `integer` keeps Alt on whole steps (integerStepFix). `onValueChange(value, { reason })` is `scrub`, `step` or `typed` (typed commits on blur or Enter, never per keystroke; Escape reverts). `onScrubStart`/`onScrubEnd` bracket one drag as the undo transaction, `onScrubCancel` is Escape mid-drag. `VectorField` is N of them with X/Y/Z/W labels. Scrubbing only on the label: no gutter scrub |
50
- | color-swatch | yes | Strata, M&T, Oblique | Chip over a checkerboard; `null` is the dashed empty chip |
51
- | side-panel | yes | Strata, M&T | Docked panel, props only (`open`, `width` owned by the app). Collapsed it leaves an icon rail that reopens it; the inner edge resizes by pointer drag (pointer capture) or arrow keys, clamped by `clampWidth`. `SidePanelHeader`, `SidePanelContent`. Strata's `SidePanel` ideas; persistence stays app-side |
52
- | status-bar | yes | Oblique, Strata, M&T | `StatusBar`, `StatusItem` (optional label), `StatusSpacer`. Contents stay app-side |
53
- | empty-state | yes | new | Icon, title, description, action slot |
54
-
55
- M&T's `src/palettes/` (presets and derivation) was considered for the colour field and left out: it is map domain data and stays in the app, which passes its colours in through `presets`.
18
+ Every component is exported from `src/index.ts`, which is the list. Each file's top comment says how it differs from shadcn and Radix. M&T's `src/palettes/` (presets and derivation) stays in the app, as map domain data; it passes its colours to the colour field through `presets`.
56
19
 
57
20
  ## Using it
58
21
 
@@ -107,12 +70,11 @@ The components are adapted from Vercel AI Elements (Apache-2.0) and shadcn/ui's
107
70
  ## Working on it
108
71
 
109
72
  ```sh
110
- just install # pnpm install
73
+ just install # pnpm install, and the playground's crates
111
74
  just dev # the playground as a desktop app
112
- just check # typecheck and lint the kit
113
- just check-all # the kit, the playground and cargo check
75
+ just check # the kit, the playground and cargo check
114
76
  ```
115
77
 
116
- The playground links the sibling packages (rhizome, app-kit, preferences, facets, math), so `just dev` and `just check-all` need them checked out next to this repo. `just check` needs only this repo.
78
+ Run them in `ux-kit/` of [preset-nz/packages](https://github.com/preset-nz/packages). The playground links the other packages in that repo (rhizome, app-kit, preferences, facets, math), so a change to one of them that breaks it fails `just check`.
117
79
 
118
80
  MIT.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@preset.nz/ux-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "One look and one way of working for the preset.nz desktop apps: tokens, palettes, fonts and Base UI primitives.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/preset-nz/ux-kit.git"
8
+ "url": "git+https://github.com/preset-nz/packages.git",
9
+ "directory": "ux-kit"
9
10
  },
10
11
  "type": "module",
11
12
  "sideEffects": [
@@ -30,13 +31,13 @@
30
31
  "@fontsource-variable/geist": "^5.2.9",
31
32
  "@fontsource-variable/jetbrains-mono": "^5.2.8",
32
33
  "@phosphor-icons/react": "^2.1.10",
33
- "@preset.nz/math": "^0.1.0",
34
34
  "@shadcn/react": "^0.3.1",
35
35
  "class-variance-authority": "^0.7.1",
36
36
  "clsx": "^2.1.1",
37
37
  "streamdown": "^2.7.0",
38
38
  "tailwind-merge": "^3.6.0",
39
- "tw-animate-css": "^1.4.0"
39
+ "tw-animate-css": "^1.4.0",
40
+ "@preset.nz/math": "^0.1.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@biomejs/biome": "^2.5.15",
@@ -0,0 +1,66 @@
1
+ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
2
+ import type * as React from "react"
3
+ import { cn } from "../lib/utils"
4
+ import { DialogFooter, DialogHeader, DialogOverlay } from "./dialog"
5
+
6
+ // AlertShell: the markup ConfirmDialog and DownloadPrompt share. Internal, not
7
+ // exported from the package. It is answered, not dismissed: no close "x", and a
8
+ // click outside does nothing, so the footer buttons and Escape are the ways out.
9
+
10
+ interface AlertShellProps {
11
+ open: boolean
12
+ /** Escape. The caller decides what that means (and may ignore it). */
13
+ onDismiss: () => void
14
+ title: string
15
+ /** The body, below the title. */
16
+ children?: React.ReactNode
17
+ /** The buttons, secondary first and primary last. */
18
+ footer: React.ReactNode
19
+ /** The element that takes focus when the dialog opens. */
20
+ initialFocus?: React.RefObject<HTMLElement | null>
21
+ }
22
+
23
+ function AlertShell({ open, onDismiss, title, children, footer, initialFocus }: AlertShellProps) {
24
+ return (
25
+ <AlertDialogPrimitive.Root
26
+ data-slot="confirm-dialog"
27
+ open={open}
28
+ onOpenChange={(next) => {
29
+ if (!next) onDismiss()
30
+ }}
31
+ >
32
+ <AlertDialogPrimitive.Portal data-slot="confirm-dialog-portal">
33
+ <DialogOverlay />
34
+ <AlertDialogPrimitive.Popup
35
+ data-slot="confirm-dialog-content"
36
+ initialFocus={initialFocus}
37
+ className={cn(
38
+ "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",
39
+ )}
40
+ >
41
+ <DialogHeader>
42
+ <AlertDialogPrimitive.Title
43
+ data-slot="dialog-title"
44
+ className="font-heading leading-none font-medium"
45
+ >
46
+ {title}
47
+ </AlertDialogPrimitive.Title>
48
+ {children ? (
49
+ <AlertDialogPrimitive.Description
50
+ render={<div />}
51
+ data-slot="dialog-description"
52
+ className="text-sm text-muted-foreground"
53
+ >
54
+ {children}
55
+ </AlertDialogPrimitive.Description>
56
+ ) : null}
57
+ </DialogHeader>
58
+ <DialogFooter>{footer}</DialogFooter>
59
+ </AlertDialogPrimitive.Popup>
60
+ </AlertDialogPrimitive.Portal>
61
+ </AlertDialogPrimitive.Root>
62
+ )
63
+ }
64
+
65
+ export type { AlertShellProps }
66
+ export { AlertShell }
@@ -4,6 +4,8 @@ import { cva, type VariantProps } from "class-variance-authority"
4
4
 
5
5
  import { cn } from "../lib/utils"
6
6
 
7
+ // Badge: a plain span. The `render` prop replaces Radix's `asChild`.
8
+
7
9
  const badgeVariants = cva(
8
10
  "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
9
11
  {
@@ -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 }
@@ -4,6 +4,9 @@ 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
  }
@@ -5,6 +5,9 @@ import type * as React from "react"
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
  }
@@ -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 }
@@ -4,6 +4,9 @@ import type * as React from "react"
4
4
 
5
5
  import { cn } from "../lib/utils"
6
6
 
7
+ // Base UI: `DropdownMenuLabel` is Base UI's `GroupLabel`, so it must sit inside a `DropdownMenuGroup`.
8
+ // Same shape as ContextMenu.
9
+
7
10
  function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
8
11
  return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
9
12
  }
@@ -0,0 +1,205 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../lib/utils"
4
+
5
+ // List and ListRow: a set of things (models, files, recent items) built from
6
+ // slots. Without `selection` it is a plain `role="list"` and the rows'
7
+ // `trailing` actions are the only interactive parts (Tab walks them). With
8
+ // `selection` it is a `role="listbox"` with roving tabindex: Up, Down, Home
9
+ // and End move focus, Enter or Space selects. Rows must be direct children of
10
+ // the List (the List reads their ids). A selectable list takes no `trailing`
11
+ // controls: a button inside an option is invalid ARIA, and its click would
12
+ // select the row too. No virtualisation, reorder or sections.
13
+ //
14
+ // `className` and other props go on the List's outer element; the accessible
15
+ // name (`aria-label`, `aria-labelledby`) goes on the element with the role.
16
+
17
+ interface ListSelection {
18
+ selectedId: string | null
19
+ onSelect: (id: string) => void
20
+ }
21
+
22
+ interface ListContextValue {
23
+ selection: ListSelection | undefined
24
+ tabStopId: string | undefined
25
+ setFocusId: (id: string) => void
26
+ }
27
+
28
+ const ListContext = React.createContext<ListContextValue>({
29
+ selection: undefined,
30
+ tabStopId: undefined,
31
+ setFocusId: () => {},
32
+ })
33
+
34
+ interface ListProps extends Omit<React.ComponentProps<"div">, "role" | "children"> {
35
+ /** Makes the list a selectable listbox. Leave undefined for a plain list. */
36
+ selection?: ListSelection
37
+ /** Shown in place of the rows when there are no children. Usually an EmptyState. */
38
+ empty?: React.ReactNode
39
+ /** After the rows. */
40
+ footer?: React.ReactNode
41
+ children?: React.ReactNode
42
+ }
43
+
44
+ function List({
45
+ selection,
46
+ empty,
47
+ footer,
48
+ className,
49
+ children,
50
+ onKeyDown,
51
+ "aria-label": ariaLabel,
52
+ "aria-labelledby": ariaLabelledBy,
53
+ ...props
54
+ }: ListProps) {
55
+ const rows = React.Children.toArray(children)
56
+ const ids = rows.flatMap((row) =>
57
+ React.isValidElement<{ id?: string }>(row) && typeof row.props.id === "string"
58
+ ? [row.props.id]
59
+ : [],
60
+ )
61
+ const [focusId, setFocusId] = React.useState<string | undefined>(undefined)
62
+ const rowsRef = React.useRef<HTMLDivElement>(null)
63
+
64
+ // The one tab stop: the last focused row, else the selected one, else the first.
65
+ const tabStopId =
66
+ [focusId, selection?.selectedId ?? undefined].find(
67
+ (id): id is string => id !== undefined && ids.includes(id),
68
+ ) ?? ids[0]
69
+
70
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
71
+ onKeyDown?.(event)
72
+ if (!selection || event.defaultPrevented) return
73
+ const options = Array.from(
74
+ rowsRef.current?.querySelectorAll<HTMLElement>('[role="option"]') ?? [],
75
+ )
76
+ const current = options.indexOf(event.target as HTMLElement)
77
+ if (current === -1) return
78
+ let next: number
79
+ switch (event.key) {
80
+ case "ArrowDown":
81
+ next = Math.min(current + 1, options.length - 1)
82
+ break
83
+ case "ArrowUp":
84
+ next = Math.max(current - 1, 0)
85
+ break
86
+ case "Home":
87
+ next = 0
88
+ break
89
+ case "End":
90
+ next = options.length - 1
91
+ break
92
+ default:
93
+ return
94
+ }
95
+ event.preventDefault()
96
+ options[next]?.focus()
97
+ }
98
+
99
+ const context = React.useMemo<ListContextValue>(
100
+ () => ({ selection, tabStopId, setFocusId }),
101
+ [selection, tabStopId],
102
+ )
103
+
104
+ return (
105
+ <div data-slot="list" className={cn("flex flex-col", className)} {...props}>
106
+ {rows.length === 0 ? (
107
+ empty
108
+ ) : (
109
+ <ListContext.Provider value={context}>
110
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: arrow keys on the listbox are the roving-tabindex pattern, the options hold the focus */}
111
+ {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: the role is always list or listbox, and both take a name; Biome can't read the ternary */}
112
+ <div
113
+ ref={rowsRef}
114
+ role={selection ? "listbox" : "list"}
115
+ aria-label={ariaLabel}
116
+ aria-labelledby={ariaLabelledBy}
117
+ data-slot="list-rows"
118
+ className="flex flex-col divide-y divide-border"
119
+ onKeyDown={handleKeyDown}
120
+ >
121
+ {rows}
122
+ </div>
123
+ </ListContext.Provider>
124
+ )}
125
+ {footer && rows.length > 0 ? (
126
+ <div
127
+ data-slot="list-footer"
128
+ className="border-t border-border px-3 py-2 text-sm text-muted-foreground"
129
+ >
130
+ {footer}
131
+ </div>
132
+ ) : null}
133
+ </div>
134
+ )
135
+ }
136
+
137
+ interface ListRowProps
138
+ extends Omit<
139
+ React.ComponentProps<"div">,
140
+ "id" | "title" | "role" | "children" | "onClick" | "onSelect"
141
+ > {
142
+ /** Identifies the row to `selection`. Not set as the DOM id. */
143
+ id: string
144
+ /** Before the text: an icon or thumbnail. */
145
+ leading?: React.ReactNode
146
+ title: React.ReactNode
147
+ /** One muted line under the title. */
148
+ detail?: React.ReactNode
149
+ /** After the text: a Button, a Badge. Clickable things go here, not on the row, and only in a plain list. */
150
+ trailing?: React.ReactNode
151
+ }
152
+
153
+ function ListRow({ id, leading, title, detail, trailing, className, ...props }: ListRowProps) {
154
+ const { selection, tabStopId, setFocusId } = React.useContext(ListContext)
155
+ const selectable = selection !== undefined
156
+ const selected = selectable && selection.selectedId === id
157
+
158
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
159
+ props.onKeyDown?.(event)
160
+ // Keys from a trailing control stay with that control.
161
+ if (!selectable || event.defaultPrevented || event.target !== event.currentTarget) return
162
+ if (event.key === "Enter" || event.key === " ") {
163
+ event.preventDefault()
164
+ selection.onSelect(id)
165
+ }
166
+ }
167
+
168
+ return (
169
+ // biome-ignore lint/a11y/noStaticElementInteractions: the role is option when selectable; a plain row has no handlers that act
170
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-selected is set only when the role is option
171
+ <div
172
+ data-slot="list-row"
173
+ data-row-id={id}
174
+ data-selected={selected ? "" : undefined}
175
+ role={selectable ? "option" : "listitem"}
176
+ aria-selected={selectable ? selected : undefined}
177
+ tabIndex={selectable ? (tabStopId === id ? 0 : -1) : undefined}
178
+ className={cn(
179
+ "flex min-h-11 items-center gap-3 px-3 py-1.5 outline-none",
180
+ selectable &&
181
+ "cursor-default hover:bg-accent/50 focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 data-selected:bg-accent",
182
+ className,
183
+ )}
184
+ {...props}
185
+ onKeyDown={handleKeyDown}
186
+ onFocus={(event) => {
187
+ props.onFocus?.(event)
188
+ if (selectable && event.target === event.currentTarget) setFocusId(id)
189
+ }}
190
+ onClick={selectable ? () => selection.onSelect(id) : undefined}
191
+ >
192
+ {leading ? (
193
+ <div className="shrink-0 text-muted-foreground [&_svg]:size-5">{leading}</div>
194
+ ) : null}
195
+ <div className="min-w-0 flex-1">
196
+ <p className="truncate text-sm font-medium">{title}</p>
197
+ {detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
198
+ </div>
199
+ {trailing ? <div className="flex shrink-0 items-center gap-2">{trailing}</div> : null}
200
+ </div>
201
+ )
202
+ }
203
+
204
+ export type { ListProps, ListRowProps, ListSelection }
205
+ export { List, ListRow }
@@ -7,6 +7,7 @@ import { cn } from "../lib/utils"
7
7
  // Base UI renders content through a shared Portal > Positioner > Popup >
8
8
  // Viewport. NavigationMenu bundles that, so `NavigationMenuContent` items
9
9
  // morph inside one popup like the shadcn viewport variant.
10
+ // Always the shared-viewport form: the Radix indicator and viewport={false} mode are gone.
10
11
  function NavigationMenu({
11
12
  className,
12
13
  children,
@@ -0,0 +1,308 @@
1
+ import { CaretDownIcon, FilePlusIcon } from "@phosphor-icons/react"
2
+ import * as React from "react"
3
+
4
+ import { cn } from "../lib/utils"
5
+ import { Button } from "./button"
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuTrigger,
11
+ } from "./dropdown-menu"
12
+ import { Input } from "./input"
13
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
14
+
15
+ // PathField: label, a text field for a file path, then a ▾ list of recent paths and a browse icon,
16
+ // both bare. The kit knows nothing of Tauri or the file system: the native dialog, the recent list
17
+ // and the Missing check arrive through props (app-kit supplies them). Paths are shown exactly as
18
+ // written, relative or absolute; the field never rewrites one. Text commits on Return or blur,
19
+ // never per keystroke; Esc reverts. A refused commit (status "invalid") keeps the typed draft.
20
+
21
+ /**
22
+ * The DOM event a path field listens for on its root: a `CustomEvent` with `detail: { path }`,
23
+ * sent by whatever turns an OS file drop into DOM (app-kit's `installPathDrop`). The kit knows
24
+ * nothing of Tauri, only this name. app-kit/core cannot import it, so it repeats the string.
25
+ */
26
+ const PATH_DROP_EVENT = "path-drop"
27
+ /** Set on the field's root, by the same code, while a drag is over it; the field draws a ring. */
28
+ const PATH_DROP_TARGET_ATTRIBUTE = "data-drop-target"
29
+
30
+ type PathFieldStatus = "ok" | "missing" | "invalid"
31
+
32
+ interface PathFieldProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
33
+ /** The path as written, relative or absolute. `null` is empty. */
34
+ value: string | null
35
+ /** Return or blur with changed text: the trimmed text, or null when empty. Missing makes the field read-only. */
36
+ onCommit?: (path: string | null) => void
37
+ /** Recent paths, newest first, for the ▾ list. Empty disables the ▾. */
38
+ recent?: string[]
39
+ /** Opens the native file dialog. A resolved path is committed; null (cancelled) does nothing. Missing hides the icon. */
40
+ onBrowse?: () => Promise<string | null>
41
+ /** "missing": the path in the warning tone with a "Missing" tag. "invalid": the field is marked and `message` shown. */
42
+ status?: PathFieldStatus
43
+ /** Why the path was refused; shown below the field when `status` is "invalid". */
44
+ message?: string
45
+ label?: string
46
+ placeholder?: string
47
+ disabled?: boolean
48
+ }
49
+
50
+ /** Splits a path at its last separator into the file name and the folder (with no trailing slash). */
51
+ function splitPath(path: string): { name: string; folder: string } {
52
+ const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
53
+ if (i < 0) return { name: path, folder: "" }
54
+ return { name: path.slice(i + 1) || path, folder: path.slice(0, i) || path.slice(i, i + 1) }
55
+ }
56
+
57
+ /** A bare icon button with a tooltip. A disabled button gets a span trigger so the tooltip still shows. */
58
+ function IconButton({
59
+ label,
60
+ disabled,
61
+ children,
62
+ ...props
63
+ }: Omit<React.ComponentProps<typeof Button>, "aria-label"> & { label: string }) {
64
+ const button = (
65
+ <Button
66
+ type="button"
67
+ variant="ghost"
68
+ size="icon-xs"
69
+ aria-label={label}
70
+ disabled={disabled}
71
+ className="text-muted-foreground"
72
+ {...props}
73
+ >
74
+ {children}
75
+ </Button>
76
+ )
77
+ return (
78
+ <Tooltip>
79
+ {disabled ? (
80
+ <TooltipTrigger render={<span className="inline-flex" />}>{button}</TooltipTrigger>
81
+ ) : (
82
+ <TooltipTrigger render={button} />
83
+ )}
84
+ <TooltipContent side="bottom">{label}</TooltipContent>
85
+ </Tooltip>
86
+ )
87
+ }
88
+
89
+ function PathField({
90
+ value,
91
+ onCommit,
92
+ recent,
93
+ onBrowse,
94
+ status = "ok",
95
+ message,
96
+ label,
97
+ placeholder,
98
+ disabled,
99
+ id,
100
+ className,
101
+ ref,
102
+ ...props
103
+ }: PathFieldProps) {
104
+ const generatedId = React.useId()
105
+ const inputId = id ?? generatedId
106
+ const noteId = `${inputId}-note`
107
+ const locked = !onCommit
108
+ const inert = locked || !!disabled
109
+
110
+ const inputRef = React.useRef<HTMLInputElement>(null)
111
+ // The typed text while it differs from `value`. It outlives a commit the app refused ("invalid")
112
+ // and is dropped when `value` changes, on Esc, or on a blur that changed nothing.
113
+ const [draft, setDraft] = React.useState<string | null>(null)
114
+ // The text last sent to onCommit, so Return then blur does not send it twice.
115
+ const sent = React.useRef<string | null>(null)
116
+
117
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a new value is the app accepting (or replacing) the text
118
+ React.useEffect(() => {
119
+ setDraft(null)
120
+ sent.current = null
121
+ }, [value])
122
+
123
+ // At rest the input shows the end of a long path (the file name), not the start.
124
+ const showEnd = React.useCallback(() => {
125
+ const el = inputRef.current
126
+ if (el && document.activeElement !== el) el.scrollLeft = el.scrollWidth
127
+ }, [])
128
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the shown text changes
129
+ React.useLayoutEffect(showEnd, [showEnd, value, draft])
130
+
131
+ const commit = () => {
132
+ if (draft === null) return
133
+ const text = draft.trim()
134
+ if (text === (value ?? "")) {
135
+ setDraft(null)
136
+ return
137
+ }
138
+ if (text === sent.current) return
139
+ sent.current = text
140
+ onCommit?.(text === "" ? null : text)
141
+ }
142
+
143
+ const choose = (path: string) => {
144
+ setDraft(null)
145
+ onCommit?.(path)
146
+ }
147
+
148
+ // A file dropped on the field commits like a pick, unless the field is read-only or disabled.
149
+ // The listener is on the root, which the host owns, so it reads the latest `choose` from a ref.
150
+ const dropped = React.useRef<((path: string) => void) | null>(null)
151
+ dropped.current = inert ? null : choose
152
+ const rootRef = React.useCallback(
153
+ (el: HTMLDivElement | null) => {
154
+ const setRef = (value: HTMLDivElement | null) => {
155
+ if (typeof ref === "function") ref(value)
156
+ else if (ref) ref.current = value
157
+ }
158
+ setRef(el)
159
+ if (!el) return
160
+ const onDrop = (e: Event) => {
161
+ const path = (e as CustomEvent<{ path?: unknown }>).detail?.path
162
+ if (typeof path === "string" && path !== "") dropped.current?.(path)
163
+ }
164
+ el.addEventListener(PATH_DROP_EVENT, onDrop)
165
+ return () => {
166
+ el.removeEventListener(PATH_DROP_EVENT, onDrop)
167
+ setRef(null)
168
+ }
169
+ },
170
+ [ref],
171
+ )
172
+
173
+ const browse = async () => {
174
+ const picked = await onBrowse?.()
175
+ if (picked != null) choose(picked)
176
+ }
177
+
178
+ const missing = status === "missing" && draft === null && !!value
179
+ const invalid = status === "invalid"
180
+ const note = invalid && message ? message : null
181
+ const hasRecent = !!recent?.length
182
+
183
+ return (
184
+ <div
185
+ ref={rootRef}
186
+ data-slot="path-field"
187
+ className={cn("group/path-field flex flex-col gap-1", className)}
188
+ {...props}
189
+ >
190
+ <div className={cn("flex items-center gap-1", disabled && "opacity-50")}>
191
+ {label ? (
192
+ <label htmlFor={inputId} className="mr-1 shrink-0 text-xs text-muted-foreground">
193
+ {label}
194
+ </label>
195
+ ) : null}
196
+ <div
197
+ className={cn(
198
+ "relative min-w-0 flex-1",
199
+ !inert &&
200
+ "group-data-[drop-target]/path-field:ring-2 group-data-[drop-target]/path-field:ring-ring",
201
+ )}
202
+ >
203
+ <Input
204
+ ref={inputRef}
205
+ id={inputId}
206
+ value={draft ?? value ?? ""}
207
+ placeholder={placeholder}
208
+ readOnly={locked}
209
+ disabled={disabled}
210
+ spellCheck={false}
211
+ aria-invalid={invalid || undefined}
212
+ aria-describedby={note || missing ? noteId : undefined}
213
+ onChange={(e) => {
214
+ sent.current = null
215
+ setDraft(e.target.value)
216
+ }}
217
+ onBlur={() => {
218
+ commit()
219
+ showEnd()
220
+ }}
221
+ onKeyDown={(e) => {
222
+ if (e.key === "Enter") commit()
223
+ if (e.key === "Escape" && draft !== null) {
224
+ e.stopPropagation()
225
+ sent.current = null
226
+ setDraft(null)
227
+ }
228
+ }}
229
+ className={cn("font-mono", missing && "pr-16 text-destructive")}
230
+ />
231
+ {missing ? (
232
+ <span
233
+ id={noteId}
234
+ className="pointer-events-none absolute inset-y-0 right-2 flex items-center bg-background pl-1 text-[11px] font-medium text-destructive dark:bg-transparent"
235
+ >
236
+ Missing
237
+ </span>
238
+ ) : null}
239
+ </div>
240
+ <DropdownMenu>
241
+ <Tooltip>
242
+ {hasRecent && !inert ? (
243
+ <TooltipTrigger
244
+ render={
245
+ <DropdownMenuTrigger
246
+ render={
247
+ <Button
248
+ type="button"
249
+ variant="ghost"
250
+ size="icon-xs"
251
+ aria-label="Recent"
252
+ className="text-muted-foreground"
253
+ />
254
+ }
255
+ />
256
+ }
257
+ >
258
+ <CaretDownIcon />
259
+ </TooltipTrigger>
260
+ ) : (
261
+ <TooltipTrigger render={<span className="inline-flex" />}>
262
+ <Button
263
+ type="button"
264
+ variant="ghost"
265
+ size="icon-xs"
266
+ aria-label="Recent"
267
+ disabled
268
+ className="text-muted-foreground"
269
+ >
270
+ <CaretDownIcon />
271
+ </Button>
272
+ </TooltipTrigger>
273
+ )}
274
+ <TooltipContent side="bottom">
275
+ {hasRecent ? "Recent" : "No recent files"}
276
+ </TooltipContent>
277
+ </Tooltip>
278
+ <DropdownMenuContent align="end" className="max-w-96">
279
+ {recent?.map((path) => {
280
+ const { name, folder } = splitPath(path)
281
+ return (
282
+ <DropdownMenuItem key={path} onClick={() => choose(path)} title={path}>
283
+ <span className="shrink-0 font-medium">{name}</span>
284
+ {folder ? (
285
+ <span className="min-w-0 truncate text-xs text-muted-foreground">{folder}</span>
286
+ ) : null}
287
+ </DropdownMenuItem>
288
+ )
289
+ })}
290
+ </DropdownMenuContent>
291
+ </DropdownMenu>
292
+ {onBrowse ? (
293
+ <IconButton label="Choose File…" disabled={inert} onClick={browse}>
294
+ <FilePlusIcon />
295
+ </IconButton>
296
+ ) : null}
297
+ </div>
298
+ {note ? (
299
+ <p id={noteId} className="text-xs text-destructive">
300
+ {note}
301
+ </p>
302
+ ) : null}
303
+ </div>
304
+ )
305
+ }
306
+
307
+ export type { PathFieldProps, PathFieldStatus }
308
+ export { PATH_DROP_EVENT, PATH_DROP_TARGET_ATTRIBUTE, PathField, splitPath }
@@ -3,6 +3,9 @@ import type * as React from "react"
3
3
 
4
4
  import { cn } from "../lib/utils"
5
5
 
6
+ // Popover: Base UI Popover. There is no `PopoverAnchor`: Base UI anchors through the
7
+ // Positioner's `anchor` prop.
8
+
6
9
  function Popover({ ...props }: PopoverPrimitive.Root.Props) {
7
10
  return <PopoverPrimitive.Root data-slot="popover" {...props} />
8
11
  }
@@ -0,0 +1,77 @@
1
+ import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
2
+
3
+ import { cn } from "../lib/utils"
4
+
5
+ type ProgressProps = {
6
+ /** `null` is indeterminate: a slow moving segment, still under `prefers-reduced-motion`. */
7
+ value: number | null
8
+ max?: number
9
+ /** The accessible name; shown above the bar when `showLabel`. */
10
+ label?: string
11
+ showLabel?: boolean
12
+ /** Right-aligned text the caller builds ("12.4 / 44.7 MB", "63%"). The kit does not format it. */
13
+ detail?: string
14
+ className?: string
15
+ }
16
+
17
+ // A 6 px rounded bar that fills its container: `--primary` on `--muted`. No per-state colour:
18
+ // a failure is the caller's message, not a red bar.
19
+ function Progress({
20
+ value,
21
+ max = 100,
22
+ label,
23
+ showLabel = false,
24
+ detail,
25
+ className,
26
+ }: ProgressProps) {
27
+ const labelShown = showLabel && label !== undefined
28
+ return (
29
+ <ProgressPrimitive.Root
30
+ data-slot="progress"
31
+ value={value}
32
+ max={max}
33
+ aria-label={labelShown ? undefined : label}
34
+ className={cn("flex w-full flex-col gap-1.5", className)}
35
+ >
36
+ {(labelShown || detail !== undefined) && (
37
+ <div className="flex items-baseline justify-between gap-3 text-xs">
38
+ {labelShown ? (
39
+ <ProgressPrimitive.Label data-slot="progress-label" className="font-medium">
40
+ {label}
41
+ </ProgressPrimitive.Label>
42
+ ) : (
43
+ <span />
44
+ )}
45
+ {detail !== undefined && (
46
+ <span data-slot="progress-detail" className="text-muted-foreground tabular-nums">
47
+ {detail}
48
+ </span>
49
+ )}
50
+ </div>
51
+ )}
52
+ <ProgressPrimitive.Track
53
+ data-slot="progress-track"
54
+ className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted"
55
+ >
56
+ <ProgressPrimitive.Indicator
57
+ data-slot="progress-indicator"
58
+ className="h-full rounded-full bg-primary transition-[width] data-indeterminate:w-2/5 data-indeterminate:animate-[progress-slide_2s_ease-in-out_infinite] data-indeterminate:transition-none motion-reduce:data-indeterminate:animate-none"
59
+ />
60
+ </ProgressPrimitive.Track>
61
+ </ProgressPrimitive.Root>
62
+ )
63
+ }
64
+
65
+ // Decimal sizes, one place: bytes under a kilobyte, then kilobytes, megabytes and gigabytes.
66
+ // 46_900_000 is "46.9 MB", 1_200_000_000 is "1.2 GB", 2_048 is "2.0 KB", 0 is "0 B".
67
+ function formatBytes(n: number): string {
68
+ if (!Number.isFinite(n) || n <= 0) return "0 B"
69
+ if (n < 1_000) return `${Math.round(n)} B`
70
+ // Each threshold sits where one place rounds up to 1000.0 of the smaller unit.
71
+ if (n < 999_950) return `${(n / 1_000).toFixed(1)} KB`
72
+ if (n < 999_950_000) return `${(n / 1_000_000).toFixed(1)} MB`
73
+ return `${(n / 1_000_000_000).toFixed(1)} GB`
74
+ }
75
+
76
+ export type { ProgressProps }
77
+ export { formatBytes, Progress }
@@ -14,6 +14,10 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "
14
14
  import { Skeleton } from "./skeleton"
15
15
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
16
16
 
17
+ // Sidebar: shadcn's sidebar with `asChild` replaced by `render`, and `useIsMobile` kept in
18
+ // the kit. Only the `collapsible="none"` form is exercised in the playground; the offcanvas
19
+ // and icon forms are untested here.
20
+
17
21
  // Base UI's replacement for Radix's asChild: pass `render={<a href="..." />}`
18
22
  // to swap the element, props and ref are merged onto it.
19
23
  function Slotted({
@@ -4,8 +4,9 @@ import type * as React from "react"
4
4
 
5
5
  import { cn } from "../lib/utils"
6
6
 
7
- // The kit's one toast: Strata's snackbar, on Base UI Toast.
8
- // One manager for the module, so `notify()` works outside React. Mount one provider per window.
7
+ // The kit's one toast: Strata's snackbar, on Base UI Toast. Kinds: plain, success, warning, error.
8
+ // One manager for the module, so `notify()` works outside React. Mount one `SnackbarProvider` per
9
+ // window, with a `SnackbarViewport` inside it.
9
10
  const manager = Toast.createToastManager()
10
11
 
11
12
  export function SnackbarProvider({ children }: { children: React.ReactNode }) {
@@ -44,6 +44,8 @@ function TabsList({
44
44
  )
45
45
  }
46
46
 
47
+ // Orientation styles key on `data-[orientation=...]`.
48
+
47
49
  // Base UI calls the trigger `Tab` and the content `Panel`; the kit keeps the
48
50
  // shadcn names (TabsTrigger, TabsContent) so apps read the same.
49
51
  function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
@@ -3,6 +3,8 @@ import { cva, type VariantProps } from "class-variance-authority"
3
3
 
4
4
  import { cn } from "../lib/utils"
5
5
 
6
+ // Toggle: the pressed state is `data-pressed`, not Radix's `data-[state=on]`. Style with that.
7
+
6
8
  const toggleVariants = cva(
7
9
  "group/toggle inline-flex items-center justify-center gap-1 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
10
  {
@@ -33,6 +33,10 @@ interface ToolbarMenuEntry {
33
33
  shortcut?: string
34
34
  /** Default true. */
35
35
  enabled?: boolean
36
+ /** Drawn before the label: a tool flyout's entries are tools with icons. */
37
+ icon?: React.ReactNode
38
+ /** The active one of a tool flyout's entries. */
39
+ pressed?: boolean
36
40
  }
37
41
 
38
42
  /** One toolbar item. `id` is the command id shared with the menu. */
@@ -48,7 +52,11 @@ interface ToolbarItemSpec {
48
52
  disabledReason?: string
49
53
  /** Present (true or false) makes the item a toggle. Absent means a plain button. */
50
54
  pressed?: boolean
51
- /** Present makes the item a dropdown; picking an entry calls `onCommand(entry.id)`. */
55
+ /**
56
+ * Present makes the item a dropdown; picking an entry calls `onCommand(entry.id)`. With
57
+ * `pressed` too it is a tool flyout: several tools behind one button, pressed while one of
58
+ * them is active.
59
+ */
52
60
  menu?: ToolbarMenuEntry[]
53
61
  }
54
62
 
@@ -167,7 +175,7 @@ function ToolbarButton({
167
175
  )
168
176
  }
169
177
 
170
- interface ToolbarDropdownProps extends Omit<ToolbarItemSpec, "id" | "menu" | "pressed"> {
178
+ interface ToolbarDropdownProps extends Omit<ToolbarItemSpec, "id" | "menu"> {
171
179
  entries: ToolbarMenuEntry[]
172
180
  onCommand?: (id: string) => void
173
181
  }
@@ -179,6 +187,7 @@ function ToolbarDropdown({
179
187
  shortcut,
180
188
  enabled = true,
181
189
  disabledReason,
190
+ pressed,
182
191
  entries,
183
192
  onCommand,
184
193
  }: ToolbarDropdownProps) {
@@ -192,8 +201,9 @@ function ToolbarDropdown({
192
201
  <ToolbarPrimitive.Button
193
202
  data-slot="toolbar-dropdown"
194
203
  aria-label={label}
204
+ aria-pressed={pressed}
195
205
  disabled={!enabled}
196
- className={buttonClass()}
206
+ className={buttonClass(pressed)}
197
207
  />
198
208
  }
199
209
  />
@@ -215,7 +225,9 @@ function ToolbarDropdown({
215
225
  key={e.id}
216
226
  disabled={e.enabled === false}
217
227
  onClick={() => onCommand?.(e.id)}
228
+ className={cn(e.pressed && "bg-muted")}
218
229
  >
230
+ {e.icon}
219
231
  {e.label}
220
232
  {e.shortcut ? <DropdownMenuShortcut>{e.shortcut}</DropdownMenuShortcut> : null}
221
233
  </DropdownMenuItem>
@@ -16,10 +16,12 @@ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
16
16
  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
17
17
  }
18
18
 
19
+ // No arrow: a tooltip's diamond sat over the control being clicked (Georg, 2026-10-28), so the
20
+ // popup stands a little clear of the trigger instead.
19
21
  function TooltipContent({
20
22
  className,
21
23
  side = "top",
22
- sideOffset = 4,
24
+ sideOffset = 6,
23
25
  align = "center",
24
26
  alignOffset = 0,
25
27
  children,
@@ -44,7 +46,6 @@ function TooltipContent({
44
46
  {...props}
45
47
  >
46
48
  {children}
47
- <TooltipPrimitive.Arrow className="z-50 size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
48
49
  </TooltipPrimitive.Popup>
49
50
  </TooltipPrimitive.Positioner>
50
51
  </TooltipPrimitive.Portal>
package/src/index.css CHANGED
@@ -15,6 +15,14 @@
15
15
 
16
16
  @custom-variant dark (&:is(.dark *));
17
17
 
18
+ /* Progress, indeterminate: the 40%-wide segment crosses the track left to right. */
19
+ @theme inline {
20
+ @keyframes progress-slide {
21
+ from { transform: translateX(-100%); }
22
+ to { transform: translateX(250%); }
23
+ }
24
+ }
25
+
18
26
  @theme inline {
19
27
  /* Headings use the interface face; hierarchy comes from size and weight. */
20
28
  --font-heading: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
package/src/index.ts CHANGED
@@ -6,15 +6,20 @@ export * from "./components/card"
6
6
  export * from "./components/checkbox"
7
7
  export * from "./components/color-field"
8
8
  export * from "./components/color-swatch"
9
+ export * from "./components/confirm-dialog"
9
10
  export * from "./components/context-menu"
10
11
  export * from "./components/dialog"
12
+ export * from "./components/download-prompt"
11
13
  export * from "./components/dropdown-menu"
12
14
  export * from "./components/empty-state"
13
15
  export * from "./components/input"
14
16
  export * from "./components/label"
17
+ export * from "./components/list"
15
18
  export * from "./components/navigation-menu"
16
19
  export * from "./components/number-field"
20
+ export * from "./components/path-field"
17
21
  export * from "./components/popover"
22
+ export * from "./components/progress"
18
23
  export * from "./components/select"
19
24
  export * from "./components/separator"
20
25
  export * from "./components/sheet"