@vvedantb/ui 0.5.3 → 0.6.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 CHANGED
@@ -69,7 +69,7 @@ import { InputArea, InputBanners, buildComposerSlashItems } from "@vvedantb/ui";
69
69
 
70
70
  | Prop | Default | Notes |
71
71
  | --- | --- | --- |
72
- | `value`, `onChange`, `defaultValue` | uncontrolled, `""` | `onSubmit` clears the draft through `onChange("")` first. Restore it yourself if sending fails. |
72
+ | `value`, `onChange`, `defaultValue` | uncontrolled, `""` | `onSubmit` clears the draft through `onChange("")` first. Restore it yourself if sending fails. A value of `"\n"` shows as an empty editor with the placeholder, and the editor never reports a trailing line holder newline (0.5.4). |
73
73
  | `onSubmit(text)` | none | Trimmed text. Not called while `isSending`, or when send is disabled. |
74
74
  | `onStop`, `isStreaming`, `isSending`, `isUploading` | off | While streaming, stop shows next to send, and send queues. Sending or uploading shows a spinner and disables send. |
75
75
  | `canSubmit` | text, or `fileCount` or `chipCount` above 0 | Overrides when send is enabled. |
@@ -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)).
218
219
 
219
220
  ## Buttons, menus and fields (0.4.4)
220
221
 
@@ -250,7 +251,7 @@ import {
250
251
  - `SelectField`: a labelled single select built from `Select`. Children are `SelectItem`s. An empty `value` shows the placeholder. `id` goes to the trigger and the label points at it. `className` merges onto the trigger.
251
252
  - Motion: `springTransition`, `springFastTransition` and `fadeUpSpring` presets. `FadePresence` takes optional `variants` (`hidden`, `show`, `exit`); omitted, it keeps its opacity fade. `PanelTransition` wraps children in React `ViewTransition` with `default="none"` and `enter`/`exit` classes (`vt-panel-in`, `vt-panel-out` by default). It needs a React build that exports `ViewTransition`, and the app supplies the `::view-transition-*` CSS.
252
253
 
253
- ## App shell (0.5.0 to 0.5.3)
254
+ ## App shell (0.5.0 to 0.5.4)
254
255
 
255
256
  A sidebar and main page layout, extracted from existing app markup. It is router-free: pass your own links (with `asChild`), state and media query result. Tooltips need a `TooltipProvider` above the shell.
256
257
 
@@ -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.
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,163 @@ function PanelWidth() {
380
381
  </Tabs>
381
382
  ```
382
383
 
384
+ ## Composable parts (0.6.0)
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). |
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. |
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. |
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, 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 full shell:
487
+
488
+ ```tsx
489
+ <AppShell.Root panelHidden={collapsed || !hasPanel} resizable={resizeOptions}>
490
+ <AppShell.SkipLink />
491
+ <Sidebar.Root
492
+ isDesktop={isDesktop}
493
+ mobileOpen={open}
494
+ onMobileOpenChange={setOpen}
495
+ collapsed={collapsed}
496
+ panelHidden={!hasPanel}
497
+ >
498
+ <Sidebar.MobileHeader>
499
+ <Sidebar.MobileMenuButton />
500
+ <Sidebar.MobileTitle>{pageTitle}</Sidebar.MobileTitle>
501
+ <Sidebar.MobileActions />
502
+ </Sidebar.MobileHeader>
503
+ <Sidebar.Backdrop />
504
+ <Sidebar.Aside>
505
+ <Sidebar.Rail showDivider>
506
+ <Sidebar.RailHeader>
507
+ <Sidebar.RailItem asChild label="Home" active={isHome}>
508
+ <Link to="/"><IconHome size={22} /></Link>
509
+ </Sidebar.RailItem>
510
+ </Sidebar.RailHeader>
511
+ <Sidebar.RailBody>
512
+ <Sidebar.RailItem label="Search"><IconSearch size={22} /></Sidebar.RailItem>
513
+ </Sidebar.RailBody>
514
+ <Sidebar.RailFooter>
515
+ <Sidebar.AccountMenu>
516
+ <Sidebar.AccountTrigger userName={name} imageUrl={avatarUrl} />
517
+ <Sidebar.AccountContent>
518
+ <Sidebar.AccountLabel userName={name} email={email} imageUrl={avatarUrl} />
519
+ <Sidebar.AccountSeparator />
520
+ <DropdownMenuItem>Settings</DropdownMenuItem>
521
+ </Sidebar.AccountContent>
522
+ </Sidebar.AccountMenu>
523
+ </Sidebar.RailFooter>
524
+ </Sidebar.Rail>
525
+ <Sidebar.Panel>
526
+ <Sidebar.PanelHeader>
527
+ <Sidebar.PanelTitle>Library</Sidebar.PanelTitle>
528
+ <Sidebar.PanelActions>{isDesktop ? null : <Sidebar.PanelClose />}</Sidebar.PanelActions>
529
+ </Sidebar.PanelHeader>
530
+ {nav}
531
+ </Sidebar.Panel>
532
+ <Sidebar.ResizeHandle />
533
+ </Sidebar.Aside>
534
+ </Sidebar.Root>
535
+ <AppShell.Main>
536
+ <AppShell.Content>{page}</AppShell.Content>
537
+ </AppShell.Main>
538
+ </AppShell.Root>
539
+ ```
540
+
383
541
  ## Toaster
384
542
 
385
543
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.5.3",
3
+ "version": "0.6.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",
@@ -1,7 +1,8 @@
1
- import type { CSSProperties, ReactNode } from "react";
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
- /** Full-height app frame: skip link, a sidebar slot and the main content card. */
44
- export function AppShell({
45
- sidebar,
46
- children,
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
- mainClassName,
56
- contentClassName,
57
- }: AppShellProps) {
63
+ style,
64
+ ...props
65
+ }: AppShellRootProps) {
58
66
  const resize = useSidebarResizeState(resizable);
59
- const style: ShellVars = {
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
- <SidebarResizeProvider value={resize}>
66
- <div className={cn("relative h-dvh overflow-hidden bg-background", className)} style={style}>
67
- <a
68
- href={`#${mainId}`}
69
- className="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"
70
- >
71
- {skipLinkLabel}
72
- </a>
73
- {sidebar}
74
- <main
75
- id={mainId}
76
- tabIndex={-1}
77
- className={cn(
78
- "relative z-10 flex h-full min-h-0 outline-none md:p-2",
79
- panelHidden
80
- ? "md:ml-[var(--app-shell-rail-width)]"
81
- : "md:ml-[var(--app-shell-sidebar-width)]",
82
- !resize?.isResizing &&
83
- "md:transition-[margin-left] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
84
- mainClassName,
85
- )}
86
- >
87
- <div
88
- className={cn(
89
- "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",
90
- contentClassName,
91
- )}
92
- >
93
- {children}
94
- </div>
95
- </main>
96
- </div>
97
- </SidebarResizeProvider>
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 });
@@ -1,11 +1,5 @@
1
- import { useEffect, useId, type KeyboardEvent, type ReactNode } from "react";
2
- import { AnimatePresence, motion, type PanInfo } from "motion/react";
3
- import { IconMenu2 } from "@tabler/icons-react";
4
- import { Button } from "../../src/ui/button";
5
- import { motionEase, motionTiming } from "../../src/motion/presets";
6
- import { cn } from "../../src/utils/cn";
7
- import { mobileMenuButtonClass } from "./app-shell-classes";
8
- import { SidebarResizeHandle, useSidebarResizeContext } from "./SidebarResize";
1
+ import type { ReactNode } from "react";
2
+ import { SidebarChromeParts as Parts } from "./SidebarChrome";
9
3
 
