entasis 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ export interface ScrollAreaOptions {
2
+ delay: number;
3
+ type: 'auto' | 'always' | 'scroll' | 'hover';
4
+ scrollOnEdges: boolean;
5
+ /**
6
+ * Px of content that may stay hidden past an edge before that edge counts as overflowing.
7
+ * Defaults to 1.
8
+ */
9
+ overflowEdgeThreshold?: number;
10
+ /**
11
+ * Writing direction of the viewport. In `rtl` the horizontal offset runs from 0 down to a
12
+ * negative number, so every horizontal read and write is mirrored. Defaults to `ltr`.
13
+ */
14
+ direction?: () => 'ltr' | 'rtl';
15
+ }
16
+ declare const ScrollArea_base: new (props: ScrollAreaOptions) => {} & ScrollAreaOptions;
17
+ export declare class ScrollArea extends ScrollArea_base {
18
+ viewportElement: HTMLElement | undefined;
19
+ contentElement: HTMLElement | undefined;
20
+ scrollbarXElement: HTMLElement | undefined;
21
+ scrollbarYElement: HTMLElement | undefined;
22
+ scrollY: number;
23
+ scrollX: number;
24
+ dragOffset: number;
25
+ viewportDimensions: {
26
+ width: number;
27
+ height: number;
28
+ };
29
+ contentDimensions: {
30
+ width: number;
31
+ height: number;
32
+ };
33
+ measureVersion: number;
34
+ maxScrollY: number;
35
+ scrollbarYEnabled: boolean;
36
+ maxScrollX: number;
37
+ scrollbarXEnabled: boolean;
38
+ visible: boolean;
39
+ visibleX: boolean;
40
+ rtl: boolean;
41
+ trackYThickness: number;
42
+ trackXThickness: number;
43
+ private edgeThreshold;
44
+ overflowYStart: number;
45
+ overflowYEnd: number;
46
+ overflowXStart: number;
47
+ overflowXEnd: number;
48
+ hasOverflowYStart: boolean;
49
+ hasOverflowYEnd: boolean;
50
+ hasOverflowXStart: boolean;
51
+ hasOverflowXEnd: boolean;
52
+ canScrollUp: boolean;
53
+ canScrollDown: boolean;
54
+ thumbRect: {
55
+ readonly current: DOMRect;
56
+ reference: (ref: HTMLElement) => (() => void) | undefined;
57
+ };
58
+ trackRect: {
59
+ readonly current: DOMRect;
60
+ reference: (ref: HTMLElement) => (() => void) | undefined;
61
+ };
62
+ trackRectX: {
63
+ readonly current: DOMRect;
64
+ reference: (ref: HTMLElement) => (() => void) | undefined;
65
+ };
66
+ private trackYLength;
67
+ private trackXLength;
68
+ /** Thumb length the content ratio asks for, before any overscroll shrinks it. */
69
+ thumbYBaseSize: number;
70
+ thumbXBaseSize: number;
71
+ overscrollY: number;
72
+ overscrollX: number;
73
+ thumbYSize: number;
74
+ thumbXSize: number;
75
+ private savedScrollSnapType;
76
+ private pauseScrollSnap;
77
+ private restoreScrollSnap;
78
+ isDraggingY: boolean;
79
+ dragY: import("svelte/attachments").Attachment<HTMLElement>;
80
+ isDraggingX: boolean;
81
+ dragX: import("svelte/attachments").Attachment<HTMLElement>;
82
+ overflowing: boolean;
83
+ hoover: {
84
+ reference: (ref: HTMLElement) => () => void;
85
+ destroy: () => void;
86
+ cancel: () => void;
87
+ readonly isHovered: boolean;
88
+ };
89
+ thumbYPosition: number;
90
+ /** Distance from the track's inline start to the thumb's inline start edge. */
91
+ thumbXPosition: number;
92
+ /** The horizontal thumb's `translateX` in px: mirrored in RTL, where the track fills from the right. */
93
+ thumbXTranslate: number;
94
+ isScrolling: boolean;
95
+ private scrollingTimer;
96
+ private clearScrollingTimer;
97
+ /** Horizontal scroll offset as distance from the inline start, whatever the direction. */
98
+ private readScrollX;
99
+ /** Scrolls horizontally to a distance from the inline start. */
100
+ private writeScrollX;
101
+ /** Re-resolves the writing direction; true when it flipped. */
102
+ private syncDirection;
103
+ private readScrollOffsets;
104
+ private handleScroll;
105
+ handleTrackClick: (event: MouseEvent) => void;
106
+ handleTrackClickX: (event: MouseEvent) => void;
107
+ private measureFrame;
108
+ private lastExtentY;
109
+ private lastExtentX;
110
+ /**
111
+ * Read the live boxes and publish them. Idempotent: state is only written when a number
112
+ * actually moved, so a chatty MutationObserver cannot start a re-render loop.
113
+ */
114
+ measure: () => void;
115
+ /** Coalesce bursts of observer callbacks into one post-layout measurement. */
116
+ private scheduleMeasure;
117
+ private cancelScheduledMeasure;
118
+ viewportAttachment: (element: HTMLElement) => () => void;
119
+ contentAttachment: (element: HTMLElement) => () => void;
120
+ private forwardWheelToViewport;
121
+ private keepFocusOnPress;
122
+ trackAttachment: (element: HTMLElement) => () => void;
123
+ trackAttachmentX: (element: HTMLElement) => () => void;
124
+ private scrollAnimationFrame;
125
+ private edgeScrollSpeed;
126
+ get scrollOnEdgesAttachment(): ((element: HTMLElement) => () => void) | null;
127
+ private startEdgeScroll;
128
+ private stopEdgeScroll;
129
+ }
130
+ export {};
@@ -28,6 +28,7 @@
28
28
  import SidebarFlyout from './SidebarFlyout.svelte';
29
29
  import SidebarIcon from './SidebarIcon.svelte';
30
30
  import SidebarMenuSubItem from './SidebarMenuSubItem.svelte';
31
+ import { useSidebarSuffixWidth } from './sidebar.suffix.svelte.js';
31
32
  import { useSidebarMotion, useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
32
33
 
33
34
  let {
@@ -61,6 +62,8 @@
61
62
  let submenuTriggerRef = $state<HTMLButtonElement | null>(null);
62
63
  let submenuRef = $state<HTMLUListElement | null>(null);
63
64
  let actionRef = $state<HTMLElement | null>(null);
65
+ let suffixRef = $state<HTMLElement | null>(null);
66
+ const suffixWidth = useSidebarSuffixWidth();
64
67
  const classes = $derived(useSidebarTheme(theme));
65
68
  // Motion preset from `sidebarTheme.motion`, through the override ladder
66
69
  // (registry → `setSidebarTheme` → the instance `theme.motion` slot).
@@ -86,9 +89,20 @@
86
89
  (item.view !== undefined && !back) ||
87
90
  (hasSubmenu && !item.href && item.collapsible !== false && collapseIcon !== 'none')
88
91
  );
92
+ // A suffix sits beside the row and stays visible before the action, so a row with one reserves
93
+ // the suffix's width and the action's box at every width, and its badge never steps aside.
89
94
  const endReserve = $derived(
90
- !item.action ? 'none' : showBadge && !hasTrailingGlyph ? 'actionBelowMd' : 'action'
95
+ item.suffix
96
+ ? item.action
97
+ ? 'suffixAction'
98
+ : 'suffix'
99
+ : !item.action
100
+ ? 'none'
101
+ : showBadge && !hasTrailingGlyph
102
+ ? 'actionBelowMd'
103
+ : 'action'
91
104
  );
105
+ const badgeAction = $derived(item.action && !item.suffix ? 'row' : 'none');
92
106
  // A bare glyph stays bare: the wrapper only appears when the entry asks for a role tint or a
93
107
  // tile, so an untinted row never inherits the ambient `data-color`.
94
108
  const hasIconSurface = $derived(!!item.icon && (item.iconVariant === 'tile' || !!item.iconColor));
@@ -113,6 +127,9 @@
113
127
  const tooltipPosition = () => sidebarInwardPhysicalSide(api.side, direction());
114
128
  const uid = $props.id();
115
129
  const submenuId = `${uid}-submenu`;
130
+ const suffixId = `${uid}-suffix`;
131
+ // The row reads with its suffix (« Dossiers, 20 ») while the suffix is on screen.
132
+ const describedBy = $derived(item.suffix && !isIconCollapsed ? suffixId : undefined);
116
133
  // The submenu's `hidden` state and its height animation, reversible mid-way, come from the
117
134
  // shared disclosure panel, as Collapsible's and Accordion's do.
118
135
  const submenuPanel = disclosurePanel({ open: () => showSubmenu, motion: () => collapseMotion });
@@ -126,7 +143,10 @@
126
143
  return;
127
144
  }
128
145
 
129
- if (isIconCollapsed && actionRef?.contains(activeElement)) {
146
+ if (
147
+ isIconCollapsed &&
148
+ (actionRef?.contains(activeElement) || suffixRef?.contains(activeElement))
149
+ ) {
130
150
  (submenuTriggerRef ?? rowRef)?.focus();
131
151
  }
132
152
  });
@@ -172,7 +192,7 @@
172
192
  <span
173
193
  data-slot="sidebar-menu-badge"
174
194
  data-sidebar="menu-badge"
175
- class={classes.badge({ size, action: item.action ? 'row' : 'none' })}
195
+ class={classes.badge({ size, action: badgeAction })}
176
196
  >
177
197
  {item.badge}
178
198
  </span>
@@ -209,6 +229,7 @@
209
229
  data-active-variant={activeVariant}
210
230
  aria-current={item.isActive ? 'page' : undefined}
211
231
  aria-disabled={item.disabled || undefined}
232
+ aria-describedby={describedBy}
212
233
  tabindex={item.disabled ? -1 : undefined}
