@vvedantb/ui 0.5.4 → 0.6.1
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 +184 -5
- package/app/README.md +100 -0
- package/package.json +11 -10
- package/shell/src/AppShell.tsx +130 -43
- package/shell/src/AppSidebar.tsx +27 -125
- package/shell/src/Sidebar.tsx +78 -0
- package/shell/src/SidebarAccountMenu.tsx +169 -77
- package/shell/src/SidebarChrome.tsx +295 -0
- package/shell/src/SidebarHeader.tsx +6 -7
- package/shell/src/SidebarPanel.tsx +95 -33
- package/shell/src/SidebarRail.tsx +74 -28
- package/shell/src/SignOutDialog.tsx +24 -51
- package/shell/src/app-shell-classes.ts +6 -0
- package/shell/src/index.ts +9 -1
- package/src/index.ts +9 -1
- package/src/ui/confirm-dialog-parts.tsx +355 -0
- package/src/ui/confirm-dialog.tsx +65 -106
- package/src/ui/overlay-a11y.ts +5 -3
- package/tokens/README.md +98 -0
package/README.md
CHANGED
|
@@ -215,6 +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-options-061)).
|
|
218
219
|
|
|
219
220
|
## Buttons, menus and fields (0.4.4)
|
|
220
221
|
|
|
@@ -291,13 +292,13 @@ import {
|
|
|
291
292
|
|
|
292
293
|
- Widths: `AppShell` sets `--app-shell-rail-width` (default `4rem`), `--app-shell-sidebar-width` (rail plus panel, default `20rem`) and `--app-shell-mobile-header-height` (default `calc(3.5rem + env(safe-area-inset-top, 0px))`) as inline style. Change them with `railWidth`, `sidebarWidth` and `mobileHeaderHeight`. The other pieces read these variables. Outside `AppShell`, set the variables yourself or pass a width class with `className`.
|
|
293
294
|
- `AppShell`: `sidebar`, `children`, `panelHidden` (main offsets by the rail width instead of the sidebar width), `skipLinkLabel` (`"Skip to main content"`), `mainId` (`"main-content"`), `className`, `mainClassName`, `contentClassName`.
|
|
294
|
-
- `AppSidebar`: `rail`, `panel` (not rendered while `panelHidden`), `collapsed`, `panelHidden`, `isDesktop`, `mobileOpen`, `onMobileOpenChange`, `id` (default from `useId`), `asideProps` (`data-*` attributes for the `<aside>`), `className`, `mobileTitle`, `mobileHeaderActions` (0.5.2; trailing actions such as icon buttons, in place of the end spacer), `mobileMenuIcon` (`(open) => node`, default a menu icon), `mobileMenuLabel` (`"Open navigation menu"`), `closeLabel` (`"Close navigation"`). Swipe left or Escape closes the drawer. The drawer is `inert` while closed on mobile, and closes when `isDesktop` becomes `true`. Close it on route change in the app. The mobile header is a three-column grid, so the title and trailing actions stay on one row and a long title truncates (0.5.4).
|
|
295
|
-
- `SidebarRail`: `top`, `children` (scroll body), `footer`, `showDivider`, `bodyAsChild` (the single child, for example a tabs list, becomes the body), `className`, `topClassName`, `bodyClassName`, `footerClassName`. Other HTML attributes (`id`, `data-*`, `aria-*`) go to the root `<div>`. The top and footer wrappers render only when their slot is set.
|
|
295
|
+
- `AppSidebar`: legacy (still supported) `rail`, `panel` and `mobile*` props; compose `Sidebar.*` instead. `rail`, `panel` (not rendered while `panelHidden`), `collapsed`, `panelHidden`, `isDesktop`, `mobileOpen`, `onMobileOpenChange`, `id` (default from `useId`), `asideProps` (`data-*` attributes for the `<aside>`), `className`, `mobileTitle`, `mobileHeaderActions` (0.5.2; trailing actions such as icon buttons, in place of the end spacer), `mobileMenuIcon` (`(open) => node`, default a menu icon), `mobileMenuLabel` (`"Open navigation menu"`), `closeLabel` (`"Close navigation"`). Swipe left or Escape closes the drawer. The drawer is `inert` while closed on mobile, and closes when `isDesktop` becomes `true`. Close it on route change in the app. The mobile header is a three-column grid, so the title and trailing actions stay on one row and a long title truncates (0.5.4).
|
|
296
|
+
- `SidebarRail`: legacy (still supported) `top`, `footer`, `bodyAsChild`, `tabs` and `*ClassName` props; compose `Sidebar.Rail` parts instead. `top`, `children` (scroll body), `footer`, `showDivider`, `bodyAsChild` (the single child, for example a tabs list, becomes the body), `className`, `topClassName`, `bodyClassName`, `footerClassName`. Other HTML attributes (`id`, `data-*`, `aria-*`) go to the root `<div>`. The top and footer wrappers render only when their slot is set.
|
|
296
297
|
- `RailTile`: `label` (`aria-label` and tooltip), `active`, `tooltip`, `showTooltip` (default `true`), `badge` (inside the tile, after the children; style it with `railBadgeClass`), `asChild`, `className`. Without `asChild` it is a native `<button type="button">` (0.5.3), so icons keep their authored size; other props go to it. With `asChild` the child (for example a router link) gets the tile classes and `aria-current="page"` when active.
|
|
297
298
|
- `RailDivider`, `SidebarIconTooltip` (`label`, `enabled`, `side` `"right"`, `sideOffset` 8).
|
|
298
|
-
- `SidebarPanel`: `title`, `headerTrailing`, `onClose` (adds the close button; pass it on the mobile drawer only), `closeLabel`, `closeButtonClassName`, `collapsed` (`md:hidden`), `className`, `children`.
|
|
299
|
-
- `SidebarAccountMenu`: `name`, `email` (two-line label when set), `stackedLabel` (two-line layout even without `email`, default `false`), `imageUrl` (initials without it), `align` (`"center"`), `side` (`"right"`), `sideOffset` (8), `collisionPadding`, `contentClassName`, `children` (menu items). 0.5.2 adds `badge` (unread dot beside the trigger, in a `relative` wrapper around the menu, so the trigger's dimming and press scale skip it: `true` shows it, `false` fades it out, omitted renders no wrapper and no dot), `triggerProps` (spread onto the trigger `Button` after the defaults; `className` merges last) and `contentProps` (spread onto `DropdownMenuContent`, for example `fitViewport` or `aria-label`; no `className`, use `contentClassName`). `SidebarAccountMenuPlaceholder` is the tile-sized loading box.
|
|
300
|
-
- `SignOutDialog`: `open`, `onOpenChange` (not called with `false` while `pending`), `pending`, `onConfirm`, `description`, `contentClassName`, `title` (`"Sign out"`), `question` (`"Sign out of your account?"`).
|
|
299
|
+
- `SidebarPanel`: legacy (still supported) `headerTrailing`, `onClose`, `closeLabel` and `closeButtonClassName`; compose `Sidebar.Panel` parts instead. `title`, `headerTrailing`, `onClose` (adds the close button; pass it on the mobile drawer only), `closeLabel`, `closeButtonClassName`, `collapsed` (`md:hidden`), `className`, `children`.
|
|
300
|
+
- `SidebarAccountMenu`: legacy (still supported) `badge`, `triggerProps`, `contentProps` and `stackedLabel`; compose `Sidebar.Account*` parts instead. `name`, `email` (two-line label when set), `stackedLabel` (two-line layout even without `email`, default `false`), `imageUrl` (initials without it), `align` (`"center"`), `side` (`"right"`), `sideOffset` (8), `collisionPadding`, `contentClassName`, `children` (menu items). 0.5.2 adds `badge` (unread dot beside the trigger, in a `relative` wrapper around the menu, so the trigger's dimming and press scale skip it: `true` shows it, `false` fades it out, omitted renders no wrapper and no dot), `triggerProps` (spread onto the trigger `Button` after the defaults; `className` merges last) and `contentProps` (spread onto `DropdownMenuContent`, for example `fitViewport` or `aria-label`; no `className`, use `contentClassName`). `SidebarAccountMenuPlaceholder` is the tile-sized loading box.
|
|
301
|
+
- `SignOutDialog`: legacy (still supported) `question` and `title`; compose `ConfirmDialog` parts with `alert={false}` for other copy. `open`, `onOpenChange` (not called with `false` while `pending`), `pending`, `onConfirm`, `description`, `contentClassName`, `title` (`"Sign out"`), `question` (`"Sign out of your account?"`).
|
|
301
302
|
- Class helpers: `railTileClass`, `railTileStateClass(active)`, `railBadgeClass`, `sidebarNavRowClass(isIconOnly)`, `sidebarNavLinkTextClass(isActive)`, `sidebarSectionLabelClass`.
|
|
302
303
|
|
|
303
304
|
### Header actions and account badge (0.5.2)
|
|
@@ -380,6 +381,184 @@ function PanelWidth() {
|
|
|
380
381
|
</Tabs>
|
|
381
382
|
```
|
|
382
383
|
|
|
384
|
+
## Composable parts (0.6.0, options 0.6.1)
|
|
385
|
+
|
|
386
|
+
Shadcn-style building blocks. The prop-driven components above are thin compositions of them and render the same DOM.
|
|
387
|
+
|
|
388
|
+
Convention: compound parts are static members of a namespace object, `<Namespace>.<Part>`. There are three namespaces: `ConfirmDialog.*`, `AppShell.*` and `Sidebar.*`. `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
|
+
|
|
390
|
+
### `ConfirmDialog.*`
|
|
391
|
+
|
|
392
|
+
| Part | Purpose |
|
|
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). 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
|
+
| `Content` | `AlertDialogContent` with `alert`, else `DialogContent`. `sm` and `md` add `sm:max-w-96`. Takes every `DialogContent` prop (`role`, `hideCloseButton`, ...). |
|
|
396
|
+
| `Header` | `DialogHeader`; `md` adds `pb-4`. |
|
|
397
|
+
| `Title` | `DialogTitle`; `sm` adds `mb-2 text-sm font-medium`. |
|
|
398
|
+
| `Description` | `DialogDescription`; `sm` and `md` add their spacing. |
|
|
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. 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
|
+
| `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
|
+
|
|
405
|
+
`useConfirmDialog()` returns `{ size, alert, pending, focusCancel, phraseMatches, typed, setTyped, requestClose }` for custom parts.
|
|
406
|
+
|
|
407
|
+
### `AppShell.*`
|
|
408
|
+
|
|
409
|
+
| Part | Purpose |
|
|
410
|
+
| --- | --- |
|
|
411
|
+
| `Root` | Frame `<div>`. Sets the `--app-shell-*` variables (`railWidth`, `sidebarWidth`, `mobileHeaderHeight`), owns the `resizable` state, and passes `panelHidden` and `mainId` to the parts. `style` merges over the variables. |
|
|
412
|
+
| `SkipLink` | Visually hidden link to `<main>`. `label` (`"Skip to main content"`), `targetId` (default the Root `mainId`). |
|
|
413
|
+
| `Main` | The `<main>`. Offsets by the sidebar width, or the rail width while `panelHidden`. `id` defaults to the Root `mainId` (`"main-content"`). |
|
|
414
|
+
| `Content` | The content card inside `<main>`. |
|
|
415
|
+
|
|
416
|
+
### `Sidebar.*`
|
|
417
|
+
|
|
418
|
+
| Part | Purpose |
|
|
419
|
+
| --- | --- |
|
|
420
|
+
| `Root` | No DOM. Holds `isDesktop`, `mobileOpen`, `onMobileOpenChange`, `collapsed`, `panelHidden` and the drawer `id`. Closes the drawer at the desktop breakpoint. The chrome parts need it. |
|
|
421
|
+
| `MobileHeader` | Fixed header below `md`, a three-column grid (menu button, title, actions). Escape closes the drawer. Keep all three children so the columns line up. |
|
|
422
|
+
| `MobileMenuButton` | Opens the drawer. `label`, `icon` (`(open) => node`). |
|
|
423
|
+
| `MobileTitle` | Centred `<h1>` that truncates. Without children, an empty `<span>` keeps the middle column. |
|
|
424
|
+
| `MobileActions` | Trailing header actions. Without children, a spacer that keeps the title centred. |
|
|
425
|
+
| `Backdrop` | Drawer backdrop; a click closes the drawer. `label` (`"Close navigation"`). |
|
|
426
|
+
| `Aside` | The `<aside>`: fixed on desktop, a swipe-to-close drawer below it. `id` overrides the Root id. |
|
|
427
|
+
| `ResizeHandle` | `SidebarResizeHandle` in a resizable `AppShell`, shown on desktop while the panel shows. Place it inside `Aside`. |
|
|
428
|
+
| `Rail` | Rail column. `showDivider`. |
|
|
429
|
+
| `RailHeader` / `RailBody` / `RailFooter` | Top block, scroll body (`asChild`, `tabs` for a Radix tabs list) and bottom block. `Footer` is an alias of `RailFooter`. |
|
|
430
|
+
| `RailItem` | Rail tile with tooltip (`RailTile`): `label`, `active`, `tooltip`, `showTooltip`, `badge`, `asChild`, `tabValue`. |
|
|
431
|
+
| `RailSeparator` | Short rule between rail items (`RailDivider`). |
|
|
432
|
+
| `Tooltip` | Right-side icon tooltip (`SidebarIconTooltip`). |
|
|
433
|
+
| `Panel` | Panel column. `collapsed` (`md:hidden`) and `panelHidden` (renders nothing) default to the Root values. |
|
|
434
|
+
| `PanelHeader` | Padded header row for `PanelTitle` and `PanelActions`. |
|
|
435
|
+
| `PanelTitle` / `PanelActions` | The `<h2>` and the trailing actions. |
|
|
436
|
+
| `PanelClose` | Ghost close button. `label` (`"Close navigation"`). Without `onClick` it closes the drawer. |
|
|
437
|
+
| `AccountMenu` | Dropdown root. `badge` adds the unread dot (`AccountBadge`) in a `relative` wrapper. |
|
|
438
|
+
| `AccountTrigger` | Rail-tile-sized avatar button. `userName` (default `title` and `aria-label`, and the initials), `imageUrl`, plus `Button` props; native `name` goes to the button. |
|
|
439
|
+
| `AccountAvatar` | Avatar image or initials. `userName`, `imageUrl`. |
|
|
440
|
+
| `AccountContent` | Menu content. Defaults `align="center"`, `side="right"`, `sideOffset={8}`, `w-56`. |
|
|
441
|
+
| `AccountLabel` | Identity label: avatar, `userName`, optional `email`, `stacked`. |
|
|
442
|
+
| `AccountSeparator` | Separator after the label. Kept separate so the label can stand alone. |
|
|
443
|
+
| `AccountBadge` | Unread dot. `visible` (default `true`; `false` fades it out). |
|
|
444
|
+
|
|
445
|
+
### Examples
|
|
446
|
+
|
|
447
|
+
A destructive confirm with a pending state and type-to-confirm:
|
|
448
|
+
|
|
449
|
+
```tsx
|
|
450
|
+
<ConfirmDialog.Root open={open} onOpenChange={setOpen} pending={deleting} confirmPhrase={item.name}>
|
|
451
|
+
<ConfirmDialog.Content>
|
|
452
|
+
<ConfirmDialog.Header>
|
|
453
|
+
<ConfirmDialog.Title>Delete item?</ConfirmDialog.Title>
|
|
454
|
+
<ConfirmDialog.Description>This cannot be undone.</ConfirmDialog.Description>
|
|
455
|
+
</ConfirmDialog.Header>
|
|
456
|
+
<ConfirmDialog.Field onEnter={remove} />
|
|
457
|
+
<ConfirmDialog.Footer>
|
|
458
|
+
<ConfirmDialog.Cancel />
|
|
459
|
+
<ConfirmDialog.Action variant="destructive" pendingLabel="Deleting..." onClick={remove}>
|
|
460
|
+
Delete
|
|
461
|
+
</ConfirmDialog.Action>
|
|
462
|
+
</ConfirmDialog.Footer>
|
|
463
|
+
</ConfirmDialog.Content>
|
|
464
|
+
</ConfirmDialog.Root>
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
A dismissible rename dialog:
|
|
468
|
+
|
|
469
|
+
```tsx
|
|
470
|
+
<ConfirmDialog.Root open={open} onOpenChange={setOpen} alert={false}>
|
|
471
|
+
<ConfirmDialog.Content>
|
|
472
|
+
<ConfirmDialog.Header>
|
|
473
|
+
<ConfirmDialog.Title>Rename</ConfirmDialog.Title>
|
|
474
|
+
</ConfirmDialog.Header>
|
|
475
|
+
<ConfirmDialog.Body>
|
|
476
|
+
<Input value={name} onChange={(event) => setName(event.target.value)} aria-label="Name" />
|
|
477
|
+
</ConfirmDialog.Body>
|
|
478
|
+
<ConfirmDialog.Footer>
|
|
479
|
+
<ConfirmDialog.Cancel />
|
|
480
|
+
<ConfirmDialog.Action disabled={!name.trim()} onClick={save}>Save</ConfirmDialog.Action>
|
|
481
|
+
</ConfirmDialog.Footer>
|
|
482
|
+
</ConfirmDialog.Content>
|
|
483
|
+
</ConfirmDialog.Root>
|
|
484
|
+
```
|
|
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
|
+
|
|
507
|
+
A full shell:
|
|
508
|
+
|
|
509
|
+
```tsx
|
|
510
|
+
<AppShell.Root panelHidden={collapsed || !hasPanel} resizable={resizeOptions}>
|
|
511
|
+
<AppShell.SkipLink />
|
|
512
|
+
<Sidebar.Root
|
|
513
|
+
isDesktop={isDesktop}
|
|
514
|
+
mobileOpen={open}
|
|
515
|
+
onMobileOpenChange={setOpen}
|
|
516
|
+
collapsed={collapsed}
|
|
517
|
+
panelHidden={!hasPanel}
|
|
518
|
+
>
|
|
519
|
+
<Sidebar.MobileHeader>
|
|
520
|
+
<Sidebar.MobileMenuButton />
|
|
521
|
+
<Sidebar.MobileTitle>{pageTitle}</Sidebar.MobileTitle>
|
|
522
|
+
<Sidebar.MobileActions />
|
|
523
|
+
</Sidebar.MobileHeader>
|
|
524
|
+
<Sidebar.Backdrop />
|
|
525
|
+
<Sidebar.Aside>
|
|
526
|
+
<Sidebar.Rail showDivider>
|
|
527
|
+
<Sidebar.RailHeader>
|
|
528
|
+
<Sidebar.RailItem asChild label="Home" active={isHome}>
|
|
529
|
+
<Link to="/"><IconHome size={22} /></Link>
|
|
530
|
+
</Sidebar.RailItem>
|
|
531
|
+
</Sidebar.RailHeader>
|
|
532
|
+
<Sidebar.RailBody>
|
|
533
|
+
<Sidebar.RailItem label="Search"><IconSearch size={22} /></Sidebar.RailItem>
|
|
534
|
+
</Sidebar.RailBody>
|
|
535
|
+
<Sidebar.RailFooter>
|
|
536
|
+
<Sidebar.AccountMenu>
|
|
537
|
+
<Sidebar.AccountTrigger userName={name} imageUrl={avatarUrl} />
|
|
538
|
+
<Sidebar.AccountContent>
|
|
539
|
+
<Sidebar.AccountLabel userName={name} email={email} imageUrl={avatarUrl} />
|
|
540
|
+
<Sidebar.AccountSeparator />
|
|
541
|
+
<DropdownMenuItem>Settings</DropdownMenuItem>
|
|
542
|
+
</Sidebar.AccountContent>
|
|
543
|
+
</Sidebar.AccountMenu>
|
|
544
|
+
</Sidebar.RailFooter>
|
|
545
|
+
</Sidebar.Rail>
|
|
546
|
+
<Sidebar.Panel>
|
|
547
|
+
<Sidebar.PanelHeader>
|
|
548
|
+
<Sidebar.PanelTitle>Library</Sidebar.PanelTitle>
|
|
549
|
+
<Sidebar.PanelActions>{isDesktop ? null : <Sidebar.PanelClose />}</Sidebar.PanelActions>
|
|
550
|
+
</Sidebar.PanelHeader>
|
|
551
|
+
{nav}
|
|
552
|
+
</Sidebar.Panel>
|
|
553
|
+
<Sidebar.ResizeHandle />
|
|
554
|
+
</Sidebar.Aside>
|
|
555
|
+
</Sidebar.Root>
|
|
556
|
+
<AppShell.Main>
|
|
557
|
+
<AppShell.Content>{page}</AppShell.Content>
|
|
558
|
+
</AppShell.Main>
|
|
559
|
+
</AppShell.Root>
|
|
560
|
+
```
|
|
561
|
+
|
|
383
562
|
## Toaster
|
|
384
563
|
|
|
385
564
|
```tsx
|
package/app/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# App helpers (`@vvedantb/ui`)
|
|
2
|
+
|
|
3
|
+
Shared web React helpers that were duplicated across apps. Source-exported as the `./app` subpath of `@vvedantb/ui`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @vvedantb/ui
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Peers
|
|
12
|
+
|
|
13
|
+
Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, needed only for the app helpers: `@clerk/clerk-react` (`useStableAuth`), `@tanstack/react-hotkeys` and `zod`.
|
|
14
|
+
|
|
15
|
+
`sonner` is not a dependency. The update watcher takes the toast API as an option. The app source uses a local class join, which the root does not export (the root `cn` is the clsx and tailwind-merge one). Tailwind must scan `node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}` so the class names are generated, and the app needs the `@vvedantb/ui/tokens` names (`bg-surface-card`, `divide-separator`, `text-muted-foreground` and so on).
|
|
16
|
+
|
|
17
|
+
```css
|
|
18
|
+
@source "../node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Confirm skip (Alt-click)
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui";
|
|
25
|
+
|
|
26
|
+
const { request } = useRequestConfirm();
|
|
27
|
+
|
|
28
|
+
<DropdownMenuItem
|
|
29
|
+
title={skipConfirmTitle("Delete")}
|
|
30
|
+
onSelect={(event) => request(() => setOpen(true), () => remove(), event)}
|
|
31
|
+
>
|
|
32
|
+
Delete <ConfirmSkipHint />
|
|
33
|
+
</DropdownMenuItem>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `useRequestConfirm` does not subscribe to Alt. It reads `getAltHeld()` when the request happens, so the menu owner does not re-render when Alt goes down. It returns `{ request }` only.
|
|
37
|
+
- `ConfirmSkipHint` stays mounted and gains a `hidden` class when Alt is not held, so an open Radix menu keeps a stable child tree.
|
|
38
|
+
- The Alt store resets when the last subscriber leaves, clears on blur and tab hide, and cancels a bare Alt keydown only inside `[role='menu']` or `[data-radix-menu-content]`.
|
|
39
|
+
- Also exported: `useAltHeld`, `getAltHeld`, `createAltHeldStore`, `bindToWindow`, `shouldSkipConfirm`, `requestConfirm`.
|
|
40
|
+
|
|
41
|
+
## Update available watcher
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { toast } from "sonner";
|
|
45
|
+
import { startUpdateAvailableWatcher } from "@vvedantb/ui";
|
|
46
|
+
|
|
47
|
+
useEffect(
|
|
48
|
+
() =>
|
|
49
|
+
startUpdateAvailableWatcher({
|
|
50
|
+
origin: window.location.origin,
|
|
51
|
+
toast,
|
|
52
|
+
closeConvex: () => void convex.close(),
|
|
53
|
+
enabled: !import.meta.env.DEV,
|
|
54
|
+
}),
|
|
55
|
+
[],
|
|
56
|
+
);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Options: `origin`, `toast`, `closeConvex?`, `pollMs?` (default 60000), `toastId?` (default `app-update-available`), `enabled?` (default true), `reload?` (default `window.location.reload`). On a changed asset fingerprint it shows a sticky "Update available" toast. Refresh calls `closeConvex` (errors ignored), then reloads. `fingerprintFromHtml` and `fetchDeployFingerprint` are exported too. The fingerprint looks for Vite `/assets/*.js` URLs in the index HTML.
|
|
60
|
+
|
|
61
|
+
## Settings chrome
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui";
|
|
65
|
+
|
|
66
|
+
<SettingsPage
|
|
67
|
+
title="Notifications"
|
|
68
|
+
layout={({ title, headerRight, toolbar, tabs, fillHeight, children }) => (
|
|
69
|
+
<PageContainer title={title} rightSection={headerRight} toolbar={toolbar} tabs={tabs} noScroll={fillHeight}>
|
|
70
|
+
{children}
|
|
71
|
+
</PageContainer>
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
<SettingsSection title="Email" bodyVariant="list">
|
|
75
|
+
<SettingsToggleRow title="Weekly digest" action={<Switch />} />
|
|
76
|
+
</SettingsSection>
|
|
77
|
+
</SettingsPage>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Exports: `SettingsSection`, `SettingsField`, `SettingsStack`, `SettingsToggleRow`, `SettingsEmptyState` (icon is `ComponentType<{ size?: number; className?: string }>`), `SettingsPage`. `SettingsField` `layout`: `"stacked"` (default) puts the label above the control. `"horizontal"` renders one row with the label and description on the left and the control on the right. Without `layout`, `SettingsPage` renders a plain `div` with a heading and the optional slots.
|
|
81
|
+
|
|
82
|
+
## useStableAuth
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { useStableAuth } from "@vvedantb/ui"; // 2000 ms debounce, wraps Clerk useAuth
|
|
86
|
+
import { createUseStableAuth } from "@vvedantb/ui";
|
|
87
|
+
|
|
88
|
+
const useAuth = createUseStableAuth(useClerkAuth, { debounceMs: 3000 });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
After a signed-in session, a drop to signed-out reports `isLoaded: false` for the debounce window so Convex does not cascade. State is module-level. `resetStableAuthSession()` clears it and is for tests only.
|
|
92
|
+
|
|
93
|
+
## Develop
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
pnpm --filter @vvedantb/ui typecheck
|
|
97
|
+
pnpm --filter @vvedantb/ui test
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Source is in `app/src` and tests in `app/tests`. Publish steps are in the root `README.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|
|
@@ -52,6 +52,15 @@
|
|
|
52
52
|
"sideEffects": [
|
|
53
53
|
"./src/ui/sonner.css"
|
|
54
54
|
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"check:tokens": "node tokens/scripts/check-component-tokens.mjs",
|
|
57
|
+
"build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
|
|
59
|
+
"test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
|
|
60
|
+
"verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
|
|
61
|
+
"analyze": "node tokens/verify/analyze.mjs",
|
|
62
|
+
"prepack": "node tokens/scripts/check-exports.mjs"
|
|
63
|
+
},
|
|
55
64
|
"peerDependencies": {
|
|
56
65
|
"@clerk/clerk-react": ">=5",
|
|
57
66
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
@@ -162,13 +171,5 @@
|
|
|
162
171
|
"zod": {
|
|
163
172
|
"optional": true
|
|
164
173
|
}
|
|
165
|
-
},
|
|
166
|
-
"scripts": {
|
|
167
|
-
"check:tokens": "node tokens/scripts/check-component-tokens.mjs",
|
|
168
|
-
"build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
|
|
169
|
-
"typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
|
|
170
|
-
"test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
|
|
171
|
-
"verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
|
|
172
|
-
"analyze": "node tokens/verify/analyze.mjs"
|
|
173
174
|
}
|
|
174
|
-
}
|
|
175
|
+
}
|
package/shell/src/AppShell.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createContext, useContext, type ComponentProps, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "../../src/utils/cn";
|
|
3
3
|
import {
|
|
4
4
|
SidebarResizeProvider,
|
|
5
|
+
useSidebarResizeContext,
|
|
5
6
|
useSidebarResizeState,
|
|
6
7
|
type SidebarResizeOptions,
|
|
7
8
|
} from "./SidebarResize";
|
|
@@ -40,60 +41,146 @@ export type AppShellProps = {
|
|
|
40
41
|
|
|
41
42
|
type ShellVars = CSSProperties & Record<`--app-shell-${string}`, string>;
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
type AppShellContextValue = { mainId: string; panelHidden: boolean };
|
|
45
|
+
|
|
46
|
+
const AppShellContext = createContext<AppShellContextValue>({
|
|
47
|
+
mainId: "main-content",
|
|
48
|
+
panelHidden: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type AppShellRootProps = ComponentProps<"div"> &
|
|
52
|
+
Pick<AppShellProps, "panelHidden" | "railWidth" | "sidebarWidth" | "resizable" | "mobileHeaderHeight" | "mainId">;
|
|
53
|
+
|
|
54
|
+
/** Frame `<div>`: sets the `--app-shell-*` variables and owns the resize state. `style` merges over the variables. */
|
|
55
|
+
function Root({
|
|
47
56
|
panelHidden = false,
|
|
48
57
|
railWidth = "4rem",
|
|
49
58
|
sidebarWidth = "20rem",
|
|
50
59
|
resizable,
|
|
51
60
|
mobileHeaderHeight = "calc(3.5rem + env(safe-area-inset-top, 0px))",
|
|
52
|
-
skipLinkLabel = "Skip to main content",
|
|
53
61
|
mainId = "main-content",
|
|
54
62
|
className,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}:
|
|
63
|
+
style,
|
|
64
|
+
...props
|
|
65
|
+
}: AppShellRootProps) {
|
|
58
66
|
const resize = useSidebarResizeState(resizable);
|
|
59
|
-
const
|
|
67
|
+
const vars: ShellVars = {
|
|
60
68
|
"--app-shell-rail-width": railWidth,
|
|
61
69
|
"--app-shell-sidebar-width": resize ? `calc(${railWidth} + ${resize.width}px)` : sidebarWidth,
|
|
62
70
|
"--app-shell-mobile-header-height": mobileHeaderHeight,
|
|
63
71
|
};
|
|
64
72
|
return (
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
className="
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
</
|
|
73
|
+
<AppShellContext.Provider value={{ mainId, panelHidden }}>
|
|
74
|
+
<SidebarResizeProvider value={resize}>
|
|
75
|
+
<div
|
|
76
|
+
{...props}
|
|
77
|
+
className={cn("relative h-dvh overflow-hidden bg-background", className)}
|
|
78
|
+
style={{ ...vars, ...style }}
|
|
79
|
+
/>
|
|
80
|
+
</SidebarResizeProvider>
|
|
81
|
+
</AppShellContext.Provider>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type AppShellSkipLinkProps = Omit<ComponentProps<"a">, "children" | "href"> & {
|
|
86
|
+
/** Default `"Skip to main content"`. */
|
|
87
|
+
label?: ReactNode;
|
|
88
|
+
/** Default the Root `mainId`. */
|
|
89
|
+
targetId?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Visually hidden link to `<main>`, shown on focus. */
|
|
93
|
+
function SkipLink({ label = "Skip to main content", targetId, className, ...props }: AppShellSkipLinkProps) {
|
|
94
|
+
const { mainId } = useContext(AppShellContext);
|
|
95
|
+
return (
|
|
96
|
+
<a
|
|
97
|
+
href={`#${targetId ?? mainId}`}
|
|
98
|
+
{...props}
|
|
99
|
+
className={cn(
|
|
100
|
+
"sr-only focus:absolute focus:left-3 focus:top-[max(0.75rem,env(safe-area-inset-top))] focus:z-[100] focus:block focus:rounded-lg focus:bg-surface-secondary focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring",
|
|
101
|
+
className,
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{label}
|
|
105
|
+
</a>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type AppShellMainProps = ComponentProps<"main">;
|
|
110
|
+
|
|
111
|
+
/** `<main>`, offset by the sidebar (or rail while the Root `panelHidden`). `id` defaults to the Root `mainId`. */
|
|
112
|
+
function Main({ id, className, ...props }: AppShellMainProps) {
|
|
113
|
+
const { mainId, panelHidden } = useContext(AppShellContext);
|
|
114
|
+
const resize = useSidebarResizeContext();
|
|
115
|
+
return (
|
|
116
|
+
<main
|
|
117
|
+
id={id ?? mainId}
|
|
118
|
+
tabIndex={-1}
|
|
119
|
+
{...props}
|
|
120
|
+
className={cn(
|
|
121
|
+
"relative z-10 flex h-full min-h-0 outline-none md:p-2",
|
|
122
|
+
panelHidden ? "md:ml-[var(--app-shell-rail-width)]" : "md:ml-[var(--app-shell-sidebar-width)]",
|
|
123
|
+
!resize?.isResizing &&
|
|
124
|
+
"md:transition-[margin-left] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
|
|
125
|
+
className,
|
|
126
|
+
)}
|
|
127
|
+
/>
|
|
98
128
|
);
|
|
99
129
|
}
|
|
130
|
+
|
|
131
|
+
export type AppShellContentProps = ComponentProps<"div">;
|
|
132
|
+
|
|
133
|
+
/** Content card inside `<main>`. Pads for the mobile header below `md`. */
|
|
134
|
+
function Content({ className, ...props }: AppShellContentProps) {
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
{...props}
|
|
138
|
+
className={cn(
|
|
139
|
+
"flex min-h-0 flex-1 flex-col overflow-hidden bg-surface pt-[var(--app-shell-mobile-header-height)] md:rounded-lg md:pt-0",
|
|
140
|
+
className,
|
|
141
|
+
)}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Legacy (still supported): full-height app frame with a skip link, a sidebar
|
|
148
|
+
* slot and the main content card. Composed from `AppShell.Root`, `.SkipLink`,
|
|
149
|
+
* `.Main` and `.Content`.
|
|
150
|
+
*/
|
|
151
|
+
function LegacyAppShell({
|
|
152
|
+
sidebar,
|
|
153
|
+
children,
|
|
154
|
+
panelHidden = false,
|
|
155
|
+
railWidth,
|
|
156
|
+
sidebarWidth,
|
|
157
|
+
resizable,
|
|
158
|
+
mobileHeaderHeight,
|
|
159
|
+
skipLinkLabel,
|
|
160
|
+
mainId = "main-content",
|
|
161
|
+
className,
|
|
162
|
+
mainClassName,
|
|
163
|
+
contentClassName,
|
|
164
|
+
}: AppShellProps) {
|
|
165
|
+
return (
|
|
166
|
+
<Root
|
|
167
|
+
panelHidden={panelHidden}
|
|
168
|
+
railWidth={railWidth}
|
|
169
|
+
sidebarWidth={sidebarWidth}
|
|
170
|
+
resizable={resizable}
|
|
171
|
+
mobileHeaderHeight={mobileHeaderHeight}
|
|
172
|
+
mainId={mainId}
|
|
173
|
+
className={className}
|
|
174
|
+
>
|
|
175
|
+
<SkipLink label={skipLinkLabel} targetId={mainId} />
|
|
176
|
+
{sidebar}
|
|
177
|
+
<Main id={mainId} className={mainClassName}>
|
|
178
|
+
<Content className={contentClassName}>{children}</Content>
|
|
179
|
+
</Main>
|
|
180
|
+
</Root>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
LegacyAppShell.displayName = "AppShell";
|
|
184
|
+
|
|
185
|
+
/** App frame. Call it with props (legacy), or compose `AppShell.Root`, `.SkipLink`, `.Main` and `.Content`. */
|
|
186
|
+
export const AppShell = Object.assign(LegacyAppShell, { Root, SkipLink, Main, Content });
|