@lapismd/design-core 0.1.0 → 0.2.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 (124) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE.md +3 -3
  3. package/README.md +21 -1
  4. package/package.json +34 -15
  5. package/spec/src/styling-and-themes.md +11 -9
  6. package/src/shared/ai/chat.css +25 -1
  7. package/src/shared/ai/chat.ts +12 -0
  8. package/src/shared/ai/composer-input/ComposerInput.svelte +3 -2
  9. package/src/shared/ai/context.svelte.ts +2 -0
  10. package/src/shared/ai/experimental/reaction-bar/ReactionBar.svelte +1 -1
  11. package/src/shared/ai/layout/Layout.svelte +116 -10
  12. package/src/shared/ai/message-list/MessageList.svelte +123 -43
  13. package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
  14. package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
  15. package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
  16. package/src/shared/ai/message-list/position.ts +7 -0
  17. package/src/shared/ai/new-messages.svelte.ts +24 -5
  18. package/src/shared/ai/stream-scroll.svelte.ts +135 -6
  19. package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
  20. package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
  21. package/src/shared/ai/tool-calls/ToolCalls.svelte +1 -1
  22. package/src/shared/ai/trigger-menu.ts +2 -3
  23. package/src/shared/diff/core/merge/actions.ts +4 -3
  24. package/src/shared/diff/core/merge/render-model.ts +14 -10
  25. package/src/shared/diff/file-diff/FileDiff.css +68 -1
  26. package/src/shared/diff/file-diff/FileDiff.svelte +27 -14
  27. package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
  28. package/src/shared/diff/file-diff/index.ts +2 -0
  29. package/src/shared/diff/file-diff/types.ts +13 -0
  30. package/src/shared/diff/file-listing/FileListingViewModeToggle.css +42 -5
  31. package/src/shared/diff/index.ts +2 -0
  32. package/src/shared/diff/merge-editor/MergeEditor.svelte +2 -7
  33. package/src/shared/filter/filter-query/filters.ts +2 -2
  34. package/src/shared/filter/filter-query/parser.ts +9 -8
  35. package/src/shared/filter/power-search/PowerSearch.svelte +1 -1
  36. package/src/shared/filter/search-filter-bar/SearchFilterBar.svelte +2 -2
  37. package/src/shared/filter/search-filter-bar/SearchFilterInput.svelte +1 -1
  38. package/src/shared/filter/search-filter-bar/SearchFilterPredicateEditor.svelte +1 -1
  39. package/src/shared/filter/search-filter-bar/search-filter-syntax.ts +1 -1
  40. package/src/shared/forms/autocomplete-input/AutocompleteInput.svelte +19 -7
  41. package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
  42. package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
  43. package/src/shared/forms/confirm-button/index.ts +1 -0
  44. package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
  45. package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
  46. package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
  47. package/src/shared/forms/confirm-dialog/index.ts +2 -0
  48. package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
  49. package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
  50. package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
  51. package/src/shared/forms/dialog-frame/index.ts +6 -0
  52. package/src/shared/forms/dialog.ts +8 -0
  53. package/src/shared/forms/index.ts +11 -0
  54. package/src/shared/shadcn/button/button.svelte +4 -0
  55. package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
  56. package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
  57. package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
  58. package/src/shared/shadcn/command/command-dialog.svelte +1 -1
  59. package/src/shared/shadcn/command/command-group.svelte +3 -1
  60. package/src/shared/shadcn/command-view/command-view.svelte +1 -6
  61. package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
  62. package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
  63. package/src/shared/shadcn/dropdown-menu/dropdown-menu-checkbox-item.svelte +0 -691
  64. package/src/shared/shadcn/dropdown-menu/dropdown-menu-content.svelte +691 -0
  65. package/src/shared/shadcn/dropdown-menu/dropdown-menu-radio-group.svelte +1 -1
  66. package/src/shared/shadcn/scroll-area/scroll-area.svelte +2 -0
  67. package/src/shared/shadcn/spinner/spinner.svelte +3 -3
  68. package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
  69. package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
  70. package/src/shared/shell/app-shell/AppShell.css +112 -32
  71. package/src/shared/shell/app-shell/AppShell.demo.css +37 -0
  72. package/src/shared/shell/app-shell/AppShellBodyContent.svelte +5 -1
  73. package/src/shared/shell/app-shell/AppShellBodySidebar.svelte +5 -1
  74. package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
  75. package/src/shared/shell/app-shell/AppShellMobilePanelSelector.svelte +1 -1
  76. package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
  77. package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
  78. package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
  79. package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
  80. package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
  81. package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
  82. package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
  83. package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
  84. package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +269 -11
  85. package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
  86. package/src/shared/shell/app-shell/examples/AppShellBodyDemo.svelte +4 -0
  87. package/src/shared/shell/app-shell/examples/AppShellCompleteDemo.svelte +1 -0
  88. package/src/shared/shell/app-shell/examples/AppShellConversationDemo.svelte +1 -0
  89. package/src/shared/shell/app-shell/examples/AppShellExamples.css +624 -0
  90. package/src/shared/shell/app-shell/examples/AppShellFilesSidebarDemo.svelte +1 -0
  91. package/src/shared/shell/app-shell/examples/AppShellMarkdownDocumentDemo.svelte +6 -1
  92. package/src/shared/shell/app-shell/examples/AppShellMarkdownFilesDemo.svelte +1 -0
  93. package/src/shared/shell/app-shell/examples/AppShellModeSwitchDemo.svelte +1 -0
  94. package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
  95. package/src/shared/shell/app-shell/examples/AppShellProjectSidebarDemo.svelte +1 -0
  96. package/src/shared/shell/app-shell/examples/AppShellSidebarDemo.svelte +1 -0
  97. package/src/shared/shell/app-shell/examples/AppShellToolbarDemo.svelte +1 -0
  98. package/src/shared/shell/app-shell/index.ts +14 -0
  99. package/src/shared/shell/shell.tokens.css +2 -0
  100. package/src/shared/shell/shell.tokens.ts +5 -0
  101. package/src/shared/workspace/menu/WorkspaceContextMenuItems.svelte +2 -1
  102. package/src/shared/workspace/menu/WorkspaceMenuItems.svelte +2 -1
  103. package/src/shared/workspace/problems/ProblemsQuickFix.svelte +1 -4
  104. package/src/shared/workspace/settings/AppSettingsContent.svelte +6 -0
  105. package/src/shared/workspace/settings/AppSettingsNavigation.svelte +7 -0
  106. package/src/shared/workspace/settings/CopyableValue.svelte +81 -0
  107. package/src/shared/workspace/settings/DeclarativeSettingsSection.svelte +20 -0
  108. package/src/shared/workspace/settings/WorkspaceSettingField.svelte +110 -41
  109. package/src/shared/workspace/settings/WorkspaceSettingMultiSelect.svelte +7 -6
  110. package/src/shared/workspace/settings/WorkspaceSettingObjectTable.svelte +187 -23
  111. package/src/shared/workspace/settings/WorkspaceSettingSelect.svelte +29 -29
  112. package/src/shared/workspace/settings/WorkspaceSettings.css +190 -4
  113. package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomField.svelte +2 -2
  114. package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomPage.svelte +13 -0
  115. package/src/shared/workspace/settings/WorkspaceSettingsSurface.svelte +50 -0
  116. package/src/shared/workspace/settings/configuration.ts +5 -0
  117. package/src/shared/workspace/settings/copyable-value.ts +10 -0
  118. package/src/shared/workspace/settings/index.ts +5 -0
  119. package/src/shared/workspace/settings/settings-controller.svelte.ts +349 -15
  120. package/src/shared/workspace/settings/types.ts +90 -8
  121. package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.css +25 -0
  122. package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.svelte +48 -0
  123. package/src/storybook/app-shell-reference/index.ts +1 -0
  124. package/src/theme.css +10 -0
