@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 +4 -42
- package/package.json +5 -4
- package/src/components/alert-shell.tsx +66 -0
- package/src/components/badge.tsx +2 -0
- package/src/components/confirm-dialog.tsx +93 -0
- package/src/components/context-menu.tsx +3 -0
- package/src/components/dialog.tsx +3 -0
- package/src/components/download-prompt.tsx +108 -0
- package/src/components/dropdown-menu.tsx +3 -0
- package/src/components/list.tsx +205 -0
- package/src/components/navigation-menu.tsx +1 -0
- package/src/components/path-field.tsx +308 -0
- package/src/components/popover.tsx +3 -0
- package/src/components/progress.tsx +77 -0
- package/src/components/sidebar.tsx +4 -0
- package/src/components/snackbar.tsx +3 -2
- package/src/components/tabs.tsx +2 -0
- package/src/components/toggle.tsx +2 -0
- package/src/components/toolbar.tsx +15 -3
- package/src/components/tooltip.tsx +3 -2
- package/src/index.css +8 -0
- package/src/index.ts +5 -0
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
|
-
|
|
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 #
|
|
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
|
|
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.
|
|
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/
|
|
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 }
|
package/src/components/badge.tsx
CHANGED
|
@@ -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
|
|
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 }) {
|
package/src/components/tabs.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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"
|
|
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 =
|
|
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"
|