@lapismd/design-core 0.1.2 → 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.
- package/CHANGELOG.md +22 -0
- package/LICENSE.md +3 -3
- package/README.md +20 -0
- package/package.json +24 -9
- package/spec/src/styling-and-themes.md +2 -0
- package/src/shared/ai/chat.css +23 -0
- package/src/shared/ai/chat.ts +12 -0
- package/src/shared/ai/context.svelte.ts +2 -0
- package/src/shared/ai/layout/Layout.svelte +116 -10
- package/src/shared/ai/message-list/MessageList.svelte +123 -43
- package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
- package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
- package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
- package/src/shared/ai/message-list/position.ts +7 -0
- package/src/shared/ai/new-messages.svelte.ts +24 -5
- package/src/shared/ai/stream-scroll.svelte.ts +135 -6
- package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
- package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
- package/src/shared/diff/core/merge/actions.ts +4 -3
- package/src/shared/diff/core/merge/render-model.ts +14 -10
- package/src/shared/diff/file-diff/FileDiff.css +68 -1
- package/src/shared/diff/file-diff/FileDiff.svelte +22 -6
- package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
- package/src/shared/diff/file-diff/index.ts +2 -0
- package/src/shared/diff/file-diff/types.ts +13 -0
- package/src/shared/diff/index.ts +2 -0
- package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
- package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
- package/src/shared/forms/confirm-button/index.ts +1 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
- package/src/shared/forms/confirm-dialog/index.ts +2 -0
- package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
- package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
- package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
- package/src/shared/forms/dialog-frame/index.ts +6 -0
- package/src/shared/forms/dialog.ts +8 -0
- package/src/shared/forms/index.ts +11 -0
- package/src/shared/shadcn/button/button.svelte +4 -0
- package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
- package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
- package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
- package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
- package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
- package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
- package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
- package/src/shared/shell/app-shell/AppShell.css +112 -32
- package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
- package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
- package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
- package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
- package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
- package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
- package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
- package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
- package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
- package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +246 -2
- package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
- package/src/shared/shell/app-shell/examples/AppShellExamples.css +103 -0
- package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
- package/src/shared/shell/app-shell/index.ts +14 -0
- package/src/shared/shell/shell.tokens.css +2 -0
- package/src/shared/shell/shell.tokens.ts +5 -0
- package/src/theme.css +10 -0
|
@@ -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>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
import type { WithElementRef } from "../../../lib/utils.js";
|
|
4
|
+
import { useAppShell } from "./app-shell-context.svelte.js";
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
ref = $bindable(null),
|
|
8
|
+
class: className,
|
|
9
|
+
children,
|
|
10
|
+
...restProps
|
|
11
|
+
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
|
12
|
+
|
|
13
|
+
useAppShell();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<header
|
|
17
|
+
bind:this={ref}
|
|
18
|
+
{...restProps}
|
|
19
|
+
class={["ui-minimal-app-shell__surface-layer-header", className]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join(" ")}
|
|
22
|
+
data-ui-component="app-shell"
|
|
23
|
+
data-ui-part="surface-layer-header"
|
|
24
|
+
>
|
|
25
|
+
{@render children?.()}
|
|
26
|
+
</header>
|
|
@@ -15,9 +15,7 @@ export function setAppShellContext(
|
|
|
15
15
|
* Call this during component initialization, as required by Svelte context.
|
|
16
16
|
*/
|
|
17
17
|
export function useAppShell(): AppShellController {
|
|
18
|
-
const controller =
|
|
19
|
-
APP_SHELL_CONTEXT,
|
|
20
|
-
);
|
|
18
|
+
const controller = useOptionalAppShell();
|
|
21
19
|
if (!controller) {
|
|
22
20
|
throw new Error(
|
|
23
21
|
"AppShell components must be rendered inside AppShell.Root",
|
|
@@ -25,3 +23,8 @@ export function useAppShell(): AppShellController {
|
|
|
25
23
|
}
|
|
26
24
|
return controller;
|
|
27
25
|
}
|
|
26
|
+
|
|
27
|
+
/** Return the nearest root controller when a compound action may live outside it. */
|
|
28
|
+
export function useOptionalAppShell(): AppShellController | undefined {
|
|
29
|
+
return getContext<AppShellController | undefined>(APP_SHELL_CONTEXT);
|
|
30
|
+
}
|
|
@@ -14,6 +14,7 @@ export type AppShellDisplayMode = "auto" | "desktop" | "mobile";
|
|
|
14
14
|
export type AppShellResolvedDisplayMode = Exclude<AppShellDisplayMode, "auto">;
|
|
15
15
|
export type AppShellMobileStage = "left" | "main" | "right";
|
|
16
16
|
export type AppShellMobilePanelKind = "sidebar" | "body-sidebar";
|
|
17
|
+
export type AppShellConstrainedPresentation = "preview" | "replace-main";
|
|
17
18
|
|
|
18
19
|
export interface AppShellMobilePanelRegistration {
|
|
19
20
|
/** Stable id used by mobile selectors and targeted toggle actions. */
|
|
@@ -26,6 +27,19 @@ export interface AppShellMobilePanelRegistration {
|
|
|
26
27
|
readonly kind: AppShellMobilePanelKind;
|
|
27
28
|
/** Current panel landmark, when mounted in a browser. */
|
|
28
29
|
readonly element?: HTMLElement | null;
|
|
30
|
+
/** Lower values leave inline desktop flow before higher values. */
|
|
31
|
+
readonly constraintPriority?: number;
|
|
32
|
+
/** Presentation used when protected main width displaces this panel. */
|
|
33
|
+
readonly constrainedPresentation?: AppShellConstrainedPresentation;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AppShellSurfaceLayerRegistration {
|
|
37
|
+
/** Unique mounted layer owner. Only one may be active per root. */
|
|
38
|
+
readonly token: symbol;
|
|
39
|
+
/** Sidebar ids included in the covered, inert surface. */
|
|
40
|
+
readonly coverPanelIds: readonly string[];
|
|
41
|
+
/** Sidebar ids removed transiently without changing durable layout. */
|
|
42
|
+
readonly suspendPanelIds: readonly string[];
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
export const APP_SHELL_DEFAULT_SIDEBAR_WIDTH = 288;
|
|
@@ -171,14 +185,17 @@ export class AppShellMobileController {
|
|
|
171
185
|
if (returnFocus) this.#returnFocus = returnFocus;
|
|
172
186
|
this.#rootElement?.focus({ preventScroll: true });
|
|
173
187
|
this.stage = side;
|
|
174
|
-
queueMicrotask(() =>
|
|
188
|
+
queueMicrotask(() =>
|
|
189
|
+
this.activePanel(side)?.element?.focus({ preventScroll: true }),
|
|
190
|
+
);
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
showMain(restoreFocus = true): void {
|
|
178
194
|
const focusTarget = this.#returnFocus ?? this.#mainElement;
|
|
179
195
|
this.#rootElement?.focus({ preventScroll: true });
|
|
180
196
|
this.stage = "main";
|
|
181
|
-
if (restoreFocus)
|
|
197
|
+
if (restoreFocus)
|
|
198
|
+
queueMicrotask(() => focusTarget?.focus({ preventScroll: true }));
|
|
182
199
|
this.#returnFocus = null;
|
|
183
200
|
}
|
|
184
201
|
|
|
@@ -208,11 +225,21 @@ export class AppShellMobileController {
|
|
|
208
225
|
this.#mainElement = element;
|
|
209
226
|
}
|
|
210
227
|
|
|
228
|
+
/** @internal Return the mounted main landmark. */
|
|
229
|
+
getMainElement(): HTMLElement | null {
|
|
230
|
+
return this.#mainElement;
|
|
231
|
+
}
|
|
232
|
+
|
|
211
233
|
/** @internal Register the root as a safe focus waypoint between lanes. */
|
|
212
234
|
setRootElement(element: HTMLElement | null): void {
|
|
213
235
|
this.#rootElement = element;
|
|
214
236
|
}
|
|
215
237
|
|
|
238
|
+
/** @internal Return the mounted shell root. */
|
|
239
|
+
getRootElement(): HTMLElement | null {
|
|
240
|
+
return this.#rootElement;
|
|
241
|
+
}
|
|
242
|
+
|
|
216
243
|
#ensureActivePanel(side: AppShellSide): void {
|
|
217
244
|
const panels = this.panelsFor(side);
|
|
218
245
|
const current = this.activePanelId(side);
|
|
@@ -231,6 +258,7 @@ type AppShellSidebarLayoutChangeSource = Exclude<
|
|
|
231
258
|
type AppShellSidebarLayoutChangeListener = (
|
|
232
259
|
source: AppShellSidebarLayoutChangeSource,
|
|
233
260
|
) => void;
|
|
261
|
+
type AppShellProjectionChangeListener = () => void;
|
|
234
262
|
|
|
235
263
|
/** Reactive state for one side of an App Shell. */
|
|
236
264
|
export class AppShellSidebarController {
|
|
@@ -449,6 +477,11 @@ export class AppShellController {
|
|
|
449
477
|
readonly right: AppShellSidebarController;
|
|
450
478
|
readonly mobile = new AppShellMobileController();
|
|
451
479
|
layoutReady = $state(false);
|
|
480
|
+
constrainedPanelIds = $state<string[]>([]);
|
|
481
|
+
desktopPreviewPanelIds = $state<string[]>([]);
|
|
482
|
+
activeSurfaceLayer = $state<AppShellSurfaceLayerRegistration | null>(null);
|
|
483
|
+
projectionVersion = $state(0);
|
|
484
|
+
panelElementsVersion = $state(0);
|
|
452
485
|
|
|
453
486
|
readonly #persistence: AppShellLayoutPersistence | undefined;
|
|
454
487
|
readonly #saveDebounceMs: number;
|
|
@@ -458,10 +491,14 @@ export class AppShellController {
|
|
|
458
491
|
readonly #panels = new Map<string, AppShellSidebarController>();
|
|
459
492
|
readonly #panelIds = new Map<AppShellSidebarController, string>();
|
|
460
493
|
readonly #panelDisposers = new Map<string, () => void>();
|
|
494
|
+
readonly #panelElements = new Map<string, HTMLElement>();
|
|
495
|
+
readonly #projectionChangeListeners =
|
|
496
|
+
new Set<AppShellProjectionChangeListener>();
|
|
461
497
|
#restoredPanels = new Map<string, AppShellSidebarLayout>();
|
|
462
498
|
#hydrating = false;
|
|
463
499
|
#restorePromise: Promise<void> | null = null;
|
|
464
500
|
#saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
501
|
+
#projectionFrame: number | null = null;
|
|
465
502
|
#pendingSaveEvent: AppShellLayoutChangeEvent | null = null;
|
|
466
503
|
#saveChain: Promise<void> = Promise.resolve();
|
|
467
504
|
|
|
@@ -522,6 +559,133 @@ export class AppShellController {
|
|
|
522
559
|
return this.#panelIds.get(sidebar);
|
|
523
560
|
}
|
|
524
561
|
|
|
562
|
+
/** Whether protected desktop width has displaced this panel from inline flow. */
|
|
563
|
+
isPanelConstrained(id: string | undefined): boolean {
|
|
564
|
+
return id !== undefined && this.constrainedPanelIds.includes(id);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Return the mounted panel's configured constrained presentation. */
|
|
568
|
+
getConstrainedPresentation(
|
|
569
|
+
id: string | undefined,
|
|
570
|
+
): AppShellConstrainedPresentation {
|
|
571
|
+
if (!id) return "preview";
|
|
572
|
+
return (
|
|
573
|
+
this.mobile.panels.find((panel) => panel.id === id)
|
|
574
|
+
?.constrainedPresentation ?? "preview"
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Whether a constrained preview panel is currently presented. */
|
|
579
|
+
isPanelDesktopPreviewed(id: string | undefined): boolean {
|
|
580
|
+
return id !== undefined && this.desktopPreviewPanelIds.includes(id);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Present or dismiss one constrained desktop preview without durable changes. */
|
|
584
|
+
setPanelDesktopPreviewed(id: string, previewed: boolean): void {
|
|
585
|
+
if (!this.isPanelConstrained(id)) previewed = false;
|
|
586
|
+
const current = this.desktopPreviewPanelIds.includes(id);
|
|
587
|
+
if (current === previewed) return;
|
|
588
|
+
this.desktopPreviewPanelIds = previewed
|
|
589
|
+
? [...this.desktopPreviewPanelIds, id]
|
|
590
|
+
: this.desktopPreviewPanelIds.filter((candidate) => candidate !== id);
|
|
591
|
+
this.#projectionChanged();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** @internal Install the current constrained projection from AppShell.Root. */
|
|
595
|
+
setConstrainedPanelIds(ids: readonly string[]): void {
|
|
596
|
+
const next = [...new Set(ids)];
|
|
597
|
+
if (
|
|
598
|
+
next.length === this.constrainedPanelIds.length &&
|
|
599
|
+
next.every((id, index) => this.constrainedPanelIds[index] === id)
|
|
600
|
+
) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
this.constrainedPanelIds = next;
|
|
604
|
+
this.desktopPreviewPanelIds = this.desktopPreviewPanelIds.filter((id) =>
|
|
605
|
+
next.includes(id),
|
|
606
|
+
);
|
|
607
|
+
this.#projectionChanged();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** @internal Subscribe mounted shell parts to transient projection changes. */
|
|
611
|
+
onProjectionChange(listener: AppShellProjectionChangeListener): () => void {
|
|
612
|
+
this.#projectionChangeListeners.add(listener);
|
|
613
|
+
return () => this.#projectionChangeListeners.delete(listener);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** @internal Register a mounted sidebar landmark for shell geometry. */
|
|
617
|
+
setPanelElement(id: string | undefined, element: HTMLElement | null): void {
|
|
618
|
+
if (!id) return;
|
|
619
|
+
if (element) {
|
|
620
|
+
if (this.#panelElements.get(id) === element) return;
|
|
621
|
+
this.#panelElements.set(id, element);
|
|
622
|
+
this.#syncPanelProjection(id);
|
|
623
|
+
} else {
|
|
624
|
+
if (!this.#panelElements.delete(id)) return;
|
|
625
|
+
}
|
|
626
|
+
this.panelElementsVersion += 1;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** @internal Return a mounted sidebar landmark. */
|
|
630
|
+
getPanelElement(id: string): HTMLElement | null {
|
|
631
|
+
void this.panelElementsVersion;
|
|
632
|
+
return this.#panelElements.get(id) ?? null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** @internal Reconcile every mounted panel with durable and transient state. */
|
|
636
|
+
syncPanelProjections(): void {
|
|
637
|
+
for (const id of this.#panelElements.keys()) {
|
|
638
|
+
this.#syncPanelProjection(id);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Whether the active structural layer temporarily removes this panel. */
|
|
643
|
+
isPanelSuspended(id: string | undefined): boolean {
|
|
644
|
+
return (
|
|
645
|
+
id !== undefined &&
|
|
646
|
+
(this.activeSurfaceLayer?.suspendPanelIds.includes(id) ?? false)
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Whether the active structural layer covers this mounted panel. */
|
|
651
|
+
isPanelCovered(id: string | undefined): boolean {
|
|
652
|
+
return (
|
|
653
|
+
id !== undefined &&
|
|
654
|
+
(this.activeSurfaceLayer?.coverPanelIds.includes(id) ?? false)
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Whether a structural layer or replacement panel currently covers main. */
|
|
659
|
+
get mainOccluded(): boolean {
|
|
660
|
+
if (this.activeSurfaceLayer) return true;
|
|
661
|
+
return this.constrainedPanelIds.some((id) => {
|
|
662
|
+
const panel = this.getPanel(id);
|
|
663
|
+
return (
|
|
664
|
+
panel !== undefined &&
|
|
665
|
+
!panel.closed &&
|
|
666
|
+
!this.isPanelSuspended(id) &&
|
|
667
|
+
this.getConstrainedPresentation(id) === "replace-main"
|
|
668
|
+
);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** @internal Activate the root's single structural surface layer. */
|
|
673
|
+
activateSurfaceLayer(registration: AppShellSurfaceLayerRegistration): void {
|
|
674
|
+
const current = this.activeSurfaceLayer;
|
|
675
|
+
if (current && current.token !== registration.token) {
|
|
676
|
+
throw new Error("AppShell.Root supports one active SurfaceLayer.");
|
|
677
|
+
}
|
|
678
|
+
this.activeSurfaceLayer = registration;
|
|
679
|
+
this.#projectionChanged();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** @internal Remove a mounted structural surface layer. */
|
|
683
|
+
deactivateSurfaceLayer(token: symbol): void {
|
|
684
|
+
if (this.activeSurfaceLayer?.token !== token) return;
|
|
685
|
+
this.activeSurfaceLayer = null;
|
|
686
|
+
this.#projectionChanged();
|
|
687
|
+
}
|
|
688
|
+
|
|
525
689
|
/** Create and register an independently persisted same-side panel. */
|
|
526
690
|
createSidebar(
|
|
527
691
|
id: string,
|
|
@@ -563,6 +727,15 @@ export class AppShellController {
|
|
|
563
727
|
this.#panelDisposers.delete(panelId);
|
|
564
728
|
this.#panels.delete(panelId);
|
|
565
729
|
this.#panelIds.delete(sidebar);
|
|
730
|
+
this.#panelElements.delete(panelId);
|
|
731
|
+
this.constrainedPanelIds = this.constrainedPanelIds.filter(
|
|
732
|
+
(id) => id !== panelId,
|
|
733
|
+
);
|
|
734
|
+
this.desktopPreviewPanelIds = this.desktopPreviewPanelIds.filter(
|
|
735
|
+
(id) => id !== panelId,
|
|
736
|
+
);
|
|
737
|
+
this.panelElementsVersion += 1;
|
|
738
|
+
this.#projectionChanged();
|
|
566
739
|
this.#requestSave({ source: "unregister", panelId });
|
|
567
740
|
};
|
|
568
741
|
}
|
|
@@ -619,6 +792,18 @@ export class AppShellController {
|
|
|
619
792
|
this.#panelDisposers.clear();
|
|
620
793
|
this.#panels.clear();
|
|
621
794
|
this.#panelIds.clear();
|
|
795
|
+
this.#panelElements.clear();
|
|
796
|
+
this.constrainedPanelIds = [];
|
|
797
|
+
this.desktopPreviewPanelIds = [];
|
|
798
|
+
this.activeSurfaceLayer = null;
|
|
799
|
+
this.#projectionChangeListeners.clear();
|
|
800
|
+
if (
|
|
801
|
+
this.#projectionFrame !== null &&
|
|
802
|
+
typeof cancelAnimationFrame !== "undefined"
|
|
803
|
+
) {
|
|
804
|
+
cancelAnimationFrame(this.#projectionFrame);
|
|
805
|
+
this.#projectionFrame = null;
|
|
806
|
+
}
|
|
622
807
|
}
|
|
623
808
|
|
|
624
809
|
#attachPanel(id: string, sidebar: AppShellSidebarController): void {
|
|
@@ -627,6 +812,7 @@ export class AppShellController {
|
|
|
627
812
|
this.#panelDisposers.set(
|
|
628
813
|
id,
|
|
629
814
|
sidebar.onLayoutChange((source) => {
|
|
815
|
+
this.#projectionChanged();
|
|
630
816
|
this.#requestSave({ source, panelId: id });
|
|
631
817
|
}),
|
|
632
818
|
);
|
|
@@ -659,6 +845,64 @@ export class AppShellController {
|
|
|
659
845
|
void this.flushSave();
|
|
660
846
|
}, this.#saveDebounceMs);
|
|
661
847
|
}
|
|
848
|
+
|
|
849
|
+
#projectionChanged(): void {
|
|
850
|
+
this.projectionVersion += 1;
|
|
851
|
+
for (const listener of this.#projectionChangeListeners) listener();
|
|
852
|
+
const sync = () => {
|
|
853
|
+
this.#projectionFrame = null;
|
|
854
|
+
this.syncPanelProjections();
|
|
855
|
+
};
|
|
856
|
+
if (typeof requestAnimationFrame === "undefined") {
|
|
857
|
+
queueMicrotask(sync);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (this.#projectionFrame !== null)
|
|
861
|
+
cancelAnimationFrame(this.#projectionFrame);
|
|
862
|
+
this.#projectionFrame = requestAnimationFrame(sync);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
#syncPanelProjection(id: string): void {
|
|
866
|
+
const element = this.#panelElements.get(id);
|
|
867
|
+
const sidebar = this.#panels.get(id);
|
|
868
|
+
if (!element || !sidebar) return;
|
|
869
|
+
const suspended = this.isPanelSuspended(id);
|
|
870
|
+
const covered = suspended || this.isPanelCovered(id);
|
|
871
|
+
const constrained = this.isPanelConstrained(id);
|
|
872
|
+
const mobile = this.mobile.resolvedMode === "mobile";
|
|
873
|
+
const presentation = this.getConstrainedPresentation(id);
|
|
874
|
+
const previewed = this.isPanelDesktopPreviewed(id) || sidebar.previewed;
|
|
875
|
+
const consumerAriaHidden = element.getAttribute(
|
|
876
|
+
"data-consumer-aria-hidden",
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
element.toggleAttribute("data-suspended", suspended);
|
|
880
|
+
element.toggleAttribute("data-desktop-constrained", constrained);
|
|
881
|
+
element.dataset.state = sidebar.state;
|
|
882
|
+
element.dataset.presentation = mobile
|
|
883
|
+
? "mobile"
|
|
884
|
+
: constrained
|
|
885
|
+
? previewed
|
|
886
|
+
? "overlay"
|
|
887
|
+
: presentation === "replace-main"
|
|
888
|
+
? "replace-main"
|
|
889
|
+
: "inline"
|
|
890
|
+
: "inline";
|
|
891
|
+
element.hidden =
|
|
892
|
+
element.hasAttribute("data-consumer-hidden") ||
|
|
893
|
+
suspended ||
|
|
894
|
+
(!mobile &&
|
|
895
|
+
(sidebar.closed ||
|
|
896
|
+
(constrained && presentation === "preview" && !previewed)));
|
|
897
|
+
element.inert = element.hasAttribute("data-consumer-inert") || covered;
|
|
898
|
+
if (covered || consumerAriaHidden === "true") {
|
|
899
|
+
element.setAttribute("aria-hidden", "true");
|
|
900
|
+
} else if (consumerAriaHidden === null) {
|
|
901
|
+
element.removeAttribute("aria-hidden");
|
|
902
|
+
} else {
|
|
903
|
+
element.setAttribute("aria-hidden", consumerAriaHidden);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
662
906
|
}
|
|
663
907
|
|
|
664
908
|
function normalizeAppShellLayout(
|