@@ -22,11 +22,16 @@
22
22
  resizable = true,
23
23
  closeable = false,
24
24
  variant = "default",
25
+ constraintPriority,
26
+ constrainedPresentation = "preview",
25
27
  revealOnEdgeHover = false,
26
28
  edgeRevealLabel,
27
29
  resizeLabel,
28
30
  mobileLabel,
29
31
  tabindex: _tabindex,
32
+ hidden: hiddenProp,
33
+ inert: inertProp,
34
+ "aria-hidden": ariaHidden,
30
35
  style,
31
36
  class: className,
32
37
  children,
@@ -43,7 +48,11 @@
43
48
  /** Whether nested `AppShell.Sidebar.Close` actions may close this sidebar. */
44
49
  closeable?: boolean;
45
50
  /** Full-height outer chrome or the default transparent shell sidebar. */
46
- variant?: "default" | "outer";
51
+ variant?: "default" | "outer" | "rail";
52
+ /** Lower values leave inline desktop flow before higher values. */
53
+ constraintPriority?: number;
54
+ /** Presentation used when protected main width displaces this panel. */
55
+ constrainedPresentation?: "preview" | "replace-main";
47
56
  /** Preview a collapsed or closed sidebar from the corresponding page edge. */
48
57
  revealOnEdgeHover?: boolean;
49
58
  /** Accessible name for the collapsed/closed edge-preview control. */
