@preset.nz/ux-kit 0.1.1 → 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.
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.2",
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,
@@ -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
  {
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,19 @@ 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"
17
20
  export * from "./components/popover"
21
+ export * from "./components/progress"
18
22
  export * from "./components/select"
19
23
  export * from "./components/separator"
20
24
  export * from "./components/sheet"