entasis 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +7 -1
  2. package/dist/components/AppShell/appShell.theme.js +2 -2
  3. package/dist/components/FloatingWindow/FloatingWindow.svelte +26 -2
  4. package/dist/components/FloatingWindow/floatingWindow.dock.svelte.js +11 -2
  5. package/dist/components/FloatingWindow/floatingWindow.mcp.d.ts +1 -1
  6. package/dist/components/FloatingWindow/floatingWindow.mcp.js +7 -4
  7. package/dist/components/FloatingWindow/floatingWindow.props.d.ts +6 -0
  8. package/dist/components/FloatingWindow/floatingWindow.state.svelte.d.ts +3 -0
  9. package/dist/components/FloatingWindow/floatingWindow.state.svelte.js +18 -4
  10. package/dist/components/FloatingWindow/floatingWindow.theme.d.ts +3 -0
  11. package/dist/components/FloatingWindow/floatingWindow.theme.js +20 -7
  12. package/dist/components/Form/File/FileInput.svelte +6 -42
  13. package/dist/components/Form/Form/form.state.svelte.d.ts +4 -0
  14. package/dist/components/Form/Form/visibility.d.ts +2 -0
  15. package/dist/components/Form/MultiStepForm/multiStepForm.state.svelte.d.ts +4 -0
  16. package/dist/components/Form/Select/Select.svelte +28 -2
  17. package/dist/components/Form/Select/select.align.d.ts +55 -0
  18. package/dist/components/Form/Select/select.align.js +41 -0
  19. package/dist/components/Form/Select/select.mcp.d.ts +1 -1
  20. package/dist/components/Form/Select/select.mcp.js +7 -3
  21. package/dist/components/Form/Select/select.props.d.ts +8 -0
  22. package/dist/components/Form/Select/select.state.svelte.d.ts +24 -0
  23. package/dist/components/Form/Select/select.state.svelte.js +111 -1
  24. package/dist/components/Popover/Popover.svelte +4 -0
  25. package/dist/components/Popover/popover.mcp.d.ts +1 -1
  26. package/dist/components/Popover/popover.mcp.js +1 -0
  27. package/dist/components/Popover/popover.props.d.ts +16 -0
  28. package/dist/components/Popover/popover.state.svelte.d.ts +1 -1
  29. package/dist/components/Popover/popover.state.svelte.js +13 -0
  30. package/dist/components/Sidebar/Sidebar.svelte +58 -10
  31. package/dist/components/Sidebar/Sidebar.svelte.d.ts +1 -1
  32. package/dist/components/Sidebar/SidebarMenuItem.svelte +19 -2
  33. package/dist/components/Sidebar/SidebarMenuItem.svelte.d.ts +4 -0
  34. package/dist/components/Sidebar/SidebarPanel.svelte +199 -88
  35. package/dist/components/Sidebar/SidebarPanel.svelte.d.ts +3 -0
  36. package/dist/components/Sidebar/SidebarViewStage.svelte +103 -0
  37. package/dist/components/Sidebar/SidebarViewStage.svelte.d.ts +18 -0
  38. package/dist/components/Sidebar/index.d.ts +1 -1
  39. package/dist/components/Sidebar/sidebar.mcp.d.ts +1 -1
  40. package/dist/components/Sidebar/sidebar.mcp.js +19 -2
  41. package/dist/components/Sidebar/sidebar.props.d.ts +56 -1
  42. package/dist/components/Sidebar/sidebar.state.svelte.d.ts +4 -0
  43. package/dist/components/Sidebar/sidebar.state.svelte.js +9 -1
  44. package/dist/components/Sidebar/sidebar.theme.d.ts +128 -5
  45. package/dist/components/Sidebar/sidebar.theme.js +69 -3
  46. package/dist/components/Sidebar/sidebar.views.svelte.d.ts +138 -0
  47. package/dist/components/Sidebar/sidebar.views.svelte.js +303 -0
  48. package/dist/components/Theme/theme.floatingWindows.d.ts +6 -1
  49. package/dist/components/Theme/theme.floatingWindows.js +7 -2
  50. package/dist/components/Theme/theme.mcp.d.ts +1 -1
  51. package/dist/components/Theme/theme.mcp.js +1 -0
  52. package/dist/generated/componentContract.d.ts +1 -1
  53. package/dist/generated/componentContract.js +1 -0
  54. package/dist/generated/componentMcpRegistry.d.ts +6 -6
  55. package/dist/tailwind/index.mcp.d.ts +1 -1
  56. package/dist/tailwind/index.mcp.js +4 -0
  57. package/dist/tailwind/scales.js +11 -3
  58. package/dist/tailwind/spacing.js +17 -0
  59. package/dist/utils/cva/merge.d.ts +7 -0
  60. package/dist/utils/cva/merge.js +6 -1
  61. package/dist/utils/pointerDrag.js +7 -1
  62. package/package.json +1 -1