@@ -79,6 +88,7 @@
79
88
  get controller() {
80
89
  return sidebar;
81
90
  },
91
+ syncLayout,
82
92
  dismissOverlay,
83
93
  });
84
94
  let renderedWidth = $state(APP_SHELL_DEFAULT_SIDEBAR_WIDTH);
@@ -99,6 +109,18 @@
99
109
  (side === "left" ? "Preview left sidebar" : "Preview right sidebar"),
100
110
  );
101
111
  let mobilePanelId = $derived(controller.getPanelId(sidebar));
112
+ let desktopConstrained = $state(false);
113
+ let desktopPreviewed = $state(false);
114
+ let suspended = $state(false);
115
+ let covered = $state(false);
116
+ function syncProjection(): void {
117
+ desktopConstrained = controller.isPanelConstrained(mobilePanelId);
118
+ desktopPreviewed = controller.isPanelDesktopPreviewed(mobilePanelId);
119
+ suspended = controller.isPanelSuspended(mobilePanelId);
120
+ covered = controller.isPanelCovered(mobilePanelId);
121
+ }
122
+ syncProjection();
123
+ const unsubscribeProjection = controller.onProjectionChange(syncProjection);
102
124
  let mobileMode = $derived(controller.mobile.resolvedMode === "mobile");
103
125
  let mobileActive = $derived(
104
126
  !mobileMode ||
@@ -106,13 +128,15 @@
106
128
  controller.mobile.activePanelId(side) === mobilePanelId),
107
129
  );
108
130
  let presentation = $derived(
109
- mobileMode ? "mobile" : sidebar.previewed ? "overlay" : "inline",
110
- );
111
- let renderSidebar = $derived(
112
131
  mobileMode
113
- ? mobilePanelId !== undefined
114
- : !sidebarClosed || sidebar.previewed,
132
+ ? "mobile"
133
+ : sidebar.previewed || desktopPreviewed
134
+ ? "overlay"
135
+ : desktopConstrained && constrainedPresentation === "replace-main"
136
+ ? "replace-main"
137
+ : "inline",
115
138
  );
139
+ let renderSidebar = $derived(mobileMode ? mobilePanelId !== undefined : true);
116
140
  let preserveCollapsedRail = $derived(
117
141
  !mobileMode && !sidebarClosed && sidebarCollapsed && sidebar.previewed,
118
142
  );
@@ -147,10 +171,78 @@
147
171
  get element() {
148
172
  return ref;
149
173
  },
174
+ ...(constraintPriority === undefined ? {} : { constraintPriority }),
175
+ constrainedPresentation,
150
176
  };
151
177
  return untrack(() => controller.mobile.registerPanel(registration));
152
178
  });
153
179
 
