@vvedantb/ui 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -8
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/ui/badge.tsx +2 -0
- package/src/ui/confirm-dialog-parts.tsx +44 -7
- package/src/ui/empty-state.tsx +119 -0
- package/src/ui/kbd.tsx +16 -6
- package/src/ui/spinner.tsx +22 -6
- package/src/ui/tabs.tsx +28 -17
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ import { ConfirmDialog, SlideOverPanel, ToolbarSearchInput } from "@vvedantb/ui"
|
|
|
215
215
|
- `SlideOverPanel`: a right-side panel, absolutely positioned inside its nearest positioned parent. It is not a portal or dialog. It slides in with a spring and is hidden below `md`. Only the close button closes it (no Escape, no outside click). `width` is `"w-96"` (default) or `"w-80"`. `closeLabel` defaults to `"Close panel"`. `className` merges last.
|
|
216
216
|
- `ToolbarSearchInput`: a compact `h-8` search field for toolbars, with a search icon and a clear button. The clear button stays mounted and is hidden while the value is blank. Clearing calls `onChange("")` and does not move focus. Defaults: `placeholder="Search..."`, `label="Search"` (the `aria-label`), `clearLabel="Clear search"`. The ref goes to the `<input>`.
|
|
217
217
|
- `ConfirmDialog` `size`: `"default"` keeps the alert dialog. `"sm"` renders a compact, dismissible dialog (outside click, Escape and the close button close it) with small Cancel and confirm buttons. Cancel calls `onCancel` and then `onOpenChange(false)`. Confirm calls `onConfirm` only, so the caller closes it. `"md"` (0.5.2) is dismissible like `"sm"`, with the title in a header (`pb-4`), the description in its own section (`py-6`) and a footer (`pt-4`) with default-size buttons. It has the same Cancel and confirm behaviour as `"sm"`.
|
|
218
|
-
- Legacy (still supported): the `ConfirmDialog` `size`, `variant`, `cancelLabel` and `confirmLabel` props. For anything they do not cover, compose the parts (see [Composable parts](#composable-parts-060)).
|
|
218
|
+
- Legacy (still supported): the `ConfirmDialog` `size`, `variant`, `cancelLabel` and `confirmLabel` props. For anything they do not cover, compose the parts (see [Composable parts](#composable-parts-060-options-061)).
|
|
219
219
|
|
|
220
220
|
## Buttons, menus and fields (0.4.4)
|
|
221
221
|
|
|
@@ -381,28 +381,28 @@ function PanelWidth() {
|
|
|
381
381
|
</Tabs>
|
|
382
382
|
```
|
|
383
383
|
|
|
384
|
-
## Composable parts (0.6.0)
|
|
384
|
+
## Composable parts (0.6.0, options 0.6.1)
|
|
385
385
|
|
|
386
386
|
Shadcn-style building blocks. The prop-driven components above are thin compositions of them and render the same DOM.
|
|
387
387
|
|
|
388
|
-
Convention: compound parts are static members of a namespace object, `<Namespace>.<Part>`. There are
|
|
388
|
+
Convention: compound parts are static members of a namespace object, `<Namespace>.<Part>`. There are four namespaces: `ConfirmDialog.*`, `AppShell.*`, `Sidebar.*` and (0.7.0) `EmptyState.*` (see [Spinner, empty state, tabs, badge and kbd](#spinner-empty-state-tabs-badge-and-kbd-070)). `ConfirmDialog` and `AppShell` stay callable with their legacy props. `Sidebar` is a plain object. Prop types are exported flat, for example `ConfirmDialogRootProps`, `AppShellMainProps` and `SidebarRailItemProps`. Every part that renders an element merges `className` last and forwards native props.
|
|
389
389
|
|
|
390
390
|
### `ConfirmDialog.*`
|
|
391
391
|
|
|
392
392
|
| Part | Purpose |
|
|
393
393
|
| --- | --- |
|
|
394
|
-
| `Root` | Radix dialog root. `size` (`"default"`, `"sm"`, `"md"`: layout slot for the other parts), `alert` (blocking alert dialog; default `size === "default"`), `pending` (swallows close requests, disables Cancel, Action shows its pending state), `confirmPhrase` (type-to-confirm; the typed text resets each time the dialog opens). |
|
|
394
|
+
| `Root` | Radix dialog root. `size` (`"default"`, `"sm"`, `"md"`: layout slot for the other parts), `alert` (blocking alert dialog; default `size === "default"`), `pending` (swallows close requests, disables Cancel, Action shows its pending state), `confirmPhrase` (type-to-confirm; the typed text resets each time the dialog opens). 0.6.1: `dismissibleWhilePending` (Escape, outside click and the close button still close while `pending`; Cancel stays disabled), `focusCancel` (with `alert={false}`, Cancel gets `data-alert-cancel` so a `role="alertdialog"` Content focuses it on open). |
|
|
395
395
|
| `Content` | `AlertDialogContent` with `alert`, else `DialogContent`. `sm` and `md` add `sm:max-w-96`. Takes every `DialogContent` prop (`role`, `hideCloseButton`, ...). |
|
|
396
396
|
| `Header` | `DialogHeader`; `md` adds `pb-4`. |
|
|
397
397
|
| `Title` | `DialogTitle`; `sm` adds `mb-2 text-sm font-medium`. |
|
|
398
398
|
| `Description` | `DialogDescription`; `sm` and `md` add their spacing. |
|
|
399
399
|
| `Body` | Plain `<div>` for extra content. No default classes. |
|
|
400
|
-
| `Footer` | `DialogFooter` (`md` adds `pt-4`); `sm` renders a `flex justify-end gap-2` row. |
|
|
401
|
-
| `Cancel` | Alert: the outline `AlertDialogCancel` (closes through Radix). Otherwise a ghost `Button` that runs `onClick`, then (unless it called `preventDefault()`) the Root `onOpenChange(false)`. Disabled while pending. Default text `"Cancel"`. |
|
|
402
|
-
| `Action` | Confirm button. Never closes the dialog. Disabled while `disabled`, pending, or until `confirmPhrase` matches. `pendingLabel` replaces the children with a spinner (`spinnerSize`, default 14) and the label while pending. |
|
|
400
|
+
| `Footer` | `DialogFooter` (`md` adds `pt-4`); `sm` renders a `flex justify-end gap-2` row. 0.6.1: `row` keeps a right-aligned row at every width, compact sheet included (`justify-end flex-row!`); no-op for `sm`. |
|
|
401
|
+
| `Cancel` | Alert: the outline `AlertDialogCancel` (closes through Radix). Otherwise a ghost `Button` that runs `onClick`, then (unless it called `preventDefault()`) the Root `onOpenChange(false)`. In both modes `preventDefault()` in `onClick` skips the close, and `variant` overrides the default. Disabled while pending. Default text `"Cancel"`. |
|
|
402
|
+
| `Action` | Confirm button. Never closes the dialog. Disabled while `disabled`, pending, or until `confirmPhrase` matches. `pendingLabel` replaces the children with a spinner (`spinnerSize`, default 14) and the label while pending. `pending` on Action alone shows the pending state without blocking close. 0.6.1: `spinnerClassName` merges after `animate-spin` (for example `"mr-2"`). |
|
|
403
403
|
| `Field` | Type-to-confirm input bound to `confirmPhrase` (trimmed, case-insensitive). `label` replaces the label content, `onEnter` runs on Enter while the phrase matches, `inputProps` go to the `Input`. |
|
|
404
404
|
|
|
405
|
-
`useConfirmDialog()` returns `{ size, alert, pending, phraseMatches, typed, setTyped, requestClose }` for custom parts.
|
|
405
|
+
`useConfirmDialog()` returns `{ size, alert, pending, focusCancel, phraseMatches, typed, setTyped, requestClose }` for custom parts.
|
|
406
406
|
|
|
407
407
|
### `AppShell.*`
|
|
408
408
|
|
|
@@ -483,6 +483,27 @@ A dismissible rename dialog:
|
|
|
483
483
|
</ConfirmDialog.Root>
|
|
484
484
|
```
|
|
485
485
|
|
|
486
|
+
A dismissible alert (0.6.1 options) that focuses Cancel, keeps a button row on mobile, and can still be closed while busy:
|
|
487
|
+
|
|
488
|
+
```tsx
|
|
489
|
+
<ConfirmDialog.Root open={open} onOpenChange={setOpen} alert={false} focusCancel pending={busy} dismissibleWhilePending>
|
|
490
|
+
<ConfirmDialog.Content role="alertdialog">
|
|
491
|
+
<ConfirmDialog.Header>
|
|
492
|
+
<ConfirmDialog.Title>Delete item?</ConfirmDialog.Title>
|
|
493
|
+
<ConfirmDialog.Description>This cannot be undone.</ConfirmDialog.Description>
|
|
494
|
+
</ConfirmDialog.Header>
|
|
495
|
+
<ConfirmDialog.Footer row>
|
|
496
|
+
<ConfirmDialog.Cancel variant="outline" />
|
|
497
|
+
<ConfirmDialog.Action variant="destructive" pendingLabel="Delete" spinnerSize={16} spinnerClassName="mr-2" onClick={remove}>
|
|
498
|
+
Delete
|
|
499
|
+
</ConfirmDialog.Action>
|
|
500
|
+
</ConfirmDialog.Footer>
|
|
501
|
+
</ConfirmDialog.Content>
|
|
502
|
+
</ConfirmDialog.Root>
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Parts read the nearest Root through context, so `Footer`, `Cancel` and `Action` work inside a child component or a `<form>` rendered in `Content`. To show another step, render a different footer (and Content `role`) conditionally inside the same Root and Content.
|
|
506
|
+
|
|
486
507
|
A full shell:
|
|
487
508
|
|
|
488
509
|
```tsx
|
|
@@ -538,6 +559,65 @@ A full shell:
|
|
|
538
559
|
</AppShell.Root>
|
|
539
560
|
```
|
|
540
561
|
|
|
562
|
+
## Spinner, empty state, tabs, badge and kbd (0.7.0)
|
|
563
|
+
|
|
564
|
+
All of these are opt-in. Without the new props, components render as in 0.6.1.
|
|
565
|
+
|
|
566
|
+
```tsx
|
|
567
|
+
import {
|
|
568
|
+
Badge, Button, EmptyState, Kbd, Spinner, Tabs, TabsList, TabsTrigger,
|
|
569
|
+
} from "@vvedantb/ui";
|
|
570
|
+
|
|
571
|
+
<Button disabled={saving}>
|
|
572
|
+
{saving ? <Spinner size={14} tone="inherit" decorative className="mr-1.5" /> : null}
|
|
573
|
+
Save
|
|
574
|
+
</Button>
|
|
575
|
+
|
|
576
|
+
<EmptyState.Root className="h-full">
|
|
577
|
+
<EmptyState.Icon icon={IconFolder} />
|
|
578
|
+
<EmptyState.Description>No files yet.</EmptyState.Description>
|
|
579
|
+
</EmptyState.Root>
|
|
580
|
+
|
|
581
|
+
<EmptyState.Root variant="panel">
|
|
582
|
+
<EmptyState.Icon icon={IconLink} />
|
|
583
|
+
<EmptyState.Title>No linked items</EmptyState.Title>
|
|
584
|
+
<EmptyState.Description>Link one to see it here.</EmptyState.Description>
|
|
585
|
+
<EmptyState.Actions><Button size="sm">Link</Button></EmptyState.Actions>
|
|
586
|
+
</EmptyState.Root>
|
|
587
|
+
|
|
588
|
+
<Tabs value={active}>
|
|
589
|
+
<TabsList>
|
|
590
|
+
<TabsTrigger value="keys" asChild>
|
|
591
|
+
<Link to="/settings/keys">Keys</Link>
|
|
592
|
+
</TabsTrigger>
|
|
593
|
+
</TabsList>
|
|
594
|
+
</Tabs>
|
|
595
|
+
|
|
596
|
+
<Tabs value={feature} onValueChange={setFeature}>
|
|
597
|
+
<TabsList variant="soft" aria-label="Features">
|
|
598
|
+
<TabsTrigger value="notes"><IconNotes size={15} />Notes</TabsTrigger>
|
|
599
|
+
</TabsList>
|
|
600
|
+
</Tabs>
|
|
601
|
+
|
|
602
|
+
<Badge variant="destructive-soft">Revoked</Badge>
|
|
603
|
+
<Kbd variant="subtle">Esc</Kbd>
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
- `Spinner` `size`: `"sm"`, `"md"` (default) and `"lg"` add a `size-*` class; a number is passed to the icon as its pixel size and adds no size class. `stroke` sets the stroke width. `tone`: `"muted"` (default) adds `text-muted-foreground`; `"inherit"` adds no colour class, so the spinner takes the current text colour. A colour class in `className` also replaces the muted default. `decorative` renders `aria-hidden="true"` with no `role="status"` or `aria-label`; use it where the parent already has a label, such as a busy button. Without it, `aria-label` in props replaces `"Loading"`.
|
|
607
|
+
- `EmptyState.*`: composable "nothing here yet" block. Parts read the Root `variant`.
|
|
608
|
+
|
|
609
|
+
| Part | Purpose |
|
|
610
|
+
| --- | --- |
|
|
611
|
+
| `Root` | `<div>`, centred column. `variant`: `"default"` (`px-4 py-20`, open) or `"panel"` (`rounded-lg bg-surface-secondary px-4 py-10`). |
|
|
612
|
+
| `Icon` | `icon` component (for example a Tabler icon). `default`: the bare icon, `size` 40, `mb-3 text-muted-foreground`. `panel`: the icon (`size` 22, `stroke` 1.5, `text-muted-foreground`) in an `h-11 w-11 rounded-lg bg-surface-tertiary` tile. `size` and `stroke` override; `className` goes on the outer element (icon or tile). |
|
|
613
|
+
| `Title` | `<p>`, `text-sm font-medium text-foreground`; `default` adds `mb-1`. `asChild` renders your element (for example an `h3`) instead. |
|
|
614
|
+
| `Description` | `<p>`, `text-sm text-muted-foreground`; `panel` adds `mt-1 max-w-[240px]`. |
|
|
615
|
+
| `Actions` | `<div>`, `mt-4`. |
|
|
616
|
+
|
|
617
|
+
- `TabsList` `variant="soft"`: borderless pills that wrap and centre (`flex flex-wrap items-center justify-center gap-1.5`). Triggers inside it are `rounded-full px-4 py-2 text-sm`, muted until active, filled with `bg-surface-secondary` when active or hovered, and `relative overflow-hidden` so a child such as a progress bar can sit on the bottom edge. It uses no `t-tabs` CSS and has no focus ring of its own. `pills` and `segmented` are unchanged. Link tabs work with any variant: `TabsTrigger asChild` around the router link (Radix sets `role="tab"`, `data-state` and roving focus on it). The package does not depend on a router.
|
|
618
|
+
- `Badge` `variant="destructive-soft"`: neutral border with a faint danger tint (`border-border bg-danger/10 text-danger`). Resolves to the same classes as a default badge with `className="bg-danger/10 text-danger"`. `variant="success"` already resolves to the same classes as a default badge with `className="border-success/25 bg-success/12 text-success"`.
|
|
619
|
+
- `Kbd` `variant="subtle"`: a plain filled key for inline hints, `rounded bg-surface-secondary px-1.5 py-0.5 text-foreground`. No fixed height, font or size, so it follows the surrounding text.
|
|
620
|
+
|
|
541
621
|
## Toaster
|
|
542
622
|
|
|
543
623
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export * from "./ui/dialog";
|
|
|
21
21
|
export * from "./ui/alert-dialog";
|
|
22
22
|
export * from "./ui/confirm-dialog";
|
|
23
23
|
export * from "./ui/dropdown-menu";
|
|
24
|
+
export * from "./ui/empty-state";
|
|
24
25
|
export * from "./ui/hover-card";
|
|
25
26
|
export * from "./ui/input";
|
|
26
27
|
export * from "./ui/label";
|
package/src/ui/badge.tsx
CHANGED
|
@@ -9,6 +9,8 @@ const badgeVariants = cva(
|
|
|
9
9
|
default: "border-border bg-default text-foreground",
|
|
10
10
|
secondary: "border-separator bg-default text-default-foreground",
|
|
11
11
|
destructive: "border-danger/25 bg-danger/12 text-danger",
|
|
12
|
+
// Neutral border with a faint danger tint, e.g. a revoked or expired status.
|
|
13
|
+
"destructive-soft": "border-border bg-danger/10 text-danger",
|
|
12
14
|
outline: "border-separator bg-transparent text-muted-foreground",
|
|
13
15
|
success: "border-success/25 bg-success/12 text-success",
|
|
14
16
|
warning: "border-warning/25 bg-warning/12 text-warning",
|
|
@@ -24,11 +24,12 @@ type ConfirmDialogContextValue = {
|
|
|
24
24
|
size: ConfirmDialogSize;
|
|
25
25
|
alert: boolean;
|
|
26
26
|
pending: boolean;
|
|
27
|
+
focusCancel: boolean;
|
|
27
28
|
confirmPhrase: string | undefined;
|
|
28
29
|
typed: string;
|
|
29
30
|
setTyped: (value: string) => void;
|
|
30
31
|
phraseMatches: boolean;
|
|
31
|
-
/** Calls the Root's `onOpenChange(false)` unless `pending
|
|
32
|
+
/** Calls the Root's `onOpenChange(false)` unless `pending` (and not `dismissibleWhilePending`). */
|
|
32
33
|
requestClose: () => void;
|
|
33
34
|
};
|
|
34
35
|
|
|
@@ -36,6 +37,7 @@ const ConfirmDialogContext = React.createContext<ConfirmDialogContextValue>({
|
|
|
36
37
|
size: "default",
|
|
37
38
|
alert: true,
|
|
38
39
|
pending: false,
|
|
40
|
+
focusCancel: false,
|
|
39
41
|
confirmPhrase: undefined,
|
|
40
42
|
typed: "",
|
|
41
43
|
setTyped: () => {},
|
|
@@ -59,6 +61,18 @@ export type ConfirmDialogRootProps = React.ComponentProps<typeof Dialog> & {
|
|
|
59
61
|
* Needs a controlled `open`.
|
|
60
62
|
*/
|
|
61
63
|
pending?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* While `pending`, Escape, outside click and the close button still close
|
|
66
|
+
* (Root `onOpenChange(false)` runs). Cancel stays disabled and Action stays
|
|
67
|
+
* pending. Default `false`.
|
|
68
|
+
*/
|
|
69
|
+
dismissibleWhilePending?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Non-alert only: Cancel renders `data-alert-cancel=""`, so a Content with
|
|
72
|
+
* `role="alertdialog"` focuses it on open. An explicit `data-alert-cancel`
|
|
73
|
+
* on Cancel wins. No-op with `alert` (its Cancel already has it). Default `false`.
|
|
74
|
+
*/
|
|
75
|
+
focusCancel?: boolean;
|
|
62
76
|
/** Type-to-confirm phrase for `ConfirmDialog.Field`. Action stays disabled until it matches (trimmed, case-insensitive). */
|
|
63
77
|
confirmPhrase?: string;
|
|
64
78
|
};
|
|
@@ -67,6 +81,8 @@ function Root({
|
|
|
67
81
|
size = "default",
|
|
68
82
|
alert = size === "default",
|
|
69
83
|
pending = false,
|
|
84
|
+
dismissibleWhilePending = false,
|
|
85
|
+
focusCancel = false,
|
|
70
86
|
confirmPhrase,
|
|
71
87
|
open,
|
|
72
88
|
onOpenChange,
|
|
@@ -81,7 +97,7 @@ function Root({
|
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
const handleOpenChange = (next: boolean) => {
|
|
84
|
-
if (!next && pending) return;
|
|
100
|
+
if (!next && pending && !dismissibleWhilePending) return;
|
|
85
101
|
if (next) setTyped("");
|
|
86
102
|
onOpenChange?.(next);
|
|
87
103
|
};
|
|
@@ -96,6 +112,7 @@ function Root({
|
|
|
96
112
|
size,
|
|
97
113
|
alert,
|
|
98
114
|
pending,
|
|
115
|
+
focusCancel,
|
|
99
116
|
confirmPhrase,
|
|
100
117
|
typed,
|
|
101
118
|
setTyped,
|
|
@@ -163,12 +180,27 @@ function Body(props: ConfirmDialogBodyProps) {
|
|
|
163
180
|
}
|
|
164
181
|
Body.displayName = "ConfirmDialog.Body";
|
|
165
182
|
|
|
166
|
-
export type ConfirmDialogFooterProps = React.HTMLAttributes<HTMLDivElement
|
|
183
|
+
export type ConfirmDialogFooterProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
184
|
+
/**
|
|
185
|
+
* Right-aligned row of buttons at every width, laid out like a
|
|
186
|
+
* `flex justify-end gap-2` div inside the footer. Adds `justify-end flex-row!`:
|
|
187
|
+
* the `!important` beats both the footer's own `flex-col-reverse` below `sm`
|
|
188
|
+
* and the compact sheet's descendant `flex-col-reverse` rule, which has higher
|
|
189
|
+
* specificity than a plain class. Keeps `data-slot` and every other footer
|
|
190
|
+
* class. No-op for `size="sm"` (already a row). Default `false`.
|
|
191
|
+
*/
|
|
192
|
+
row?: boolean;
|
|
193
|
+
};
|
|
167
194
|
|
|
168
|
-
function Footer({ className, ...props }: ConfirmDialogFooterProps) {
|
|
195
|
+
function Footer({ row = false, className, ...props }: ConfirmDialogFooterProps) {
|
|
169
196
|
const { size } = useConfirmDialog();
|
|
170
197
|
if (size === "sm") return <div {...props} className={cn("flex justify-end gap-2", className)} />;
|
|
171
|
-
return
|
|
198
|
+
return (
|
|
199
|
+
<DialogFooter
|
|
200
|
+
{...props}
|
|
201
|
+
className={cn(size === "md" && "pt-4", row && "justify-end flex-row!", className)}
|
|
202
|
+
/>
|
|
203
|
+
);
|
|
172
204
|
}
|
|
173
205
|
Footer.displayName = "ConfirmDialog.Footer";
|
|
174
206
|
|
|
@@ -177,7 +209,8 @@ export type ConfirmDialogCancelProps = ButtonProps;
|
|
|
177
209
|
/**
|
|
178
210
|
* Alert: a `DialogClose` outline button. Otherwise a ghost button that runs
|
|
179
211
|
* `onClick` and then, unless it called `preventDefault()`, the Root's
|
|
180
|
-
* `onOpenChange(false)`. Disabled while pending.
|
|
212
|
+
* `onOpenChange(false)`. Disabled while pending. With Root `focusCancel`
|
|
213
|
+
* it also gets `data-alert-cancel`.
|
|
181
214
|
*/
|
|
182
215
|
function Cancel({ children = "Cancel", variant, size, disabled, onClick, ...props }: ConfirmDialogCancelProps) {
|
|
183
216
|
const dialog = useConfirmDialog();
|
|
@@ -198,6 +231,7 @@ function Cancel({ children = "Cancel", variant, size, disabled, onClick, ...prop
|
|
|
198
231
|
<Button
|
|
199
232
|
variant={variant ?? "ghost"}
|
|
200
233
|
size={size ?? (dialog.size === "sm" ? "sm" : undefined)}
|
|
234
|
+
data-alert-cancel={dialog.focusCancel ? "" : undefined}
|
|
201
235
|
{...props}
|
|
202
236
|
onClick={(event) => {
|
|
203
237
|
onClick?.(event);
|
|
@@ -218,6 +252,8 @@ export type ConfirmDialogActionProps = ButtonProps & {
|
|
|
218
252
|
pendingLabel?: React.ReactNode;
|
|
219
253
|
/** Spinner size in px. Default `14`. */
|
|
220
254
|
spinnerSize?: number;
|
|
255
|
+
/** Merged into the spinner className after `animate-spin`, e.g. `"mr-2"`. */
|
|
256
|
+
spinnerClassName?: string;
|
|
221
257
|
};
|
|
222
258
|
|
|
223
259
|
/** Confirm button. Does not close the dialog. Disabled while pending or until the confirm phrase matches. */
|
|
@@ -225,6 +261,7 @@ function Action({
|
|
|
225
261
|
pending: pendingProp,
|
|
226
262
|
pendingLabel,
|
|
227
263
|
spinnerSize = 14,
|
|
264
|
+
spinnerClassName,
|
|
228
265
|
size,
|
|
229
266
|
disabled,
|
|
230
267
|
children,
|
|
@@ -235,7 +272,7 @@ function Action({
|
|
|
235
272
|
const content =
|
|
236
273
|
pending && pendingLabel !== undefined ? (
|
|
237
274
|
<>
|
|
238
|
-
<IconLoader2 size={spinnerSize} className="animate-spin" />
|
|
275
|
+
<IconLoader2 size={spinnerSize} className={cn("animate-spin", spinnerClassName)} />
|
|
239
276
|
{pendingLabel}
|
|
240
277
|
</>
|
|
241
278
|
) : (
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cn } from "../utils/cn";
|
|
4
|
+
|
|
5
|
+
// `default`: open centred block, large bare icon, one muted line (a page or list body).
|
|
6
|
+
// `panel`: filled rounded block with the icon in a tile (a detail panel or modal section).
|
|
7
|
+
export type EmptyStateVariant = "default" | "panel";
|
|
8
|
+
|
|
9
|
+
const EmptyStateContext = React.createContext<EmptyStateVariant>("default");
|
|
10
|
+
|
|
11
|
+
export type EmptyStateRootProps = React.ComponentProps<"div"> & {
|
|
12
|
+
/** Layout for the other parts. Default `"default"`. */
|
|
13
|
+
variant?: EmptyStateVariant;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function Root({ variant = "default", className, ...props }: EmptyStateRootProps) {
|
|
17
|
+
return (
|
|
18
|
+
<EmptyStateContext value={variant}>
|
|
19
|
+
<div
|
|
20
|
+
{...props}
|
|
21
|
+
className={cn(
|
|
22
|
+
"flex flex-col items-center justify-center text-center",
|
|
23
|
+
variant === "panel" ? "rounded-lg bg-surface-secondary px-4 py-10" : "px-4 py-20",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
/>
|
|
27
|
+
</EmptyStateContext>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
Root.displayName = "EmptyState.Root";
|
|
31
|
+
|
|
32
|
+
export type EmptyStateIconProps = {
|
|
33
|
+
/** Icon component, e.g. a Tabler icon. */
|
|
34
|
+
icon: React.ComponentType<{ size?: number | string; stroke?: number | string; className?: string }>;
|
|
35
|
+
/** Pixel size. Default 40 (`default`) or 22 (`panel`). */
|
|
36
|
+
size?: number;
|
|
37
|
+
/** Stroke width. Default the icon's own (`default`) or 1.5 (`panel`). */
|
|
38
|
+
stroke?: number;
|
|
39
|
+
/** Classes for the outer element: the icon (`default`) or its tile (`panel`). */
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function Icon({ icon: IconComponent, size, stroke, className }: EmptyStateIconProps) {
|
|
44
|
+
if (React.use(EmptyStateContext) === "panel") {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"mb-3 flex h-11 w-11 items-center justify-center rounded-lg bg-surface-tertiary",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<IconComponent size={size ?? 22} className="text-muted-foreground" stroke={stroke ?? 1.5} />
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
<IconComponent
|
|
58
|
+
size={size ?? 40}
|
|
59
|
+
stroke={stroke}
|
|
60
|
+
className={cn("mb-3 text-muted-foreground", className)}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
Icon.displayName = "EmptyState.Icon";
|
|
65
|
+
|
|
66
|
+
export type EmptyStateTitleProps = React.ComponentProps<"p"> & {
|
|
67
|
+
/** Render the child element (e.g. an `h3`) with the title classes. */
|
|
68
|
+
asChild?: boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function Title({ asChild = false, className, ...props }: EmptyStateTitleProps) {
|
|
72
|
+
const Comp = asChild ? Slot : "p";
|
|
73
|
+
return (
|
|
74
|
+
<Comp
|
|
75
|
+
{...props}
|
|
76
|
+
className={cn(
|
|
77
|
+
"text-sm font-medium text-foreground",
|
|
78
|
+
React.use(EmptyStateContext) === "default" && "mb-1",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
Title.displayName = "EmptyState.Title";
|
|
85
|
+
|
|
86
|
+
export type EmptyStateDescriptionProps = React.ComponentProps<"p">;
|
|
87
|
+
|
|
88
|
+
function Description({ className, ...props }: EmptyStateDescriptionProps) {
|
|
89
|
+
return (
|
|
90
|
+
<p
|
|
91
|
+
{...props}
|
|
92
|
+
className={cn(
|
|
93
|
+
React.use(EmptyStateContext) === "panel" && "mt-1 max-w-[240px]",
|
|
94
|
+
"text-sm text-muted-foreground",
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
Description.displayName = "EmptyState.Description";
|
|
101
|
+
|
|
102
|
+
export type EmptyStateActionsProps = React.ComponentProps<"div">;
|
|
103
|
+
|
|
104
|
+
function Actions({ className, ...props }: EmptyStateActionsProps) {
|
|
105
|
+
return <div {...props} className={cn("mt-4", className)} />;
|
|
106
|
+
}
|
|
107
|
+
Actions.displayName = "EmptyState.Actions";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Composable "nothing here yet" block: `EmptyState.Root` sets the variant, the
|
|
111
|
+
* other parts read it. Render only the parts you need.
|
|
112
|
+
*/
|
|
113
|
+
export const EmptyState = {
|
|
114
|
+
Root,
|
|
115
|
+
Icon,
|
|
116
|
+
Title,
|
|
117
|
+
Description,
|
|
118
|
+
Actions,
|
|
119
|
+
};
|
package/src/ui/kbd.tsx
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { cn } from "../utils/cn";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// `subtle`: plain filled key for inline hints; size and font come from the
|
|
5
|
+
// surrounding text.
|
|
6
|
+
const kbdVariantClasses = {
|
|
7
|
+
default:
|
|
8
|
+
"pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded-md bg-surface-secondary px-1.5 font-mono text-[10px] font-medium text-muted-foreground",
|
|
9
|
+
subtle: "rounded bg-surface-secondary px-1.5 py-0.5 text-foreground",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type KbdProps = React.HTMLAttributes<HTMLElement> & {
|
|
13
|
+
/** Default `"default"`. */
|
|
14
|
+
variant?: keyof typeof kbdVariantClasses;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const Kbd = React.forwardRef<HTMLElement, KbdProps>(
|
|
18
|
+
({ className, variant = "default", ...props }, ref) => (
|
|
6
19
|
<kbd
|
|
7
20
|
ref={ref}
|
|
8
|
-
className={cn(
|
|
9
|
-
"pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded-md bg-surface-secondary px-1.5 font-mono text-[10px] font-medium text-muted-foreground",
|
|
10
|
-
className,
|
|
11
|
-
)}
|
|
21
|
+
className={cn(kbdVariantClasses[variant], className)}
|
|
12
22
|
{...props}
|
|
13
23
|
/>
|
|
14
24
|
),
|
package/src/ui/spinner.tsx
CHANGED
|
@@ -7,18 +7,34 @@ const sizeClasses = {
|
|
|
7
7
|
lg: "size-8",
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
export type SpinnerProps = Omit<React.ComponentPropsWithoutRef<"svg">, "size" | "stroke"> & {
|
|
11
|
+
/** Named size (a `size-*` class) or a pixel size passed to the icon (no size class). Default `"md"`. */
|
|
12
|
+
size?: "sm" | "md" | "lg" | number;
|
|
13
|
+
/** Icon stroke width. Default the icon's own (2). */
|
|
14
|
+
stroke?: number | string;
|
|
15
|
+
/** `"muted"` adds `text-muted-foreground`; `"inherit"` adds no colour class, so the icon uses `currentColor`. Default `"muted"`. */
|
|
16
|
+
tone?: "muted" | "inherit";
|
|
17
|
+
/** Hide from assistive tech (`aria-hidden`, no `role="status"` or label), e.g. inside a labelled button. Default `false`. */
|
|
18
|
+
decorative?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
10
21
|
function Spinner({
|
|
11
22
|
size = "md",
|
|
23
|
+
tone = "muted",
|
|
24
|
+
decorative = false,
|
|
12
25
|
className,
|
|
13
26
|
...props
|
|
14
|
-
}:
|
|
15
|
-
size?: "sm" | "md" | "lg";
|
|
16
|
-
}) {
|
|
27
|
+
}: SpinnerProps) {
|
|
17
28
|
return (
|
|
18
29
|
<IconLoader2
|
|
19
|
-
role
|
|
20
|
-
|
|
21
|
-
className={cn(
|
|
30
|
+
{...(decorative ? { "aria-hidden": true } : { role: "status", "aria-label": "Loading" })}
|
|
31
|
+
size={typeof size === "number" ? size : undefined}
|
|
32
|
+
className={cn(
|
|
33
|
+
"animate-spin",
|
|
34
|
+
tone === "muted" && "text-muted-foreground",
|
|
35
|
+
typeof size === "number" ? undefined : sizeClasses[size],
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
22
38
|
{...props}
|
|
23
39
|
/>
|
|
24
40
|
);
|
package/src/ui/tabs.tsx
CHANGED
|
@@ -8,7 +8,11 @@ const Tabs = TabsPrimitive.Root;
|
|
|
8
8
|
|
|
9
9
|
// `pills` (default): isolated rounded pills with a gap, no shared track.
|
|
10
10
|
// `segmented`: connected bar with a sliding pill, kept for marketing surfaces.
|
|
11
|
-
|
|
11
|
+
// `soft`: borderless wrapping pills, the active one filled. Uses no `t-tabs`
|
|
12
|
+
// CSS, so triggers can hold an absolutely placed child (e.g. a progress bar).
|
|
13
|
+
type TabsListVariant = "pills" | "segmented" | "soft";
|
|
14
|
+
|
|
15
|
+
const TabsListVariantContext = React.createContext<TabsListVariant>("pills");
|
|
12
16
|
|
|
13
17
|
const TabsList = React.forwardRef<
|
|
14
18
|
React.ComponentRef<typeof TabsPrimitive.List>,
|
|
@@ -82,21 +86,26 @@ const TabsList = React.forwardRef<
|
|
|
82
86
|
}, [variant]);
|
|
83
87
|
|
|
84
88
|
return (
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
89
|
+
<TabsListVariantContext value={variant}>
|
|
90
|
+
<TabsPrimitive.List
|
|
91
|
+
ref={mergedRef}
|
|
92
|
+
className={cn(
|
|
93
|
+
variant === "soft"
|
|
94
|
+
? "flex flex-wrap items-center justify-center gap-1.5"
|
|
95
|
+
: "t-tabs max-sm:max-w-full max-sm:justify-center-safe max-sm:overflow-x-auto max-sm:scrollbar-none",
|
|
96
|
+
variant === "segmented" && "t-tabs--segmented",
|
|
97
|
+
variant === "pills" && "t-tabs--pills",
|
|
98
|
+
className,
|
|
99
|
+
)}
|
|
100
|
+
data-variant={variant}
|
|
101
|
+
{...props}
|
|
102
|
+
>
|
|
103
|
+
{variant === "segmented" ? (
|
|
104
|
+
<span ref={pillRef} className="t-tabs-pill" aria-hidden="true" />
|
|
105
|
+
) : null}
|
|
106
|
+
{children}
|
|
107
|
+
</TabsPrimitive.List>
|
|
108
|
+
</TabsListVariantContext>
|
|
100
109
|
);
|
|
101
110
|
});
|
|
102
111
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
|
@@ -108,7 +117,9 @@ const TabsTrigger = React.forwardRef<
|
|
|
108
117
|
<TabsPrimitive.Trigger
|
|
109
118
|
ref={ref}
|
|
110
119
|
className={cn(
|
|
111
|
-
|
|
120
|
+
React.use(TabsListVariantContext) === "soft"
|
|
121
|
+
? "relative flex items-center gap-2 overflow-hidden rounded-full px-4 py-2 text-sm text-muted-foreground transition-[transform,background-color] hover:bg-surface-secondary active:scale-[0.97] data-[state=active]:bg-surface-secondary data-[state=active]:text-foreground"
|
|
122
|
+
: "t-tab inline-flex items-center justify-center gap-1.5 whitespace-nowrap text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
112
123
|
className,
|
|
113
124
|
)}
|
|
114
125
|
{...props}
|