@@ -18,6 +18,7 @@
18
18
  import { SidebarResizeState } from './sidebar.resize.svelte.js';
19
19
  import { SidebarStateController } from './sidebar.state.svelte.js';
20
20
  import { useSidebarTheme } from './sidebar.theme.js';
21
+ import { SidebarViewsState } from './sidebar.views.svelte.js';
21
22
  import { useDefaultColor } from '../Theme/theme.state.svelte.js';
22
23
  let {
23
24
  id: customId,
@@ -47,6 +48,10 @@
47
48
  expandOnHover = false,
48
49
  activityBar,
49
50
  items,
51
+ views,
52
+ view = $bindable(),
53
+ defaultView,
54
+ onViewChange,
50
55
  class: className,
51
56
  collapseIcon = 'chevron',
52
57
  tooltips = 'auto',
@@ -111,19 +116,26 @@
111
116
  const navLabel = $derived(`${t.sidebar} ${t.navigation}`);
112
117
  const classes = $derived(useSidebarTheme(theme));
113
118
  const resolvedColor = $derived(useDefaultColor());
119
+ // Every view counts, not just the one on screen: icon collapse must not come and go as the
120
+ // view changes.
114
121
  const canCollapseToIcon = $derived.by(() => {
115
- if (content) return false;
122
+ const viewList = Object.values(views ?? {});
123
+ if (viewList.length ? viewList.some((entry) => entry.content) : content) return false;
116
124
 
117
- const menuEntries = [
118
- ...(headerMenu ?? []),
119
- ...(items?.flatMap((group) => group.items ?? []) ?? []),
120
- ...(footerMenu ?? [])
121
- ];
125
+ const sources = [{ headerMenu, items, footerMenu, headerButton, footerButton }, ...viewList];
126
+ const menuEntries = sources.flatMap((source) => [
127
+ ...(source.headerMenu ?? []),
128
+ ...(source.items?.flatMap((group) => group.items ?? []) ?? []),
129
+ ...(source.footerMenu ?? [])
130
+ ]);
122
131
 
123
132
  return (
124
133
  menuEntries.every(hasIcon) &&
125
- hasCollapsibleMedia(headerButton) &&
126
- hasCollapsibleMedia(footerButton)
134
+ sources.every(
135
+ (source) =>
136
+ hasCollapsibleMedia(source.headerButton ?? undefined) &&
137
+ hasCollapsibleMedia(source.footerButton ?? undefined)
138
+ )
127
139
  );
128
140
  });
129
141
  const resolvedCollapsible = $derived(
@@ -150,6 +162,32 @@
150
162
  return onDisplayStateChange;
151
163
  }
152
164
  });