213
234
  class={classes.menuButton({
214
235
  variant: item.variant,
@@ -238,6 +259,7 @@
238
259
  data-active-variant={activeVariant}
239
260
  aria-current={item.isActive ? 'page' : undefined}
240
261
  aria-label={ariaLabel}
262
+ aria-describedby={describedBy}
241
263
  data-sidebar-view-target={item.view}
242
264
  disabled={item.disabled || undefined}
243
265
  class={classes.menuButton({
@@ -295,6 +317,7 @@
295
317
  aria-expanded={popover.isOpen}
296
318
  aria-haspopup="menu"
297
319
  aria-controls={popover.isOpen ? popover.id : undefined}
320
+ aria-describedby={describedBy}
298
321
  {@attach tooltipContent
299
322
  ? tooltip(sidebarTooltip(tooltipContent, tooltipPosition))
300
323
  : undefined}
@@ -374,7 +397,10 @@
374
397
  data-sidebar="menu-item"
375
398
  class={classes.menuItem()}
376
399
  >
377
- <div class="group/menu-row relative">
400
+ <div
401
+ class="group/menu-row relative"
402
+ style:--sidebar-suffix-width={item.suffix ? suffixWidth.value : undefined}
403
+ >
378
404
  {#if item.menu}
379
405
  {@render dropdownButton()}
380
406
  {:else if hasFlyout}
@@ -423,6 +449,7 @@
423
449
  })}
424
450
  aria-expanded={showSubmenu}
425
451
  aria-controls={submenuPanel.rendered ? submenuId : undefined}
452
+ aria-describedby={describedBy}
426
453
  {...openStateAttributes(showSubmenu)}
427
454
  {@attach tooltipContent
428
455
  ? tooltip(sidebarTooltip(tooltipContent, tooltipPosition))
@@ -437,6 +464,22 @@
437
464
  {@render leafButton()}
438
465
  {/if}
439
466
 
467
+ {#if item.suffix}
468
+ <!-- Beside the row, never inside it: the suffix may hold its own controls. -->
469
+ <div
470
+ bind:this={suffixRef}
471
+ id={suffixId}
472
+ data-slot="sidebar-menu-suffix"
473
+ data-sidebar="menu-suffix"
474
+ inert={isIconCollapsed ? true : undefined}
475
+ aria-hidden={isIconCollapsed ? 'true' : undefined}
476
+ class={classes.suffix({ size, density, action: item.action ? 'action' : 'none' })}
477
+ {@attach suffixWidth.measure}
478
+ >
479
+ {@render item.suffix(api, item)}
480
+ </div>
481
+ {/if}
482
+
440
483
  {#if item.action}
441
484
  <div
442
485
  bind:this={actionRef}
@@ -11,6 +11,7 @@
11
11
  import { sidebarInwardPhysicalSide, sidebarTooltip } from './sidebar-position.js';
12
12
  import SidebarAction from './SidebarAction.svelte';
13
13
  import SidebarIcon from './SidebarIcon.svelte';
14
+ import { useSidebarSuffixWidth } from './sidebar.suffix.svelte.js';
14
15
  import { useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
15
16
 
16
17
  let {
@@ -38,6 +39,11 @@
38
39
  // tile, so an untinted row never inherits the ambient `data-color`.
39
40
  const hasIconSurface = $derived(!!sub.icon && (sub.iconVariant === 'tile' || !!sub.iconColor));
40
41
  const hasBadge = $derived(sub.badge != null);
42
+ const suffixWidth = useSidebarSuffixWidth();
43
+ const uid = $props.id();
44
+ const suffixId = `${uid}-suffix`;
45
+ // The row reads with its suffix (« Dossiers, 20 ») while the suffix is on screen.
46
+ const describedBy = $derived(sub.suffix && !iconCollapsed ? suffixId : undefined);
41
47
  // The label is always visible on a submenu row, so the tooltip is extra information: shown
42
48
  // whenever one is set, never on mobile, where hover does not exist.
43
49
  const tooltipContent = $derived(sub.tooltip && !api.isMobile ? sub.tooltip : undefined);
@@ -53,8 +59,17 @@
53
59
  activeVariant,
54
60
  density,
55
61
  // With a badge last in the row, the badge holds the end column from `md` up and the
56
- // action takes its place while it shows; below `md` the two sit side by side.
57
- endReserve: !sub.action ? 'none' : hasBadge ? 'actionBelowMd' : 'action',
62
+ // action takes its place while it shows; below `md` the two sit side by side. A suffix
63
+ // stays visible before the action, so a row with one reserves both at every width.
64
+ endReserve: sub.suffix
65
+ ? sub.action
66
+ ? 'suffixAction'
67
+ : 'suffix'
68
+ : !sub.action
69
+ ? 'none'
70
+ : hasBadge
71
+ ? 'actionBelowMd'
72
+ : 'action',
58
73
  className: sub.class
59
74
  })
60
75
  );
@@ -90,7 +105,7 @@
90
105
  <span
91
106
  data-slot="sidebar-menu-sub-badge"
92
107
  data-sidebar="menu-sub-badge"
93
- class={classes.badge({ size: subSize, action: sub.action ? 'sub' : 'none' })}
108
+ class={classes.badge({ size: subSize, action: sub.action && !sub.suffix ? 'sub' : 'none' })}
94
109
  >
95
110
  {sub.badge}
96
111
  </span>
@@ -104,6 +119,7 @@
104
119
  data-slot="sidebar-menu-sub-item"
105
120
  data-sidebar="menu-sub-item"
106
121
  class="group/menu-sub-item relative"
122
+ style:--sidebar-suffix-width={sub.suffix ? suffixWidth.value : undefined}
107
123
  >
108
124
  {#if sub.href}
109
125
  <!-- eslint-disable svelte/no-navigation-without-resolve -- Package consumers supply URLs; library links cannot depend on SvelteKit routing. -->
@@ -117,6 +133,7 @@
117
133
  data-active-variant={activeVariant}
118
134
  aria-current={sub.isActive ? 'page' : undefined}
119
135
  aria-disabled={sub.disabled || undefined}
136
+ aria-describedby={describedBy}
120
137
  tabindex={sub.disabled ? -1 : undefined}
121
138
  class={buttonClass}
122
139
  {@attach tooltipContent
@@ -136,6 +153,7 @@
136
153
  data-active={sub.isActive ? 'true' : undefined}
137
154
  data-active-variant={activeVariant}
138
155
  disabled={sub.disabled || undefined}
156
+ aria-describedby={describedBy}
139
157
  class={buttonClass}
140
158
  {@attach tooltipContent
141
159
  ? tooltip(sidebarTooltip(tooltipContent, tooltipPosition))
@@ -146,6 +164,25 @@
146
164
  </button>
147
165
  {/if}
148
166
 
167
+ {#if sub.suffix}
168
+ <!-- Beside the row, never inside it: the suffix may hold its own controls. -->
169
+ <div
170
+ id={suffixId}
171
+ data-slot="sidebar-menu-sub-suffix"
172
+ data-sidebar="menu-sub-suffix"
173
+ inert={iconCollapsed ? true : undefined}
174
+ aria-hidden={iconCollapsed ? 'true' : undefined}
175
+ class={classes.suffix({
176
+ size: subSize,
177
+ density,
178
+ action: sub.action ? 'action' : 'none'
179
+ })}
180
+ {@attach suffixWidth.measure}
181
+ >
182
+ {@render sub.suffix(api, sub)}
183
+ </div>
184
+ {/if}
185
+
149
186
  {#if sub.action}
150
187
  <div
151
188
  data-slot="sidebar-menu-sub-action"
@@ -27,6 +27,7 @@ export declare const sidebarCssVariables: {
27
27
  readonly '--sidebar-width': "On the root: the expanded panel width.";
28
28
  readonly '--sidebar-width-icon': "On the root: the width of the collapsed icon rail.";
29
29
  readonly '--sidebar-width-mobile': "On the root: the width of the mobile drawer.";
30
+ readonly '--sidebar-suffix-width': "On a row with a `suffix` (its `menu-item` row wrapper or `menu-sub-item`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.";
30
31
  readonly '--disclosure-panel-height': "On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.";
31
32
  };
32
33
  /** The name of a Sidebar CSS variable. */
@@ -25,5 +25,6 @@ export const sidebarCssVariables = {
25
25
  '--sidebar-width': 'On the root: the expanded panel width.',
26
26
  '--sidebar-width-icon': 'On the root: the width of the collapsed icon rail.',
27
27
  '--sidebar-width-mobile': 'On the root: the width of the mobile drawer.',
28
+ '--sidebar-suffix-width': "On a row with a `suffix` (its `menu-item` row wrapper or `menu-sub-item`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.",
28
29
  '--disclosure-panel-height': 'On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.'
29
30
  };
@@ -1 +1 @@
1
- export declare const sidebarDescription = "\n# Sidebar Component\n\nSidebar navigation with data-driven groups, icon collapse, mobile drawer behavior,\nrecursive tree groups, header/footer rows, search, actions, and snippet escape hatches.\n\n## Import\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n</script>\n```\n\n## Basic Usage\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { houseIcon } from 'entasis/icons/house';\n\timport { gearIcon } from 'entasis/icons/gear';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Workspace',\n\t\t\titems: [\n\t\t\t\t{ label: 'Dashboard', href: '/', icon: houseIcon, isActive: true },\n\t\t\t\t{ label: 'Settings', href: '/settings', icon: gearIcon }\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar items={items}>\n\t{#snippet children({ toggle })}\n\t\t<header>\n\t\t\t<button type=\"button\" onclick={toggle}>Toggle</button>\n\t\t</header>\n\t\t<main>Page content</main>\n\t{/snippet}\n</Sidebar>\n```\n\n## AI-Safe Usage Contract\n\n1. Use `items` for normal navigation. Use `content` only when data-driven rows cannot express the layout.\n2. Use local Entasis icon snippets such as `houseIcon`, not Lucide component constructors.\n3. Use `MenuItem[]` from `entasis/menu` for `menu` and action dropdowns.\n4. Do not combine `menu` with `href` or `onclick` on the same row; use `action` for a trailing row menu.\n5. Keep `children`, `header`, `content`, `footer`, `banner`, and action snippets pure; they receive `SidebarApi`.\n6. Use `collapsible=\"icon\"` for icon rail behavior, `collapsible=\"offcanvas\"` for hidden desktop panels, and `collapsible=\"none\"` for fixed sidebars. Icon collapse automatically falls back to offcanvas when any data-driven row lacks an icon.\n7. Offcanvas sidebars reveal over the content from the screen edge by default when hidden; set `edgeReveal={false}` to disable that. A revealed hidden sidebar keeps its resize handle and dismisses through a small rectangular pointer tolerance.\n8. Set `keyboardShortcut={false}` when embedding Sidebar inside another shortcut-heavy surface.\n9. Sidebar owns navigation, resize mechanics, the lower application wall, and variant surface geometry. AppShell forwards its variant and composes PageShell inside that surface.\n10. Use `size` for typography, icon scale, and item height. Use `density` independently for section padding, gaps, and submenu spacing.\n11. Use `activityBar` for a persistent icon rail outside the panel (section switching, workspaces). Every item needs an `icon` and a `label`; the label is the accessible name and the tooltip. It is layout mode only: `mode=\"panel\"` renders the navigation panel alone. The rail does not change the panel by itself: keep the selected rail item in state from `onSelect`, mark it `isActive`, and either derive the Sidebar's `items` from it or, for an animated switch, give each rail item a view in `views` and set `view` from it.\n12. Use `expandOnHover` only with `collapsible=\"icon\"`. It is a temporary peek, not a toggle: the persisted collapsed state never changes, while the peeked panel renders with expanded semantics.\n13. Use `views` when the panel's contents change as the user moves through the app: sections switched from the activity bar or the route, and nested menus that open inside the panel. Key each view, set `parent` on nested ones, open them with a row's `view`, and drive `view` from state or the URL. Do not hand-animate `items` swaps.\n\n## Data Model\n\n### SidebarGroup\n- **label**: string - Group label, hidden in icon-collapsed mode.\n- **items**: SidebarMenuEntry[] - Menu rows.\n- **tree**: SidebarTreeNode[] - Recursive tree rows instead of menu items.\n- **action**: SidebarMenuActionDescriptor | SidebarMenuActionDescriptor[] | Snippet<[SidebarApi]> - Group actions pinned to the label's end. The label truncates on one line before them. Pass an array to pin several affordances (a `+` and a drag handle) to one group header; each renders as its own icon-only ghost button.\n- **collapsible**: boolean - Makes the group label a toggle.\n- **defaultOpen**: boolean - Initial collapsible group state.\n- **separator**: boolean - Divider before the group.\n\n### SidebarMenuEntry\n- **label**: string - Visible row label.\n- **icon**: SidebarIcon - Entasis icon snippet or string.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - Leading icon treatment. `tile` paints a rounded square (`bg-color-muted text-color-muted-readable`) around the glyph, so per-project colour chips come from the role scale instead of hand-built markup.\n- **href**: string - Render as an anchor. Mutually exclusive with menu.\n- **onclick**: (event: MouseEvent) => void - Native click handler for button or anchor rows. Mutually exclusive with menu. On a row with `items` and no `href`, it runs before the submenu toggles, inline or in the collapsed flyout; `event.preventDefault()` skips the toggle.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **disabled**: boolean - Disables button rows and marks anchor rows disabled.\n- **badge**: string | number - Trailing count/status, hidden in icon mode. It follows the label inside the row (before any chevron), so the label truncates before it whatever its width. On a row with an `action`, the badge steps aside while the action shows (hover, focus, open menu) from `md` up, and sits just before the always-visible action below `md`: the two never overlap.\n- **tooltip**: string - Entasis Tooltip content in icon mode. Defaults to label.\n- **items**: SidebarMenuSubEntry[] - Inline nested menu. Collapsed to icons on desktop, a row with `items` and no `href`, `view` or `menu` opens them in a flyout of links instead.\n- **collapsible**: boolean - Set false for an always-open submenu.\n- **defaultOpen**: boolean - Initial nested menu state.\n- **menu**: MenuItem[] - Popup menu opened from the full row. Mutually exclusive with href/onclick.\n- **menuSide** / **menuAlign**: SidebarMenuSide / 'start' | 'center' | 'end' - Where that menu opens. `menuSide` takes a physical side (`top`, `right`, `bottom`, `left`) or a logical one (`inline-start`, `inline-end`, mirrored in right-to-left). Left out, a desktop menu opens toward the page (`inline-end` of a left sidebar, `inline-start` of a right one, so it follows the reading direction) aligned to the row's start, and a mobile one below the row aligned to its end. The same pair sits on action descriptors and on `headerButton` / `footerButton`.\n- **view**: string - Key of the `views` entry the row opens, sliding it in; the row shows a trailing chevron. Mutually exclusive with href, menu and items.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.\n\n### SidebarMenuSubEntry\nA row of an inline submenu (`items` on a menu entry). It carries the same row features as a\ntop-level entry, so nested navigation (pages filed in folders) keeps its menus, pins and tints\nwithout flattening the tree. Every field below `size` is optional, and a sub entry that sets none\nof them renders exactly as a plain one.\n- **label**: string - Visible row label.\n- **href** / **onclick** - Render as an anchor, or a button with a native click handler.\n- **icon**: SidebarIcon - Leading icon.\n- **isActive** / **disabled** / **size** - As on a top-level entry; `size` defaults to the Sidebar size.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - `tile` paints the same tinted rounded square as a top-level row.\n- **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's `action` as a top-level badge does.\n- **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, `href` or `onclick`, and `isActive`/`disabled` state: it shows no tooltip, `badge` or `action`.\n- **class**: string - Classes applied to the row button.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Trailing action pinned to the row's end (`data-sidebar=\"menu-sub-action\"`), drawn by the `subAction` theme part: hidden until the row is hovered or focused from `md` up, kept visible while its menu is open, always visible below `md`. The label truncates before it.\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { folderIcon } from 'entasis/icons/folder';\n\timport { fileTextIcon } from 'entasis/icons/fileText';\n\timport { pencilSimpleIcon } from 'entasis/icons/pencilSimple';\n\timport { pushPinIcon } from 'entasis/icons/pushPin';\n\timport { trashIcon } from 'entasis/icons/trash';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Pages',\n\t\t\titems: [\n\t\t\t\t{\n\t\t\t\t\tlabel: 'Planning',\n\t\t\t\t\ticon: folderIcon,\n\t\t\t\t\tdefaultOpen: true,\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlabel: 'Q3 roadmap',\n\t\t\t\t\t\t\thref: '/pages/q3-roadmap',\n\t\t\t\t\t\t\ticon: fileTextIcon,\n\t\t\t\t\t\t\ticonColor: 'success',\n\t\t\t\t\t\t\ticonVariant: 'tile',\n\t\t\t\t\t\t\tbadge: 2,\n\t\t\t\t\t\t\taction: {\n\t\t\t\t\t\t\t\tlabel: 'More options for Q3 roadmap',\n\t\t\t\t\t\t\t\tmenu: [\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Rename', prefix: pencilSimpleIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Pin', prefix: pushPinIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Delete', prefix: trashIcon, color: 'danger' }\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ label: 'Retro notes', href: '/pages/retro-notes', icon: fileTextIcon }\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar {items} />\n```\n\n### SidebarView\nOne named panel content in `views`.\n- **label**: string - The view's name, shown on the back row of the views nested under it.\n- **parent**: string - Key of the view this one is nested under. A nested view opens with a back row to its parent (named \"Back, <parent label>\"), and on mobile a swipe toward the inline end goes back. A view without `parent` is a top-level section.\n- **items**: SidebarGroup[] / **content**: Snippet<[SidebarApi]> - The view's body.\n- **headerButton**, **search**, **headerMenu**, **header**, **footerButton**, **footerMenu**, **footer** - Header and footer props for this view. Each one left undefined comes from the parent view, then from the Sidebar's own prop; `null` removes an inherited one.\n\nChanging the view slides the two views side by side, like pages: a deeper view comes in from the inline end while the old one leaves to the start, a shallower one slides back the other way, and between views at the same depth (sections) the later one in `views` counts as forward. When both views get every header and footer prop from the same place, the header and footer stay still and only the menu slides; when a view changes any of them, the whole panel slides as one page. `api.view` reads the current view and `api.setView(key)` changes it from a snippet.\n\n### SidebarActivityBar\nIcon rail pinned to the outer edge of the sidebar, visible in every display state.\n- **items**: SidebarActivityBarItem[] - Items rendered from the top.\n- **footerItems**: SidebarActivityBarItem[] - Items pinned to the end of the column.\n- **header** / **footer**: Snippet - Custom content before the first item and after the pinned ones.\n- **width**: string (default '3rem') - Column thickness, published as `--sidebar-width-activity`.\n- **label**: string - Accessible name for the column landmark. Set it whenever the panel also renders navigation.\n- **onSelect**: ({ item, index }) => void - Fires after an item is activated. `index` counts `items` then `footerItems`.\n\n### SidebarActivityBarItem\n- **icon**: SidebarIcon (required) - Icon rendered in the square.\n- **label**: string (required) - Accessible name and default tooltip; the square shows no text.\n- **id**: string - Stable render key.\n- **href** / **target** / **rel** - Render an anchor instead of a button.\n- **onclick**: (event: MouseEvent) => void - Native click handler.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **badge**: string | number | Snippet - Corner badge pinned to the outer top corner. An empty string renders a bare dot. A string or number badge joins the accessible name (`\"Alerts, 3\"`); a dot and a Snippet badge are decorative, so put their meaning in `label`.\n- **disabled**: boolean - Blocks activation and skips the item during keyboard navigation.\n- **tooltip**: string | false - Tooltip override; `false` suppresses it.\n\n### SidebarMenuButtonItem\nUse for `headerButton`, `footerButton`, or direct `<SidebarMenuButton />` rows.\n- **icon**: SidebarIcon - Leading logo/icon.\n- **avatar**: { src?: string; alt?: string; fallback?: string } - Leading avatar.\n- **variant**: 'default' | 'brand' | 'compact'.\n- **title**: string - Primary text.\n- **subtitle**: string - Secondary text.\n- **trailing**: SidebarIcon | false | SidebarMenuActionDescriptor - Trailing content. An icon is decorative; an action descriptor (`{ icon, label, onclick }`, the same shape as a group action) renders its own icon-only ghost button beside the row, so a workspace card can carry its own collapse control without a custom `header` snippet. Descriptor handlers are `onclick(event, api)`, so `api.toggle()` is reachable.\n- **href** / **onclick** / **menu** - Choose link, button, or popup behavior. `onclick(event, api)` receives the SidebarApi beside the event, so `api.toggle()` is reachable from the row itself.\n- **menuIconClass**: string - Class override for option icons inside the popup menu.\n\n## Props\n\n### State\n- **open**: boolean (bindable, default true) - Desktop expanded state.\n- **defaultOpen**: boolean (default true) - Initial desktop state when `open` is omitted.\n- **onOpenChange**: (open: boolean) => void - Called once for a library-originated desktop state change. Repeated requests and parent prop updates stay silent.\n- **onDisplayStateChange**: (state: SidebarDisplayState) => void - Called once for a library-originated semantic display-state change.\n- **view**: string (bindable) - Key of the view on screen when `views` is set. Defaults to `defaultView`, then the first view.\n- **defaultView**: string - Initial view when `view` is omitted.\n- **onViewChange**: (view: string) => void - Called once for a library-originated view change: a view row, a back row, a swipe. Parent prop updates stay silent; with a route-driven `view`, navigate here.\n- **api.displayState**: 'expanded' | 'collapsed' | 'hidden' - Semantic desktop state; hidden means closed offcanvas. A hover peek does not change it.\n- **api.isPeeking**: boolean - True while a hover peek renders the collapsed panel at full width. Read it alongside `displayState` when a snippet hides content in icon mode.\n- **keyboardShortcut**: string | false (default 'b') - Ctrl/Cmd shortcut key.\n\n### Layout\n- **side**: 'left' | 'right' - Desktop and mobile side.\n- **variant**: 'admin' | 'floating' | 'inset' | 'split' | 'framed' - Sidebar geometry. `framed` is the admin geometry for a sidebar hosted inside a raised card (AppShell variant framed), with a `surface-recessed` well. `admin` renders the conventional full-height navigation column; `inset` integrates navigation into the lower wall with an inset content surface; `split` renders detached sidebar and content surfaces.\n- **size**: 'small' | 'normal' | 'large' (default 'normal') - Typography, icon, avatar, badge, leading-media, item-height, and search-height scale.\n- **iconSize**: 'small' | 'normal' | 'large' - Icon and leading-media scale inside the panel on its own; defaults to `size`. Menu rows, sub rows, group labels and the header button follow it; the activity bar keeps `size`.\n- **activeVariant**: 'soft' | 'outline' | 'solid' (default 'soft') - How active rows are painted. `soft` is the shared selected recipe (`selectedSoft`: `bg-selected-muted text-selected-muted-readable`), `solid` its loud counterpart (`selectedSolid`: `bg-selected text-selected-contrast`), `outline` a bordered surface card (`bg-surface border border-neutral-muted text-neutral`) that reads as a raised card on a tinted well. Each row carries the choice as `data-active-variant`, so no descendant selector is needed to restyle selection.\n- **density**: 'compact' | 'normal' | 'comfortable' (default 'normal') - Section padding, group padding, gaps, horizontal inset, and submenu spacing.\n- **collapsible**: 'offcanvas' | 'icon' | 'none' - Collapse behavior. Icon mode requires icons on every data-driven row and otherwise resolves to offcanvas.\n- **collapseIcon**: DisclosureIndicator \u2014 'chevron' | 'plus-minus' | 'none' (default 'chevron') - Disclosure indicator drawn on collapsible menu rows. 'none' renders no indicator.\n- **mode**: 'layout' | 'panel' - Full resizing layout or only the visible navigation panel.\n- **frame**: 'viewport' | 'contained' - Standalone Sidebar positioning. Viewport mode uses Theme's dynamic window-height token; contained mode fills a positioned parent.\n- **width**: string - Expanded width.\n- **widthIcon**: string - Icon-collapsed width.\n- **widthMobile**: string - Mobile drawer width. The drawer runs the full screen height, flush with its edge (no inset); an activity bar adds its own width beside it. A swipe toward its edge closes it, from anywhere on it, rows included. Following a link in the panel closes it too (a plain click, also when a client-side router handles it); the activity bar's links, buttons, and clicks that open elsewhere (a modifier key, `target=\"_blank\"`, `download`) keep it open.\n- **rail**: boolean | 'line' | 'thumb' - Edge toggle rail. `true` keeps the thin line style; `thumb` renders a short visible handle with the same full-height hitbox. The appearance is preserved when the rail shares the resize control.\n- **activityBar**: SidebarActivityBar - Icon rail pinned outside the panel, in layout mode only (`mode=\"panel\"` renders the panel alone and ignores it). It never slides off screen: only the panel takes the offcanvas offset, and the reserved layout column is the panel width plus the rail width. It wears the surface of the variant's panel: a hairline column on the canvas for `admin`, the recessed well for `framed`, borderless on the canvas for `inset`, and a card of its own (the panel's radius, edge and gutter) for `floating` and `split`. Every gutter between the activity bar, the panel and the page, and between them and the frame edge, is `--space-md`, so it scales with the theme's spacing. With `inset` and `split`, hiding the panel normally lets the page drop its frame and fill the edge; with an activity bar the rail is still beside the page, so the page keeps its framed form (padding, rounding, AppShell's border and shadow), and a `split` page takes the gutter the panel used to supply. The wrapper carries `data-page-flush` and the `main` part a `flush` variant only when the page does fill the edge. On mobile it stays a column at the drawer's outer edge, beside the panel, as the hairline column on the canvas whatever the variant.\n- **expandOnHover**: boolean (default false) - With `collapsible=\"icon\"`, hovering or focusing into the collapsed panel expands it to `width` over the page (`data-peek=\"true\"`) while the reserved column stays at `widthIcon`, so page content does not reflow. The persisted collapsed state is untouched, and the peeked panel renders exactly like an expanded one: group headers, badges, search, inline submenus, and inline tree branches all come back, and the rail or resize handle travels to its inner edge.\n- **edgeReveal**: boolean (default true) - Pointer/focus edge preview for hidden offcanvas sidebars. Hover reveal overlays content, remains resizable when configured, and re-hides after the pointer leaves its small rectangular tolerance. Dragging the sidebar closed suppresses immediate hover reopening until the pointer leaves the edge trigger; toggle/click opens persistently.\n- **resizable**: boolean | SidebarResizableOptions - Enables pointer and keyboard resizing while expanded, icon-collapsed, or temporarily edge-revealed. `minWidth` and `maxWidth` (a CSS length or a number of px; defaults `12rem` and `32rem`) bound both the drag and the keyboard resize; a `maxWidth` below `minWidth` throws. By default, collapse requires dragging 75% of `minWidth` beyond the minimum; override `collapseThreshold` for a custom boundary. Use `storageKey` to restore and persist the expanded width across sessions.\n - `onWidthChange({ width, isUserInteraction })` reports every expanded-width change with one named payload: continuously while the user resizes (`isUserInteraction: true`) and once when a stored width is restored (`isUserInteraction: false`).\n\n### Content\n- **items**: SidebarGroup[] - Data-driven body navigation.\n- **views**: Record<string, SidebarView> - Named panel contents, one on screen at a time, replacing `items` and `content`. See SidebarView.\n- **headerButton** / **footerButton**: SidebarMenuButtonItem - Sticky large rows.\n- **search**: SidebarSearch - Header search input; use its native `oninput` handler.\n- **headerMenu** / **footerMenu**: SidebarMenuEntry[] - Sticky quick menus.\n- Menu entries and nested entries accept `size: 'small' | 'normal' | 'large'` for row geometry.\n- **header**, **content**, **footer**, **children**, **banner**: Snippet<[SidebarApi]> - Escape hatches. The `header` snippet renders **first** in the header region, above `headerButton`, `search` and `headerMenu`.\n\n### Styling\n- **class**: string - Classes applied to the Sidebar root.\n- **theme**: SidebarThemeProps - Semantic part overrides such as `panel`, `header`, `nav`, `footer`, menu, search, rail, and mobile drawer parts. Every part, with the element it lands on, its variants and their default classes, is listed in the entasis skill's `theme-parts/sidebar.md`; the registry key is `sidebar`.\n\n## Motion\n\n- **motion** theme slot: the height animation shared by collapsible groups, inline submenus, and\n tree branches. Its `in` / `out` `duration`, `delay` and `easing` (motion tokens) time the\n open and the close; the panel animates its height, block padding, borders and margins.\n- Ladder: `<Theme components={{ sidebar: { motion } }}>` \u2192 `setSidebarTheme({ motion })` \u2192\n `theme.motion`. Reduced motion collapses it to 0.\n- The `view` variant (`part: 'view'`) is the pager between views: `in.x` / `out.x` is how far a view travels (default `'100%'`: the two views slide side by side like pages) while it fades between `opacity` (default 0) and 1, at the `slow` duration on the `enter` easing. The back swipe moves the same layers the view change would, with the same travel and opacity. Reduced motion makes the switch instant while a swipe still follows the finger.\n\n## Restyle recipes\n\nThe five asks that come up first, as the override to copy. Each one is rendered and asserted by\n`sidebar-recipes.svelte.test.ts`, so it cannot drift from the component. One rule behind them: a\ndefault written under a variant prefix (`data-[active-variant=solid]:data-active:bg-selected`,\n`data-[side=left]:border-r`) is only replaced by an override carrying the same prefixes; an\nunprefixed class coexists with it and loses on specificity. `theme-parts/sidebar.md` shows every\ndefault verbatim, prefixes included.\n\n- **Dark panel.** `dark` scopes the dark theme's variables to the panel, so every neutral ink inside\n flips with it (every theme also emits its variables on `.<name>`, so the class scopes the dark theme to one subtree); needs a theme named `dark`, which the documented setup declares.\n `theme={{ panel: { base: 'dark bg-slate-900' }, mobilePanel: { base: 'dark bg-slate-900' } }}`\n- **Active row in your colour.** `activeVariant=\"solid\"` plus the same prefix chain as the default:\n `theme={{ menuButton: { base: 'rounded-full', activeVariant: { solid: 'data-[active-variant=solid]:data-active:bg-indigo-600 data-[active-variant=solid]:data-active:text-white' } } }}`\n- **No hover change.** The overlay is a `::before` and the default also brightens the ink on hover:\n `theme={{ menuButton: { base: 'state-layer-none hover:text-inherit' }, subButton: { base: 'state-layer-none hover:text-neutral/70' } }}`\n- **Flat panel.** The edge is a prefixed `border-r` / `border-l` on the admin variant, the shadow a\n `raised-*` on the floating ones:\n `theme={{ panel: { base: 'raised-none', variant: { admin: 'data-[side=left]:border-r-0 data-[side=right]:border-l-0' } } }}`\n- **Row height.** A plain height beats the compound `h-control-*`: `theme={{ menuButton: { base: 'h-11' } }}`\n- **Bigger icons.** A prop, not a theme: `iconSize=\"large\"`.\n\n## Styling hooks\n\n`data-open` on a disclosure toggle is a presence attribute (before 0.12.0 a submenu toggle carried `data-open=\"true\"`); match it with `[data-open]` or `data-open:`.\n\nAttributes are presence attributes (set while the state holds, omitted otherwise) unless they carry a value.\n\n**Data attributes**\n- `data-state`: On the root: `expanded` or `collapsed`.\n- `data-display-state`: On the root: what is on screen, `expanded`, `collapsed` (the icon rail) or `hidden`.\n- `data-collapsible`: On the root: `icon` while collapsed to the icon rail, `offcanvas` while hidden, empty while expanded.\n- `data-side`: On the root, the panel and its rail and handles: `left` or `right`.\n- `data-variant`: On the root: the resolved `variant`.\n- `data-size`: On the root: the resolved `size`.\n- `data-density`: On the root: the resolved `density`.\n- `data-peek`: On the layout wrapper: `true` while a hover peek shows the collapsed panel at full width.\n- `data-active`: On a row: `true` while it is the current page (`isActive`).\n- `data-active-variant`: On a row: the resolved `activeVariant`.\n- `data-open`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is open.\n- `data-closed`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is closed, including during the exit transition.\n- `data-entering`: On a group, submenu or tree branch panel: present while its opening animation plays.\n- `data-exiting`: On a group, submenu or tree branch panel: present while its closing animation plays.\n- `data-popup-open`: On a row: present while one of its popups is open, its `menu`, its flyout on the collapsed rail, or its tooltip (every row on the collapsed rail, and every row with `tooltips=\"always\"`). Match `[aria-expanded=\"true\"]` to style an open menu or flyout only.\n- `data-sidebar`: The part (`sidebar`, `menu-button`, `menu-sub`, `group-content`, `rail`, ...).\n\n**CSS variables**\n- `--sidebar-width`: On the root: the expanded panel width.\n- `--sidebar-width-icon`: On the root: the width of the collapsed icon rail.\n- `--sidebar-width-mobile`: On the root: the width of the mobile drawer.\n- `--disclosure-panel-height`: On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.\n\nThe typed maps `sidebarDataAttributes` and `sidebarCssVariables` (from `entasis/sidebar`) list each name with its meaning.\n\n## Accessibility\n\n- The body navigation renders inside a named `<nav>` landmark (`data-sidebar=\"nav\"`), so assistive tech can jump straight to it and tell it apart from the activity bar's own landmark.\n- Active links set `aria-current=\"page\"`.\n- Collapsible rows and groups set `aria-expanded`.\n- Disabled buttons use `disabled`; disabled links omit `href`, use `aria-disabled` and `tabindex=-1`, and block activation.\n- Mobile drawer includes a backdrop button labelled \"Close Sidebar\".\n- Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.\n- Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.\n- A submenu row's `action` is a sibling of its link, so Tab reaches the action button right after the row; its trigger takes `label` as its accessible name, and Escape closes its menu and returns focus to the trigger. It is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons.\n- Collapsible groups, tree branches, and inline submenus are disclosure panels (the shared `disclosurePanel` Collapsible and Accordion use): the toggle sets `aria-expanded` and, while the panel is rendered, `aria-controls` to its id; the panel carries `data-open` / `data-closed` and `data-entering` / `data-exiting`, exposes `--disclosure-panel-height` while it animates, and stays `inert` while it closes. Open, close and sidebar-collapse changes animate the panel's height and reverse from where they are when interrupted; the `motion` slot's duration, delay and easing time them.\n- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, a root folder, and a menu row with `items` and no `href`, `view` or `menu`, is a disclosure button (`aria-expanded`, `aria-controls`) that opens a flyout holding a named `<nav>` of links, with NavigationMenu's semantics: nested folders are named sub-lists, a row with an `onclick` and no `href` is a button, a disabled sub entry stays listed without a destination, and a press on any row closes the flyout (the row's own handler runs first, and `preventDefault()` keeps it open). Opening from the keyboard keeps focus on the row; Tab enters the flyout's links, Tab past the last one continues after the row and closes it, Shift+Tab from the first returns to the row, and Escape closes it and refocuses the row. There is no `role=\"menu\"`; root leaves retain direct navigation and tooltips.\n- Row menus, flyouts and tooltips open toward the page: at the inline end of a `left` sidebar and the inline start of a `right` one, the side the layout row puts the page on in either reading direction (a `left` sidebar is the row's start, on the right in right-to-left). An explicit `menuSide` wins.\n- When `rail` and `resizable` are both enabled, one edge control owns click-to-toggle, drag resize, and keyboard resize without overlapping hitboxes.\n- Hidden offcanvas sidebars keep that combined edge control while temporarily revealed. Resizing does not pin the sidebar open; leaving the panel, trigger, and handle tolerance re-hides it without discarding the configured width.\n- Both peeks (edge reveal and `expandOnHover`) stay open while focus is inside the panel or while an overlay opened from inside it is open, including nested submenus. They release about 120ms after the pointer, focus, and every such overlay are gone.\n- The activity bar is its own `<nav>` landmark with a `<ul>` of items, a roving tabindex, and ArrowUp/ArrowDown/Home/End navigation that loops and skips disabled items. Tab lands on the `isActive` item. Each square takes its accessible name from `label`, with a string or number `badge` appended to it, and shows `label` as a tooltip on hover and focus.\n- A hidden offcanvas panel is `inert`, so Tab never lands in a panel parked off screen; a peek makes it interactive again.\n- Views: when focus sat in the view being replaced, it lands on the back row after going deeper and on the row that opened the view after coming back. A view change from outside the panel (a route) leaves focus where it is. The back swipe is a shortcut; the back row stays the accessible path.\n\n## Notes\n\n- Dropdown menus use Entasis `PopupMenu` and `MenuItem[]`.\n- The component uses semantic Entasis tokens. Do not add shadcn `sidebar-*` color tokens.\n- Snippet icons from `entasis/icons/*` are the preferred icon format.\n";
1
+ export declare const sidebarDescription = "\n# Sidebar Component\n\nSidebar navigation with data-driven groups, icon collapse, mobile drawer behavior,\nrecursive tree groups, header/footer rows, search, actions, and snippet escape hatches.\n\n## Import\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n</script>\n```\n\n## Basic Usage\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { houseIcon } from 'entasis/icons/house';\n\timport { gearIcon } from 'entasis/icons/gear';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Workspace',\n\t\t\titems: [\n\t\t\t\t{ label: 'Dashboard', href: '/', icon: houseIcon, isActive: true },\n\t\t\t\t{ label: 'Settings', href: '/settings', icon: gearIcon }\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar items={items}>\n\t{#snippet children({ toggle })}\n\t\t<header>\n\t\t\t<button type=\"button\" onclick={toggle}>Toggle</button>\n\t\t</header>\n\t\t<main>Page content</main>\n\t{/snippet}\n</Sidebar>\n```\n\n## AI-Safe Usage Contract\n\n1. Use `items` for normal navigation. Use `content` only when data-driven rows cannot express the layout.\n2. Use local Entasis icon snippets such as `houseIcon`, not Lucide component constructors.\n3. Use `MenuItem[]` from `entasis/menu` for `menu` and action dropdowns.\n4. Do not combine `menu` with `href` or `onclick` on the same row; use `action` for a trailing row menu.\n5. Keep `children`, `header`, `content`, `footer`, `banner`, and action snippets pure; they receive `SidebarApi`.\n6. Use `collapsible=\"icon\"` for icon rail behavior, `collapsible=\"offcanvas\"` for hidden desktop panels, and `collapsible=\"none\"` for fixed sidebars. Icon collapse automatically falls back to offcanvas when any data-driven row lacks an icon.\n7. Offcanvas sidebars reveal over the content from the screen edge by default when hidden; set `edgeReveal={false}` to disable that. A revealed hidden sidebar keeps its resize handle and dismisses through a small rectangular pointer tolerance.\n8. Set `keyboardShortcut={false}` when embedding Sidebar inside another shortcut-heavy surface.\n9. Sidebar owns navigation, resize mechanics, the lower application wall, and variant surface geometry. AppShell forwards its variant and composes PageShell inside that surface.\n10. Use `size` for typography, icon scale, and item height. Use `density` independently for section padding, gaps, and submenu spacing.\n11. Use `activityBar` for a persistent icon rail outside the panel (section switching, workspaces). Every item needs an `icon` and a `label`; the label is the accessible name and the tooltip. It is layout mode only: `mode=\"panel\"` renders the navigation panel alone. The rail does not change the panel by itself: keep the selected rail item in state from `onSelect`, mark it `isActive`, and either derive the Sidebar's `items` from it or, for an animated switch, give each rail item a view in `views` and set `view` from it.\n12. Use `expandOnHover` only with `collapsible=\"icon\"`. It is a temporary peek, not a toggle: the persisted collapsed state never changes, while the peeked panel renders with expanded semantics.\n13. Use `views` when the panel's contents change as the user moves through the app: sections switched from the activity bar or the route, and nested menus that open inside the panel. Key each view, set `parent` on nested ones, open them with a row's `view`, and drive `view` from state or the URL. Do not hand-animate `items` swaps.\n\n## Data Model\n\n### SidebarGroup\n- **label**: string - Group label, hidden in icon-collapsed mode.\n- **items**: SidebarMenuEntry[] - Menu rows.\n- **tree**: SidebarTreeNode[] - Recursive tree rows instead of menu items.\n- **action**: SidebarMenuActionDescriptor | SidebarMenuActionDescriptor[] | Snippet<[SidebarApi]> - Group actions pinned to the label's end. The label truncates on one line before them. Pass an array to pin several affordances (a `+` and a drag handle) to one group header; each renders as its own icon-only ghost button.\n- **collapsible**: boolean - Makes the group label a toggle.\n- **defaultOpen**: boolean - Initial collapsible group state.\n- **separator**: boolean - Divider before the group.\n\n### SidebarMenuEntry\n- **label**: string - Visible row label.\n- **icon**: SidebarIcon - Entasis icon snippet or string.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - Leading icon treatment. `tile` paints a rounded square (`bg-color-muted text-color-muted-readable`) around the glyph, so per-project colour chips come from the role scale instead of hand-built markup.\n- **href**: string - Render as an anchor. Mutually exclusive with menu.\n- **onclick**: (event: MouseEvent) => void - Native click handler for button or anchor rows. Mutually exclusive with menu. On a row with `items` and no `href`, it runs before the submenu toggles, inline or in the collapsed flyout; `event.preventDefault()` skips the toggle.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **disabled**: boolean - Disables button rows and marks anchor rows disabled.\n- **badge**: string | number - Trailing count/status, hidden in icon mode. It follows the label inside the row (before any chevron), so the label truncates before it whatever its width. On a row with an `action`, the badge steps aside while the action shows (hover, focus, open menu) from `md` up, and sits just before the always-visible action below `md`: the two never overlap.\n- **tooltip**: string - Entasis Tooltip content in icon mode. Defaults to label.\n- **items**: SidebarMenuSubEntry[] - Inline nested menu. Collapsed to icons on desktop, a row with `items` and no `href`, `view` or `menu` opens them in a flyout of links instead.\n- **collapsible**: boolean - Set false for an always-open submenu.\n- **defaultOpen**: boolean - Initial nested menu state.\n- **menu**: MenuItem[] - Popup menu opened from the full row. Mutually exclusive with href/onclick.\n- **menuSide** / **menuAlign**: SidebarMenuSide / 'start' | 'center' | 'end' - Where that menu opens. `menuSide` takes a physical side (`top`, `right`, `bottom`, `left`) or a logical one (`inline-start`, `inline-end`, mirrored in right-to-left). Left out, a desktop menu opens toward the page (`inline-end` of a left sidebar, `inline-start` of a right one, so it follows the reading direction) aligned to the row's start, and a mobile one below the row aligned to its end. The same pair sits on action descriptors and on `headerButton` / `footerButton`.\n- **view**: string - Key of the `views` entry the row opens, sliding it in; the row shows a trailing chevron. Mutually exclusive with href, menu and items.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.\n- **suffix**: Snippet<[SidebarApi, SidebarMenuEntry]> - Content at the row's end, beside the label: a count, a `Chip`, an icon, a status dot, its own `PopupMenu` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (`{#snippet suffix(api, entry)}`) and tell themselves apart by `entry.href`. It renders beside the row's link or button, never inside it (`data-sidebar=\"menu-suffix\"`, drawn by the `suffix` theme part), so it may hold controls: a click or a keypress inside it never follows the row's `href` nor toggles it, and Tab reaches its controls right after the row, before the `action`. The row's link or button is `aria-describedby` the suffix, so the row reads with it (\"Dossiers\", \"20\"). The label truncates before it: the row reserves its measured width (`--sidebar-suffix-width`), and the `suffix` part bounds it (`max-w-[40%]` of the row), so a wide suffix clips instead of breaking the row. With an `action`, it stays visible just before the action at every width (the row keeps the action's room, and a `badge` then stays put instead of stepping aside). Hidden, `inert` and `aria-hidden` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.\n\n### SidebarMenuSubEntry\nA row of an inline submenu (`items` on a menu entry). It carries the same row features as a\ntop-level entry, so nested navigation (pages filed in folders) keeps its menus, pins and tints\nwithout flattening the tree. Every field below `size` is optional, and a sub entry that sets none\nof them renders exactly as a plain one.\n- **label**: string - Visible row label.\n- **href** / **onclick** - Render as an anchor, or a button with a native click handler.\n- **icon**: SidebarIcon - Leading icon.\n- **isActive** / **disabled** / **size** - As on a top-level entry; `size` defaults to the Sidebar size.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - `tile` paints the same tinted rounded square as a top-level row.\n- **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's `action` as a top-level badge does.\n- **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, `href` or `onclick`, and `isActive`/`disabled` state: it shows no tooltip, `badge`, `suffix` or `action`.\n- **class**: string - Classes applied to the row button.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Trailing action pinned to the row's end (`data-sidebar=\"menu-sub-action\"`), drawn by the `subAction` theme part: hidden until the row is hovered or focused from `md` up, kept visible while its menu is open, always visible below `md`. The label truncates before it.\n- **suffix**: Snippet<[SidebarApi, SidebarMenuSubEntry]> - Content at the row's end, beside the label: a count, a `Chip`, an icon, a status dot, its own `PopupMenu` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (`{#snippet suffix(api, entry)}`) and tell themselves apart by `entry.href`. It renders beside the row's link or button, never inside it (`data-sidebar=\"menu-sub-suffix\"`, drawn by the `suffix` theme part), so it may hold controls: a click or a keypress inside it never follows the row's `href` nor toggles it, and Tab reaches its controls right after the row, before the `action`. The row's link or button is `aria-describedby` the suffix, so the row reads with it (\"Dossiers\", \"20\"). The label truncates before it: the row reserves its measured width (`--sidebar-suffix-width`), and the `suffix` part bounds it (`max-w-[40%]` of the row), so a wide suffix clips instead of breaking the row. With an `action`, it stays visible just before the action at every width (the row keeps the action's room, and a `badge` then stays put instead of stepping aside). Hidden, `inert` and `aria-hidden` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { folderIcon } from 'entasis/icons/folder';\n\timport { fileTextIcon } from 'entasis/icons/fileText';\n\timport { pencilSimpleIcon } from 'entasis/icons/pencilSimple';\n\timport { pushPinIcon } from 'entasis/icons/pushPin';\n\timport { trashIcon } from 'entasis/icons/trash';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Pages',\n\t\t\titems: [\n\t\t\t\t{\n\t\t\t\t\tlabel: 'Planning',\n\t\t\t\t\ticon: folderIcon,\n\t\t\t\t\tdefaultOpen: true,\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlabel: 'Q3 roadmap',\n\t\t\t\t\t\t\thref: '/pages/q3-roadmap',\n\t\t\t\t\t\t\ticon: fileTextIcon,\n\t\t\t\t\t\t\ticonColor: 'success',\n\t\t\t\t\t\t\ticonVariant: 'tile',\n\t\t\t\t\t\t\tbadge: 2,\n\t\t\t\t\t\t\taction: {\n\t\t\t\t\t\t\t\tlabel: 'More options for Q3 roadmap',\n\t\t\t\t\t\t\t\tmenu: [\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Rename', prefix: pencilSimpleIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Pin', prefix: pushPinIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Delete', prefix: trashIcon, color: 'danger' }\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ label: 'Retro notes', href: '/pages/retro-notes', icon: fileTextIcon }\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar {items} />\n```\n\n### SidebarView\nOne named panel content in `views`.\n- **label**: string - The view's name, shown on the back row of the views nested under it.\n- **parent**: string - Key of the view this one is nested under. A nested view opens with a back row to its parent (named \"Back, <parent label>\"), and on mobile a swipe toward the inline end goes back. A view without `parent` is a top-level section.\n- **items**: SidebarGroup[] / **content**: Snippet<[SidebarApi]> - The view's body.\n- **headerButton**, **search**, **headerMenu**, **header**, **footerButton**, **footerMenu**, **footer** - Header and footer props for this view. Each one left undefined comes from the parent view, then from the Sidebar's own prop; `null` removes an inherited one.\n\nChanging the view slides the two views side by side, like pages: a deeper view comes in from the inline end while the old one leaves to the start, a shallower one slides back the other way, and between views at the same depth (sections) the later one in `views` counts as forward. When both views get every header and footer prop from the same place, the header and footer stay still and only the menu slides; when a view changes any of them, the whole panel slides as one page. `api.view` reads the current view and `api.setView(key)` changes it from a snippet.\n\n### SidebarActivityBar\nIcon rail pinned to the outer edge of the sidebar, visible in every display state.\n- **items**: SidebarActivityBarItem[] - Items rendered from the top.\n- **footerItems**: SidebarActivityBarItem[] - Items pinned to the end of the column.\n- **header** / **footer**: Snippet - Custom content before the first item and after the pinned ones.\n- **width**: string (default '3rem') - Column thickness, published as `--sidebar-width-activity`.\n- **label**: string - Accessible name for the column landmark. Set it whenever the panel also renders navigation.\n- **onSelect**: ({ item, index }) => void - Fires after an item is activated. `index` counts `items` then `footerItems`.\n\n### SidebarActivityBarItem\n- **icon**: SidebarIcon (required) - Icon rendered in the square.\n- **label**: string (required) - Accessible name and default tooltip; the square shows no text.\n- **id**: string - Stable render key.\n- **href** / **target** / **rel** - Render an anchor instead of a button.\n- **onclick**: (event: MouseEvent) => void - Native click handler.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **badge**: string | number | Snippet - Corner badge pinned to the outer top corner. An empty string renders a bare dot. A string or number badge joins the accessible name (`\"Alerts, 3\"`); a dot and a Snippet badge are decorative, so put their meaning in `label`.\n- **disabled**: boolean - Blocks activation and skips the item during keyboard navigation.\n- **tooltip**: string | false - Tooltip override; `false` suppresses it.\n\n### SidebarMenuButtonItem\nUse for `headerButton`, `footerButton`, or direct `<SidebarMenuButton />` rows.\n- **icon**: SidebarIcon - Leading logo/icon.\n- **avatar**: { src?: string; alt?: string; fallback?: string } - Leading avatar.\n- **variant**: 'default' | 'brand' | 'compact'.\n- **title**: string - Primary text.\n- **subtitle**: string - Secondary text.\n- **trailing**: SidebarIcon | false | SidebarMenuActionDescriptor - Trailing content. An icon is decorative; an action descriptor (`{ icon, label, onclick }`, the same shape as a group action) renders its own icon-only ghost button beside the row, so a workspace card can carry its own collapse control without a custom `header` snippet. Descriptor handlers are `onclick(event, api)`, so `api.toggle()` is reachable.\n- **href** / **onclick** / **menu** - Choose link, button, or popup behavior. `onclick(event, api)` receives the SidebarApi beside the event, so `api.toggle()` is reachable from the row itself.\n- **menuIconClass**: string - Class override for option icons inside the popup menu.\n\n## Props\n\n### State\n- **open**: boolean (bindable, default true) - Desktop expanded state.\n- **defaultOpen**: boolean (default true) - Initial desktop state when `open` is omitted.\n- **onOpenChange**: (open: boolean) => void - Called once for a library-originated desktop state change. Repeated requests and parent prop updates stay silent.\n- **onDisplayStateChange**: (state: SidebarDisplayState) => void - Called once for a library-originated semantic display-state change.\n- **view**: string (bindable) - Key of the view on screen when `views` is set. Defaults to `defaultView`, then the first view.\n- **defaultView**: string - Initial view when `view` is omitted.\n- **onViewChange**: (view: string) => void - Called once for a library-originated view change: a view row, a back row, a swipe. Parent prop updates stay silent; with a route-driven `view`, navigate here.\n- **api.displayState**: 'expanded' | 'collapsed' | 'hidden' - Semantic desktop state; hidden means closed offcanvas. A hover peek does not change it.\n- **api.isPeeking**: boolean - True while a hover peek renders the collapsed panel at full width. Read it alongside `displayState` when a snippet hides content in icon mode.\n- **keyboardShortcut**: string | false (default 'b') - Ctrl/Cmd shortcut key.\n\n### Layout\n- **side**: 'left' | 'right' - Desktop and mobile side.\n- **variant**: 'admin' | 'floating' | 'inset' | 'split' | 'framed' - Sidebar geometry. `framed` is the admin geometry for a sidebar hosted inside a raised card (AppShell variant framed), with a `surface-recessed` well. `admin` renders the conventional full-height navigation column; `inset` integrates navigation into the lower wall with an inset content surface; `split` renders detached sidebar and content surfaces.\n- **size**: 'small' | 'normal' | 'large' (default 'normal') - Typography, icon, avatar, badge, leading-media, item-height, and search-height scale.\n- **iconSize**: 'small' | 'normal' | 'large' - Icon and leading-media scale inside the panel on its own; defaults to `size`. Menu rows, sub rows, group labels and the header button follow it; the activity bar keeps `size`.\n- **activeVariant**: 'soft' | 'outline' | 'solid' (default 'soft') - How active rows are painted. `soft` is the shared selected recipe (`selectedSoft`: `bg-selected-muted text-selected-muted-readable`), `solid` its loud counterpart (`selectedSolid`: `bg-selected text-selected-contrast`), `outline` a bordered surface card (`bg-surface border border-neutral-muted text-neutral`) that reads as a raised card on a tinted well. Each row carries the choice as `data-active-variant`, so no descendant selector is needed to restyle selection.\n- **density**: 'compact' | 'normal' | 'comfortable' (default 'normal') - Section padding, group padding, gaps, horizontal inset, and submenu spacing.\n- **collapsible**: 'offcanvas' | 'icon' | 'none' - Collapse behavior. Icon mode requires icons on every data-driven row and otherwise resolves to offcanvas.\n- **collapseIcon**: DisclosureIndicator \u2014 'chevron' | 'plus-minus' | 'none' (default 'chevron') - Disclosure indicator drawn on collapsible menu rows. 'none' renders no indicator.\n- **mode**: 'layout' | 'panel' - Full resizing layout or only the visible navigation panel.\n- **frame**: 'viewport' | 'contained' - Standalone Sidebar positioning. Viewport mode uses Theme's dynamic window-height token; contained mode fills a positioned parent.\n- **width**: string - Expanded width.\n- **widthIcon**: string - Icon-collapsed width.\n- **widthMobile**: string - Mobile drawer width. The drawer runs the full screen height, flush with its edge (no inset); an activity bar adds its own width beside it. A swipe toward its edge closes it, from anywhere on it, rows included. Following a link in the panel closes it too (a plain click, also when a client-side router handles it); the activity bar's links, buttons, and clicks that open elsewhere (a modifier key, `target=\"_blank\"`, `download`) keep it open.\n- **rail**: boolean | 'line' | 'thumb' - Edge toggle rail. `true` keeps the thin line style; `thumb` renders a short visible handle with the same full-height hitbox. The appearance is preserved when the rail shares the resize control.\n- **activityBar**: SidebarActivityBar - Icon rail pinned outside the panel, in layout mode only (`mode=\"panel\"` renders the panel alone and ignores it). It never slides off screen: only the panel takes the offcanvas offset, and the reserved layout column is the panel width plus the rail width. It wears the surface of the variant's panel: a hairline column on the canvas for `admin`, the recessed well for `framed`, borderless on the canvas for `inset`, and a card of its own (the panel's radius, edge and gutter) for `floating` and `split`. Every gutter between the activity bar, the panel and the page, and between them and the frame edge, is `--space-md`, so it scales with the theme's spacing. With `inset` and `split`, hiding the panel normally lets the page drop its frame and fill the edge; with an activity bar the rail is still beside the page, so the page keeps its framed form (padding, rounding, AppShell's border and shadow), and a `split` page takes the gutter the panel used to supply. The wrapper carries `data-page-flush` and the `main` part a `flush` variant only when the page does fill the edge. On mobile it stays a column at the drawer's outer edge, beside the panel, as the hairline column on the canvas whatever the variant.\n- **expandOnHover**: boolean (default false) - With `collapsible=\"icon\"`, hovering or focusing into the collapsed panel expands it to `width` over the page (`data-peek=\"true\"`) while the reserved column stays at `widthIcon`, so page content does not reflow. The persisted collapsed state is untouched, and the peeked panel renders exactly like an expanded one: group headers, badges, search, inline submenus, and inline tree branches all come back, and the rail or resize handle travels to its inner edge.\n- **edgeReveal**: boolean (default true) - Pointer/focus edge preview for hidden offcanvas sidebars. Hover reveal overlays content, remains resizable when configured, and re-hides after the pointer leaves its small rectangular tolerance. Dragging the sidebar closed suppresses immediate hover reopening until the pointer leaves the edge trigger; toggle/click opens persistently.\n- **resizable**: boolean | SidebarResizableOptions - Enables pointer and keyboard resizing while expanded, icon-collapsed, or temporarily edge-revealed. `minWidth` and `maxWidth` (a CSS length or a number of px; defaults `12rem` and `32rem`) bound both the drag and the keyboard resize; a `maxWidth` below `minWidth` throws. By default, collapse requires dragging 75% of `minWidth` beyond the minimum; override `collapseThreshold` for a custom boundary. Use `storageKey` to restore and persist the expanded width across sessions.\n - `onWidthChange({ width, isUserInteraction })` reports every expanded-width change with one named payload: continuously while the user resizes (`isUserInteraction: true`) and once when a stored width is restored (`isUserInteraction: false`).\n\n### Content\n- **items**: SidebarGroup[] - Data-driven body navigation.\n- **views**: Record<string, SidebarView> - Named panel contents, one on screen at a time, replacing `items` and `content`. See SidebarView.\n- **headerButton** / **footerButton**: SidebarMenuButtonItem - Sticky large rows.\n- **search**: SidebarSearch - Header search input; use its native `oninput` handler.\n- **headerMenu** / **footerMenu**: SidebarMenuEntry[] - Sticky quick menus.\n- Menu entries and nested entries accept `size: 'small' | 'normal' | 'large'` for row geometry.\n- **header**, **content**, **footer**, **children**, **banner**: Snippet<[SidebarApi]> - Escape hatches. The `header` snippet renders **first** in the header region, above `headerButton`, `search` and `headerMenu`.\n\n### Styling\n- **class**: string - Classes applied to the Sidebar root.\n- **theme**: SidebarThemeProps - Semantic part overrides such as `panel`, `header`, `nav`, `footer`, menu, search, rail, and mobile drawer parts. Every part, with the element it lands on, its variants and their default classes, is listed in the entasis skill's `theme-parts/sidebar.md`; the registry key is `sidebar`.\n\n## Motion\n\n- **motion** theme slot: the height animation shared by collapsible groups, inline submenus, and\n tree branches. Its `in` / `out` `duration`, `delay` and `easing` (motion tokens) time the\n open and the close; the panel animates its height, block padding, borders and margins.\n- Ladder: `<Theme components={{ sidebar: { motion } }}>` \u2192 `setSidebarTheme({ motion })` \u2192\n `theme.motion`. Reduced motion collapses it to 0.\n- The `view` variant (`part: 'view'`) is the pager between views: `in.x` / `out.x` is how far a view travels (default `'100%'`: the two views slide side by side like pages) while it fades between `opacity` (default 0) and 1, at the `slow` duration on the `enter` easing. The back swipe moves the same layers the view change would, with the same travel and opacity. Reduced motion makes the switch instant while a swipe still follows the finger.\n\n## Restyle recipes\n\nThe five asks that come up first, as the override to copy. Each one is rendered and asserted by\n`sidebar-recipes.svelte.test.ts`, so it cannot drift from the component. One rule behind them: a\ndefault written under a variant prefix (`data-[active-variant=solid]:data-active:bg-selected`,\n`data-[side=left]:border-r`) is only replaced by an override carrying the same prefixes; an\nunprefixed class coexists with it and loses on specificity. `theme-parts/sidebar.md` shows every\ndefault verbatim, prefixes included.\n\n- **Dark panel.** `dark` scopes the dark theme's variables to the panel, so every neutral ink inside\n flips with it (every theme also emits its variables on `.<name>`, so the class scopes the dark theme to one subtree); needs a theme named `dark`, which the documented setup declares.\n `theme={{ panel: { base: 'dark bg-slate-900' }, mobilePanel: { base: 'dark bg-slate-900' } }}`\n- **Active row in your colour.** `activeVariant=\"solid\"` plus the same prefix chain as the default:\n `theme={{ menuButton: { base: 'rounded-full', activeVariant: { solid: 'data-[active-variant=solid]:data-active:bg-indigo-600 data-[active-variant=solid]:data-active:text-white' } } }}`\n- **No hover change.** The overlay is a `::before` and the default also brightens the ink on hover:\n `theme={{ menuButton: { base: 'state-layer-none hover:text-inherit' }, subButton: { base: 'state-layer-none hover:text-neutral/70' } }}`\n- **Flat panel.** The edge is a prefixed `border-r` / `border-l` on the admin variant, the shadow a\n `raised-*` on the floating ones:\n `theme={{ panel: { base: 'raised-none', variant: { admin: 'data-[side=left]:border-r-0 data-[side=right]:border-l-0' } } }}`\n- **Row height.** A plain height beats the compound `h-control-*`: `theme={{ menuButton: { base: 'h-11' } }}`\n- **Bigger icons.** A prop, not a theme: `iconSize=\"large\"`.\n\n## Styling hooks\n\n`data-open` on a disclosure toggle is a presence attribute (before 0.12.0 a submenu toggle carried `data-open=\"true\"`); match it with `[data-open]` or `data-open:`.\n\nAttributes are presence attributes (set while the state holds, omitted otherwise) unless they carry a value.\n\n**Data attributes**\n- `data-state`: On the root: `expanded` or `collapsed`.\n- `data-display-state`: On the root: what is on screen, `expanded`, `collapsed` (the icon rail) or `hidden`.\n- `data-collapsible`: On the root: `icon` while collapsed to the icon rail, `offcanvas` while hidden, empty while expanded.\n- `data-side`: On the root, the panel and its rail and handles: `left` or `right`.\n- `data-variant`: On the root: the resolved `variant`.\n- `data-size`: On the root: the resolved `size`.\n- `data-density`: On the root: the resolved `density`.\n- `data-peek`: On the layout wrapper: `true` while a hover peek shows the collapsed panel at full width.\n- `data-active`: On a row: `true` while it is the current page (`isActive`).\n- `data-active-variant`: On a row: the resolved `activeVariant`.\n- `data-open`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is open.\n- `data-closed`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is closed, including during the exit transition.\n- `data-entering`: On a group, submenu or tree branch panel: present while its opening animation plays.\n- `data-exiting`: On a group, submenu or tree branch panel: present while its closing animation plays.\n- `data-popup-open`: On a row: present while one of its popups is open, its `menu`, its flyout on the collapsed rail, or its tooltip (every row on the collapsed rail, and every row with `tooltips=\"always\"`). Match `[aria-expanded=\"true\"]` to style an open menu or flyout only.\n- `data-sidebar`: The part (`sidebar`, `menu-button`, `menu-sub`, `group-content`, `rail`, ...).\n\n**CSS variables**\n- `--sidebar-width`: On the root: the expanded panel width.\n- `--sidebar-width-icon`: On the root: the width of the collapsed icon rail.\n- `--sidebar-width-mobile`: On the root: the width of the mobile drawer.\n- `--sidebar-suffix-width`: On a row with a `suffix` (its `menu-item` row wrapper or `menu-sub-item`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.\n- `--disclosure-panel-height`: On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.\n\nThe typed maps `sidebarDataAttributes` and `sidebarCssVariables` (from `entasis/sidebar`) list each name with its meaning.\n\n## Accessibility\n\n- The body navigation renders inside a named `<nav>` landmark (`data-sidebar=\"nav\"`), so assistive tech can jump straight to it and tell it apart from the activity bar's own landmark.\n- Active links set `aria-current=\"page\"`.\n- Collapsible rows and groups set `aria-expanded`.\n- Disabled buttons use `disabled`; disabled links omit `href`, use `aria-disabled` and `tabindex=-1`, and block activation.\n- Mobile drawer includes a backdrop button labelled \"Close Sidebar\".\n- Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.\n- Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.\n- A row's `suffix` is a sibling of its link or button, between it and the row's `action`: Tab reaches its controls right after the row, its content describes the row (`aria-describedby`), and it is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons, focus inside it returning to the row.\n- A submenu row's `action` is a sibling of its link, so Tab reaches the action button right after the row; its trigger takes `label` as its accessible name, and Escape closes its menu and returns focus to the trigger. It is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons.\n- Collapsible groups, tree branches, and inline submenus are disclosure panels (the shared `disclosurePanel` Collapsible and Accordion use): the toggle sets `aria-expanded` and, while the panel is rendered, `aria-controls` to its id; the panel carries `data-open` / `data-closed` and `data-entering` / `data-exiting`, exposes `--disclosure-panel-height` while it animates, and stays `inert` while it closes. Open, close and sidebar-collapse changes animate the panel's height and reverse from where they are when interrupted; the `motion` slot's duration, delay and easing time them.\n- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, a root folder, and a menu row with `items` and no `href`, `view` or `menu`, is a disclosure button (`aria-expanded`, `aria-controls`) that opens a flyout holding a named `<nav>` of links, with NavigationMenu's semantics: nested folders are named sub-lists, a row with an `onclick` and no `href` is a button, a disabled sub entry stays listed without a destination, and a press on any row closes the flyout (the row's own handler runs first, and `preventDefault()` keeps it open). Opening from the keyboard keeps focus on the row; Tab enters the flyout's links, Tab past the last one continues after the row and closes it, Shift+Tab from the first returns to the row, and Escape closes it and refocuses the row. There is no `role=\"menu\"`; root leaves retain direct navigation and tooltips.\n- Row menus, flyouts and tooltips open toward the page: at the inline end of a `left` sidebar and the inline start of a `right` one, the side the layout row puts the page on in either reading direction (a `left` sidebar is the row's start, on the right in right-to-left). An explicit `menuSide` wins.\n- When `rail` and `resizable` are both enabled, one edge control owns click-to-toggle, drag resize, and keyboard resize without overlapping hitboxes.\n- Hidden offcanvas sidebars keep that combined edge control while temporarily revealed. Resizing does not pin the sidebar open; leaving the panel, trigger, and handle tolerance re-hides it without discarding the configured width.\n- Both peeks (edge reveal and `expandOnHover`) stay open while focus is inside the panel or while an overlay opened from inside it is open, including nested submenus. They release about 120ms after the pointer, focus, and every such overlay are gone.\n- The activity bar is its own `<nav>` landmark with a `<ul>` of items, a roving tabindex, and ArrowUp/ArrowDown/Home/End navigation that loops and skips disabled items. Tab lands on the `isActive` item. Each square takes its accessible name from `label`, with a string or number `badge` appended to it, and shows `label` as a tooltip on hover and focus.\n- A hidden offcanvas panel is `inert`, so Tab never lands in a panel parked off screen; a peek makes it interactive again.\n- Views: when focus sat in the view being replaced, it lands on the back row after going deeper and on the row that opened the view after coming back. A view change from outside the panel (a route) leaves focus where it is. The back swipe is a shortcut; the back row stays the accessible path.\n\n## Notes\n\n- Dropdown menus use Entasis `PopupMenu` and `MenuItem[]`.\n- The component uses semantic Entasis tokens. Do not add shadcn `sidebar-*` color tokens.\n- Snippet icons from `entasis/icons/*` are the preferred icon format.\n";
@@ -86,6 +86,7 @@ recursive tree groups, header/footer rows, search, actions, and snippet escape h
86
86
  - **menuSide** / **menuAlign**: SidebarMenuSide / 'start' | 'center' | 'end' - Where that menu opens. \`menuSide\` takes a physical side (\`top\`, \`right\`, \`bottom\`, \`left\`) or a logical one (\`inline-start\`, \`inline-end\`, mirrored in right-to-left). Left out, a desktop menu opens toward the page (\`inline-end\` of a left sidebar, \`inline-start\` of a right one, so it follows the reading direction) aligned to the row's start, and a mobile one below the row aligned to its end. The same pair sits on action descriptors and on \`headerButton\` / \`footerButton\`.
87
87
  - **view**: string - Key of the \`views\` entry the row opens, sliding it in; the row shows a trailing chevron. Mutually exclusive with href, menu and items.
88
88
  - **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.
89
+ - **suffix**: Snippet<[SidebarApi, SidebarMenuEntry]> - Content at the row's end, beside the label: a count, a \`Chip\`, an icon, a status dot, its own \`PopupMenu\` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (\`{#snippet suffix(api, entry)}\`) and tell themselves apart by \`entry.href\`. It renders beside the row's link or button, never inside it (\`data-sidebar="menu-suffix"\`, drawn by the \`suffix\` theme part), so it may hold controls: a click or a keypress inside it never follows the row's \`href\` nor toggles it, and Tab reaches its controls right after the row, before the \`action\`. The row's link or button is \`aria-describedby\` the suffix, so the row reads with it ("Dossiers", "20"). The label truncates before it: the row reserves its measured width (\`--sidebar-suffix-width\`), and the \`suffix\` part bounds it (\`max-w-[40%]\` of the row), so a wide suffix clips instead of breaking the row. With an \`action\`, it stays visible just before the action at every width (the row keeps the action's room, and a \`badge\` then stays put instead of stepping aside). Hidden, \`inert\` and \`aria-hidden\` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.
89
90
 
90
91
  ### SidebarMenuSubEntry
91
92
  A row of an inline submenu (\`items\` on a menu entry). It carries the same row features as a
@@ -99,9 +100,10 @@ of them renders exactly as a plain one.
99
100
  - **iconColor**: Colors - Role tint for the leading icon, applied through \`data-color\`.
100
101
  - **iconVariant**: 'bare' | 'tile' - \`tile\` paints the same tinted rounded square as a top-level row.
101
102
  - **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's \`action\` as a top-level badge does.
102
- - **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, \`href\` or \`onclick\`, and \`isActive\`/\`disabled\` state: it shows no tooltip, \`badge\` or \`action\`.
103
+ - **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, \`href\` or \`onclick\`, and \`isActive\`/\`disabled\` state: it shows no tooltip, \`badge\`, \`suffix\` or \`action\`.
103
104
  - **class**: string - Classes applied to the row button.
104
105
  - **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Trailing action pinned to the row's end (\`data-sidebar="menu-sub-action"\`), drawn by the \`subAction\` theme part: hidden until the row is hovered or focused from \`md\` up, kept visible while its menu is open, always visible below \`md\`. The label truncates before it.
106
+ - **suffix**: Snippet<[SidebarApi, SidebarMenuSubEntry]> - Content at the row's end, beside the label: a count, a \`Chip\`, an icon, a status dot, its own \`PopupMenu\` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (\`{#snippet suffix(api, entry)}\`) and tell themselves apart by \`entry.href\`. It renders beside the row's link or button, never inside it (\`data-sidebar="menu-sub-suffix"\`, drawn by the \`suffix\` theme part), so it may hold controls: a click or a keypress inside it never follows the row's \`href\` nor toggles it, and Tab reaches its controls right after the row, before the \`action\`. The row's link or button is \`aria-describedby\` the suffix, so the row reads with it ("Dossiers", "20"). The label truncates before it: the row reserves its measured width (\`--sidebar-suffix-width\`), and the \`suffix\` part bounds it (\`max-w-[40%]\` of the row), so a wide suffix clips instead of breaking the row. With an \`action\`, it stays visible just before the action at every width (the row keeps the action's room, and a \`badge\` then stays put instead of stepping aside). Hidden, \`inert\` and \`aria-hidden\` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.
105
107
 
106
108
  \`\`\`svelte
107
109
  <script lang="ts">
@@ -295,6 +297,7 @@ Attributes are presence attributes (set while the state holds, omitted otherwise
295
297
  - \`--sidebar-width\`: On the root: the expanded panel width.
296
298
  - \`--sidebar-width-icon\`: On the root: the width of the collapsed icon rail.
297
299
  - \`--sidebar-width-mobile\`: On the root: the width of the mobile drawer.
300
+ - \`--sidebar-suffix-width\`: On a row with a \`suffix\` (its \`menu-item\` row wrapper or \`menu-sub-item\`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.
298
301
  - \`--disclosure-panel-height\`: On a group, submenu or tree branch panel: its measured height in px while it animates, \`auto\` once open and \`0px\` once closed.
299
302
 
300
303
  The typed maps \`sidebarDataAttributes\` and \`sidebarCssVariables\` (from \`entasis/sidebar\`) list each name with its meaning.
@@ -308,6 +311,7 @@ The typed maps \`sidebarDataAttributes\` and \`sidebarCssVariables\` (from \`ent
308
311
  - Mobile drawer includes a backdrop button labelled "Close Sidebar".
309
312
  - Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.
310
313
  - Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.
314
+ - A row's \`suffix\` is a sibling of its link or button, between it and the row's \`action\`: Tab reaches its controls right after the row, its content describes the row (\`aria-describedby\`), and it is \`inert\` and \`aria-hidden\` whenever the Sidebar is collapsed to icons, focus inside it returning to the row.
311
315
  - A submenu row's \`action\` is a sibling of its link, so Tab reaches the action button right after the row; its trigger takes \`label\` as its accessible name, and Escape closes its menu and returns focus to the trigger. It is \`inert\` and \`aria-hidden\` whenever the Sidebar is collapsed to icons.
312
316
  - Collapsible groups, tree branches, and inline submenus are disclosure panels (the shared \`disclosurePanel\` Collapsible and Accordion use): the toggle sets \`aria-expanded\` and, while the panel is rendered, \`aria-controls\` to its id; the panel carries \`data-open\` / \`data-closed\` and \`data-entering\` / \`data-exiting\`, exposes \`--disclosure-panel-height\` while it animates, and stays \`inert\` while it closes. Open, close and sidebar-collapse changes animate the panel's height and reverse from where they are when interrupted; the \`motion\` slot's duration, delay and easing time them.
313
317
  - Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, a root folder, and a menu row with \`items\` and no \`href\`, \`view\` or \`menu\`, is a disclosure button (\`aria-expanded\`, \`aria-controls\`) that opens a flyout holding a named \`<nav>\` of links, with NavigationMenu's semantics: nested folders are named sub-lists, a row with an \`onclick\` and no \`href\` is a button, a disabled sub entry stays listed without a destination, and a press on any row closes the flyout (the row's own handler runs first, and \`preventDefault()\` keeps it open). Opening from the keyboard keeps focus on the row; Tab enters the flyout's links, Tab past the last one continues after the row and closes it, Shift+Tab from the first returns to the row, and Escape closes it and refocuses the row. There is no \`role="menu"\`; root leaves retain direct navigation and tooltips.
@@ -197,6 +197,14 @@ export type SidebarMenuSubEntry = {
197
197
  * from `md` up and while its menu is open, always on touch-sized screens.
198
198
  */
199
199
  action?: Snippet<[SidebarApi]> | SidebarMenuActionDescriptor;
200
+ /**
201
+ * Content rendered at the row's end, beside the label: a count, a Chip, an icon, a status dot,
202
+ * its own menu trigger. It sits beside the row's link or button, never inside it, so it may hold
203
+ * controls; the row's link is described by it. It stays visible before the `action`, and is
204
+ * hidden in icon-collapsed mode. It receives the row's entry, so one snippet can serve every
205
+ * row (`{#snippet suffix(api, entry)}`) and tell them apart by `entry.href`.
206
+ */
207
+ suffix?: Snippet<[SidebarApi, SidebarMenuSubEntry]>;
200
208
  };
201
209
  type SidebarMenuEntryBase = {
202
210
  /** Visible label. */
@@ -240,6 +248,14 @@ type SidebarMenuEntryBase = {
240
248
  defaultOpen?: boolean;
241
249
  /** Secondary action pinned to the row edge. */
242
250
  action?: Snippet<[SidebarApi]> | SidebarMenuActionDescriptor;
251
+ /**
252
+ * Content rendered at the row's end, beside the label: a count, a Chip, an icon, a status dot,
253
+ * its own menu trigger. It sits beside the row's link or button, never inside it, so it may hold
254
+ * controls; the row's link is described by it. It stays visible before the `action`, and is
255
+ * hidden in icon-collapsed mode. It receives the row's entry, so one snippet can serve every
256
+ * row (`{#snippet suffix(api, entry)}`) and tell them apart by `entry.href`.
257
+ */
258
+ suffix?: Snippet<[SidebarApi, SidebarMenuEntry]>;
243
259
  };
244
260
  type SidebarMenuEntryNavigation = SidebarMenuEntryBase & {
245
261
  /** Link href. Mutually exclusive with menu and view. */
@@ -0,0 +1,13 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+ /**
3
+ * A row's `suffix` sits beside its link or button, never inside it, so it may hold its own
4
+ * controls. The row reserves the suffix's measured width at its end, through
5
+ * `--sidebar-suffix-width`, so the label truncates before it. Until the first measurement (on the
6
+ * server, before hydration) the row reserves the suffix's maximum width instead, so the two never
7
+ * overlap.
8
+ */
9
+ export declare function useSidebarSuffixWidth(): {
10
+ measure: Attachment<HTMLElement>;
11
+ /** The value for `style:--sidebar-suffix-width` on the row. */
12
+ readonly value: string | undefined;
13
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A row's `suffix` sits beside its link or button, never inside it, so it may hold its own
3
+ * controls. The row reserves the suffix's measured width at its end, through
4
+ * `--sidebar-suffix-width`, so the label truncates before it. Until the first measurement (on the
5
+ * server, before hydration) the row reserves the suffix's maximum width instead, so the two never
6
+ * overlap.
7
+ */
8
+ export function useSidebarSuffixWidth() {
9
+ let width = $state();
10
+ const measure = (node) => {
11
+ const observer = new ResizeObserver(([entry]) => {
12
+ width = entry?.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
13
+ });
14
+ observer.observe(node);
15
+ return () => observer.disconnect();
16
+ };
17
+ return {
18
+ measure,
19
+ /** The value for `style:--sidebar-suffix-width` on the row. */
20
+ get value() {
21
+ return width === undefined ? undefined : `${width}px`;
22
+ }
23
+ };
24
+ }
@@ -191,6 +191,8 @@ export declare const sidebarTheme: {
191
191
  none: string;
192
192
  action: string;
193
193
  actionBelowMd: string;
194
+ suffix: string;
195
+ suffixAction: string;
194
196
  };
195
197
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
196
198
  menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
@@ -247,6 +249,8 @@ export declare const sidebarTheme: {
247
249
  none: string;
248
250
  action: string;
249
251
  actionBelowMd: string;
252
+ suffix: string;
253
+ suffixAction: string;
250
254
  };
251
255
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
252
256
  menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
@@ -273,6 +277,22 @@ export declare const sidebarTheme: {
273
277
  comfortable: string;
274
278
  };
275
279
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
280
+ suffix: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
281
+ size: {
282
+ small: string;
283
+ normal: string;
284
+ large: string;
285
+ };
286
+ density: {
287
+ compact: string;
288
+ normal: string;
289
+ comfortable: string;
290
+ };
291
+ action: {
292
+ none: string;
293
+ action: string;
294
+ };
295
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
276
296
  actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
277
297
  size: {
278
298
  small: string;
@@ -732,6 +752,8 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
732
752
  none: string;
733
753
  action: string;
734
754
  actionBelowMd: string;
755
+ suffix: string;
756
+ suffixAction: string;
735
757
  };
736
758
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
737
759
  menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
@@ -788,6 +810,8 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
788
810
  none: string;
789
811
  action: string;
790
812
  actionBelowMd: string;
813
+ suffix: string;
814
+ suffixAction: string;
791
815
  };
792
816
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
793
817
  menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
@@ -814,6 +838,22 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
814
838
  comfortable: string;
815
839
  };
816
840
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
841
+ suffix: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
842
+ size: {
843
+ small: string;
844
+ normal: string;
845
+ large: string;
846
+ };
847
+ density: {
848
+ compact: string;
849
+ normal: string;
850
+ comfortable: string;
851
+ };
852
+ action: {
853
+ none: string;
854
+ action: string;
855
+ };
856
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
817
857
  actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
818
858
  size: {
819
859
  small: string;
@@ -1271,6 +1311,8 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
1271
1311
  none: string;
1272
1312
  action: string;
1273
1313
  actionBelowMd: string;
1314
+ suffix: string;
1315
+ suffixAction: string;
1274
1316
  };
1275
1317
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1276
1318
  menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
@@ -1327,6 +1369,8 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
1327
1369
  none: string;
1328
1370
  action: string;
1329
1371
  actionBelowMd: string;
1372
+ suffix: string;
1373
+ suffixAction: string;
1330
1374
  };
1331
1375
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1332
1376
  menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
@@ -1353,6 +1397,22 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
1353
1397
  comfortable: string;
1354
1398
  };
1355
1399
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1400
+ suffix: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
1401
+ size: {
1402
+ small: string;
1403
+ normal: string;
1404
+ large: string;
1405
+ };
1406
+ density: {
1407
+ compact: string;
1408
+ normal: string;
1409
+ comfortable: string;
1410
+ };
1411
+ action: {
1412
+ none: string;
1413
+ action: string;
1414
+ };
1415
+ }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
1356
1416
  actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
1357
1417
  size: {
1358
1418
  small: string;
@@ -250,12 +250,35 @@ const defaultMenu = cva({
250
250
  // always. `actionBelowMd` is for a row whose last trailing part is its badge: from `md` up the
251
251
  // badge holds the end column and the action takes its place while it shows, so the room is only
252
252
  // needed below `md`, where the action is always visible and the badge sits before it.
253
- const rowEndReserveKeys = { none: '', action: '', actionBelowMd: '' };
253
+ // `suffix` and `suffixAction` hold a row's `suffix` (see `suffix`), which sits beside the row
254
+ // rather than inside it: the row reserves the suffix's measured width (`--sidebar-suffix-width`,
255
+ // its maximum width until measured), plus the row's own end padding or the action's box.
256
+ const rowEndReserveKeys = { none: '', action: '', actionBelowMd: '', suffix: '', suffixAction: '' };
254
257
  const rowEndReserve = [
255
258
  { endReserve: 'action', size: ['small', 'normal'], class: 'pe-layout-lg' },
256
259
  { endReserve: 'action', size: 'large', class: 'pe-[calc(var(--spacing)*10)]' },
257
260
  { endReserve: 'actionBelowMd', size: ['small', 'normal'], class: 'max-md:pe-layout-lg' },
258
- { endReserve: 'actionBelowMd', size: 'large', class: 'max-md:pe-[calc(var(--spacing)*10)]' }
261
+ { endReserve: 'actionBelowMd', size: 'large', class: 'max-md:pe-[calc(var(--spacing)*10)]' },
262
+ {
263
+ endReserve: 'suffix',
264
+ density: 'compact',
265
+ class: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--space-sm)*2)]'
266
+ },
267
+ {
268
+ endReserve: 'suffix',
269
+ density: ['normal', 'comfortable'],
270
+ class: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--space-md)+var(--space-sm))]'
271
+ },
272
+ {
273
+ endReserve: 'suffixAction',
274
+ size: ['small', 'normal'],
275
+ class: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--layout-space-lg)+var(--space-sm))]'
276
+ },
277
+ {
278
+ endReserve: 'suffixAction',
279
+ size: 'large',
280
+ class: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--spacing)*10+var(--space-sm))]'
281
+ }
259
282
  ];