180
+ $effect(() => {
181
+ const panelId = mobilePanelId;
182
+ const element = ref;
183
+ untrack(() => controller.setPanelElement(panelId, element));
184
+ return () => untrack(() => controller.setPanelElement(panelId, null));
185
+ });
186
+
187
+ $effect(() => {
188
+ const element = ref;
189
+ const root = controller.mobile.getRootElement();
190
+ const main = controller.mobile.getMainElement();
191
+ if (
192
+ presentation !== "replace-main" ||
193
+ !element ||
194
+ !root ||
195
+ !main ||
196
+ typeof ResizeObserver === "undefined"
197
+ ) {
198
+ return;
199
+ }
200
+
201
+ const updateInsets = () => {
202
+ const rootBounds = root.getBoundingClientRect();
203
+ const mainBounds = main.getBoundingClientRect();
204
+ const paddingLeft = rootBounds.left + root.clientLeft;
205
+ const paddingRight = paddingLeft + root.clientWidth;
206
+ const paddingTop = rootBounds.top + root.clientTop;
207
+ const paddingBottom = paddingTop + root.clientHeight;
208
+ const rtl = getComputedStyle(root).direction === "rtl";
209
+ const inlineStart = rtl
210
+ ? paddingRight - mainBounds.right
211
+ : mainBounds.left - paddingLeft;
212
+ const inlineEnd = rtl
213
+ ? mainBounds.left - paddingLeft
214
+ : paddingRight - mainBounds.right;
215
+ element.style.setProperty(
216
+ "--ui-shell-replacement-inline-start",
217
+ `${Math.max(0, inlineStart)}px`,
218
+ );
219
+ element.style.setProperty(
220
+ "--ui-shell-replacement-inline-end",
221
+ `${Math.max(0, inlineEnd)}px`,
222
+ );
223
+ element.style.setProperty(
224
+ "--ui-shell-replacement-block-start",
225
+ `${Math.max(0, mainBounds.top - paddingTop)}px`,
226
+ );
227
+ element.style.setProperty(
228
+ "--ui-shell-replacement-block-end",
229
+ `${Math.max(0, paddingBottom - mainBounds.bottom)}px`,
230
+ );
231
+ };
232
+ let frame = requestAnimationFrame(updateInsets);
233
+ const scheduleUpdate = () => {
234
+ cancelAnimationFrame(frame);
235
+ frame = requestAnimationFrame(updateInsets);
236
+ };
237
+ const observer = new ResizeObserver(scheduleUpdate);
238
+ observer.observe(root);
239
+ observer.observe(main);
240
+ return () => {
241
+ cancelAnimationFrame(frame);
242
+ observer.disconnect();
243
+ };
244
+ });
245
+
154
246
  $effect(() => {
155
247
  const sidebarElement = ref;
156
248
  if (!sidebarElement) return;
@@ -227,8 +319,12 @@
227
319
 
228
320
  onDestroy(() => {
229
321
  unsubscribeLayout();
322
+ unsubscribeProjection();
230
323
  stopResize();
231
324
  sidebar.dismissPreview();
325
+ controller.setPanelElement(mobilePanelId, null);
326
+ if (mobilePanelId)
327
+ controller.setPanelDesktopPreviewed(mobilePanelId, false);
232
328
  });
233
329
 
234
330
  function revealFromEdge(element?: Element | null): void {
@@ -239,7 +335,9 @@
239
335
  return;
240
336
  }
241
337
  if (isDesktopOverlayOnly(element)) {
242
- ref?.setAttribute("data-desktop-overlay-preview", "");
338
+ if (mobilePanelId) {
339
+ controller.setPanelDesktopPreviewed(mobilePanelId, true);
340
+ }
243
341
  queueMicrotask(() => ref?.focus({ preventScroll: true }));
244
342
  return;
245
343
  }
@@ -247,18 +345,14 @@
247
345
  }
248
346
 
249
347
  function isDesktopOverlayOnly(element?: Element | null): boolean {
250
- if (!mobilePanelId) return false;
251
- return (
252
- (element ?? ref)
253
- ?.closest("[data-shell-root]")
254
- ?.getAttribute("data-desktop-overlay-panels")
255
- ?.split(/\s+/)
256
- .includes(mobilePanelId) ?? false
257
- );
348
+ void element;
349
+ return desktopConstrained && constrainedPresentation === "preview";
258
350
  }
259
351
 
260
352
  function dismissOverlay(): void {
261
353
  sidebar.dismissPreview();
354
+ if (mobilePanelId)
355
+ controller.setPanelDesktopPreviewed(mobilePanelId, false);
262
356
  }
263
357
 
264
358
  function keepOverlay(): void {
@@ -412,7 +506,9 @@
412
506
  onblur={handleOverlayBlur}
413
507
  onclick={(event) => {
414
508
  if (isDesktopOverlayOnly(event.currentTarget)) {
415
- ref?.setAttribute("data-desktop-overlay-preview", "");
509
+ if (mobilePanelId) {
510
+ controller.setPanelDesktopPreviewed(mobilePanelId, true);
511
+ }
416
512
  queueMicrotask(() => ref?.focus({ preventScroll: true }));
417
513
  } else sidebar.toggle();
418
514
  }}
@@ -448,15 +544,21 @@
448
544
  data-state={sidebarState}
449
545
  data-variant={variant}
450
546
  data-presentation={presentation}
547
+ data-desktop-constrained={desktopConstrained || undefined}
548
+ data-constrained-presentation={constrainedPresentation}
451
549
  data-collapsed={sidebarCollapsed || undefined}
452
550
  data-closeable={closeable || undefined}
453
551
  data-previewed={sidebar.previewed || undefined}
454
552
  data-mobile-panel-id={mobilePanelId}
455
553
  data-mobile-panel-active={mobileActive || undefined}
456
554
  data-resizing={resizing || undefined}
555
+ data-consumer-hidden={Boolean(hiddenProp) || undefined}
556
+ data-consumer-inert={Boolean(inertProp) || undefined}
557
+ data-consumer-aria-hidden={ariaHidden}
457
558
  aria-label={accessibleLabel}
458
- aria-hidden={mobileMode && !mobileActive}
459
- inert={mobileMode && !mobileActive}
559
+ hidden={Boolean(hiddenProp) || suspended}
560
+ aria-hidden={(mobileMode && !mobileActive) || covered ? "true" : ariaHidden}
561
+ inert={Boolean(inertProp) || (mobileMode && !mobileActive) || covered}
460
562
  onmouseenter={keepOverlay}
461
563
  onmouseleave={presentation === "overlay"
462
564
  ? handleOverlayPointerLeave
@@ -465,7 +567,7 @@
465
567
  onfocusout={presentation === "overlay" ? handleOverlayBlur : undefined}
466
568
  >
467
569
  {@render children?.()}
468
- {#if !mobileMode && resizable && (!sidebarCollapsed || presentation === "overlay")}
570
+ {#if !mobileMode && resizable && presentation !== "replace-main" && (!sidebarCollapsed || presentation === "overlay")}
469
571
  <div
470
572
  role="slider"
471
573
  tabindex="0"
@@ -28,13 +28,10 @@
28
28
 
29
29
  function isDesktopOverlayOnly(element?: Element | null): boolean {
30
30
  const panelId = controller.getPanelId(sidebar);
31
- if (!panelId) return false;
31
+ void element;
32
32
  return (
33
- (element ?? ref)
34
- ?.closest("[data-shell-root]")
35
- ?.getAttribute("data-desktop-overlay-panels")
36
- ?.split(/\s+/)
37
- .includes(panelId) ?? false
33
+ controller.isPanelConstrained(panelId) &&
34
+ controller.getConstrainedPresentation(panelId) === "preview"
38
35
  );
39
36
  }
40
37
 
@@ -43,19 +40,16 @@
43
40
  '[data-ui-part="sidebar"][data-mobile-panel-id]',
44
41
  );
45
42
  const panelId = sidebarElement?.dataset.mobilePanelId;
46
- const shellRoot = element.closest<HTMLElement>("[data-shell-root]");
47
- const toggle =
48
- panelId && shellRoot
49
- ? shellRoot.querySelector<HTMLButtonElement>(
50
- `[data-ui-part="sidebar-toggle"][data-target-panel-id="${CSS.escape(panelId)}"]`,
51
- )
52
- : null;
43
+ const shellRoot = controller.mobile.getRootElement();
44
+ const toggle = panelId
45
+ ? shellRoot?.querySelector<HTMLButtonElement>(
46
+ `[data-ui-part="sidebar-toggle"][data-target-panel-id="${CSS.escape(panelId)}"]`,
47
+ )
48
+ : null;
53
49
  if (toggle) {
54
50
  toggle.click();
55
51
  toggle.focus({ preventScroll: true });
56
- } else {
57
- sidebarElement?.removeAttribute("data-desktop-overlay-preview");
58
- }
52
+ } else if (panelId) controller.setPanelDesktopPreviewed(panelId, false);
59
53
  }
60
54
  </script>
61
55
 
@@ -85,11 +79,17 @@
85
79
  const targetSidebar = panelId ? controller.getPanel(panelId) : sidebar;
86
80
  flushSync(() => {
87
81
  targetSidebar?.close();
82
+ context.syncLayout();
88
83
  sidebarElement?.dispatchEvent(
89
84
  new Event(APP_SHELL_SIDEBAR_LAYOUT_SYNC_EVENT),
90
85
  );
91
86
  });
92
- sidebarElement?.remove();
87
+ controller.syncPanelProjections();
88
+ requestAnimationFrame(() => {
89
+ if (!targetSidebar?.closed || !sidebarElement?.isConnected) return;
90
+ sidebarElement.hidden = true;
91
+ sidebarElement.dataset.state = "closed";
92
+ });
93
93
  context.dismissOverlay();
94
94
  }
95
95
  onclick?.(event);
@@ -2,14 +2,16 @@
2
2
  import { onDestroy, untrack } from "svelte";
3
3
  import type { HTMLButtonAttributes } from "svelte/elements";
4
4
  import { Button } from "../../shadcn/button/index.js";
5
- import { useAppShell } from "./app-shell-context.svelte.js";
5
+ import { useOptionalAppShell } from "./app-shell-context.svelte.js";
6
6
  import type {
7
+ AppShellController,
7
8
  AppShellSide,
8
9
  AppShellSidebarController,
9
10
  } from "./app-shell-controller.svelte.js";
10
11
 
11
12
  let {
12
13
  ref = $bindable(null),
14
+ shellController,
13
15
  side,
14
16
  sidebarController,
15
17
  sidebarName,
@@ -25,6 +27,8 @@
25
27
  ...restProps
26
28
  }: HTMLButtonAttributes & {
27
29
  ref?: HTMLButtonElement | null;
30
+ /** Explicit root controller for persistent chrome rendered outside Root. */
31
+ shellController?: AppShellController;
28
32
  /** Sidebar controlled by this toolbar action. */
29
33
  side: AppShellSide;
30
34
  /** Optional independent sidebar controller for repeated same-side layouts. */
@@ -41,7 +45,16 @@
41
45
  previewDismissDelay?: number;
42
46
  } = $props();
43
47
 
44
- const controller = useAppShell();
48
+ const contextualController = useOptionalAppShell();
49
+ const resolvedController = untrack(
50
+ () => shellController ?? contextualController,
51
+ );
52
+ if (!resolvedController) {
53
+ throw new Error(
54
+ "AppShell.Sidebar.Toggle requires AppShell.Root context or shellController.",
55
+ );
56
+ }
57
+ const controller: AppShellController = resolvedController;
45
58
  const sidebar = untrack(
46
59
  () => sidebarController ?? controller.getSidebar(side),
47
60
  );
@@ -56,8 +69,13 @@
56
69
  const unsubscribeLayout = sidebar.onLayoutChange(syncLayout);
57
70
  let mobileMode = $derived(controller.mobile.resolvedMode === "mobile");
58
71
  let mobilePanelId = $derived(controller.getPanelId(sidebar));
59
- let desktopDomPreview = $state(false);
60
- let desktopOverlayKnown = $state(false);
72
+ let desktopDomPreview = $derived(
73
+ controller.isPanelDesktopPreviewed(mobilePanelId),
74
+ );
75
+ let desktopOverlayKnown = $derived(
76
+ controller.isPanelConstrained(mobilePanelId) &&
77
+ controller.getConstrainedPresentation(mobilePanelId) === "preview",
78
+ );
61
79
  let suppressHoverPreview = false;
62
80
  let desktopOverlayOnly = $derived(!mobileMode && desktopOverlayKnown);
63
81
  let mobileExpanded = $derived(
@@ -82,71 +100,28 @@
82
100
  onDestroy(() => {
83
101
  unsubscribeLayout();
84
102
  sidebar.dismissPreview();
85
- getDesktopOverlayTarget(ref)?.removeAttribute(
86
- "data-desktop-overlay-preview",
87
- );
88
- });
89
-
90
- $effect(() => {
91
- const button = ref;
92
- const panelId = mobilePanelId;
93
- const shellRoot = button?.closest<HTMLElement>("[data-shell-root]");
94
- if (!shellRoot || !panelId) {
95
- desktopOverlayKnown = false;
96
- return;
97
- }
98
-
99
- const update = () => {
100
- desktopOverlayKnown =
101
- shellRoot
102
- .getAttribute("data-desktop-overlay-panels")
103
- ?.split(/\s+/)
104
- .includes(panelId) ?? false;
105
- };
106
- update();
107
- const observer = new MutationObserver(update);
108
- observer.observe(shellRoot, {
109
- attributes: true,
110
- attributeFilter: ["data-desktop-overlay-panels"],
111
- });
112
- return () => observer.disconnect();
103
+ if (mobilePanelId)
104
+ controller.setPanelDesktopPreviewed(mobilePanelId, false);
113
105
  });
114
106
 
115
107
  function isDesktopOverlayOnly(element?: Element | null): boolean {
116
- if (!mobilePanelId) return false;
117
- return (
118
- (element ?? ref)
119
- ?.closest("[data-shell-root]")
120
- ?.getAttribute("data-desktop-overlay-panels")
121
- ?.split(/\s+/)
122
- .includes(mobilePanelId) ?? false
123
- );
108
+ void element;
109
+ return desktopOverlayOnly;
124
110
  }
125
111
 
126
112
  function getDesktopOverlayTarget(
127
113
  element?: Element | null,
128
114
  ): HTMLElement | null {
129
- if (!mobilePanelId) return null;
130
- const shellRoot = (element ?? ref)?.closest("[data-shell-root]");
131
- if (
132
- !shellRoot
133
- ?.getAttribute("data-desktop-overlay-panels")
134
- ?.split(/\s+/)
135
- .includes(mobilePanelId)
136
- ) {
137
- return null;
138
- }
139
- return shellRoot.querySelector<HTMLElement>(
140
- `[data-ui-part="sidebar"][data-mobile-panel-id="${CSS.escape(mobilePanelId)}"]`,
141
- );
115
+ void element;
116
+ return mobilePanelId ? controller.getPanelElement(mobilePanelId) : null;
142
117
  }
143
118
 
144
119
  function toggleDesktopDomPreview(element: Element): boolean {
145
120
  const target = getDesktopOverlayTarget(element);
146
- if (!target) return false;
147
- desktopDomPreview = !target.hasAttribute("data-desktop-overlay-preview");
148
- target.toggleAttribute("data-desktop-overlay-preview", desktopDomPreview);
149
- if (desktopDomPreview) {
121
+ if (!target || !mobilePanelId || !desktopOverlayOnly) return false;
122
+ const nextPreviewed = !desktopDomPreview;
123
+ controller.setPanelDesktopPreviewed(mobilePanelId, nextPreviewed);
124
+ if (nextPreviewed) {
150
125
  queueMicrotask(() => target.focus({ preventScroll: true }));
151
126
  }
152
127
  return true;
@@ -168,7 +143,11 @@
168
143
  ? mobileExpanded
169
144
  ? "expanded"
170
145
  : "collapsed"
171
- : sidebarState}
146
+ : desktopOverlayOnly
147
+ ? desktopDomPreview
148
+ ? "expanded"
149
+ : "closed"
150
+ : sidebarState}
172
151
  aria-label={resolveAccessibleLabel()}
173
152
  aria-expanded={mobileMode
174
153
  ? mobileExpanded
@@ -209,6 +188,7 @@
209
188
  sidebar.dismissPreview();
210
189
  sidebar.toggle();
211
190
  syncLayout();
191
+ controller.syncPanelProjections();
212
192
  }
213
193
  onclick?.(event);
214
194
  }}
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ import { untrack, type Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { useAppShell } from "./app-shell-context.svelte.js";
5
+
6
+ let {
7
+ ref = $bindable(null),
8
+ open,
9
+ label,
10
+ coverPanelIds = [],
11
+ suspendPanelIds = [],
12
+ returnFocus,
13
+ dismissLabel = `Close ${label}`,
14
+ dismissOnEscape = true,
15
+ onDismiss,
16
+ class: className,
17
+ children,
18
+ ...restProps
19
+ }: HTMLAttributes<HTMLDivElement> & {
20
+ ref?: HTMLElement | null;
21
+ /** Whether the structural replacement layer is mounted. */
22
+ open: boolean;
23
+ /** Accessible name for the non-modal replacement surface. */
24
+ label: string;
25
+ /** Mounted sidebar ids included in the covered surface bounds. */
26
+ coverPanelIds?: readonly string[];
27
+ /** Sidebar ids hidden transiently while the layer is active. */
28
+ suspendPanelIds?: readonly string[];
29
+ /** Focus target restored after dismissal. Defaults to the opener. */
30
+ returnFocus?: HTMLElement | null;
31
+ /** Accessible name for the dismissible scrim. */
32
+ dismissLabel?: string;
33
+ /** Whether unhandled Escape dismisses the layer. */
34
+ dismissOnEscape?: boolean;
35
+ /** Consumer-owned route or state transition that closes the layer. */
36
+ onDismiss?: () => void;
37
+ children?: Snippet;
38
+ } = $props();
39
+
40
+ const controller = useAppShell();
41
+ const token = Symbol("app-shell-surface-layer");
42
+ let surfaceElement = $state<HTMLElement | null>(null);
43
+ let inlineStart = $state(0);
44
+ let inlineEnd = $state(0);
45
+ let blockStart = $state(0);
46
+ let blockEnd = $state(0);
47
+
48
+ $effect(() => {
49
+ if (!open || typeof document === "undefined") return;
50
+ const { focusTarget, registration } = untrack(() => ({
51
+ focusTarget:
52
+ returnFocus ??
53
+ (document.activeElement instanceof HTMLElement
54
+ ? document.activeElement
55
+ : null),
56
+ registration: {
57
+ token,
58
+ coverPanelIds: [...coverPanelIds],
59
+ suspendPanelIds: [...suspendPanelIds],
60
+ },
61
+ }));
62
+ untrack(() => controller.activateSurfaceLayer(registration));
63
+ queueMicrotask(() => surfaceElement?.focus({ preventScroll: true }));
64
+
65
+ const handleKeydown = (event: KeyboardEvent) => {
66
+ if (
67
+ !dismissOnEscape ||
68
+ event.defaultPrevented ||
69
+ event.key !== "Escape"
70
+ ) {
71
+ return;
72
+ }
73
+ event.preventDefault();
74
+ onDismiss?.();
75
+ };
76
+ document.addEventListener("keydown", handleKeydown);
77
+ return () => {
78
+ document.removeEventListener("keydown", handleKeydown);
79
+ untrack(() => controller.deactivateSurfaceLayer(token));
80
+ queueMicrotask(() => {
81
+ const target = returnFocus ?? focusTarget;
82
+ if (target?.isConnected) target.focus({ preventScroll: true });
83
+ else controller.mobile.getMainElement()?.focus({ preventScroll: true });
84
+ });
85
+ };
86
+ });
87
+
88
+ $effect(() => {
89
+ void controller.panelElementsVersion;
90
+ if (!open || !ref || typeof ResizeObserver === "undefined") return;
91
+ const root = controller.mobile.getRootElement();
92
+ const main = controller.mobile.getMainElement();
93
+ if (!root || !main) return;
94
+ const coveredElements = coverPanelIds.flatMap((id) => {
95
+ const element = controller.getPanelElement(id);
96
+ return element ? [element] : [];
97
+ });
98
+
99
+ const updateInsets = () => {
100
+ const rootBounds = root.getBoundingClientRect();
101
+ const paddingLeft = rootBounds.left + root.clientLeft;
102
+ const paddingRight = paddingLeft + root.clientWidth;
103
+ const paddingTop = rootBounds.top + root.clientTop;
104
+ const paddingBottom = paddingTop + root.clientHeight;
105
+ const visibleBounds = [main, ...coveredElements]
106
+ .map((element) => element.getBoundingClientRect())
107
+ .filter((bounds) => bounds.width > 0 && bounds.height > 0);
108
+ if (visibleBounds.length === 0) return;
109
+ const left = Math.min(...visibleBounds.map((bounds) => bounds.left));
110
+ const right = Math.max(...visibleBounds.map((bounds) => bounds.right));
111
+ const top = main.getBoundingClientRect().top;
112
+ const bottom = main.getBoundingClientRect().bottom;
113
+ const rtl = getComputedStyle(root).direction === "rtl";
114
+ inlineStart = Math.max(
115
+ 0,
116
+ rtl ? paddingRight - right : left - paddingLeft,
117
+ );
118
+ inlineEnd = Math.max(0, rtl ? left - paddingLeft : paddingRight - right);
119
+ blockStart = Math.max(0, top - paddingTop);
120
+ blockEnd = Math.max(0, paddingBottom - bottom);
121
+ };
122
+
123
+ let frame = requestAnimationFrame(updateInsets);
124
+ const scheduleUpdate = () => {
125
+ cancelAnimationFrame(frame);
126
+ frame = requestAnimationFrame(updateInsets);
127
+ };
128
+ const observer = new ResizeObserver(scheduleUpdate);
129
+ observer.observe(root);
130
+ observer.observe(main);
131
+ for (const element of coveredElements) observer.observe(element);
132
+ return () => {
133
+ cancelAnimationFrame(frame);
134
+ observer.disconnect();
135
+ };
136
+ });
137
+ </script>
138
+
139
+ {#if open}
140
+ <div
141
+ bind:this={ref}
142
+ {...restProps}
143
+ class={["ui-minimal-app-shell__surface-layer", className]
144
+ .filter(Boolean)
145
+ .join(" ")}
146
+ data-ui-component="app-shell"
147
+ data-ui-part="surface-layer"
148
+ style:inset-inline-start={`${inlineStart}px`}
149
+ style:inset-inline-end={`${inlineEnd}px`}
150
+ style:inset-block-start={`${blockStart}px`}
151
+ style:inset-block-end={`${blockEnd}px`}
152
+ >
153
+ <button
154
+ type="button"
155
+ tabindex="-1"
156
+ class="ui-minimal-app-shell__surface-layer-scrim"
157
+ data-ui-component="app-shell"
158
+ data-ui-part="surface-layer-scrim"
159
+ aria-label={dismissLabel}
160
+ onclick={() => onDismiss?.()}
161
+ ></button>
162
+ <section
163
+ bind:this={surfaceElement}
164
+ class="ui-minimal-app-shell__surface-layer-surface"
165
+ data-ui-component="app-shell"
166
+ data-ui-part="surface-layer-surface"
167
+ aria-label={label}
168
+ tabindex="-1"
169
+ >
170
+ {@render children?.()}
171
+ </section>
172
+ </div>
173
+ {/if}
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { WithElementRef } from "../../../lib/utils.js";
4
+ import { ScrollArea } from "../../shadcn/scroll-area/index.js";
5
+ import { useAppShell } from "./app-shell-context.svelte.js";
6
+
7
+ let {
8
+ ref = $bindable(null),
9
+ class: className,
10
+ children,
11
+ ...restProps
12
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
13
+
14
+ useAppShell();
15
+ </script>
16
+
17
+ <div
18
+ bind:this={ref}
19
+ {...restProps}
20
+ class={["ui-minimal-app-shell__surface-layer-body", className]
21
+ .filter(Boolean)
22
+ .join(" ")}
23
+ data-ui-component="app-shell"
24
+ data-ui-part="surface-layer-body"
25
+ >
26
+ <ScrollArea
27
+ type="auto"
28
+ class="ui-minimal-app-shell__surface-layer-scroll-area"
29
+ >
30
+ {@render children?.()}
31
+ </ScrollArea>
32
+ </div>