10
4
  export type AppSidebarProps = {
11
5
  /** Icon rail, usually a `SidebarRail`. */
@@ -39,13 +33,15 @@ export type AppSidebarProps = {
39
33
  closeLabel?: string;
40
34
  };
41
35
 
42
- const defaultMobileMenuIcon = () => <IconMenu2 size={20} />;
43
-
44
36
  /**
45
37
  * Sidebar chrome: the mobile header, the drawer backdrop and the `<aside>`
46
38
  * (fixed on desktop, a swipe-to-close drawer below it). Reads the
47
39
  * `--app-shell-*` width variables set by `AppShell`. Inside a resizable
48
40
  * `AppShell` it adds the resize handle while the panel shows on desktop.
41
+ *
42
+ * Legacy (still supported): the `rail`, `panel` and `mobile*` slot props.
43
+ * Compose `Sidebar.Root`, `.MobileHeader`, `.Backdrop`, `.Aside` and
44
+ * `.ResizeHandle` instead.
49
45
  */
50
46
  export function AppSidebar({
51
47
  rail,
@@ -60,118 +56,30 @@ export function AppSidebar({
60
56
  className,
61
57
  mobileTitle,
62
58
  mobileHeaderActions,
63
- mobileMenuIcon = defaultMobileMenuIcon,
64
- mobileMenuLabel = "Open navigation menu",
65
- closeLabel = "Close navigation",
59
+ mobileMenuIcon,
60
+ mobileMenuLabel,
61
+ closeLabel,
66
62
  }: AppSidebarProps) {
67
- const autoId = useId();
68
- const drawerId = id ?? autoId;
69
- const layout = isDesktop ? "desktop" : "drawer";
70
- const resize = useSidebarResizeContext();
71
- const showResizeHandle = resize !== null && isDesktop && !collapsed && !panelHidden;
72
-
73
- // Reaching the desktop breakpoint closes the drawer, so it does not reopen
74
- // on the way back down. Runs on breakpoint change only.
75
- useEffect(() => {
76
- if (isDesktop && mobileOpen) onMobileOpenChange(false);
77
- }, [isDesktop]);
78
-
79
- const close = () => onMobileOpenChange(false);
80
-
81
- const handleDrawerDragEnd = (
82
- _event: MouseEvent | TouchEvent | PointerEvent,
83
- info: PanInfo,
84
- ) => {
85
- if (isDesktop || !mobileOpen) return;
86
- if (info.offset.x < -72 || info.velocity.x < -500) {
87
- close();
88
- }
89
- };
90
-
91
- const closeOnEscape = (event: KeyboardEvent) => {
92
- if (event.key === "Escape" && !isDesktop && mobileOpen) {
93
- close();
94
- }
95
- };
96
-
97
63
  return (
98
- <>
99
- <header
100
- onKeyDown={closeOnEscape}
101
- className="fixed inset-x-0 top-0 z-30 flex h-[var(--app-shell-mobile-header-height)] items-center gap-2 bg-surface px-3 pt-[env(safe-area-inset-top,0px)] pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] md:hidden"
102
- >
103
- <Button
104
- type="button"
105
- variant="ghost"
106
- size="icon"
107
- onClick={() => onMobileOpenChange(true)}
108
- aria-label={mobileMenuLabel}
109
- aria-expanded={mobileOpen}
110
- aria-controls={drawerId}
111
- className={mobileMenuButtonClass}
112
- >
113
- {mobileMenuIcon(mobileOpen)}
114
- </Button>
115
- {mobileTitle ? (
116
- <h1 className="pointer-events-none absolute inset-x-14 top-[env(safe-area-inset-top,0px)] bottom-0 flex items-center justify-center truncate text-center text-base font-semibold leading-none tracking-tight text-foreground text-balance">
117
- {mobileTitle}
118
- </h1>
119
- ) : null}
120
- {mobileHeaderActions ? (
121
- <div data-app-sidebar-mobile-actions="" className="ml-auto flex shrink-0 items-center gap-2">
122
- {mobileHeaderActions}
123
- </div>
124
- ) : (
125
- <div className="ml-auto h-11 w-11 shrink-0" aria-hidden="true" />
126
- )}
127
- </header>
128
-
129
- <AnimatePresence initial={false}>
130
- {mobileOpen && !isDesktop ? (
131
- <motion.button
132
- type="button"
133
- aria-label={closeLabel}
134
- className="fixed inset-0 z-40 bg-backdrop/80 md:hidden"
135
- onClick={close}
136
- initial={{ opacity: 0 }}
137
- animate={{ opacity: 1 }}
138
- exit={{ opacity: 0 }}
139
- transition={{ duration: motionTiming.sidebar, ease: motionEase }}
140
- />
141
- ) : null}
142
- </AnimatePresence>
143
-
144
- <motion.aside
145
- id={drawerId}
146
- data-sidebar-layout={layout}
147
- {...asideProps}
148
- inert={!isDesktop && !mobileOpen}
149
- onKeyDown={closeOnEscape}
150
- className={cn(
151
- "fixed inset-y-0 left-0 z-50 flex bg-background pb-[env(safe-area-inset-bottom,0px)] pt-[env(safe-area-inset-top,0px)] md:py-0",
152
- panelHidden
153
- ? "w-[var(--app-shell-rail-width)]"
154
- : "w-[min(var(--app-shell-sidebar-width),calc(100vw-1.5rem))]",
155
- collapsed || panelHidden
156
- ? "md:w-[var(--app-shell-rail-width)]"
157
- : "md:w-[var(--app-shell-sidebar-width)]",
158
- !resize?.isResizing &&
159
- "md:transition-[width] md:[transition-duration:280ms] md:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
160
- className,
161
- )}
162
- initial={false}
163
- animate={{ x: isDesktop || mobileOpen ? 0 : "-100%" }}
164
- transition={{ duration: motionTiming.sidebar, ease: motionEase }}
165
- drag={!isDesktop && mobileOpen ? "x" : false}
166
- dragDirectionLock
167
- dragConstraints={{ left: 0, right: 0 }}
168
- dragElastic={{ left: 0.45, right: 0.08 }}
169
- onDragEnd={handleDrawerDragEnd}
170
- >
64
+ <Parts.Root
65
+ isDesktop={isDesktop}
66
+ mobileOpen={mobileOpen}
67
+ onMobileOpenChange={onMobileOpenChange}
68
+ collapsed={collapsed}
69
+ panelHidden={panelHidden}
70
+ id={id}
71
+ >
72
+ <Parts.MobileHeader>
73
+ <Parts.MobileMenuButton label={mobileMenuLabel} icon={mobileMenuIcon} />
74
+ <Parts.MobileTitle>{mobileTitle}</Parts.MobileTitle>
75
+ <Parts.MobileActions>{mobileHeaderActions}</Parts.MobileActions>
76
+ </Parts.MobileHeader>
77
+ <Parts.Backdrop label={closeLabel} />
78
+ <Parts.Aside {...asideProps} className={className}>
171
79
  {rail}
172
80
  {panelHidden ? null : panel}
173
- {showResizeHandle ? <SidebarResizeHandle /> : null}
174
- </motion.aside>
175
- </>
81
+ <Parts.ResizeHandle />
82
+ </Parts.Aside>
83
+ </Parts.Root>
176
84
  );
177
85
  }