260
283
  const defaultMenuButton = cva({
261
284
  base: 'state-layer peer/menu-button group/menu-button flex w-full items-center overflow-hidden rounded-sm text-start outline-none transition-[background,color,width,height,padding,margin,border-radius] duration-normal ease-linear hover:text-neutral focus-visible:ring-2 focus-visible:ring-focus/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:font-medium group-data-[collapsible=icon]:mx-[calc((var(--sidebar-width-icon)-var(--sidebar-icon-button-width))/2-var(--sidebar-group-padding))] group-data-[collapsible=icon]:w-[var(--sidebar-icon-button-width)] group-data-[collapsible=icon]:rounded-none group-data-[variant=admin]:group-data-[collapsible=icon]:rounded-sm group-data-[variant=framed]:group-data-[collapsible=icon]:rounded-sm group-data-[variant=inset]:group-data-[collapsible=icon]:rounded-sm group-data-[collapsible=icon]:![padding-inline:calc((var(--sidebar-icon-button-width)-var(--sidebar-icon-size))/2)] group-data-[collapsible=icon]:ring-inset [&_svg]:shrink-0 [&_svg]:size-[var(--sidebar-icon-size)]',
@@ -285,6 +308,11 @@ const defaultMenuButton = cva({
285
308
  },
286
309
  compoundVariants: [
287
310
  ...rowEndReserve,
311
+ // The suffix covers the row's end, so hovering it keeps the row's hover tint.
312
+ {
313
+ endReserve: ['suffix', 'suffixAction'],
314
+ class: 'group-has-[[data-sidebar=menu-suffix]:hover]/menu-row:before:opacity-[var(--state-hover-opacity)]'
315
+ },
288
316
  { size: 'small', itemSize: 'small', class: 'h-control-sm' },
289
317
  { size: 'small', itemSize: 'normal', class: 'h-control-md' },
290
318
  { size: 'small', itemSize: 'large', class: 'h-row-lg' },
@@ -386,7 +414,13 @@ const defaultSubButton = cva({
386
414
  // plain row keeps its exact classes.
387
415
  endReserve: rowEndReserveKeys
388
416
  },
389
- compoundVariants: [...rowEndReserve],
417
+ compoundVariants: [
418
+ ...rowEndReserve,
419
+ {
420
+ endReserve: ['suffix', 'suffixAction'],
421
+ class: 'group-has-[[data-sidebar=menu-sub-suffix]:hover]/menu-sub-item:before:opacity-[var(--state-hover-opacity)]'
422
+ }
423
+ ],
390
424
  defaultVariants: {
391
425
  activeVariant: 'soft',
392
426
  size: 'normal',
@@ -434,6 +468,43 @@ const defaultSubAction = cva({
434
468
  density: 'normal'
435
469
  }
436
470
  });
471
+ // A row's `suffix`: consumer content (a count, a Chip, a status dot, its own menu trigger) beside
472
+ // the row's link or button rather than inside it, so it may hold controls. It sits at the row's
473
+ // end, before the pinned action's box when the row has one, and stays visible while the action
474
+ // shows. Its width is bounded, so a wide suffix clips instead of pushing the label out; the row
475
+ // reserves its measured width (`endReserve`), so the label truncates before it. Hidden with the
476
+ // labels in icon-collapsed mode.
477
+ const defaultSuffix = cva({
478
+ base: 'text-neutral/70 absolute top-1/2 flex max-w-[40%] min-w-0 -translate-y-1/2 items-center justify-end gap-xs overflow-x-clip [overflow-clip-margin:var(--space-xs)] [&>*]:min-w-0 group-data-[collapsible=icon]:hidden',
479
+ variants: {
480
+ size: {
481
+ small: 'text-xs',
482
+ normal: 'text-sm',
483
+ large: 'text-sm'
484
+ },
485
+ density: {
486
+ compact: '',
487
+ normal: '',
488
+ comfortable: ''
489
+ },
490
+ // Whether the row also pins an `action`: the suffix then ends before the action's box.
491
+ action: {
492
+ none: '',
493
+ action: ''
494
+ }
495
+ },
496
+ compoundVariants: [
497
+ { action: 'none', density: 'compact', class: 'end-[var(--space-sm)]' },
498
+ { action: 'none', density: ['normal', 'comfortable'], class: 'end-[var(--space-md)]' },
499
+ { action: 'action', size: ['small', 'normal'], class: 'end-[var(--layout-space-lg)]' },
500
+ { action: 'action', size: 'large', class: 'end-[calc(var(--spacing)*10)]' }
501
+ ],
502
+ defaultVariants: {
503
+ size: 'normal',
504
+ density: 'normal',
505
+ action: 'none'
506
+ }
507
+ });
437
508
  // One icon-only ghost button box. `size` follows the descriptor, defaulting to the Sidebar size,
438
509
  // so a group's `+` lands at row scale instead of at the group label's scale.
439
510
  const defaultActionSlot = cva({
@@ -1097,6 +1168,7 @@ export const sidebarTheme = {
1097
1168
  subButton: defaultSubButton,
1098
1169
  menuAction: defaultMenuAction,
1099
1170
  subAction: defaultSubAction,
1171
+ suffix: defaultSuffix,
1100
1172
  actionSlot: defaultActionSlot,
1101
1173
  actionTrigger: defaultActionTrigger,
1102
1174
  menuIcon: defaultMenuIcon,
@@ -1830,7 +1830,7 @@ export declare const componentInventory: readonly [{
1830
1830
  readonly id: "i18n";
1831
1831
  readonly subpath: "entasis/i18n";
1832
1832
  readonly sourceIndex: "src/lib/i18n/index.ts";
1833
- readonly exportedSymbols: readonly ["I18n", "I18nInput", "LocaleCode", "LocaleMeta", "Messages", "en", "localeList", "locales", "setI18n", "useI18n"];
1833
+ readonly exportedSymbols: readonly ["I18n", "I18nInput", "LocaleCode", "LocaleMeta", "Messages", "ar", "de", "en", "es", "fr", "ja", "localeList", "locales", "pt", "setI18n", "useI18n", "zh"];
1834
1834
  readonly docs: readonly [];
1835
1835
  readonly capabilities: readonly ["svelte", "localization"];
1836
1836
  readonly relatedComponents: readonly ["theme"];
@@ -4467,11 +4467,18 @@ export const componentInventory = [
4467
4467
  'LocaleCode',
4468
4468
  'LocaleMeta',
4469
4469
  'Messages',
4470
+ 'ar',
4471
+ 'de',
4470
4472
  'en',
4473
+ 'es',
4474
+ 'fr',
4475
+ 'ja',
4471
4476
  'localeList',
4472
4477
  'locales',
4478
+ 'pt',
4473
4479
  'setI18n',
4474
- 'useI18n'
4480
+ 'useI18n',
4481
+ 'zh'
4475
4482
  ],
4476
4483
  docs: [],
4477
4484
  capabilities: ['svelte', 'localization'],
@@ -1,11 +1,14 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { BROWSER, DEV } from 'esm-env';
3
4
  import { setI18n, setI18nDirection } from './context.svelte.js';
4
- import { locales, type LocaleCode } from './locales.js';
5
- import type { Messages } from './en.js';
5
+ import { loadLocaleMessages, localeDirection } from './localeCatalogs.js';
6
+ import type { LocaleCode } from './locales.js';
7
+ import { en, type Messages } from './en.js';
6
8
 
7
9
  let {
8
10
  locale = $bindable('en'),
11
+ catalogs,
9
12
  messages,
10
13
  dir,
11
14
  manageDocument = true,
@@ -13,6 +16,12 @@
13
16
  }: {
14
17
  /** Active locale, bindable so a language picker can drive it. */
15
18
  locale?: LocaleCode;
19
+ /**
20
+ * Catalogs available without loading, by locale: `catalogs={{ fr }}` with `fr` imported from
21
+ * `entasis/i18n`. A locale missing here loads its catalog on demand (English shows until it
22
+ * arrives, and server rendering stays English), so pass the locales an app renders first.
23
+ */
24
+ catalogs?: Partial<Record<LocaleCode, Messages>>;
16
25
  /** Extra per-key overrides merged on top of the locale's messages. */
17
26
  messages?: Partial<Messages>;
18
27
  /** Force a text direction; defaults to the active locale's direction (rtl for Arabic). */
@@ -23,9 +32,32 @@
23
32
  children?: Snippet;
24
33
  } = $props();
25
34
 
35
+ let loaded = $state<Partial<Record<LocaleCode, Messages>>>({});
36
+ // While a switched-to locale loads, keep showing the previous catalog rather than English.
37
+ let shown: Messages = en;
38
+ const catalog = $derived.by(() => {
39
+ const next = locale === 'en' ? en : (catalogs?.[locale] ?? loaded[locale]);
40
+ if (next) shown = next;
41
+ return next ?? shown;
42
+ });
43
+
44
+ if (DEV && !BROWSER && locale !== 'en' && !catalogs?.[locale]) {
45
+ console.warn(
46
+ `entasis: <I18n locale="${locale}"> has no catalog for "${locale}" and renders English on the server. Pass catalogs={{ ... }} with the catalog imported from 'entasis/i18n'.`
47
+ );
48
+ }
49
+
50
+ $effect(() => {
51
+ const code = locale;
52
+ if (code === 'en' || catalogs?.[code] || loaded[code]) return;
53
+ loadLocaleMessages(code)?.then((catalog) => {
54
+ loaded[code] = catalog;
55
+ });
56
+ });
57
+
26
58
  // Provide the merged catalog to all descendants. The getter keeps them reactive to `locale`.
27
- setI18n(() => ({ ...locales[locale].messages, ...messages }));
28
- setI18nDirection(() => dir ?? locales[locale].dir);
59
+ setI18n(() => ({ ...catalog, ...messages }));
60
+ setI18nDirection(() => dir ?? localeDirection(locale));
29
61
 
30
62
  // Client-only: mirror direction + language onto the document element.
31
63
  // Restore the prior values on unmount / when manageDocument turns off, so a page-scoped
@@ -35,7 +67,7 @@
35
67
  const root = document.documentElement;
36
68
  const prevDir = root.dir;
37
69
  const prevLang = root.lang;
38
- root.dir = dir ?? locales[locale].dir;
70
+ root.dir = dir ?? localeDirection(locale);
39
71
  root.lang = locale;
40
72
  return () => {
41
73
  root.dir = prevDir;
@@ -1,9 +1,15 @@
1
1
  import type { Snippet } from 'svelte';
2
- import { type LocaleCode } from './locales.js';
3
- import type { Messages } from './en.js';
2
+ import type { LocaleCode } from './locales.js';
3
+ import { type Messages } from './en.js';
4
4
  type $$ComponentProps = {
5
5
  /** Active locale, bindable so a language picker can drive it. */
6
6
  locale?: LocaleCode;
7
+ /**
8
+ * Catalogs available without loading, by locale: `catalogs={{ fr }}` with `fr` imported from
9
+ * `entasis/i18n`. A locale missing here loads its catalog on demand (English shows until it
10
+ * arrives, and server rendering stays English), so pass the locales an app renders first.
11
+ */
12
+ catalogs?: Partial<Record<LocaleCode, Messages>>;
7
13
  /** Extra per-key overrides merged on top of the locale's messages. */
8
14
  messages?: Partial<Messages>;
9
15
  /** Force a text direction; defaults to the active locale's direction (rtl for Arabic). */
@@ -1 +1 @@
1
- export declare const i18nDescription = "\n# Internationalization\n\nImport from `entasis/i18n`.\n\nI18n provides locale messages to child components. setI18n and useI18n set and read the component context. en is the English message set; Messages and I18nInput describe translation inputs. locales and localeList expose the supported locale inventory with LocaleCode and LocaleMeta types.\n";
1
+ export declare const i18nDescription = "\n# Internationalization\n\nImport from `entasis/i18n`.\n\nI18n provides locale messages to child components. setI18n and useI18n set and read the component context. en is the English message set; Messages and I18nInput describe translation inputs. es, fr, de, ja, zh, pt and ar are the other catalogs, exported by name so an app bundles only the ones it imports: `<I18n locale=\"fr\" catalogs={{ fr }}>`. A locale missing from catalogs loads its catalog on demand and renders English until then, including on the server. locales and localeList expose the supported locale inventory with LocaleCode and LocaleMeta types; they import every catalog, so keep them for language pickers.\n";
@@ -3,5 +3,5 @@ export const i18nDescription = `
3
3
 
4
4
  Import from \`entasis/i18n\`.
5
5
 
6
- I18n provides locale messages to child components. setI18n and useI18n set and read the component context. en is the English message set; Messages and I18nInput describe translation inputs. locales and localeList expose the supported locale inventory with LocaleCode and LocaleMeta types.
6
+ I18n provides locale messages to child components. setI18n and useI18n set and read the component context. en is the English message set; Messages and I18nInput describe translation inputs. es, fr, de, ja, zh, pt and ar are the other catalogs, exported by name so an app bundles only the ones it imports: \`<I18n locale="fr" catalogs={{ fr }}>\`. A locale missing from catalogs loads its catalog on demand and renders English until then, including on the server. locales and localeList expose the supported locale inventory with LocaleCode and LocaleMeta types; they import every catalog, so keep them for language pickers.
7
7
  `;
@@ -1,4 +1,11 @@
1
1
  export { default as I18n } from './I18n.svelte';
2
2
  export { en, type Messages } from './en.js';
3
+ export { es } from './es.js';
4
+ export { fr } from './fr.js';
5
+ export { de } from './de.js';
6
+ export { ja } from './ja.js';
7
+ export { zh } from './zh.js';
8
+ export { pt } from './pt.js';
9
+ export { ar } from './ar.js';
3
10
  export { setI18n, useI18n, type I18nInput } from './context.svelte.js';
4
11
  export { locales, localeList, type LocaleCode, type LocaleMeta } from './locales.js';
@@ -1,4 +1,11 @@
1
1
  export { default as I18n } from './I18n.svelte';
2
2
  export { en } from './en.js';
3
+ export { es } from './es.js';
4
+ export { fr } from './fr.js';
5
+ export { de } from './de.js';
6
+ export { ja } from './ja.js';
7
+ export { zh } from './zh.js';
8
+ export { pt } from './pt.js';
9
+ export { ar } from './ar.js';
3
10
  export { setI18n, useI18n } from './context.svelte.js';
4
11
  export { locales, localeList } from './locales.js';
@@ -0,0 +1,6 @@
1
+ import type { Messages } from './en.js';
2
+ import type { LocaleCode } from './locales.js';
3
+ /** Load a shipped locale's catalog on demand. Repeated calls share one request. */
4
+ export declare const loadLocaleMessages: (code: LocaleCode) => Promise<Messages> | undefined;
5
+ /** Text direction of a shipped locale, without importing its catalog. */
6
+ export declare const localeDirection: (code: LocaleCode) => "ltr" | "rtl";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * One dynamic import per locale, so a bundler emits each catalog as its own chunk and an app
3
+ * only downloads the locales it renders. English is the built-in default and never loads.
4
+ */
5
+ const loaders = {
6
+ es: () => import('./es.js').then((m) => m.es),
7
+ fr: () => import('./fr.js').then((m) => m.fr),
8
+ de: () => import('./de.js').then((m) => m.de),
9
+ ja: () => import('./ja.js').then((m) => m.ja),
10
+ 'zh-CN': () => import('./zh.js').then((m) => m.zh),
11
+ 'pt-BR': () => import('./pt.js').then((m) => m.pt),
12
+ ar: () => import('./ar.js').then((m) => m.ar)
13
+ };
14
+ const pending = new Map();
15
+ /** Load a shipped locale's catalog on demand. Repeated calls share one request. */
16
+ export const loadLocaleMessages = (code) => {
17
+ if (code === 'en')
18
+ return undefined;
19
+ let request = pending.get(code);
20
+ if (!request) {
21
+ request = loaders[code]();
22
+ pending.set(code, request);
23
+ request.catch(() => pending.delete(code));
24
+ }
25
+ return request;
26
+ };
27
+ /** Text direction of a shipped locale, without importing its catalog. */
28
+ export const localeDirection = (code) => (code === 'ar' ? 'rtl' : 'ltr');
@@ -8,7 +8,11 @@ export type LocaleMeta = {
8
8
  /** The message catalog for this locale. */
9
9
  messages: Messages;
10
10
  };
11
- /** All shipped locales. Pass `locales[code].messages` to `setI18n` and set `document.dir` from `locales[code].dir`. */
11
+ /**
12
+ * Every shipped locale with its catalog, for a language picker. Importing it bundles all eight
13
+ * catalogs: an app with a fixed locale imports that catalog by name instead (`import { fr } from
14
+ * 'entasis/i18n'`) and passes it to `<I18n catalogs={{ fr }}>` or `setI18n`.
15
+ */
12
16
  export declare const locales: Record<LocaleCode, LocaleMeta>;
13
17
  export declare const localeList: {
14
18
  /** Native language name, for a language picker. */
@@ -6,7 +6,11 @@ import { ja } from './ja.js';
6
6
  import { zh } from './zh.js';
7
7
  import { pt } from './pt.js';
8
8
  import { ar } from './ar.js';
9
- /** All shipped locales. Pass `locales[code].messages` to `setI18n` and set `document.dir` from `locales[code].dir`. */
9
+ /**
10
+ * Every shipped locale with its catalog, for a language picker. Importing it bundles all eight
11
+ * catalogs: an app with a fixed locale imports that catalog by name instead (`import { fr } from
12
+ * 'entasis/i18n'`) and passes it to `<I18n catalogs={{ fr }}>` or `setI18n`.
13
+ */
10
14
  export const locales = {
11
15
  en: { name: 'English', dir: 'ltr', messages: en },
12
16
  es: { name: 'Español', dir: 'ltr', messages: es },
@@ -17,7 +21,7 @@ export const locales = {
17
21
  'pt-BR': { name: 'Português (Brasil)', dir: 'ltr', messages: pt },
18
22
  ar: { name: 'العربية', dir: 'rtl', messages: ar }
19
23
  };
20
- export const localeList = Object.entries(locales).map(([code, meta]) => ({
24
+ export const localeList = /* @__PURE__ */ Object.entries(locales).map(([code, meta]) => ({
21
25
  code: code,
22
26
  ...meta
23
27
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entasis",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -150,7 +150,7 @@
150
150
  "lightgallery": "^3.0.0",
151
151
  "melt": "^0.44.0",
152
152
  "runed": "^0.37.1",
153
- "svelte-streamdown": "^4.2.1",
153
+ "svelte-streamdown": "^5.0.0",
154
154
  "svelte-themes": "^2.0.10",
155
155
  "valibot": "^1.5.0"
156
156
  },