165
+ const panelViews = new SidebarViewsState({
166
+ get views() {
167
+ return views;
168
+ },
169
+ get view() {
170
+ return view;
171
+ },
172
+ get defaultView() {
173
+ return defaultView;
174
+ },
175
+ get onViewChange() {
176
+ return onViewChange;
177
+ },
178
+ get isMobile(): boolean {
179
+ return controller.isMobile;
180
+ },
181
+ get side() {
182
+ return side;
183
+ },
184
+ get root() {
185
+ return { headerButton, search, headerMenu, header, footerButton, footerMenu, footer };
186
+ },
187
+ setViewProp: (next) => {
188
+ view = next;
189
+ }
190
+ });
153
191
  const controller = new SidebarStateController({
154
192
  get mode() {
155
193
  return mode;
@@ -169,7 +207,11 @@
169
207
  get peeking() {
170
208
  return hoverExpanded;
171
209
  },
172
- setDisplayState: displayStateBridge.setDisplayState
210
+ get view(): string | undefined {
211
+ return panelViews.current;
212
+ },
213
+ setDisplayState: displayStateBridge.setDisplayState,
214
+ setView: panelViews.setView
173
215
  });
174
216
  const resize = new SidebarResizeState({
175
217
  get width() {
@@ -199,6 +241,9 @@
199
241
  mode === 'panel' && controller.displayState === 'collapsed' ? widthIcon : width
200
242
  );
201
243
  const withBanner = $derived(!!banner);
244
+ // The page fills the frame only when nothing of the sidebar stays beside it: the panel is
245
+ // hidden and no activity bar remains on screen.
246
+ const pageFlush = $derived(controller.displayState === 'hidden' && !activityBar);
202
247
  const rootClass = $derived(
203
248
  classes.root({
204
249
  variant,
@@ -251,6 +296,7 @@
251
296
  {activeVariant}
252
297
  {density}
253
298
  label={navLabel}
299
+ views={panelViews.enabled ? panelViews : undefined}
254
300
  {theme}
255
301
  />
256
302
  {/snippet}
@@ -309,6 +355,7 @@
309
355
  data-size={size}
310
356
  data-density={density}
311
357
  data-activity-bar={activityBar ? 'true' : undefined}
358
+ data-page-flush={pageFlush ? 'true' : undefined}
312
359
  data-width-prehydrating={resize.isWidthInitializing ? 'true' : undefined}
313
360
  style:--sidebar-width={resize.renderWidth}
314
361
  style:--sidebar-width-icon={widthIcon}
@@ -399,7 +446,8 @@
399
446
  variant,
400
447
  side,
401
448
  displayState: controller.displayState,
402
- edgeRevealed
449
+ edgeRevealed,
450
+ flush: pageFlush
403
451
  })}
404
452
  >
405
453
  {@render children?.(api)}
@@ -1,4 +1,4 @@
1
1
  import type { SidebarProps } from './sidebar.props.js';
2
- declare const Sidebar: import("svelte").Component<SidebarProps, {}, "width" | "ref" | "open" | "displayState">;
2
+ declare const Sidebar: import("svelte").Component<SidebarProps, {}, "view" | "width" | "ref" | "open" | "displayState">;
3
3
  type Sidebar = ReturnType<typeof Sidebar>;
4
4
  export default Sidebar;
@@ -30,6 +30,8 @@
30
30
  size,
31
31
  activeVariant,
32
32
  density,
33
+ back = false,
34
+ ariaLabel,
33
35
  theme
34
36
  }: {
35
37
  item: SidebarMenuEntry;
@@ -39,6 +41,10 @@
39
41
  size: SidebarSize;
40
42
  activeVariant: SidebarActiveVariant;
41
43
  density: SidebarDensity;
44
+ /** The row a nested view opens on, back to its parent: a leading back arrow, no chevron. */
45
+ back?: boolean;
46
+ /** Accessible name when the visible label alone does not say what the row does. */
47
+ ariaLabel?: string;
42
48
  theme?: SidebarThemeProps;
43
49
  } = $props();
44
50
 
@@ -62,7 +68,9 @@
62
68
  api.displayState === 'collapsed' && !api.isMobile && !api.isPeeking
63
69
  );
64
70
  const showTooltip = $derived((tooltips === 'always' || isIconCollapsed) && !api.isMobile);
65
- const tooltipContent = $derived(showTooltip ? (item.tooltip ?? item.label) : undefined);
71
+ const tooltipContent = $derived(
72
+ showTooltip ? (item.tooltip ?? ariaLabel ?? item.label) : undefined
73
+ );
66
74
  const hasSubmenu = $derived(!!item.items?.length);
67
75
  // A bare glyph stays bare: the wrapper only appears when the entry asks for a role tint or a
68
76
  // tile, so an untinted row never inherits the ambient `data-color`.
@@ -97,6 +105,7 @@
97
105
  return;
98
106
  }
99
107
  item.onclick?.(event);
108
+ if (item.view !== undefined && !event.defaultPrevented) api.setView(item.view);
100
109
  }
101
110
  </script>
102
111
 
@@ -110,7 +119,7 @@
110
119
  <SidebarIcon icon={item.icon} />
111
120
  </span>
112
121
  {:else}
113
- <SidebarIcon icon={item.icon} />
122
+ <SidebarIcon icon={item.icon} class={back ? 'rtl:-scale-x-100' : undefined} />
114
123
  {/if}
115
124
  <span class={classes.menuLabel()}>{item.label}</span>
116
125
  {/snippet}
@@ -170,6 +179,8 @@
170
179
  data-active={item.isActive ? 'true' : undefined}
171
180
  data-active-variant={activeVariant}
172
181
  aria-current={item.isActive ? 'page' : undefined}
182
+ aria-label={ariaLabel}
183
+ data-sidebar-view-target={item.view}
173
184
  disabled={item.disabled || undefined}
174
185
  class={classes.menuButton({
175
186
  variant: item.variant,
@@ -183,6 +194,12 @@
183
194
  onclick={handleClick}
184
195
  >
185
196
  {@render entryContent()}
197
+ {#if item.view !== undefined && !back}
198
+ <SidebarIcon
199
+ icon={caretRightIcon}
200
+ class={classes.menuTrailing({ size, className: 'rtl:-scale-x-100' })}
201
+ />
202
+ {/if}
186
203
  </button>
187
204
  {/if}
188
205
  {/snippet}
@@ -9,6 +9,10 @@ type $$ComponentProps = {
9
9
  size: SidebarSize;
10
10
  activeVariant: SidebarActiveVariant;
11
11
  density: SidebarDensity;
12
+ /** The row a nested view opens on, back to its parent: a leading back arrow, no chevron. */
13
+ back?: boolean;
14
+ /** Accessible name when the visible label alone does not say what the row does. */
15
+ ariaLabel?: string;
12
16
  theme?: SidebarThemeProps;
13
17
  };
14
18
  declare const SidebarMenuItem: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -16,8 +16,12 @@
16
16
  import SidebarGroupComponent from './SidebarGroup.svelte';
17
17
  import SidebarIcon from './SidebarIcon.svelte';
18
18
  import SidebarMenuButton from './SidebarMenuButton.svelte';
19
+ import SidebarMenuItem from './SidebarMenuItem.svelte';
19
20
  import SidebarMenuList from './SidebarMenuList.svelte';
21
+ import SidebarViewStage from './SidebarViewStage.svelte';
22
+ import { arrowLeftIcon } from '../Icons/arrowLeft.js';
20
23
  import { useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
24
+ import { type SidebarSlotValues, type SidebarViewsState } from './sidebar.views.svelte.js';
21
25
 
22
26
  let {
23
27
  api,
@@ -36,6 +40,7 @@
36
40
  activeVariant,
37
41
  density,
38
42
  label,
43
+ views,
39
44
  theme
40
45
  }: {
41
46
  api: SidebarApi;
@@ -55,9 +60,27 @@
55
60
  density: SidebarDensity;
56
61
  /** Accessible name for the body navigation landmark. */
57
62
  label: string;
63
+ /** Named views; when enabled, each region slides between them instead of rendering the props. */
64
+ views?: SidebarViewsState;
58
65
  theme?: SidebarThemeProps;
59
66
  } = $props();
60
67
 
68
+ const rootSource = $derived<SidebarSlotValues>({
69
+ items,
70
+ content,
71
+ headerButton,
72
+ search,
73
+ headerMenu,
74
+ header,
75
+ footerButton,
76
+ footerMenu,
77
+ footer
78
+ });
79
+ const hasHeader = (source: SidebarSlotValues) =>
80
+ !!(source.headerButton || source.search || source.headerMenu || source.header);
81
+ const hasFooter = (source: SidebarSlotValues) =>
82
+ !!(source.footerButton || source.footerMenu || source.footer);
83
+
61
84
  const classes = $derived(useSidebarTheme(theme));
62
85
  const t = $derived(useI18n());
63
86
  // A hover peek renders the collapsed panel at full width, so icon-mode behaviour has to
@@ -85,66 +108,87 @@
85
108
  });
86
109
  </script>
87
110
 
88
- {#if headerButton || search || headerMenu || header}
89
- <div data-slot="sidebar-header" data-sidebar="header" class={classes.header({ density })}>
90
- <!--
91
- The `header` snippet renders first, so a custom workspace card sits above the built-in
92
- search and menu instead of under them. Consumers never need an order override.
93
- -->
94
- {#if header}
95
- {@render header(api)}
96
- {/if}
97
- {#if headerButton}
98
- <SidebarMenuButton
99
- {...headerButton}
100
- {api}
101
- mobile={api.isMobile}
102
- defaultAlign="start"
103
- {size}
104
- {density}
105
- {theme}
106
- />
107
- {/if}
108
- {#if search}
109
- <form
110
- bind:this={searchRef}
111
- data-slot="sidebar-search"
112
- class={classes.searchContainer({ size, collapsed })}
113
- inert={collapsed ? true : undefined}
114
- aria-hidden={collapsed ? 'true' : undefined}
115
- onsubmit={(event) => event.preventDefault()}
116
- >
117
- <input
118
- placeholder={search.placeholder}
119
- aria-label={search.label ?? t.search}
120
- value={search.value}
121
- oninput={search.oninput}
122
- class={classes.search({ size, density, className: search.class })}
123
- />
124
- <SidebarIcon icon={magnifyingGlassIcon} class={classes.searchIcon({ size, density })} />
125
- </form>
126
- {/if}
127
- {#if headerMenu}
128
- <SidebarMenuList
129
- items={headerMenu}
130
- {api}
131
- {collapseIcon}
132
- {tooltips}
133
- {size}
134
- {activeVariant}
135
- {density}
136
- {theme}
137
- />
138
- {/if}
139
- </div>
140
- {/if}
111
+ {#snippet headerButtonSlot(headerButton: SidebarMenuButtonItem)}
112
+ <SidebarMenuButton
113
+ {...headerButton}
114
+ {api}
115
+ mobile={api.isMobile}
116
+ defaultAlign="start"
117
+ {size}
118
+ {density}
119
+ {theme}
120
+ />
121
+ {/snippet}
122
+
123
+ {#snippet searchSlot(searchBox: SidebarSearch)}
124
+ <form
125
+ bind:this={searchRef}
126
+ data-slot="sidebar-search"
127
+ class={classes.searchContainer({ size, collapsed })}
128
+ inert={collapsed ? true : undefined}
129
+ aria-hidden={collapsed ? 'true' : undefined}
130
+ onsubmit={(event) => event.preventDefault()}
131
+ >
132
+ <input
133
+ placeholder={searchBox.placeholder}
134
+ aria-label={searchBox.label ?? t.search}
135
+ value={searchBox.value}
136
+ oninput={searchBox.oninput}
137
+ class={classes.search({ size, density, className: searchBox.class })}
138
+ />
139
+ <SidebarIcon icon={magnifyingGlassIcon} class={classes.searchIcon({ size, density })} />
140
+ </form>
141
+ {/snippet}
142
+
143
+ {#snippet menuSlot(items: SidebarMenuEntry[])}
144
+ <SidebarMenuList
145
+ {items}
146
+ {api}
147
+ {collapseIcon}
148
+ {tooltips}
149
+ {size}
150
+ {activeVariant}
151
+ {density}
152
+ {theme}
153
+ />
154
+ {/snippet}
155
+
156
+ {#snippet footerButtonSlot(footerButton: SidebarMenuButtonItem)}
157
+ <SidebarMenuButton
158
+ {...footerButton}
159
+ {api}
160
+ mobile={api.isMobile}
161
+ defaultAlign="end"
162
+ {size}
163
+ {density}
164
+ {theme}
165
+ />
166
+ {/snippet}
141
167
 
142
- <!-- The activity bar is a second nav landmark, so this one needs its own name to tell them apart. -->
143
- <nav data-slot="sidebar-nav" data-sidebar="nav" aria-label={label} class={classes.nav({ density })}>
144
- {#if content}
145
- {@render content(api)}
146
- {:else if items}
147
- {#each items as group, index (group.label ?? `group-${index}`)}
168
+ {#snippet headerRegion(source: SidebarSlotValues)}
169
+ <!--
170
+ The `header` snippet renders first, so a custom workspace card sits above the built-in
171
+ search and menu instead of under them. Consumers never need an order override.
172
+ -->
173
+ {#if source.header}
174
+ {@render source.header(api)}
175
+ {/if}
176
+ {#if source.headerButton}
177
+ {@render headerButtonSlot(source.headerButton)}
178
+ {/if}
179
+ {#if source.search}
180
+ {@render searchSlot(source.search)}
181
+ {/if}
182
+ {#if source.headerMenu}
183
+ {@render menuSlot(source.headerMenu)}
184
+ {/if}
185
+ {/snippet}
186
+
187
+ {#snippet bodyRegion(source: SidebarSlotValues)}
188
+ {#if source.content}
189
+ {@render source.content(api)}
190
+ {:else if source.items}
191
+ {#each source.items as group, index (group.label ?? `group-${index}`)}
148
192
  {#if group.separator && index > 0}
149
193
  <div
150
194
  data-slot="sidebar-separator"
@@ -164,35 +208,102 @@
164
208
  />
165
209
  {/each}
166
210
  {/if}
167
- </nav>
211
+ {/snippet}
168
212
 
169
- {#if footerButton || footerMenu || footer}
170
- <div data-slot="sidebar-footer" data-sidebar="footer" class={classes.footer({ density })}>
171
- {#if footerButton}
172
- <SidebarMenuButton
173
- {...footerButton}
174
- {api}
175
- mobile={api.isMobile}
176
- defaultAlign="end"
177
- {size}
178
- {density}
179
- {theme}
180
- />
181
- {/if}
182
- {#if footerMenu}
183
- <SidebarMenuList
184
- items={footerMenu}
185
- {api}
186
- {collapseIcon}
187
- {tooltips}
188
- {size}
189
- {activeVariant}
190
- {density}
213
+ {#snippet footerRegion(source: SidebarSlotValues)}
214
+ {#if source.footerButton}
215
+ {@render footerButtonSlot(source.footerButton)}
216
+ {/if}
217
+ {#if source.footerMenu}
218
+ {@render menuSlot(source.footerMenu)}
219
+ {/if}
220
+ {#if source.footer}
221
+ {@render source.footer(api)}
222
+ {/if}
223
+ {/snippet}
224
+
225
+ {#if views?.enabled}
226
+ <!--
227
+ Views with the same header and footer share one panel layer, so only the menu inside slides;
228
+ a view that changes them slides the whole panel as one page.
229
+ -->
230
+ <SidebarViewStage
231
+ {views}
232
+ kind="panel"
233
+ layers={views.layers('panel')}
234
+ layerClass="flex min-h-0 flex-col"
235
+ {theme}
236
+ >
237
+ {#snippet children(panelLayer)}
238
+ {@const chrome = views.chrome(panelLayer.view)}
239
+ {#if hasHeader(chrome)}
240
+ <div data-slot="sidebar-header" data-sidebar="header" class={classes.header({ density })}>
241
+ {@render headerRegion(chrome)}
242
+ </div>
243
+ {/if}
244
+ <!-- The activity bar is a second nav landmark, so this one needs its own name to tell them apart. -->
245
+ <SidebarViewStage
246
+ {views}
247
+ kind="body"
248
+ layers={panelLayer.role
249
+ ? [{ key: panelLayer.view, view: panelLayer.view }]
250
+ : views.layers('body')}
251
+ layerClass={classes.nav({ density })}
252
+ {label}
191
253
  {theme}
192
- />
193
- {/if}
194
- {#if footer}
195
- {@render footer(api)}
196
- {/if}
197
- </div>
254
+ >
255
+ {#snippet children(bodyLayer)}
256
+ {@const parent = views.parentOf(bodyLayer.view)}
257
+ {#if parent !== undefined}
258
+ {@const parentLabel = views.labelOf(parent)}
259
+ <!-- A nested view opens on a row back to its parent, named after it like a back button. -->
260
+ <div data-slot="sidebar-group" data-sidebar="group" class={classes.group({ density })}>
261
+ <ul data-slot="sidebar-menu" data-sidebar="menu" class={classes.menu({ density })}>
262
+ <SidebarMenuItem
263
+ item={{ label: parentLabel ?? t.back, icon: arrowLeftIcon, view: parent }}
264
+ back
265
+ ariaLabel={parentLabel ? `${t.back}, ${parentLabel}` : undefined}
266
+ {api}
267
+ {collapseIcon}
268
+ {tooltips}
269
+ {size}
270
+ {activeVariant}
271
+ {density}
272
+ {theme}
273
+ />
274
+ </ul>
275
+ </div>
276
+ {/if}
277
+ {@render bodyRegion(views.body(bodyLayer.view))}
278
+ {/snippet}
279
+ </SidebarViewStage>
280
+ {#if hasFooter(chrome)}
281
+ <div data-slot="sidebar-footer" data-sidebar="footer" class={classes.footer({ density })}>
282
+ {@render footerRegion(chrome)}
283
+ </div>
284
+ {/if}
285
+ {/snippet}
286
+ </SidebarViewStage>
287
+ {:else}
288
+ {#if hasHeader(rootSource)}
289
+ <div data-slot="sidebar-header" data-sidebar="header" class={classes.header({ density })}>
290
+ {@render headerRegion(rootSource)}
291
+ </div>
292
+ {/if}
293
+
294
+ <!-- The activity bar is a second nav landmark, so this one needs its own name to tell them apart. -->
295
+ <nav
296
+ data-slot="sidebar-nav"
297
+ data-sidebar="nav"
298
+ aria-label={label}
299
+ class={classes.nav({ density })}
300
+ >
301
+ {@render bodyRegion(rootSource)}
302
+ </nav>
303
+
304
+ {#if hasFooter(rootSource)}
305
+ <div data-slot="sidebar-footer" data-sidebar="footer" class={classes.footer({ density })}>
306
+ {@render footerRegion(rootSource)}
307
+ </div>
308
+ {/if}
198
309
  {/if}
@@ -1,6 +1,7 @@
1
1
  import type { DisclosureIndicator } from '../../types/theme.js';
2
2
  import type { SidebarActiveVariant, SidebarApi, SidebarGroup, SidebarMenuButtonItem, SidebarMenuEntry, SidebarSearch, SidebarDensity, SidebarSize, SidebarTooltipMode } from './sidebar.props.js';
3
3
  import { type SidebarThemeProps } from './sidebar.theme.js';
4
+ import { type SidebarViewsState } from './sidebar.views.svelte.js';
4
5
  type $$ComponentProps = {
5
6
  api: SidebarApi;
6
7
  items?: SidebarGroup[];
@@ -19,6 +20,8 @@ type $$ComponentProps = {
19
20
  density: SidebarDensity;
20
21
  /** Accessible name for the body navigation landmark. */
21
22
  label: string;
23
+ /** Named views; when enabled, each region slides between them instead of rendering the props. */
24
+ views?: SidebarViewsState;
22
25
  theme?: SidebarThemeProps;
23
26
  };
24
27
  declare const SidebarPanel: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -0,0 +1,103 @@
1
+ <script lang="ts">
2
+ import { untrack, type Snippet } from 'svelte';
3
+ import type { Attachment } from 'svelte/attachments';
4
+ import {
5
+ sidebarViewTransition,
6
+ type SidebarViewKind,
7
+ type SidebarViewLayer,
8
+ type SidebarViewsState
9
+ } from './sidebar.views.svelte.js';
10
+ import { useSidebarMotion, useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
11
+
12
+ let {
13
+ views,
14
+ kind,
15
+ layers,
16
+ layerClass,
17
+ label,
18
+ theme,
19
+ children
20
+ }: {
21
+ views: SidebarViewsState;
22
+ /** `panel` slides the whole panel and owns the swipe; `body` slides the menu inside it. */
23
+ kind: SidebarViewKind;
24
+ layers: SidebarViewLayer[];
25
+ /** Classes each layer wears on top of `viewLayer`. */
26
+ layerClass?: string;
27
+ /** Landmark name; the body stage is the nav landmark. */
28
+ label?: string;
29
+ theme?: SidebarThemeProps;
30
+ children: Snippet<[layer: SidebarViewLayer]>;
31
+ } = $props();
32
+
33
+ const classes = $derived(useSidebarTheme(theme));
34
+ // The `view` motion of the Sidebar motion slot: travel, rest opacity, duration and easing come
35
+ // from it, so a `<Theme motion>` retune and a reduced-motion preference reach the view slide.
36
+ const resolveMotion = useSidebarMotion();
37
+ const motion = $derived(resolveMotion({ part: 'view' }, { motion: theme?.motion }));
38
+ const duration = $derived(motion.in.duration ?? 0);
39
+ const travel = $derived(motion.in.x ?? 0);
40
+ const restOpacity = $derived(motion.in.opacity ?? 0);
41
+ const isPanel = $derived(kind === 'panel');
42
+ const swipe = $derived(views.canSwipeBack);
43
+ const dragging = $derived(views.swipe?.phase === 'drag');
44
+
45
+ // A settling swipe (commit or cancel) ends once its layers have eased to rest.
46
+ $effect(() => {
47
+ const phase = views.swipe?.phase;
48
+ if (!isPanel || !phase || phase === 'drag') return;
49
+ const timer = setTimeout(() => views.settleSwipe(), duration);
50
+ return () => clearTimeout(timer);
51
+ });
52
+
53
+ // Focus that sat in the view being replaced lands in the new one: on the row that opens the view
54
+ // just left (a back row after going deeper, the row that opened it after coming back), else on
55
+ // the first control. Focus anywhere else stays put, so a route change never pulls it here.
56
+ const arrive: Attachment<HTMLElement> = (node) =>
57
+ untrack(() => {
58
+ const stage = node.parentElement;
59
+ const active = document.activeElement;
60
+ if (node.dataset.swipe || !stage || !(active instanceof HTMLElement)) return;
61
+ if (!stage.contains(active) || node.contains(active)) return;
62
+ const from = views.lastChange.from;
63
+ const target =
64
+ (from !== undefined &&
65
+ node.querySelector<HTMLElement>(`[data-sidebar-view-target="${CSS.escape(from)}"]`)) ||
66
+ node.querySelector<HTMLElement>(
67
+ 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
68
+ );
69
+ target?.focus({ preventScroll: true });
70
+ });
71
+ </script>
72
+
73
+ <svelte:element
74
+ this={isPanel ? 'div' : 'nav'}
75
+ data-slot="sidebar-view-stage"
76
+ data-sidebar={isPanel ? undefined : 'nav'}
77
+ data-view-kind={kind}
78
+ aria-label={label}
79
+ data-no-swipe={isPanel && views.ownsDrawerSwipe ? '' : undefined}
80
+ class={classes.viewStage()}
81
+ {@attach isPanel ? views.swipeAttachment : undefined}
82
+ >
83
+ {#each layers as layer (layer.key)}
84
+ {@const swipeStyle = views.swipeStyle(layer.role, travel, restOpacity)}
85
+ <div
86
+ data-slot="sidebar-view-layer"
87
+ data-sidebar="view-layer"
88
+ data-view={layer.view}
89
+ data-swipe={layer.role}
90
+ data-dragging={layer.role && dragging ? 'true' : undefined}
91
+ inert={layer.role === 'under' && dragging ? true : undefined}
92
+ class={[layerClass, classes.viewLayer({ swipe })]}
93
+ style:translate={swipeStyle.translate}
94
+ style:opacity={swipeStyle.opacity}
95
+ style:transition-duration={`${duration}ms`}
96
+ in:sidebarViewTransition={{ phase: 'in', direction: views.lastChange.direction, motion }}
97
+ out:sidebarViewTransition={{ phase: 'out', direction: views.lastChange.direction, motion }}
98
+ {@attach arrive}
99
+ >
100
+ {@render children(layer)}
101
+ </div>
102
+ {/each}
103
+ </svelte:element>