@danjelp/ngx-app-shell 1.0.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/LICENSE +21 -0
- package/README.md +559 -0
- package/fesm2022/danjelp-ngx-app-shell.mjs +1944 -0
- package/fesm2022/danjelp-ngx-app-shell.mjs.map +1 -0
- package/package.json +55 -0
- package/src/lib/styles/_tokens.scss +340 -0
- package/styles/_tokens.scss +3 -0
- package/types/danjelp-ngx-app-shell.d.ts +1210 -0
|
@@ -0,0 +1,1944 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, makeEnvironmentProviders, inject, PLATFORM_ID, signal, computed, linkedSignal, Injectable, NgZone, effect, input, ElementRef, Directive, numberAttribute, TemplateRef, DestroyRef, isDevMode, untracked, ChangeDetectionStrategy, Component, isSignal, viewChild, booleanAttribute, provideEnvironmentInitializer } from '@angular/core';
|
|
3
|
+
import { isPlatformBrowser, NgTemplateOutlet, DOCUMENT } from '@angular/common';
|
|
4
|
+
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
5
|
+
import { Router, NavigationEnd, ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router';
|
|
6
|
+
import { filter, map } from 'rxjs';
|
|
7
|
+
|
|
8
|
+
/** Ordered size buckets. Order matters: `sizeRank()` relies on the index. */
|
|
9
|
+
const SHELL_SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Container-first breakpoints. Deliberately *not* device widths: they describe
|
|
13
|
+
* the space a region has, which is what layout decisions should react to.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_BREAKPOINTS = {
|
|
16
|
+
xs: 0,
|
|
17
|
+
sm: 520,
|
|
18
|
+
md: 768,
|
|
19
|
+
lg: 1080,
|
|
20
|
+
xl: 1440,
|
|
21
|
+
};
|
|
22
|
+
function sizeRank(size) {
|
|
23
|
+
return SHELL_SIZES.indexOf(size);
|
|
24
|
+
}
|
|
25
|
+
/** Largest bucket whose minimum width is still <= `width`. */
|
|
26
|
+
function resolveSize(width, breakpoints) {
|
|
27
|
+
let match = SHELL_SIZES[0];
|
|
28
|
+
for (const size of SHELL_SIZES) {
|
|
29
|
+
if (width >= breakpoints[size]) {
|
|
30
|
+
match = size;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return match;
|
|
34
|
+
}
|
|
35
|
+
/** `atLeast('md', 'sm')` → true */
|
|
36
|
+
function atLeast(size, min) {
|
|
37
|
+
return sizeRank(size) >= sizeRank(min);
|
|
38
|
+
}
|
|
39
|
+
/** `atMost('md', 'lg')` → true */
|
|
40
|
+
function atMost(size, max) {
|
|
41
|
+
return sizeRank(size) <= sizeRank(max);
|
|
42
|
+
}
|
|
43
|
+
/** Strictly narrower than `bound` — used for "collapse below md" style rules. */
|
|
44
|
+
function isBelow(size, bound) {
|
|
45
|
+
return sizeRank(size) < sizeRank(bound);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Building blocks for `SidebarBehavior.resolve`. Each returns a complete,
|
|
50
|
+
* internally consistent state, so a custom behaviour only decides *which* mode
|
|
51
|
+
* applies — never how wide the track is or whether a scrim is needed.
|
|
52
|
+
*/
|
|
53
|
+
/** Off-canvas modal drawer. Reserves no grid track. */
|
|
54
|
+
function drawerState(open, sidebar) {
|
|
55
|
+
return {
|
|
56
|
+
mode: 'overlay',
|
|
57
|
+
open,
|
|
58
|
+
modal: open,
|
|
59
|
+
labels: true,
|
|
60
|
+
peeked: false,
|
|
61
|
+
trackWidth: '0px',
|
|
62
|
+
panelWidth: sidebar.overlayWidth,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Icon rail. While peeking the panel floats wider but the track stays put. */
|
|
66
|
+
function railState(peeking, sidebar) {
|
|
67
|
+
const peeked = peeking && sidebar.peek;
|
|
68
|
+
return {
|
|
69
|
+
mode: 'collapsed',
|
|
70
|
+
open: true,
|
|
71
|
+
modal: false,
|
|
72
|
+
labels: peeked,
|
|
73
|
+
peeked,
|
|
74
|
+
trackWidth: sidebar.collapsedWidth,
|
|
75
|
+
panelWidth: peeked ? sidebar.width : sidebar.collapsedWidth,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Full in-flow panel with labels. */
|
|
79
|
+
function panelState(sidebar) {
|
|
80
|
+
return {
|
|
81
|
+
mode: 'expanded',
|
|
82
|
+
open: true,
|
|
83
|
+
modal: false,
|
|
84
|
+
labels: true,
|
|
85
|
+
peeked: false,
|
|
86
|
+
trackWidth: sidebar.width,
|
|
87
|
+
panelWidth: sidebar.width,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const HIDDEN_STATE = {
|
|
91
|
+
mode: 'hidden',
|
|
92
|
+
open: false,
|
|
93
|
+
modal: false,
|
|
94
|
+
labels: false,
|
|
95
|
+
peeked: false,
|
|
96
|
+
trackWidth: '0px',
|
|
97
|
+
panelWidth: '0px',
|
|
98
|
+
};
|
|
99
|
+
/** Not rendered, zero-width track. */
|
|
100
|
+
function hiddenState() {
|
|
101
|
+
return HIDDEN_STATE;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Default policy.
|
|
106
|
+
*
|
|
107
|
+
* size < overlayBelow → modal drawer, closed until opened
|
|
108
|
+
* size < collapseBelow → icon rail, widened while peeking
|
|
109
|
+
* otherwise → full panel
|
|
110
|
+
*
|
|
111
|
+
* An explicit intent (`expanded`, `collapsed`, `hidden`) wins over the
|
|
112
|
+
* automatic choice, except in drawer territory where there is no room for a
|
|
113
|
+
* persistent panel. Set the intent back to `auto` to follow breakpoints again.
|
|
114
|
+
*/
|
|
115
|
+
const responsiveSidebarBehavior = {
|
|
116
|
+
resolve({ size, intent, drawerOpen, peeking, config }) {
|
|
117
|
+
const sidebar = config.sidebar;
|
|
118
|
+
if (isBelow(size, sidebar.overlayBelow)) {
|
|
119
|
+
return drawerState(drawerOpen, sidebar);
|
|
120
|
+
}
|
|
121
|
+
if (intent === 'hidden') {
|
|
122
|
+
return hiddenState();
|
|
123
|
+
}
|
|
124
|
+
const automatic = isBelow(size, sidebar.collapseBelow) ? 'collapsed' : 'expanded';
|
|
125
|
+
const resolved = intent === 'auto' ? automatic : intent;
|
|
126
|
+
return resolved === 'collapsed' ? railState(peeking, sidebar) : panelState(sidebar);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const SIDEBAR_BEHAVIOR = new InjectionToken('SIDEBAR_BEHAVIOR', {
|
|
131
|
+
providedIn: 'root',
|
|
132
|
+
factory: () => responsiveSidebarBehavior,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const DEFAULT_SHELL_CONFIG = {
|
|
136
|
+
layout: 'topbar-full',
|
|
137
|
+
scroll: 'main',
|
|
138
|
+
scrollTopOnNavigate: true,
|
|
139
|
+
initialSize: 'lg',
|
|
140
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
141
|
+
sidebar: {
|
|
142
|
+
width: 'var(--shell-sidebar-width, 264px)',
|
|
143
|
+
collapsedWidth: 'var(--shell-sidebar-collapsed-width, 68px)',
|
|
144
|
+
overlayWidth: 'var(--shell-sidebar-overlay-width, min(88vw, 320px))',
|
|
145
|
+
intent: 'auto',
|
|
146
|
+
collapseBelow: 'lg',
|
|
147
|
+
overlayBelow: 'md',
|
|
148
|
+
peek: true,
|
|
149
|
+
peekDelay: 180,
|
|
150
|
+
closeOnNavigate: true,
|
|
151
|
+
resizable: false,
|
|
152
|
+
minWidth: 200,
|
|
153
|
+
maxWidth: 420,
|
|
154
|
+
toggleShortcut: null,
|
|
155
|
+
persist: true,
|
|
156
|
+
storageKey: 'app-shell.sidebar',
|
|
157
|
+
},
|
|
158
|
+
topbar: { enabled: true, sidebarToggle: 'always' },
|
|
159
|
+
footer: { enabled: false },
|
|
160
|
+
appearance: {
|
|
161
|
+
topbar: 'solid',
|
|
162
|
+
sidebar: 'solid',
|
|
163
|
+
footer: 'solid',
|
|
164
|
+
navIndicator: 'pill',
|
|
165
|
+
subheader: 'solid',
|
|
166
|
+
},
|
|
167
|
+
motion: {
|
|
168
|
+
enabled: true,
|
|
169
|
+
speed: 1,
|
|
170
|
+
drawer: 'slide',
|
|
171
|
+
overflowMenu: 'fade',
|
|
172
|
+
scrim: 'fade',
|
|
173
|
+
collapse: true,
|
|
174
|
+
navExpand: true,
|
|
175
|
+
},
|
|
176
|
+
subheader: {
|
|
177
|
+
enabled: 'auto',
|
|
178
|
+
sticky: true,
|
|
179
|
+
overflowSlots: ['subheader-end'],
|
|
180
|
+
},
|
|
181
|
+
breadcrumbs: {
|
|
182
|
+
includeHome: false,
|
|
183
|
+
homeLabel: 'Home',
|
|
184
|
+
homeUrl: '/',
|
|
185
|
+
includeCurrent: true,
|
|
186
|
+
maxItems: 4,
|
|
187
|
+
useRouteTitle: true,
|
|
188
|
+
compactBelow: 480,
|
|
189
|
+
backBelow: 280,
|
|
190
|
+
skeletonDelay: 400,
|
|
191
|
+
separator: 'chevron',
|
|
192
|
+
},
|
|
193
|
+
overflowSlots: ['primary-nav', 'search', 'actions'],
|
|
194
|
+
labels: {
|
|
195
|
+
skipToContent: 'Skip to main content',
|
|
196
|
+
openNavigation: 'Open navigation',
|
|
197
|
+
closeNavigation: 'Close navigation',
|
|
198
|
+
expandSidebar: 'Expand sidebar',
|
|
199
|
+
collapseSidebar: 'Collapse sidebar',
|
|
200
|
+
resizeSidebar: 'Resize sidebar',
|
|
201
|
+
moreActions: 'More',
|
|
202
|
+
primaryNavigation: 'Primary',
|
|
203
|
+
breadcrumb: 'Breadcrumb',
|
|
204
|
+
showPath: 'Show full path',
|
|
205
|
+
back: 'Back to',
|
|
206
|
+
loading: 'Loading',
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
/** Configuration as provided by the app (`provideAppShell`). */
|
|
210
|
+
const SHELL_CONFIG = new InjectionToken('SHELL_CONFIG', {
|
|
211
|
+
providedIn: 'root',
|
|
212
|
+
factory: () => DEFAULT_SHELL_CONFIG,
|
|
213
|
+
});
|
|
214
|
+
/** Merge `input` over `base` (the defaults unless given). Never mutates. */
|
|
215
|
+
function mergeShellConfig(input = {}, base = DEFAULT_SHELL_CONFIG) {
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
...input,
|
|
219
|
+
breakpoints: { ...base.breakpoints, ...input.breakpoints },
|
|
220
|
+
sidebar: { ...base.sidebar, ...input.sidebar },
|
|
221
|
+
topbar: { ...base.topbar, ...input.topbar },
|
|
222
|
+
footer: { ...base.footer, ...input.footer },
|
|
223
|
+
appearance: { ...base.appearance, ...input.appearance },
|
|
224
|
+
motion: { ...base.motion, ...input.motion },
|
|
225
|
+
subheader: { ...base.subheader, ...input.subheader },
|
|
226
|
+
breadcrumbs: { ...base.breadcrumbs, ...input.breadcrumbs },
|
|
227
|
+
labels: { ...base.labels, ...input.labels },
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Register the shell once, in `app.config.ts`:
|
|
232
|
+
*
|
|
233
|
+
* ```ts
|
|
234
|
+
* provideAppShell({ layout: 'sidebar-full', sidebar: { collapseBelow: 'xl' } })
|
|
235
|
+
* ```
|
|
236
|
+
*
|
|
237
|
+
* Pass `behavior` to replace the collapse policy wholesale. Both can also be
|
|
238
|
+
* changed at runtime through `ShellStore.configure()` / `setBehavior()`.
|
|
239
|
+
*/
|
|
240
|
+
function provideAppShell(config = {}, options = {}) {
|
|
241
|
+
const providers = [
|
|
242
|
+
{ provide: SHELL_CONFIG, useValue: mergeShellConfig(config) },
|
|
243
|
+
];
|
|
244
|
+
if (options.behavior) {
|
|
245
|
+
providers.push({ provide: SIDEBAR_BEHAVIOR, useValue: options.behavior });
|
|
246
|
+
}
|
|
247
|
+
return makeEnvironmentProviders(providers);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const NOOP_STORAGE = {
|
|
251
|
+
read: () => null,
|
|
252
|
+
write: () => undefined,
|
|
253
|
+
};
|
|
254
|
+
function localShellStateStorage() {
|
|
255
|
+
if (!isPlatformBrowser(inject(PLATFORM_ID))) {
|
|
256
|
+
return NOOP_STORAGE;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
read(key) {
|
|
260
|
+
try {
|
|
261
|
+
return localStorage.getItem(key);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Private mode / disabled storage: degrade to "no preference".
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
write(key, value) {
|
|
269
|
+
try {
|
|
270
|
+
localStorage.setItem(key, value);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
/* ignore */
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const SHELL_STATE_STORAGE = new InjectionToken('SHELL_STATE_STORAGE', {
|
|
279
|
+
providedIn: 'root',
|
|
280
|
+
factory: localShellStateStorage,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Stable element ids. The shell assumes one instance per application, which
|
|
285
|
+
* lets `aria-controls` work from a toggle rendered anywhere in the tree.
|
|
286
|
+
*/
|
|
287
|
+
const SHELL_SIDEBAR_ID = 'shell-sidebar';
|
|
288
|
+
const SHELL_MAIN_ID = 'shell-main';
|
|
289
|
+
|
|
290
|
+
const INTENTS = ['auto', 'expanded', 'collapsed', 'hidden'];
|
|
291
|
+
/**
|
|
292
|
+
* Single source of truth for the shell.
|
|
293
|
+
*
|
|
294
|
+
* Root-provided on purpose: any component in the app — including lazily loaded
|
|
295
|
+
* pages and content projected into `<app-shell>` — can inject it and drive the
|
|
296
|
+
* sidebar without input/output plumbing. One shell per application.
|
|
297
|
+
*
|
|
298
|
+
* Everything is derived from four inputs: the measured shell width, the user's
|
|
299
|
+
* intent, the transient drawer/peek flags, and the (runtime-changeable)
|
|
300
|
+
* config + behaviour.
|
|
301
|
+
*/
|
|
302
|
+
class ShellStore {
|
|
303
|
+
storage = inject(SHELL_STATE_STORAGE);
|
|
304
|
+
baseConfig = signal(inject(SHELL_CONFIG), ...(ngDevMode ? [{ debugName: "baseConfig" }] : /* istanbul ignore next */ []));
|
|
305
|
+
behaviorState = signal(inject(SIDEBAR_BEHAVIOR), ...(ngDevMode ? [{ debugName: "behaviorState" }] : /* istanbul ignore next */ []));
|
|
306
|
+
/** Measured shell width; `null` until the first ResizeObserver callback. */
|
|
307
|
+
width = signal(null, ...(ngDevMode ? [{ debugName: "width" }] : /* istanbul ignore next */ []));
|
|
308
|
+
intent = signal(this.restoreIntent(), ...(ngDevMode ? [{ debugName: "intent" }] : /* istanbul ignore next */ []));
|
|
309
|
+
/** User-dragged panel width in px; `null` = use the configured width. */
|
|
310
|
+
customWidth = signal(this.restoreWidth(), ...(ngDevMode ? [{ debugName: "customWidth" }] : /* istanbul ignore next */ []));
|
|
311
|
+
peekRequested = signal(false, ...(ngDevMode ? [{ debugName: "peekRequested" }] : /* istanbul ignore next */ []));
|
|
312
|
+
resizingState = signal(false, ...(ngDevMode ? [{ debugName: "resizingState" }] : /* istanbul ignore next */ []));
|
|
313
|
+
peekTimer;
|
|
314
|
+
/** Size bucket of the shell container — not of the viewport. */
|
|
315
|
+
size = computed(() => {
|
|
316
|
+
const width = this.width();
|
|
317
|
+
const config = this.baseConfig();
|
|
318
|
+
return width === null ? config.initialSize : resolveSize(width, config.breakpoints);
|
|
319
|
+
}, ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
|
|
320
|
+
/**
|
|
321
|
+
* Drawer open state. Transient by design: it is never persisted, and any
|
|
322
|
+
* change of size bucket closes it, so rotating a phone or resizing a window
|
|
323
|
+
* never leaves a modal drawer stranded open.
|
|
324
|
+
*/
|
|
325
|
+
drawerOpen = linkedSignal({ ...(ngDevMode ? { debugName: "drawerOpen" } : /* istanbul ignore next */ {}), source: this.size,
|
|
326
|
+
computation: () => false });
|
|
327
|
+
/** Effective configuration: app config, runtime patches and the user's width. */
|
|
328
|
+
config = computed(() => {
|
|
329
|
+
const config = this.baseConfig();
|
|
330
|
+
const width = this.customWidth();
|
|
331
|
+
if (width === null || !config.sidebar.resizable) {
|
|
332
|
+
return config;
|
|
333
|
+
}
|
|
334
|
+
return { ...config, sidebar: { ...config.sidebar, width: `${width}px` } };
|
|
335
|
+
}, ...(ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []));
|
|
336
|
+
behavior = this.behaviorState.asReadonly();
|
|
337
|
+
shellWidth = this.width.asReadonly();
|
|
338
|
+
sidebarIntent = this.intent.asReadonly();
|
|
339
|
+
sidebarWidth = this.customWidth.asReadonly();
|
|
340
|
+
resizing = this.resizingState.asReadonly();
|
|
341
|
+
sidebar = computed(() => this.behaviorState().resolve(this.context()), ...(ngDevMode ? [{ debugName: "sidebar" }] : /* istanbul ignore next */ []));
|
|
342
|
+
// ── Configuration ─────────────────────────────────────────────────────────
|
|
343
|
+
/** Patch the configuration at runtime, e.g. from a settings screen. */
|
|
344
|
+
configure(patch) {
|
|
345
|
+
this.baseConfig.update((current) => mergeShellConfig(patch, current));
|
|
346
|
+
}
|
|
347
|
+
/** Swap the collapse policy at runtime. */
|
|
348
|
+
setBehavior(behavior) {
|
|
349
|
+
this.behaviorState.set(behavior);
|
|
350
|
+
}
|
|
351
|
+
/** Called by `<app-shell>` with its measured width. */
|
|
352
|
+
setWidth(width) {
|
|
353
|
+
this.width.set(width);
|
|
354
|
+
}
|
|
355
|
+
// ── Sidebar commands ──────────────────────────────────────────────────────
|
|
356
|
+
setSidebarIntent(intent) {
|
|
357
|
+
this.intent.set(intent);
|
|
358
|
+
this.persistIntent(intent);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Overlay drawers toggle open/closed; persistent panels ask the behaviour
|
|
362
|
+
* for the next intent (by default: expanded ⇄ collapsed).
|
|
363
|
+
*/
|
|
364
|
+
toggleSidebar() {
|
|
365
|
+
const state = this.sidebar();
|
|
366
|
+
if (state.mode === 'overlay') {
|
|
367
|
+
this.drawerOpen.set(!state.open);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const context = this.context();
|
|
371
|
+
const next = this.behaviorState().nextIntent?.(context) ??
|
|
372
|
+
(state.mode === 'expanded' ? 'collapsed' : 'expanded');
|
|
373
|
+
this.setSidebarIntent(next);
|
|
374
|
+
}
|
|
375
|
+
openSidebar() {
|
|
376
|
+
if (this.sidebar().mode === 'overlay') {
|
|
377
|
+
this.drawerOpen.set(true);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
this.setSidebarIntent('expanded');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
closeSidebar() {
|
|
384
|
+
if (this.sidebar().mode === 'overlay') {
|
|
385
|
+
this.drawerOpen.set(false);
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
this.setSidebarIntent('collapsed');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Set the expanded panel width in px (clamped to `minWidth`/`maxWidth`), or
|
|
393
|
+
* `null` to return to the configured width. Pass `{ persist: false }` for
|
|
394
|
+
* intermediate values, e.g. while a drag is still in progress.
|
|
395
|
+
*/
|
|
396
|
+
setSidebarWidth(width, options = {}) {
|
|
397
|
+
const { minWidth, maxWidth } = this.baseConfig().sidebar;
|
|
398
|
+
const next = width === null ? null : Math.round(Math.min(maxWidth, Math.max(minWidth, width)));
|
|
399
|
+
this.customWidth.set(next);
|
|
400
|
+
if (options.persist ?? true) {
|
|
401
|
+
this.write('width', next === null ? '' : String(next));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** Suspends layout transitions while a resize drag is in progress. */
|
|
405
|
+
setResizing(resizing) {
|
|
406
|
+
this.resizingState.set(resizing);
|
|
407
|
+
}
|
|
408
|
+
/** Hover/focus peek on the collapsed rail, debounced by `sidebar.peekDelay`. */
|
|
409
|
+
requestPeek(peeking) {
|
|
410
|
+
clearTimeout(this.peekTimer);
|
|
411
|
+
if (!peeking) {
|
|
412
|
+
this.peekRequested.set(false);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const sidebar = this.baseConfig().sidebar;
|
|
416
|
+
if (!sidebar.peek) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
this.peekTimer = setTimeout(() => this.peekRequested.set(true), sidebar.peekDelay);
|
|
420
|
+
}
|
|
421
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
422
|
+
context() {
|
|
423
|
+
return {
|
|
424
|
+
width: this.width(),
|
|
425
|
+
size: this.size(),
|
|
426
|
+
intent: this.intent(),
|
|
427
|
+
drawerOpen: this.drawerOpen(),
|
|
428
|
+
peeking: this.peekRequested(),
|
|
429
|
+
config: this.config(),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
persistIntent(intent) {
|
|
433
|
+
const allowed = this.behaviorState().persistIntent?.(intent, this.context()) ?? true;
|
|
434
|
+
if (allowed) {
|
|
435
|
+
this.write('intent', intent);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
write(key, value) {
|
|
439
|
+
const sidebar = this.baseConfig().sidebar;
|
|
440
|
+
if (sidebar.persist) {
|
|
441
|
+
this.storage.write(`${sidebar.storageKey}.${key}`, value);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
read(key) {
|
|
445
|
+
const sidebar = this.baseConfig().sidebar;
|
|
446
|
+
return sidebar.persist ? this.storage.read(`${sidebar.storageKey}.${key}`) : null;
|
|
447
|
+
}
|
|
448
|
+
restoreIntent() {
|
|
449
|
+
const stored = this.read('intent');
|
|
450
|
+
return INTENTS.includes(stored)
|
|
451
|
+
? stored
|
|
452
|
+
: this.baseConfig().sidebar.intent;
|
|
453
|
+
}
|
|
454
|
+
restoreWidth() {
|
|
455
|
+
const stored = Number.parseInt(this.read('width') ?? '', 10);
|
|
456
|
+
return Number.isFinite(stored) ? stored : null;
|
|
457
|
+
}
|
|
458
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
459
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellStore, providedIn: 'root' });
|
|
460
|
+
}
|
|
461
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellStore, decorators: [{
|
|
462
|
+
type: Injectable,
|
|
463
|
+
args: [{ providedIn: 'root' }]
|
|
464
|
+
}] });
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The sidebar never collapses on its own. Whatever the user last chose is what
|
|
468
|
+
* they get at every desktop size (`auto` means expanded); only drawer
|
|
469
|
+
* territory overrides it.
|
|
470
|
+
*
|
|
471
|
+
* ```ts
|
|
472
|
+
* provideAppShell({}, { behavior: manualSidebarBehavior });
|
|
473
|
+
* ```
|
|
474
|
+
*/
|
|
475
|
+
const manualSidebarBehavior = {
|
|
476
|
+
resolve({ size, intent, drawerOpen, peeking, config }) {
|
|
477
|
+
const sidebar = config.sidebar;
|
|
478
|
+
if (isBelow(size, sidebar.overlayBelow)) {
|
|
479
|
+
return drawerState(drawerOpen, sidebar);
|
|
480
|
+
}
|
|
481
|
+
switch (intent) {
|
|
482
|
+
case 'hidden':
|
|
483
|
+
return hiddenState();
|
|
484
|
+
case 'collapsed':
|
|
485
|
+
return railState(peeking, sidebar);
|
|
486
|
+
default:
|
|
487
|
+
return panelState(sidebar);
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Content-first policy: the sidebar is always an off-canvas drawer, at every
|
|
494
|
+
* size. Suits editors, media players and dashboards that want the full width.
|
|
495
|
+
*/
|
|
496
|
+
const drawerSidebarBehavior = {
|
|
497
|
+
resolve({ drawerOpen, config }) {
|
|
498
|
+
return drawerState(drawerOpen, config.sidebar);
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Tracks the inline size of an element as a signal.
|
|
504
|
+
*
|
|
505
|
+
* Accepts a plain `ElementRef` (host element) or a `viewChild` signal, in which
|
|
506
|
+
* case the observer follows the element as it appears, changes or is removed.
|
|
507
|
+
*
|
|
508
|
+
* Must be called from an injection context. SSR safe: with no `ResizeObserver`
|
|
509
|
+
* the signal simply stays `null`, which callers read as "not measured yet" and
|
|
510
|
+
* fall back to `config.initialSize` — so the server renders the desktop layout
|
|
511
|
+
* instead of flashing the mobile one.
|
|
512
|
+
*/
|
|
513
|
+
function observeWidth(target) {
|
|
514
|
+
const width = signal(null, ...(ngDevMode ? [{ debugName: "width" }] : /* istanbul ignore next */ []));
|
|
515
|
+
const zone = inject(NgZone);
|
|
516
|
+
const read = typeof target === 'function' ? target : () => target;
|
|
517
|
+
effect((onCleanup) => {
|
|
518
|
+
const element = read()?.nativeElement;
|
|
519
|
+
if (!element || typeof ResizeObserver === 'undefined') {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const observer = new ResizeObserver((entries) => {
|
|
523
|
+
const entry = entries[entries.length - 1];
|
|
524
|
+
const box = entry.borderBoxSize?.[0];
|
|
525
|
+
const next = Math.round(box ? box.inlineSize : entry.contentRect.width);
|
|
526
|
+
// Signal equality dedupes; NgZone.run keeps zone-based apps ticking.
|
|
527
|
+
zone.run(() => width.set(next));
|
|
528
|
+
});
|
|
529
|
+
observer.observe(element);
|
|
530
|
+
onCleanup(() => observer.disconnect());
|
|
531
|
+
});
|
|
532
|
+
return width.asReadonly();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Measure an element and bucket its width with the shell's breakpoints.
|
|
537
|
+
* Before the first measurement (and on the server) the bucket is
|
|
538
|
+
* `config.initialSize`. Call from an injection context.
|
|
539
|
+
*/
|
|
540
|
+
function measureRegion(target) {
|
|
541
|
+
const store = inject(ShellStore);
|
|
542
|
+
const width = observeWidth(target);
|
|
543
|
+
const size = computed(() => {
|
|
544
|
+
const measured = width();
|
|
545
|
+
const config = store.config();
|
|
546
|
+
return measured === null ? config.initialSize : resolveSize(measured, config.breakpoints);
|
|
547
|
+
}, ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
|
|
548
|
+
return { width, size };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const SHELL_REGION = new InjectionToken('SHELL_REGION');
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Turns any element into a measured container.
|
|
555
|
+
*
|
|
556
|
+
* ```html
|
|
557
|
+
* <div shellContainerSize #box="shellContainerSize">
|
|
558
|
+
* @if (box.atLeast('md')) { <app-filters /> }
|
|
559
|
+
* <span>{{ box.size() }}</span>
|
|
560
|
+
* </div>
|
|
561
|
+
* ```
|
|
562
|
+
*
|
|
563
|
+
* Also mirrors the bucket onto the host as `data-shell-size` and the raw width
|
|
564
|
+
* as `--shell-container-width`, and makes the host a CSS query container, so
|
|
565
|
+
* plain CSS can react without a media query:
|
|
566
|
+
*
|
|
567
|
+
* ```scss
|
|
568
|
+
* [data-shell-size='xs'] .toolbar__label { display: none; }
|
|
569
|
+
* ```
|
|
570
|
+
*
|
|
571
|
+
* Provides itself as the enclosing `SHELL_REGION`, so nested slot outlets pick
|
|
572
|
+
* the nearest measured ancestor.
|
|
573
|
+
*/
|
|
574
|
+
class ContainerSizeDirective {
|
|
575
|
+
/** Optional name, purely for debugging: `shellContainerSize="toolbar"`. */
|
|
576
|
+
label = input('', { ...(ngDevMode ? { debugName: "label" } : /* istanbul ignore next */ {}), alias: 'shellContainerSize' });
|
|
577
|
+
region = measureRegion(inject(ElementRef));
|
|
578
|
+
regionWidth = this.region.width;
|
|
579
|
+
size = this.region.size;
|
|
580
|
+
regionSize = this.size;
|
|
581
|
+
cssWidth = computed(() => {
|
|
582
|
+
const width = this.regionWidth();
|
|
583
|
+
return width === null ? null : `${width}px`;
|
|
584
|
+
}, ...(ngDevMode ? [{ debugName: "cssWidth" }] : /* istanbul ignore next */ []));
|
|
585
|
+
get regionName() {
|
|
586
|
+
return this.label() || 'container';
|
|
587
|
+
}
|
|
588
|
+
atLeast(min) {
|
|
589
|
+
return atLeast(this.size(), min);
|
|
590
|
+
}
|
|
591
|
+
atMost(max) {
|
|
592
|
+
return atMost(this.size(), max);
|
|
593
|
+
}
|
|
594
|
+
below(bound) {
|
|
595
|
+
return isBelow(this.size(), bound);
|
|
596
|
+
}
|
|
597
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ContainerSizeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
598
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.23", type: ContainerSizeDirective, isStandalone: true, selector: "[shellContainerSize]", inputs: { label: { classPropertyName: "label", publicName: "shellContainerSize", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.data-shell-size": "size()", "style.container-type": "\"inline-size\"", "style.--shell-container-width": "cssWidth()" } }, providers: [{ provide: SHELL_REGION, useExisting: ContainerSizeDirective }], exportAs: ["shellContainerSize"], ngImport: i0 });
|
|
599
|
+
}
|
|
600
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ContainerSizeDirective, decorators: [{
|
|
601
|
+
type: Directive,
|
|
602
|
+
args: [{
|
|
603
|
+
selector: '[shellContainerSize]',
|
|
604
|
+
exportAs: 'shellContainerSize',
|
|
605
|
+
host: {
|
|
606
|
+
'[attr.data-shell-size]': 'size()',
|
|
607
|
+
'[style.container-type]': '"inline-size"',
|
|
608
|
+
'[style.--shell-container-width]': 'cssWidth()',
|
|
609
|
+
},
|
|
610
|
+
providers: [{ provide: SHELL_REGION, useExisting: ContainerSizeDirective }],
|
|
611
|
+
}]
|
|
612
|
+
}], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "shellContainerSize", required: false }] }] } });
|
|
613
|
+
|
|
614
|
+
/** Reactive index of every registered slot template, keyed by name. */
|
|
615
|
+
class ShellSlotRegistry {
|
|
616
|
+
slots = signal([], ...(ngDevMode ? [{ debugName: "slots" }] : /* istanbul ignore next */ []));
|
|
617
|
+
byName = new Map();
|
|
618
|
+
register(slot) {
|
|
619
|
+
this.slots.update((current) => [...current, slot]);
|
|
620
|
+
}
|
|
621
|
+
unregister(slot) {
|
|
622
|
+
this.slots.update((current) => current.filter((candidate) => candidate !== slot));
|
|
623
|
+
}
|
|
624
|
+
/** All templates for a slot, ordered. Memoised per name. */
|
|
625
|
+
all(name) {
|
|
626
|
+
let entry = this.byName.get(name);
|
|
627
|
+
if (!entry) {
|
|
628
|
+
entry = computed(() => this.slots()
|
|
629
|
+
.filter((slot) => slot.name() === name)
|
|
630
|
+
.sort((a, b) => a.order() - b.order()));
|
|
631
|
+
this.byName.set(name, entry);
|
|
632
|
+
}
|
|
633
|
+
return entry;
|
|
634
|
+
}
|
|
635
|
+
/** Templates that fit the given region size. */
|
|
636
|
+
visible(name, size) {
|
|
637
|
+
return this.all(name)().filter((slot) => fits(slot, size));
|
|
638
|
+
}
|
|
639
|
+
/** Templates that do not fit but asked to be kept via the overflow menu. */
|
|
640
|
+
overflowing(name, size) {
|
|
641
|
+
return this.all(name)().filter((slot) => !fits(slot, size) && slot.overflow());
|
|
642
|
+
}
|
|
643
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSlotRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
644
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSlotRegistry, providedIn: 'root' });
|
|
645
|
+
}
|
|
646
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSlotRegistry, decorators: [{
|
|
647
|
+
type: Injectable,
|
|
648
|
+
args: [{ providedIn: 'root' }]
|
|
649
|
+
}] });
|
|
650
|
+
function fits(slot, size) {
|
|
651
|
+
return atLeast(size, slot.minSize()) && atMost(size, slot.maxSize());
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Declares a piece of shell chrome.
|
|
656
|
+
*
|
|
657
|
+
* ```html
|
|
658
|
+
* <ng-template shellSlot="search" slotMinSize="md" slotOverflow>
|
|
659
|
+
* <app-global-search />
|
|
660
|
+
* </ng-template>
|
|
661
|
+
* ```
|
|
662
|
+
*
|
|
663
|
+
* Templates register themselves with a root registry, so a slot can be
|
|
664
|
+
* contributed from anywhere — the app root, a routed page, a feature
|
|
665
|
+
* component — and it does not need to be a DOM child of `<app-shell>`.
|
|
666
|
+
* That is also what allows the same template to be rendered in the topbar on a
|
|
667
|
+
* wide screen and inside the overflow menu on a narrow one.
|
|
668
|
+
*/
|
|
669
|
+
class ShellSlotDirective {
|
|
670
|
+
/** Target slot. Unknown names are fine; render them with `<shell-slot-outlet>`. */
|
|
671
|
+
name = input.required({ ...(ngDevMode ? { debugName: "name" } : /* istanbul ignore next */ {}), alias: 'shellSlot' });
|
|
672
|
+
/** Ascending render order within the slot. Contribute several templates freely. */
|
|
673
|
+
order = input(0, { ...(ngDevMode ? { debugName: "order" } : /* istanbul ignore next */ {}), alias: 'slotOrder', transform: numberAttribute });
|
|
674
|
+
/** Render only when the *region* has at least this much room. */
|
|
675
|
+
minSize = input('xs', { ...(ngDevMode ? { debugName: "minSize" } : /* istanbul ignore next */ {}), alias: 'slotMinSize' });
|
|
676
|
+
/** Render only up to this region size — for narrow-only affordances. */
|
|
677
|
+
maxSize = input('xl', { ...(ngDevMode ? { debugName: "maxSize" } : /* istanbul ignore next */ {}), alias: 'slotMaxSize' });
|
|
678
|
+
/**
|
|
679
|
+
* When the region is too narrow, move the content into the overflow menu
|
|
680
|
+
* instead of dropping it. Off by default: dropping is the right answer for
|
|
681
|
+
* decorative chrome, moving is the right answer for actions. Only slots in
|
|
682
|
+
* `config.overflowSlots` have an overflow menu.
|
|
683
|
+
*/
|
|
684
|
+
overflow = input(false, { ...(ngDevMode ? { debugName: "overflow" } : /* istanbul ignore next */ {}), alias: 'slotOverflow',
|
|
685
|
+
transform: (value) => value !== false });
|
|
686
|
+
template = inject(TemplateRef);
|
|
687
|
+
registry = inject(ShellSlotRegistry);
|
|
688
|
+
constructor() {
|
|
689
|
+
this.registry.register(this);
|
|
690
|
+
inject(DestroyRef).onDestroy(() => this.registry.unregister(this));
|
|
691
|
+
if (isDevMode()) {
|
|
692
|
+
this.warnOnUnsupportedOverflow();
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/** Type guard for `let-` context inference in the template. */
|
|
696
|
+
static ngTemplateContextGuard(_directive, _context) {
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* `slotOverflow` on a slot the topbar never scans is silently ignored at
|
|
701
|
+
* runtime; say so during development. Inputs are only readable once bound,
|
|
702
|
+
* hence the effect. Config is read untracked so runtime `configure()` calls
|
|
703
|
+
* do not repeat the warning.
|
|
704
|
+
*/
|
|
705
|
+
warnOnUnsupportedOverflow() {
|
|
706
|
+
const store = inject(ShellStore);
|
|
707
|
+
effect(() => {
|
|
708
|
+
const name = this.name();
|
|
709
|
+
if (!this.overflow()) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const overflowSlots = untracked(() => {
|
|
713
|
+
const config = store.config();
|
|
714
|
+
return [...config.overflowSlots, ...config.subheader.overflowSlots];
|
|
715
|
+
});
|
|
716
|
+
if (!overflowSlots.includes(name)) {
|
|
717
|
+
console.warn(`[app-shell] slotOverflow on "${name}" has no effect: only slots listed in ` +
|
|
718
|
+
`config.overflowSlots or config.subheader.overflowSlots (${overflowSlots.join(', ')}) ` +
|
|
719
|
+
`have an overflow menu. Add "${name}" to one of them, or render it with your own ` +
|
|
720
|
+
`<shell-slot-outlet select="overflow">.`);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSlotDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
725
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.23", type: ShellSlotDirective, isStandalone: true, selector: "ng-template[shellSlot]", inputs: { name: { classPropertyName: "name", publicName: "shellSlot", isSignal: true, isRequired: true, transformFunction: null }, order: { classPropertyName: "order", publicName: "slotOrder", isSignal: true, isRequired: false, transformFunction: null }, minSize: { classPropertyName: "minSize", publicName: "slotMinSize", isSignal: true, isRequired: false, transformFunction: null }, maxSize: { classPropertyName: "maxSize", publicName: "slotMaxSize", isSignal: true, isRequired: false, transformFunction: null }, overflow: { classPropertyName: "overflow", publicName: "slotOverflow", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
|
|
726
|
+
}
|
|
727
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSlotDirective, decorators: [{
|
|
728
|
+
type: Directive,
|
|
729
|
+
args: [{
|
|
730
|
+
selector: 'ng-template[shellSlot]',
|
|
731
|
+
}]
|
|
732
|
+
}], ctorParameters: () => [], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "shellSlot", required: true }] }], order: [{ type: i0.Input, args: [{ isSignal: true, alias: "slotOrder", required: false }] }], minSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "slotMinSize", required: false }] }], maxSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "slotMaxSize", required: false }] }], overflow: [{ type: i0.Input, args: [{ isSignal: true, alias: "slotOverflow", required: false }] }] } });
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Renders the templates registered for a slot.
|
|
736
|
+
*
|
|
737
|
+
* Size filtering uses the nearest measured region, falling back to the shell
|
|
738
|
+
* itself — which is why a topbar squeezed by a wide sidebar collapses its
|
|
739
|
+
* contents even though the window never changed.
|
|
740
|
+
*/
|
|
741
|
+
class ShellSlotOutletComponent {
|
|
742
|
+
name = input.required(...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
|
|
743
|
+
/** `visible` renders what fits; `overflow` renders what did not fit. */
|
|
744
|
+
select = input('visible', ...(ngDevMode ? [{ debugName: "select" }] : /* istanbul ignore next */ []));
|
|
745
|
+
/** Overrides the measured region size. Mostly for tests. */
|
|
746
|
+
size = input(null, ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
|
|
747
|
+
registry = inject(ShellSlotRegistry);
|
|
748
|
+
store = inject(ShellStore);
|
|
749
|
+
region = inject(SHELL_REGION, { optional: true });
|
|
750
|
+
effectiveSize = computed(() => this.size() ?? this.region?.regionSize() ?? this.store.size(), ...(ngDevMode ? [{ debugName: "effectiveSize" }] : /* istanbul ignore next */ []));
|
|
751
|
+
slots = computed(() => this.select() === 'overflow'
|
|
752
|
+
? this.registry.overflowing(this.name(), this.effectiveSize())
|
|
753
|
+
: this.registry.visible(this.name(), this.effectiveSize()), ...(ngDevMode ? [{ debugName: "slots" }] : /* istanbul ignore next */ []));
|
|
754
|
+
context = computed(() => {
|
|
755
|
+
const sidebar = this.store.sidebar();
|
|
756
|
+
return {
|
|
757
|
+
$implicit: sidebar,
|
|
758
|
+
sidebar,
|
|
759
|
+
size: this.store.size(),
|
|
760
|
+
region: this.effectiveSize(),
|
|
761
|
+
overflow: this.select() === 'overflow',
|
|
762
|
+
shell: this.store,
|
|
763
|
+
};
|
|
764
|
+
}, ...(ngDevMode ? [{ debugName: "context" }] : /* istanbul ignore next */ []));
|
|
765
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSlotOutletComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
766
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: ShellSlotOutletComponent, isStandalone: true, selector: "shell-slot-outlet", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: true, transformFunction: null }, select: { classPropertyName: "select", publicName: "select", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.data-slot": "name()", "hidden": "!slots().length" } }, ngImport: i0, template: `
|
|
767
|
+
@for (slot of slots(); track slot) {
|
|
768
|
+
<ng-container *ngTemplateOutlet="slot.template; context: context()" />
|
|
769
|
+
}
|
|
770
|
+
`, isInline: true, styles: [":host{display:flex;align-items:center;min-inline-size:0}:host([hidden]){display:none}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
771
|
+
}
|
|
772
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSlotOutletComponent, decorators: [{
|
|
773
|
+
type: Component,
|
|
774
|
+
args: [{ selector: 'shell-slot-outlet', imports: [NgTemplateOutlet], template: `
|
|
775
|
+
@for (slot of slots(); track slot) {
|
|
776
|
+
<ng-container *ngTemplateOutlet="slot.template; context: context()" />
|
|
777
|
+
}
|
|
778
|
+
`, host: {
|
|
779
|
+
'[attr.data-slot]': 'name()',
|
|
780
|
+
// Collapse to nothing when unfilled, so a region's gaps never show as
|
|
781
|
+
// phantom spacing for a slot the app never provided.
|
|
782
|
+
'[hidden]': '!slots().length',
|
|
783
|
+
}, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:flex;align-items:center;min-inline-size:0}:host([hidden]){display:none}\n"] }]
|
|
784
|
+
}], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: true }] }], select: [{ type: i0.Input, args: [{ isSignal: true, alias: "select", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }] } });
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* `true` when at least one template is registered for a slot — lets a region
|
|
788
|
+
* skip its wrapper markup (and its padding) instead of rendering an empty box.
|
|
789
|
+
* Call from an injection context.
|
|
790
|
+
*/
|
|
791
|
+
function hasSlot(...names) {
|
|
792
|
+
const registry = inject(ShellSlotRegistry);
|
|
793
|
+
return computed(() => names.some((name) => registry.all(name)().length > 0));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Route `data` key for a level's label: a string (static, or produced by a
|
|
798
|
+
* resolver under `resolve: { breadcrumb: … }`), or `false` to skip the level.
|
|
799
|
+
*/
|
|
800
|
+
const BREADCRUMB_DATA_KEY = 'breadcrumb';
|
|
801
|
+
/** Route `data` key that forces the breadcrumb row on (`true`) or off (`false`) for a page. */
|
|
802
|
+
const SUBHEADER_DATA_KEY = 'subheader';
|
|
803
|
+
|
|
804
|
+
const NO_OVERRIDE = Symbol('no-override');
|
|
805
|
+
function urlOf(snapshot) {
|
|
806
|
+
const segments = snapshot.pathFromRoot.flatMap((route) => route.url.map((part) => part.path));
|
|
807
|
+
return `/${segments.join('/')}`;
|
|
808
|
+
}
|
|
809
|
+
function readLabel(label) {
|
|
810
|
+
return isSignal(label) ? label() : label;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* The breadcrumb trail, derived from the router.
|
|
814
|
+
*
|
|
815
|
+
* On every `NavigationEnd` it walks the active (primary) route tree. Each
|
|
816
|
+
* route that consumes URL segments is one level, labelled by — in order — a
|
|
817
|
+
* page override, the route's own `data.breadcrumb` (static or resolved), or
|
|
818
|
+
* its own `title`. Only what a route declares *itself* counts: Angular copies
|
|
819
|
+
* `data` from componentless and empty-path parents into children, and a child
|
|
820
|
+
* must not borrow its parent's label.
|
|
821
|
+
*/
|
|
822
|
+
class ShellBreadcrumbStore {
|
|
823
|
+
router = inject(Router, { optional: true });
|
|
824
|
+
shell = inject(ShellStore);
|
|
825
|
+
levels = signal([], ...(ngDevMode ? [{ debugName: "levels" }] : /* istanbul ignore next */ []));
|
|
826
|
+
routeSubheader = signal(undefined, ...(ngDevMode ? [{ debugName: "routeSubheader" }] : /* istanbul ignore next */ []));
|
|
827
|
+
overrides = signal([], ...(ngDevMode ? [{ debugName: "overrides" }] : /* istanbul ignore next */ []));
|
|
828
|
+
/** Labelled levels of the current route, before the home/current options. */
|
|
829
|
+
trail = computed(() => {
|
|
830
|
+
const levels = this.levels();
|
|
831
|
+
const overrides = this.overrides();
|
|
832
|
+
const useTitle = this.shell.config().breadcrumbs.useRouteTitle;
|
|
833
|
+
const crumbs = [];
|
|
834
|
+
levels.forEach((level, index) => {
|
|
835
|
+
const override = this.overrideFor(level.url, index === levels.length - 1, overrides);
|
|
836
|
+
const label = override === NO_OVERRIDE
|
|
837
|
+
? (level.label ?? (useTitle ? level.title : undefined))
|
|
838
|
+
: (override ?? null);
|
|
839
|
+
if (label !== undefined) {
|
|
840
|
+
crumbs.push({ label, url: level.url, current: false });
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
const last = crumbs.length - 1;
|
|
844
|
+
if (last >= 0) {
|
|
845
|
+
crumbs[last] = { ...crumbs[last], current: true };
|
|
846
|
+
}
|
|
847
|
+
return crumbs;
|
|
848
|
+
}, ...(ngDevMode ? [{ debugName: "trail" }] : /* istanbul ignore next */ []));
|
|
849
|
+
/** The trail to render, with `breadcrumbs.includeHome` / `includeCurrent` applied. */
|
|
850
|
+
crumbs = computed(() => {
|
|
851
|
+
const options = this.shell.config().breadcrumbs;
|
|
852
|
+
let crumbs = [...this.trail()];
|
|
853
|
+
if (options.includeHome && crumbs[0]?.url !== options.homeUrl) {
|
|
854
|
+
crumbs.unshift({ label: options.homeLabel, url: options.homeUrl, current: crumbs.length === 0 });
|
|
855
|
+
}
|
|
856
|
+
if (!options.includeCurrent) {
|
|
857
|
+
crumbs = crumbs.filter((crumb) => !crumb.current);
|
|
858
|
+
}
|
|
859
|
+
return crumbs;
|
|
860
|
+
}, ...(ngDevMode ? [{ debugName: "crumbs" }] : /* istanbul ignore next */ []));
|
|
861
|
+
/** Labelled levels in the current route; the home crumb is not counted. */
|
|
862
|
+
depth = computed(() => this.trail().length, ...(ngDevMode ? [{ debugName: "depth" }] : /* istanbul ignore next */ []));
|
|
863
|
+
/** `data.subheader` of the deepest route that declares it, if any. */
|
|
864
|
+
subheaderOverride = this.routeSubheader.asReadonly();
|
|
865
|
+
constructor() {
|
|
866
|
+
this.router?.events
|
|
867
|
+
.pipe(filter((event) => event instanceof NavigationEnd), takeUntilDestroyed())
|
|
868
|
+
.subscribe(() => this.refresh());
|
|
869
|
+
this.refresh();
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Label one level from code — for names only the page knows, e.g. a record
|
|
873
|
+
* loaded without a resolver. `route` picks the level ending at that route's
|
|
874
|
+
* URL; `null` labels the current level. While a signal label is `undefined`
|
|
875
|
+
* the crumb renders as a placeholder. Returns a function that removes the
|
|
876
|
+
* label again; prefer `contributeBreadcrumbLabel()`, which does so for you.
|
|
877
|
+
*/
|
|
878
|
+
setLabel(label, route = null) {
|
|
879
|
+
const entry = { route, label };
|
|
880
|
+
this.overrides.update((current) => [...current, entry]);
|
|
881
|
+
return () => this.overrides.update((current) => current.filter((candidate) => candidate !== entry));
|
|
882
|
+
}
|
|
883
|
+
/** The latest override for a level, `undefined` while pending, or `NO_OVERRIDE`. */
|
|
884
|
+
overrideFor(url, isCurrent, overrides) {
|
|
885
|
+
for (let index = overrides.length - 1; index >= 0; index--) {
|
|
886
|
+
const { route, label } = overrides[index];
|
|
887
|
+
const matches = route ? urlOf(route.snapshot) === url : isCurrent;
|
|
888
|
+
if (matches) {
|
|
889
|
+
return readLabel(label);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return NO_OVERRIDE;
|
|
893
|
+
}
|
|
894
|
+
refresh() {
|
|
895
|
+
const router = this.router;
|
|
896
|
+
if (!router) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const levels = [];
|
|
900
|
+
const segments = [];
|
|
901
|
+
let subheader;
|
|
902
|
+
for (let route = router.routerState.snapshot.root; route; route = route.firstChild) {
|
|
903
|
+
const config = route.routeConfig;
|
|
904
|
+
const declaresLabel = config?.data?.[BREADCRUMB_DATA_KEY] !== undefined ||
|
|
905
|
+
config?.resolve?.[BREADCRUMB_DATA_KEY] !== undefined;
|
|
906
|
+
const label = declaresLabel ? route.data[BREADCRUMB_DATA_KEY] : undefined;
|
|
907
|
+
const flag = config?.data?.[SUBHEADER_DATA_KEY];
|
|
908
|
+
if (typeof flag === 'boolean') {
|
|
909
|
+
subheader = flag;
|
|
910
|
+
}
|
|
911
|
+
segments.push(...route.url.map((part) => part.path));
|
|
912
|
+
// Empty-path routes add no level; `breadcrumb: false` removes one.
|
|
913
|
+
if (route.url.length > 0 && label !== false) {
|
|
914
|
+
levels.push({
|
|
915
|
+
url: `/${segments.join('/')}`,
|
|
916
|
+
label: typeof label === 'string' ? label : undefined,
|
|
917
|
+
title: config?.title !== undefined ? route.title : undefined,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
this.levels.set(levels);
|
|
922
|
+
this.routeSubheader.set(subheader);
|
|
923
|
+
}
|
|
924
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellBreadcrumbStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
925
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellBreadcrumbStore, providedIn: 'root' });
|
|
926
|
+
}
|
|
927
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellBreadcrumbStore, decorators: [{
|
|
928
|
+
type: Injectable,
|
|
929
|
+
args: [{ providedIn: 'root' }]
|
|
930
|
+
}], ctorParameters: () => [] });
|
|
931
|
+
/**
|
|
932
|
+
* Label the calling page's own level of the trail, for as long as the page
|
|
933
|
+
* lives. Call from a routed component's injection context:
|
|
934
|
+
*
|
|
935
|
+
* ```ts
|
|
936
|
+
* export class ProjectPage {
|
|
937
|
+
* readonly name = signal<string | undefined>(undefined); // set when loaded
|
|
938
|
+
* constructor() { contributeBreadcrumbLabel(this.name); }
|
|
939
|
+
* }
|
|
940
|
+
* ```
|
|
941
|
+
*
|
|
942
|
+
* While the signal is `undefined` the crumb is a placeholder; the breadcrumb
|
|
943
|
+
* row has reserved its height, so nothing shifts when the name arrives.
|
|
944
|
+
*/
|
|
945
|
+
function contributeBreadcrumbLabel(label) {
|
|
946
|
+
const route = inject(ActivatedRoute, { optional: true });
|
|
947
|
+
const remove = inject(ShellBreadcrumbStore).setLabel(label, route);
|
|
948
|
+
inject(DestroyRef).onDestroy(remove);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Footer strip. Three slots so the common "legal start, meta end" pattern
|
|
953
|
+
* needs no markup of its own, and so a status bar can occupy the middle.
|
|
954
|
+
* Measures itself, because its width depends on the layout and the sidebar.
|
|
955
|
+
*/
|
|
956
|
+
class ShellFooterComponent {
|
|
957
|
+
region = measureRegion(inject(ElementRef));
|
|
958
|
+
store = inject(ShellStore);
|
|
959
|
+
regionName = 'footer';
|
|
960
|
+
regionWidth = this.region.width;
|
|
961
|
+
regionSize = this.region.size;
|
|
962
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellFooterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
963
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.23", type: ShellFooterComponent, isStandalone: true, selector: "shell-footer", host: { attributes: { "role": "contentinfo" }, properties: { "attr.data-shell-size": "regionSize()", "attr.data-variant": "store.config().appearance.footer" } }, providers: [{ provide: SHELL_REGION, useExisting: ShellFooterComponent }], ngImport: i0, template: "<shell-slot-outlet name=\"footer-start\" class=\"footer__slot\" />\n<shell-slot-outlet name=\"footer\" class=\"footer__slot footer__slot--main\" />\n<shell-slot-outlet name=\"footer-end\" class=\"footer__slot\" />\n", styles: ["@charset \"UTF-8\";:host{container:shell-footer/inline-size;display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;min-block-size:var(--shell-footer-min-height, 48px);padding:8px var(--shell-topbar-padding-inline, 12px);box-sizing:border-box;border-block-start:1px solid var(--shell-footer-border-color, var(--shell-border, #e6e6e9));background:var(--shell-footer-bg, var(--shell-surface-raised, #f7f7f8));color:var(--shell-footer-fg, var(--shell-fg-muted, #6b6f76));box-shadow:var(--shell-footer-shadow, none);font-size:var(--shell-footer-font-size, var(--shell-font-size-sm, 12.5px));transition-property:background-color,border-color,box-shadow;transition-duration:calc(var(--shell-footer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-footer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){:host{transition-duration:1ms}}:host([data-variant=minimal]){border-block-start-color:var(--shell-footer-border-color, transparent);background:var(--shell-footer-bg, transparent)}.footer__slot{gap:8px 16px;flex-wrap:wrap}.footer__slot--main{flex:1 1 auto;min-inline-size:0}\n"], dependencies: [{ kind: "component", type: ShellSlotOutletComponent, selector: "shell-slot-outlet", inputs: ["name", "select", "size"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
964
|
+
}
|
|
965
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellFooterComponent, decorators: [{
|
|
966
|
+
type: Component,
|
|
967
|
+
args: [{ selector: 'shell-footer', imports: [ShellSlotOutletComponent], host: {
|
|
968
|
+
role: 'contentinfo',
|
|
969
|
+
'[attr.data-shell-size]': 'regionSize()',
|
|
970
|
+
'[attr.data-variant]': 'store.config().appearance.footer',
|
|
971
|
+
}, providers: [{ provide: SHELL_REGION, useExisting: ShellFooterComponent }], changeDetection: ChangeDetectionStrategy.OnPush, template: "<shell-slot-outlet name=\"footer-start\" class=\"footer__slot\" />\n<shell-slot-outlet name=\"footer\" class=\"footer__slot footer__slot--main\" />\n<shell-slot-outlet name=\"footer-end\" class=\"footer__slot\" />\n", styles: ["@charset \"UTF-8\";:host{container:shell-footer/inline-size;display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;min-block-size:var(--shell-footer-min-height, 48px);padding:8px var(--shell-topbar-padding-inline, 12px);box-sizing:border-box;border-block-start:1px solid var(--shell-footer-border-color, var(--shell-border, #e6e6e9));background:var(--shell-footer-bg, var(--shell-surface-raised, #f7f7f8));color:var(--shell-footer-fg, var(--shell-fg-muted, #6b6f76));box-shadow:var(--shell-footer-shadow, none);font-size:var(--shell-footer-font-size, var(--shell-font-size-sm, 12.5px));transition-property:background-color,border-color,box-shadow;transition-duration:calc(var(--shell-footer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-footer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){:host{transition-duration:1ms}}:host([data-variant=minimal]){border-block-start-color:var(--shell-footer-border-color, transparent);background:var(--shell-footer-bg, transparent)}.footer__slot{gap:8px 16px;flex-wrap:wrap}.footer__slot--main{flex:1 1 auto;min-inline-size:0}\n"] }]
|
|
972
|
+
}] });
|
|
973
|
+
|
|
974
|
+
const FOCUSABLE = [
|
|
975
|
+
'a[href]',
|
|
976
|
+
'button:not([disabled])',
|
|
977
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
978
|
+
'select:not([disabled])',
|
|
979
|
+
'textarea:not([disabled])',
|
|
980
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
981
|
+
'[contenteditable="true"]',
|
|
982
|
+
].join(',');
|
|
983
|
+
function focusable(root) {
|
|
984
|
+
return Array.from(root.querySelectorAll(FOCUSABLE)).filter((element) => !element.hasAttribute('inert') &&
|
|
985
|
+
element.offsetWidth + element.offsetHeight > 0 &&
|
|
986
|
+
getComputedStyle(element).visibility !== 'hidden');
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Keeps Tab inside `root` while a modal drawer is open and restores focus to
|
|
990
|
+
* whatever was focused before. Small on purpose: no dependency on the CDK, and
|
|
991
|
+
* the only behaviours a navigation drawer needs.
|
|
992
|
+
*/
|
|
993
|
+
function trapFocus(root, options = {}) {
|
|
994
|
+
const previous = options.restoreTo ?? document.activeElement;
|
|
995
|
+
const onKeydown = (event) => {
|
|
996
|
+
if (event.key !== 'Tab') {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const items = focusable(root);
|
|
1000
|
+
if (!items.length) {
|
|
1001
|
+
event.preventDefault();
|
|
1002
|
+
root.focus();
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const first = items[0];
|
|
1006
|
+
const last = items[items.length - 1];
|
|
1007
|
+
const active = document.activeElement;
|
|
1008
|
+
if (event.shiftKey && (active === first || !root.contains(active))) {
|
|
1009
|
+
event.preventDefault();
|
|
1010
|
+
last.focus();
|
|
1011
|
+
}
|
|
1012
|
+
else if (!event.shiftKey && active === last) {
|
|
1013
|
+
event.preventDefault();
|
|
1014
|
+
first.focus();
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
root.addEventListener('keydown', onKeydown);
|
|
1018
|
+
// Focus the first control, or the panel itself when it has none yet.
|
|
1019
|
+
(focusable(root)[0] ?? root).focus({ preventScroll: true });
|
|
1020
|
+
return {
|
|
1021
|
+
release() {
|
|
1022
|
+
root.removeEventListener('keydown', onKeydown);
|
|
1023
|
+
previous?.focus?.({ preventScroll: true });
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Drop-in toggle. Works anywhere in the app — header, page, command palette —
|
|
1030
|
+
* because it talks to the root store rather than to a parent component.
|
|
1031
|
+
*
|
|
1032
|
+
* ```html
|
|
1033
|
+
* <button shellSidebarToggle class="icon-button">☰</button>
|
|
1034
|
+
* ```
|
|
1035
|
+
*
|
|
1036
|
+
* Sets `aria-expanded`, `aria-controls` and an accessible label that tracks the
|
|
1037
|
+
* current mode, so the same button reads correctly as a hamburger on mobile and
|
|
1038
|
+
* as a collapse control on desktop.
|
|
1039
|
+
*/
|
|
1040
|
+
class SidebarToggleDirective {
|
|
1041
|
+
store = inject(ShellStore);
|
|
1042
|
+
sidebarId = SHELL_SIDEBAR_ID;
|
|
1043
|
+
/** True when the sidebar is showing its full, labelled panel. */
|
|
1044
|
+
expanded = computed(() => {
|
|
1045
|
+
const state = this.store.sidebar();
|
|
1046
|
+
return state.mode === 'expanded' || (state.mode === 'overlay' && state.open);
|
|
1047
|
+
}, ...(ngDevMode ? [{ debugName: "expanded" }] : /* istanbul ignore next */ []));
|
|
1048
|
+
label = computed(() => {
|
|
1049
|
+
const labels = this.store.config().labels;
|
|
1050
|
+
const state = this.store.sidebar();
|
|
1051
|
+
if (state.mode === 'overlay') {
|
|
1052
|
+
return state.open ? labels.closeNavigation : labels.openNavigation;
|
|
1053
|
+
}
|
|
1054
|
+
return this.expanded() ? labels.collapseSidebar : labels.expandSidebar;
|
|
1055
|
+
}, ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
1056
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: SidebarToggleDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1057
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.23", type: SidebarToggleDirective, isStandalone: true, selector: "button[shellSidebarToggle]", host: { attributes: { "type": "button" }, listeners: { "click": "store.toggleSidebar()" }, properties: { "attr.aria-controls": "sidebarId", "attr.aria-expanded": "expanded()", "attr.aria-label": "label()", "attr.title": "label()", "attr.data-sidebar-mode": "store.sidebar().mode" } }, exportAs: ["shellSidebarToggle"], ngImport: i0 });
|
|
1058
|
+
}
|
|
1059
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: SidebarToggleDirective, decorators: [{
|
|
1060
|
+
type: Directive,
|
|
1061
|
+
args: [{
|
|
1062
|
+
selector: 'button[shellSidebarToggle]',
|
|
1063
|
+
exportAs: 'shellSidebarToggle',
|
|
1064
|
+
host: {
|
|
1065
|
+
type: 'button',
|
|
1066
|
+
'[attr.aria-controls]': 'sidebarId',
|
|
1067
|
+
'[attr.aria-expanded]': 'expanded()',
|
|
1068
|
+
'[attr.aria-label]': 'label()',
|
|
1069
|
+
'[attr.title]': 'label()',
|
|
1070
|
+
'[attr.data-sidebar-mode]': 'store.sidebar().mode',
|
|
1071
|
+
'(click)': 'store.toggleSidebar()',
|
|
1072
|
+
},
|
|
1073
|
+
}]
|
|
1074
|
+
}] });
|
|
1075
|
+
|
|
1076
|
+
const RESIZE_STEP = 16;
|
|
1077
|
+
const RESIZE_STEP_LARGE = 48;
|
|
1078
|
+
/**
|
|
1079
|
+
* Sidebar with four modes — `expanded`, `collapsed` (icon rail), `overlay`
|
|
1080
|
+
* (modal drawer) and `hidden` — chosen by the injected `SidebarBehavior`
|
|
1081
|
+
* rather than by this component. All this class does is render the resolved
|
|
1082
|
+
* state and handle the mechanics each mode needs: peek timing, focus trapping,
|
|
1083
|
+
* Escape, and drag-to-resize.
|
|
1084
|
+
*
|
|
1085
|
+
* Slot order, top to bottom:
|
|
1086
|
+
*
|
|
1087
|
+
* sidebar-header brand, workspace or account switcher
|
|
1088
|
+
* sidebar-action one primary action ("New", "Compose")
|
|
1089
|
+
* sidebar-search quick find / command palette launcher
|
|
1090
|
+
* sidebar-nav the scrolling navigation (grows)
|
|
1091
|
+
* sidebar-secondary settings, help, upgrade prompts
|
|
1092
|
+
* sidebar-footer user card, storage meter — shares a row with the collapse control
|
|
1093
|
+
*/
|
|
1094
|
+
class ShellSidebarComponent {
|
|
1095
|
+
host = inject(ElementRef);
|
|
1096
|
+
panel = viewChild('panel', ...(ngDevMode ? [{ debugName: "panel" }] : /* istanbul ignore next */ []));
|
|
1097
|
+
store = inject(ShellStore);
|
|
1098
|
+
config = this.store.config;
|
|
1099
|
+
/** Render the built-in expand/collapse control in the footer row. */
|
|
1100
|
+
collapseControl = input(true, { ...(ngDevMode ? { debugName: "collapseControl" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1101
|
+
regionName = 'sidebar';
|
|
1102
|
+
/**
|
|
1103
|
+
* Measured on the *panel*, not the host: during a peek the panel is wider
|
|
1104
|
+
* than the grid track it lives in. Exposed as `--shell-container-width`.
|
|
1105
|
+
*/
|
|
1106
|
+
regionWidth = observeWidth(this.panel);
|
|
1107
|
+
/**
|
|
1108
|
+
* The sidebar reports a *semantic* bucket rather than a measured one. Its
|
|
1109
|
+
* panel is a few hundred pixels wide in every mode, so raw widths would put
|
|
1110
|
+
* it permanently in `xs` and make `slotMinSize` useless. The contract is:
|
|
1111
|
+
*
|
|
1112
|
+
* rail (icons only) → `xs`
|
|
1113
|
+
* panel / drawer (labels on) → `md`
|
|
1114
|
+
*
|
|
1115
|
+
* so `slotMinSize="sm"` on a sidebar slot reads as "only when labels show".
|
|
1116
|
+
*/
|
|
1117
|
+
regionSize = computed(() => (this.state().labels ? 'md' : 'xs'), ...(ngDevMode ? [{ debugName: "regionSize" }] : /* istanbul ignore next */ []));
|
|
1118
|
+
state = this.store.sidebar;
|
|
1119
|
+
hasHeader = hasSlot('sidebar-header');
|
|
1120
|
+
hasSecondary = hasSlot('sidebar-secondary');
|
|
1121
|
+
hasFooterSlot = hasSlot('sidebar-footer');
|
|
1122
|
+
/** The collapse control only makes sense for persistent panels. */
|
|
1123
|
+
showCollapse = computed(() => {
|
|
1124
|
+
const mode = this.state().mode;
|
|
1125
|
+
return this.collapseControl() && (mode === 'expanded' || mode === 'collapsed');
|
|
1126
|
+
}, ...(ngDevMode ? [{ debugName: "showCollapse" }] : /* istanbul ignore next */ []));
|
|
1127
|
+
showFooter = computed(() => this.hasFooterSlot() || this.showCollapse(), ...(ngDevMode ? [{ debugName: "showFooter" }] : /* istanbul ignore next */ []));
|
|
1128
|
+
containerWidth = computed(() => {
|
|
1129
|
+
const width = this.regionWidth();
|
|
1130
|
+
return width === null ? null : `${width}px`;
|
|
1131
|
+
}, ...(ngDevMode ? [{ debugName: "containerWidth" }] : /* istanbul ignore next */ []));
|
|
1132
|
+
/** Peek only makes sense on a rail, and only when configured. */
|
|
1133
|
+
peekable = computed(() => this.config().sidebar.peek && this.state().mode === 'collapsed', ...(ngDevMode ? [{ debugName: "peekable" }] : /* istanbul ignore next */ []));
|
|
1134
|
+
resizable = computed(() => this.config().sidebar.resizable && this.state().mode === 'expanded', ...(ngDevMode ? [{ debugName: "resizable" }] : /* istanbul ignore next */ []));
|
|
1135
|
+
/** Current panel width in px for `aria-valuenow` and keyboard steps. */
|
|
1136
|
+
resizeValue = computed(() => this.store.sidebarWidth() ?? this.regionWidth() ?? this.config().sidebar.minWidth, ...(ngDevMode ? [{ debugName: "resizeValue" }] : /* istanbul ignore next */ []));
|
|
1137
|
+
constructor() {
|
|
1138
|
+
// Trap focus for as long as the drawer is modal, and hand focus back on close.
|
|
1139
|
+
effect((onCleanup) => {
|
|
1140
|
+
const element = this.panel()?.nativeElement;
|
|
1141
|
+
if (!this.state().modal || !element) {
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
let handle;
|
|
1145
|
+
const frame = requestAnimationFrame(() => (handle = trapFocus(element)));
|
|
1146
|
+
onCleanup(() => {
|
|
1147
|
+
cancelAnimationFrame(frame);
|
|
1148
|
+
handle?.release();
|
|
1149
|
+
});
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
// ── Peek ──────────────────────────────────────────────────────────────────
|
|
1153
|
+
onPointerEnter() {
|
|
1154
|
+
if (this.peekable()) {
|
|
1155
|
+
this.store.requestPeek(true);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
onPointerLeave() {
|
|
1159
|
+
if (!this.store.resizing()) {
|
|
1160
|
+
this.store.requestPeek(false);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/** Keyboard users get the same peek: focus entering the rail widens it. */
|
|
1164
|
+
onFocusIn() {
|
|
1165
|
+
if (this.peekable()) {
|
|
1166
|
+
this.store.requestPeek(true);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
onFocusOut(event) {
|
|
1170
|
+
const next = event.relatedTarget;
|
|
1171
|
+
if (!next || !this.panel()?.nativeElement.contains(next)) {
|
|
1172
|
+
this.store.requestPeek(false);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
onEscape() {
|
|
1176
|
+
if (this.state().modal) {
|
|
1177
|
+
this.store.closeSidebar();
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// ── Resize ────────────────────────────────────────────────────────────────
|
|
1181
|
+
onResizeStart(event) {
|
|
1182
|
+
if (event.button !== 0) {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
event.preventDefault();
|
|
1186
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1187
|
+
this.store.setResizing(true);
|
|
1188
|
+
}
|
|
1189
|
+
onResizeMove(event) {
|
|
1190
|
+
const panel = this.panel()?.nativeElement;
|
|
1191
|
+
if (!this.store.resizing() || !panel) {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
// Measured from the grid track (the host), not the panel: a floating panel
|
|
1195
|
+
// is inset from its track, and the handle must stay under the pointer.
|
|
1196
|
+
const track = this.host.nativeElement.getBoundingClientRect();
|
|
1197
|
+
const inset = Number.parseFloat(getComputedStyle(panel).marginInlineEnd) || 0;
|
|
1198
|
+
const offset = this.isRtl(panel) ? track.right - event.clientX : event.clientX - track.left;
|
|
1199
|
+
this.store.setSidebarWidth(offset + inset, { persist: false });
|
|
1200
|
+
}
|
|
1201
|
+
onResizeEnd(event) {
|
|
1202
|
+
const handle = event.currentTarget;
|
|
1203
|
+
if (handle.hasPointerCapture(event.pointerId)) {
|
|
1204
|
+
handle.releasePointerCapture(event.pointerId);
|
|
1205
|
+
}
|
|
1206
|
+
if (this.store.resizing()) {
|
|
1207
|
+
this.store.setResizing(false);
|
|
1208
|
+
this.store.setSidebarWidth(this.store.sidebarWidth());
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
onResizeKey(event) {
|
|
1212
|
+
const panel = this.panel()?.nativeElement;
|
|
1213
|
+
if (!panel) {
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
const { minWidth, maxWidth } = this.config().sidebar;
|
|
1217
|
+
const step = event.shiftKey ? RESIZE_STEP_LARGE : RESIZE_STEP;
|
|
1218
|
+
// "Right" grows the panel in LTR and shrinks it in RTL.
|
|
1219
|
+
const grow = this.isRtl(panel) ? -step : step;
|
|
1220
|
+
const current = this.resizeValue();
|
|
1221
|
+
let next;
|
|
1222
|
+
switch (event.key) {
|
|
1223
|
+
case 'ArrowRight':
|
|
1224
|
+
next = current + grow;
|
|
1225
|
+
break;
|
|
1226
|
+
case 'ArrowLeft':
|
|
1227
|
+
next = current - grow;
|
|
1228
|
+
break;
|
|
1229
|
+
case 'Home':
|
|
1230
|
+
next = minWidth;
|
|
1231
|
+
break;
|
|
1232
|
+
case 'End':
|
|
1233
|
+
next = maxWidth;
|
|
1234
|
+
break;
|
|
1235
|
+
default:
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
event.preventDefault();
|
|
1239
|
+
this.store.setSidebarWidth(next);
|
|
1240
|
+
}
|
|
1241
|
+
/** Double-click the handle to return to the configured width. */
|
|
1242
|
+
onResizeReset() {
|
|
1243
|
+
this.store.setSidebarWidth(null);
|
|
1244
|
+
}
|
|
1245
|
+
isRtl(element) {
|
|
1246
|
+
return getComputedStyle(element).direction === 'rtl';
|
|
1247
|
+
}
|
|
1248
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSidebarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1249
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: ShellSidebarComponent, isStandalone: true, selector: "shell-sidebar", inputs: { collapseControl: { classPropertyName: "collapseControl", publicName: "collapseControl", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.data-mode": "state().mode", "attr.data-open": "state().open", "attr.data-labels": "state().labels", "attr.data-peeked": "state().peeked", "attr.data-shell-size": "regionSize()", "attr.data-resizing": "store.resizing() || null", "attr.data-variant": "config().appearance.sidebar", "attr.data-drawer-motion": "config().motion.drawer", "attr.inert": "state().open ? null : \"\"" } }, providers: [{ provide: SHELL_REGION, useExisting: ShellSidebarComponent }], viewQueries: [{ propertyName: "panel", first: true, predicate: ["panel"], descendants: true, isSignal: true }], ngImport: i0, template: "<!--\n `aside` wraps the panel so the drawer can slide and the rail can peek without\n moving the grid track. Everything below reads from one resolved state object.\n-->\n<aside\n #panel\n class=\"sidebar__panel\"\n [style.--shell-sidebar-panel-width]=\"state().panelWidth\"\n [style.--shell-container-width]=\"containerWidth()\"\n [attr.role]=\"state().modal ? 'dialog' : null\"\n [attr.aria-modal]=\"state().modal ? 'true' : null\"\n [attr.aria-label]=\"state().modal ? config().labels.primaryNavigation : null\"\n [attr.tabindex]=\"state().modal ? -1 : null\"\n (keydown.escape)=\"onEscape()\"\n (pointerenter)=\"onPointerEnter()\"\n (pointerleave)=\"onPointerLeave()\"\n (focusin)=\"onFocusIn()\"\n (focusout)=\"onFocusOut($event)\"\n>\n @if (hasHeader()) {\n <div class=\"sidebar__header\">\n <shell-slot-outlet name=\"sidebar-header\" class=\"sidebar__header-slot\" />\n </div>\n }\n\n <shell-slot-outlet name=\"sidebar-action\" class=\"sidebar__block\" />\n <shell-slot-outlet name=\"sidebar-search\" class=\"sidebar__block\" />\n\n <nav class=\"sidebar__nav\" [attr.aria-label]=\"config().labels.primaryNavigation\">\n <shell-slot-outlet name=\"sidebar-nav\" class=\"sidebar__block\" />\n </nav>\n\n @if (hasSecondary()) {\n <shell-slot-outlet name=\"sidebar-secondary\" class=\"sidebar__block sidebar__secondary\" />\n }\n\n @if (showFooter()) {\n <div class=\"sidebar__footer\">\n <shell-slot-outlet name=\"sidebar-footer\" class=\"sidebar__footer-slot\" />\n\n @if (showCollapse()) {\n <button shellSidebarToggle class=\"sidebar__collapse\">\n <span class=\"sidebar__collapse-glyph\" aria-hidden=\"true\"></span>\n </button>\n }\n </div>\n }\n\n @if (resizable()) {\n <div\n class=\"sidebar__resizer\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n tabindex=\"0\"\n [attr.aria-label]=\"config().labels.resizeSidebar\"\n [attr.aria-valuemin]=\"config().sidebar.minWidth\"\n [attr.aria-valuemax]=\"config().sidebar.maxWidth\"\n [attr.aria-valuenow]=\"resizeValue()\"\n (pointerdown)=\"onResizeStart($event)\"\n (pointermove)=\"onResizeMove($event)\"\n (pointerup)=\"onResizeEnd($event)\"\n (pointercancel)=\"onResizeEnd($event)\"\n (keydown)=\"onResizeKey($event)\"\n (dblclick)=\"onResizeReset()\"\n ></div>\n }\n</aside>\n", styles: ["@charset \"UTF-8\";:host{--shell-drawer-offset: -100%;--shell-drawer-origin: left;display:block;position:relative;min-inline-size:0}:host-context([dir=rtl]){--shell-drawer-offset: 100%;--shell-drawer-origin: right}.sidebar__panel{position:sticky;inset-block-start:var(--shell-sidebar-top-offset, 0px);display:flex;flex-direction:column;gap:var(--shell-sidebar-gap, 2px);box-sizing:border-box;inline-size:var(--shell-sidebar-panel-width, var(--shell-sidebar-width, 264px));max-inline-size:100vw;block-size:var(--shell-sidebar-block-size, 100%);padding:var(--shell-sidebar-padding, 12px);border-inline-end:1px solid var(--shell-sidebar-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-sidebar-radius, 0px);background:var(--shell-sidebar-bg, var(--shell-sidebar-surface, #fafafb));color:var(--shell-sidebar-fg, var(--shell-fg, #1b1c1f));box-shadow:var(--shell-sidebar-shadow, none);overflow:clip;transition:inline-size calc(var(--shell-collapse-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-collapse-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),box-shadow calc(var(--shell-sidebar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-sidebar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),background-color calc(var(--shell-sidebar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-sidebar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),transform calc(var(--shell-drawer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-drawer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),opacity calc(var(--shell-drawer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-drawer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),visibility calc(var(--shell-drawer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-drawer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.sidebar__panel{transition-duration:1ms}}:host([data-resizing]) .sidebar__panel{transition-duration:0s}:host([data-variant=borderless]) .sidebar__panel{border-inline-end-color:var(--shell-sidebar-border-color, transparent)}:host([data-variant=floating]:not([data-mode=overlay])) .sidebar__panel{--_inset: var(--shell-sidebar-inset, 8px);margin:var(--_inset);inline-size:calc(var(--shell-sidebar-panel-width, var(--shell-sidebar-width, 264px)) - 2 * var(--_inset));block-size:calc(var(--shell-sidebar-block-size, 100%) - 2 * var(--_inset));border:1px solid var(--shell-sidebar-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-sidebar-radius, var(--shell-radius, 10px));box-shadow:var(--shell-sidebar-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)))}:host([data-peeked=true]) .sidebar__panel{box-shadow:var(--shell-sidebar-peek-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)))}:host([data-layout=inset]:not([data-mode=overlay]):not([data-variant=floating])) .sidebar__panel{border-inline-end-color:var(--shell-sidebar-border-color, transparent);background:var(--shell-sidebar-bg, transparent);box-shadow:var(--shell-sidebar-shadow, none)}:host([data-layout=inset][data-peeked=true]:not([data-variant=floating])) .sidebar__panel{background:var(--shell-sidebar-bg, var(--shell-sidebar-surface, #fafafb));box-shadow:var(--shell-sidebar-peek-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)))}:host([data-mode=overlay]) .sidebar__panel{position:fixed;inset-block:0;inset-inline-start:0;block-size:100dvh;border-radius:0;border-start-end-radius:var(--shell-drawer-radius, 0px);border-end-end-radius:var(--shell-drawer-radius, 0px);background:var(--shell-drawer-bg, var(--shell-sidebar-bg, var(--shell-sidebar-surface, #fafafb)));transform-origin:var(--shell-drawer-origin) center;visibility:hidden}:host([data-mode=overlay][data-drawer-motion=slide]) .sidebar__panel{transform:translate(var(--shell-drawer-offset))}:host([data-mode=overlay][data-drawer-motion=fade]) .sidebar__panel{opacity:0}:host([data-mode=overlay][data-drawer-motion=scale]) .sidebar__panel{opacity:0;transform:scale(.94)}:host([data-mode=overlay][data-drawer-motion=none]) .sidebar__panel{transition-duration:0s}:host([data-mode=overlay][data-open=true]) .sidebar__panel{visibility:visible;opacity:1;transform:none;box-shadow:var(--shell-drawer-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)))}:host([data-mode=hidden]){display:none}.sidebar__header{display:flex;align-items:center;flex:0 0 auto;min-block-size:calc(var(--shell-nav-item-height, 36px) + 6px)}.sidebar__header-slot{flex:1 1 auto}.sidebar__block{flex:0 0 auto;flex-direction:column;align-items:stretch;gap:var(--shell-sidebar-gap, 2px)}.sidebar__nav{display:flex;flex-direction:column;flex:1 1 auto;min-block-size:0;margin-inline:calc(-1 * var(--shell-sidebar-padding, 12px));padding-inline:var(--shell-sidebar-padding, 12px);overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin}.sidebar__secondary{padding-block-start:var(--shell-sidebar-gap, 2px)}.sidebar__footer{display:flex;align-items:center;gap:8px;flex:0 0 auto;padding-block-start:8px;border-block-start:1px solid var(--shell-sidebar-border-color, var(--shell-border, #e6e6e9))}.sidebar__footer-slot{flex:1 1 auto;min-inline-size:0}.sidebar__collapse{display:grid;place-items:center;flex:0 0 auto;inline-size:32px;block-size:32px;margin-inline-start:auto;padding:0;border:0;border-radius:var(--shell-radius-sm, 6px);background:transparent;color:var(--shell-fg-muted, #6b6f76);cursor:pointer;transition-property:background-color,color;transition-duration:calc(var(--shell-sidebar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-sidebar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.sidebar__collapse{transition-duration:1ms}}.sidebar__collapse:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:2px}.sidebar__collapse:hover{background:var(--shell-hover-surface, #f0f0f3);color:var(--shell-sidebar-fg, var(--shell-fg, #1b1c1f))}.sidebar__collapse-glyph{inline-size:7px;block-size:7px;border-inline-start:2px solid currentcolor;border-block-end:2px solid currentcolor;transform:translate(1px) rotate(45deg);transition-property:transform;transition-duration:calc(var(--shell-collapse-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-collapse-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.sidebar__collapse-glyph{transition-duration:1ms}}:host([data-mode=collapsed]) .sidebar__collapse-glyph{transform:translate(-1px) rotate(-135deg)}:host-context([dir=rtl]) .sidebar__collapse-glyph{scale:-1 1}:host([data-labels=false]) .sidebar__panel{padding-inline:8px}:host([data-labels=false]) .sidebar__nav{margin-inline:-8px;padding-inline:8px}:host([data-labels=false]) .sidebar__footer{flex-direction:column}:host([data-labels=false]) .sidebar__collapse{margin-inline-start:0}.sidebar__resizer{position:absolute;inset-block:0;inset-inline-end:0;z-index:1;inline-size:6px;cursor:col-resize;touch-action:none}.sidebar__resizer:after{content:\"\";position:absolute;inset-block:0;inset-inline-end:0;inline-size:2px;background:transparent;transition-property:background-color;transition-duration:calc(var(--shell-sidebar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-sidebar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.sidebar__resizer:after{transition-duration:1ms}}.sidebar__resizer:hover:after,.sidebar__resizer:focus-visible:after{background:var(--shell-accent, #4f46e5)}.sidebar__resizer:focus-visible{outline:none}:host([data-resizing]) .sidebar__resizer:after{background:var(--shell-accent, #4f46e5)}\n"], dependencies: [{ kind: "component", type: ShellSlotOutletComponent, selector: "shell-slot-outlet", inputs: ["name", "select", "size"] }, { kind: "directive", type: SidebarToggleDirective, selector: "button[shellSidebarToggle]", exportAs: ["shellSidebarToggle"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1250
|
+
}
|
|
1251
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSidebarComponent, decorators: [{
|
|
1252
|
+
type: Component,
|
|
1253
|
+
args: [{ selector: 'shell-sidebar', imports: [ShellSlotOutletComponent, SidebarToggleDirective], host: {
|
|
1254
|
+
'[attr.data-mode]': 'state().mode',
|
|
1255
|
+
'[attr.data-open]': 'state().open',
|
|
1256
|
+
'[attr.data-labels]': 'state().labels',
|
|
1257
|
+
'[attr.data-peeked]': 'state().peeked',
|
|
1258
|
+
'[attr.data-shell-size]': 'regionSize()',
|
|
1259
|
+
'[attr.data-resizing]': 'store.resizing() || null',
|
|
1260
|
+
'[attr.data-variant]': 'config().appearance.sidebar',
|
|
1261
|
+
'[attr.data-drawer-motion]': 'config().motion.drawer',
|
|
1262
|
+
'[attr.inert]': 'state().open ? null : ""',
|
|
1263
|
+
}, providers: [{ provide: SHELL_REGION, useExisting: ShellSidebarComponent }], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!--\n `aside` wraps the panel so the drawer can slide and the rail can peek without\n moving the grid track. Everything below reads from one resolved state object.\n-->\n<aside\n #panel\n class=\"sidebar__panel\"\n [style.--shell-sidebar-panel-width]=\"state().panelWidth\"\n [style.--shell-container-width]=\"containerWidth()\"\n [attr.role]=\"state().modal ? 'dialog' : null\"\n [attr.aria-modal]=\"state().modal ? 'true' : null\"\n [attr.aria-label]=\"state().modal ? config().labels.primaryNavigation : null\"\n [attr.tabindex]=\"state().modal ? -1 : null\"\n (keydown.escape)=\"onEscape()\"\n (pointerenter)=\"onPointerEnter()\"\n (pointerleave)=\"onPointerLeave()\"\n (focusin)=\"onFocusIn()\"\n (focusout)=\"onFocusOut($event)\"\n>\n @if (hasHeader()) {\n <div class=\"sidebar__header\">\n <shell-slot-outlet name=\"sidebar-header\" class=\"sidebar__header-slot\" />\n </div>\n }\n\n <shell-slot-outlet name=\"sidebar-action\" class=\"sidebar__block\" />\n <shell-slot-outlet name=\"sidebar-search\" class=\"sidebar__block\" />\n\n <nav class=\"sidebar__nav\" [attr.aria-label]=\"config().labels.primaryNavigation\">\n <shell-slot-outlet name=\"sidebar-nav\" class=\"sidebar__block\" />\n </nav>\n\n @if (hasSecondary()) {\n <shell-slot-outlet name=\"sidebar-secondary\" class=\"sidebar__block sidebar__secondary\" />\n }\n\n @if (showFooter()) {\n <div class=\"sidebar__footer\">\n <shell-slot-outlet name=\"sidebar-footer\" class=\"sidebar__footer-slot\" />\n\n @if (showCollapse()) {\n <button shellSidebarToggle class=\"sidebar__collapse\">\n <span class=\"sidebar__collapse-glyph\" aria-hidden=\"true\"></span>\n </button>\n }\n </div>\n }\n\n @if (resizable()) {\n <div\n class=\"sidebar__resizer\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n tabindex=\"0\"\n [attr.aria-label]=\"config().labels.resizeSidebar\"\n [attr.aria-valuemin]=\"config().sidebar.minWidth\"\n [attr.aria-valuemax]=\"config().sidebar.maxWidth\"\n [attr.aria-valuenow]=\"resizeValue()\"\n (pointerdown)=\"onResizeStart($event)\"\n (pointermove)=\"onResizeMove($event)\"\n (pointerup)=\"onResizeEnd($event)\"\n (pointercancel)=\"onResizeEnd($event)\"\n (keydown)=\"onResizeKey($event)\"\n (dblclick)=\"onResizeReset()\"\n ></div>\n }\n</aside>\n", styles: ["@charset \"UTF-8\";:host{--shell-drawer-offset: -100%;--shell-drawer-origin: left;display:block;position:relative;min-inline-size:0}:host-context([dir=rtl]){--shell-drawer-offset: 100%;--shell-drawer-origin: right}.sidebar__panel{position:sticky;inset-block-start:var(--shell-sidebar-top-offset, 0px);display:flex;flex-direction:column;gap:var(--shell-sidebar-gap, 2px);box-sizing:border-box;inline-size:var(--shell-sidebar-panel-width, var(--shell-sidebar-width, 264px));max-inline-size:100vw;block-size:var(--shell-sidebar-block-size, 100%);padding:var(--shell-sidebar-padding, 12px);border-inline-end:1px solid var(--shell-sidebar-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-sidebar-radius, 0px);background:var(--shell-sidebar-bg, var(--shell-sidebar-surface, #fafafb));color:var(--shell-sidebar-fg, var(--shell-fg, #1b1c1f));box-shadow:var(--shell-sidebar-shadow, none);overflow:clip;transition:inline-size calc(var(--shell-collapse-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-collapse-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),box-shadow calc(var(--shell-sidebar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-sidebar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),background-color calc(var(--shell-sidebar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-sidebar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),transform calc(var(--shell-drawer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-drawer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),opacity calc(var(--shell-drawer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-drawer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))),visibility calc(var(--shell-drawer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1)) var(--shell-drawer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.sidebar__panel{transition-duration:1ms}}:host([data-resizing]) .sidebar__panel{transition-duration:0s}:host([data-variant=borderless]) .sidebar__panel{border-inline-end-color:var(--shell-sidebar-border-color, transparent)}:host([data-variant=floating]:not([data-mode=overlay])) .sidebar__panel{--_inset: var(--shell-sidebar-inset, 8px);margin:var(--_inset);inline-size:calc(var(--shell-sidebar-panel-width, var(--shell-sidebar-width, 264px)) - 2 * var(--_inset));block-size:calc(var(--shell-sidebar-block-size, 100%) - 2 * var(--_inset));border:1px solid var(--shell-sidebar-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-sidebar-radius, var(--shell-radius, 10px));box-shadow:var(--shell-sidebar-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)))}:host([data-peeked=true]) .sidebar__panel{box-shadow:var(--shell-sidebar-peek-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)))}:host([data-layout=inset]:not([data-mode=overlay]):not([data-variant=floating])) .sidebar__panel{border-inline-end-color:var(--shell-sidebar-border-color, transparent);background:var(--shell-sidebar-bg, transparent);box-shadow:var(--shell-sidebar-shadow, none)}:host([data-layout=inset][data-peeked=true]:not([data-variant=floating])) .sidebar__panel{background:var(--shell-sidebar-bg, var(--shell-sidebar-surface, #fafafb));box-shadow:var(--shell-sidebar-peek-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)))}:host([data-mode=overlay]) .sidebar__panel{position:fixed;inset-block:0;inset-inline-start:0;block-size:100dvh;border-radius:0;border-start-end-radius:var(--shell-drawer-radius, 0px);border-end-end-radius:var(--shell-drawer-radius, 0px);background:var(--shell-drawer-bg, var(--shell-sidebar-bg, var(--shell-sidebar-surface, #fafafb)));transform-origin:var(--shell-drawer-origin) center;visibility:hidden}:host([data-mode=overlay][data-drawer-motion=slide]) .sidebar__panel{transform:translate(var(--shell-drawer-offset))}:host([data-mode=overlay][data-drawer-motion=fade]) .sidebar__panel{opacity:0}:host([data-mode=overlay][data-drawer-motion=scale]) .sidebar__panel{opacity:0;transform:scale(.94)}:host([data-mode=overlay][data-drawer-motion=none]) .sidebar__panel{transition-duration:0s}:host([data-mode=overlay][data-open=true]) .sidebar__panel{visibility:visible;opacity:1;transform:none;box-shadow:var(--shell-drawer-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)))}:host([data-mode=hidden]){display:none}.sidebar__header{display:flex;align-items:center;flex:0 0 auto;min-block-size:calc(var(--shell-nav-item-height, 36px) + 6px)}.sidebar__header-slot{flex:1 1 auto}.sidebar__block{flex:0 0 auto;flex-direction:column;align-items:stretch;gap:var(--shell-sidebar-gap, 2px)}.sidebar__nav{display:flex;flex-direction:column;flex:1 1 auto;min-block-size:0;margin-inline:calc(-1 * var(--shell-sidebar-padding, 12px));padding-inline:var(--shell-sidebar-padding, 12px);overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin}.sidebar__secondary{padding-block-start:var(--shell-sidebar-gap, 2px)}.sidebar__footer{display:flex;align-items:center;gap:8px;flex:0 0 auto;padding-block-start:8px;border-block-start:1px solid var(--shell-sidebar-border-color, var(--shell-border, #e6e6e9))}.sidebar__footer-slot{flex:1 1 auto;min-inline-size:0}.sidebar__collapse{display:grid;place-items:center;flex:0 0 auto;inline-size:32px;block-size:32px;margin-inline-start:auto;padding:0;border:0;border-radius:var(--shell-radius-sm, 6px);background:transparent;color:var(--shell-fg-muted, #6b6f76);cursor:pointer;transition-property:background-color,color;transition-duration:calc(var(--shell-sidebar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-sidebar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.sidebar__collapse{transition-duration:1ms}}.sidebar__collapse:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:2px}.sidebar__collapse:hover{background:var(--shell-hover-surface, #f0f0f3);color:var(--shell-sidebar-fg, var(--shell-fg, #1b1c1f))}.sidebar__collapse-glyph{inline-size:7px;block-size:7px;border-inline-start:2px solid currentcolor;border-block-end:2px solid currentcolor;transform:translate(1px) rotate(45deg);transition-property:transform;transition-duration:calc(var(--shell-collapse-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-collapse-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.sidebar__collapse-glyph{transition-duration:1ms}}:host([data-mode=collapsed]) .sidebar__collapse-glyph{transform:translate(-1px) rotate(-135deg)}:host-context([dir=rtl]) .sidebar__collapse-glyph{scale:-1 1}:host([data-labels=false]) .sidebar__panel{padding-inline:8px}:host([data-labels=false]) .sidebar__nav{margin-inline:-8px;padding-inline:8px}:host([data-labels=false]) .sidebar__footer{flex-direction:column}:host([data-labels=false]) .sidebar__collapse{margin-inline-start:0}.sidebar__resizer{position:absolute;inset-block:0;inset-inline-end:0;z-index:1;inline-size:6px;cursor:col-resize;touch-action:none}.sidebar__resizer:after{content:\"\";position:absolute;inset-block:0;inset-inline-end:0;inline-size:2px;background:transparent;transition-property:background-color;transition-duration:calc(var(--shell-sidebar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-sidebar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.sidebar__resizer:after{transition-duration:1ms}}.sidebar__resizer:hover:after,.sidebar__resizer:focus-visible:after{background:var(--shell-accent, #4f46e5)}.sidebar__resizer:focus-visible{outline:none}:host([data-resizing]) .sidebar__resizer:after{background:var(--shell-accent, #4f46e5)}\n"] }]
|
|
1264
|
+
}], ctorParameters: () => [], propDecorators: { panel: [{ type: i0.ViewChild, args: ['panel', { isSignal: true }] }], collapseControl: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapseControl", required: false }] }] } });
|
|
1265
|
+
|
|
1266
|
+
let nextId = 0;
|
|
1267
|
+
/**
|
|
1268
|
+
* The shell's ⋯ menu: a disclosure button plus a panel for projected content.
|
|
1269
|
+
* Shared by the topbar, the breadcrumb row and the breadcrumb's folded levels,
|
|
1270
|
+
* so behaviour, motion and accessibility live in one place.
|
|
1271
|
+
*
|
|
1272
|
+
* The panel stays mounted so it can animate out as well as in; while closed it
|
|
1273
|
+
* is invisible and `inert`. It closes on Escape (returning focus to the
|
|
1274
|
+
* trigger), on a press outside, and after a link or button inside is used.
|
|
1275
|
+
*/
|
|
1276
|
+
class ShellOverflowMenuComponent {
|
|
1277
|
+
/** Accessible name and tooltip of the trigger. */
|
|
1278
|
+
label = input.required(...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
1279
|
+
/** Which edge of the trigger the panel lines up with. */
|
|
1280
|
+
align = input('end', ...(ngDevMode ? [{ debugName: "align" }] : /* istanbul ignore next */ []));
|
|
1281
|
+
host = inject(ElementRef);
|
|
1282
|
+
trigger = viewChild.required('trigger');
|
|
1283
|
+
store = inject(ShellStore);
|
|
1284
|
+
motion = computed(() => this.store.config().motion.overflowMenu, ...(ngDevMode ? [{ debugName: "motion" }] : /* istanbul ignore next */ []));
|
|
1285
|
+
panelId = `shell-overflow-menu-${nextId++}`;
|
|
1286
|
+
openState = signal(false, ...(ngDevMode ? [{ debugName: "openState" }] : /* istanbul ignore next */ []));
|
|
1287
|
+
open = this.openState.asReadonly();
|
|
1288
|
+
toggle() {
|
|
1289
|
+
this.openState.update((open) => !open);
|
|
1290
|
+
}
|
|
1291
|
+
close(restoreFocus = false) {
|
|
1292
|
+
if (!this.openState()) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
this.openState.set(false);
|
|
1296
|
+
if (restoreFocus) {
|
|
1297
|
+
this.trigger().nativeElement.focus();
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
onDocumentPointerDown(event) {
|
|
1301
|
+
if (!this.host.nativeElement.contains(event.target)) {
|
|
1302
|
+
this.close();
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** Activating anything inside the menu is a choice; close behind it. */
|
|
1306
|
+
onPanelClick(event) {
|
|
1307
|
+
if (event.target.closest('a[href], button:not([aria-haspopup])')) {
|
|
1308
|
+
this.close();
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellOverflowMenuComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1312
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.23", type: ShellOverflowMenuComponent, isStandalone: true, selector: "shell-overflow-menu", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, align: { classPropertyName: "align", publicName: "align", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keydown.escape": "close(true)", "document:pointerdown": "onDocumentPointerDown($event)" }, properties: { "attr.data-align": "align()", "attr.data-menu-motion": "motion()" } }, viewQueries: [{ propertyName: "trigger", first: true, predicate: ["trigger"], descendants: true, isSignal: true }], ngImport: i0, template: "<button\n #trigger\n type=\"button\"\n class=\"shell-menu__trigger\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"panelId\"\n [attr.aria-label]=\"label()\"\n [attr.title]=\"label()\"\n (click)=\"toggle()\"\n>\n <span class=\"shell-menu__glyph\" aria-hidden=\"true\"></span>\n</button>\n\n<div\n class=\"shell-menu__panel\"\n [id]=\"panelId\"\n [attr.data-open]=\"open()\"\n [attr.inert]=\"open() ? null : ''\"\n (click)=\"onPanelClick($event)\"\n>\n <ng-content />\n</div>\n", styles: ["@charset \"UTF-8\";:host{position:relative;display:inline-flex;flex:0 0 auto}.shell-menu__trigger{display:grid;place-items:center;inline-size:var(--shell-overflow-trigger-size, 38px);block-size:var(--shell-overflow-trigger-size, 38px);padding:0;border:0;border-radius:var(--shell-radius-sm, 6px);background:transparent;color:inherit;cursor:pointer;transition-property:background-color;transition-duration:calc(var(--shell-menu-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-menu-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.shell-menu__trigger{transition-duration:1ms}}.shell-menu__trigger:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:2px}.shell-menu__trigger:hover{background:var(--shell-hover-surface, #f0f0f3)}.shell-menu__trigger[aria-expanded=true]{background:var(--shell-active-surface, #e9e9ee)}.shell-menu__glyph{inline-size:4px;block-size:4px;border-radius:50%;background:currentcolor;box-shadow:-7px 0 0 currentcolor,7px 0 0 currentcolor}.shell-menu__panel{position:absolute;inset-inline-end:0;inset-block-start:calc(100% + 6px);z-index:var(--shell-z-flyout, 50);display:flex;flex-direction:column;gap:4px;min-inline-size:220px;max-inline-size:min(90vw,320px);padding:8px;border:1px solid var(--shell-menu-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-menu-radius, var(--shell-radius, 10px));background:var(--shell-menu-bg, var(--shell-surface, #ffffff));color:var(--shell-fg, #1b1c1f);box-shadow:var(--shell-menu-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)));transform-origin:top right;transition-property:opacity,transform,visibility;transition-duration:calc(var(--shell-menu-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-menu-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.shell-menu__panel{transition-duration:1ms}}.shell-menu__panel[data-open=false]{opacity:0;visibility:hidden;pointer-events:none}:host([data-align=start]) .shell-menu__panel{inset-inline-start:0;inset-inline-end:auto;transform-origin:top left}:host-context([dir=rtl]) .shell-menu__panel{transform-origin:top left}:host([data-menu-motion=scale]) .shell-menu__panel[data-open=false]{transform:scale(.95)}:host([data-menu-motion=slide]) .shell-menu__panel[data-open=false]{transform:translateY(-8px)}:host([data-menu-motion=none]) .shell-menu__panel{transition-duration:0s}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1313
|
+
}
|
|
1314
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellOverflowMenuComponent, decorators: [{
|
|
1315
|
+
type: Component,
|
|
1316
|
+
args: [{ selector: 'shell-overflow-menu', host: {
|
|
1317
|
+
'[attr.data-align]': 'align()',
|
|
1318
|
+
'[attr.data-menu-motion]': 'motion()',
|
|
1319
|
+
'(keydown.escape)': 'close(true)',
|
|
1320
|
+
'(document:pointerdown)': 'onDocumentPointerDown($event)',
|
|
1321
|
+
}, changeDetection: ChangeDetectionStrategy.OnPush, template: "<button\n #trigger\n type=\"button\"\n class=\"shell-menu__trigger\"\n [attr.aria-expanded]=\"open()\"\n [attr.aria-controls]=\"panelId\"\n [attr.aria-label]=\"label()\"\n [attr.title]=\"label()\"\n (click)=\"toggle()\"\n>\n <span class=\"shell-menu__glyph\" aria-hidden=\"true\"></span>\n</button>\n\n<div\n class=\"shell-menu__panel\"\n [id]=\"panelId\"\n [attr.data-open]=\"open()\"\n [attr.inert]=\"open() ? null : ''\"\n (click)=\"onPanelClick($event)\"\n>\n <ng-content />\n</div>\n", styles: ["@charset \"UTF-8\";:host{position:relative;display:inline-flex;flex:0 0 auto}.shell-menu__trigger{display:grid;place-items:center;inline-size:var(--shell-overflow-trigger-size, 38px);block-size:var(--shell-overflow-trigger-size, 38px);padding:0;border:0;border-radius:var(--shell-radius-sm, 6px);background:transparent;color:inherit;cursor:pointer;transition-property:background-color;transition-duration:calc(var(--shell-menu-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-menu-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.shell-menu__trigger{transition-duration:1ms}}.shell-menu__trigger:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:2px}.shell-menu__trigger:hover{background:var(--shell-hover-surface, #f0f0f3)}.shell-menu__trigger[aria-expanded=true]{background:var(--shell-active-surface, #e9e9ee)}.shell-menu__glyph{inline-size:4px;block-size:4px;border-radius:50%;background:currentcolor;box-shadow:-7px 0 0 currentcolor,7px 0 0 currentcolor}.shell-menu__panel{position:absolute;inset-inline-end:0;inset-block-start:calc(100% + 6px);z-index:var(--shell-z-flyout, 50);display:flex;flex-direction:column;gap:4px;min-inline-size:220px;max-inline-size:min(90vw,320px);padding:8px;border:1px solid var(--shell-menu-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-menu-radius, var(--shell-radius, 10px));background:var(--shell-menu-bg, var(--shell-surface, #ffffff));color:var(--shell-fg, #1b1c1f);box-shadow:var(--shell-menu-shadow, var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28)));transform-origin:top right;transition-property:opacity,transform,visibility;transition-duration:calc(var(--shell-menu-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-menu-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.shell-menu__panel{transition-duration:1ms}}.shell-menu__panel[data-open=false]{opacity:0;visibility:hidden;pointer-events:none}:host([data-align=start]) .shell-menu__panel{inset-inline-start:0;inset-inline-end:auto;transform-origin:top left}:host-context([dir=rtl]) .shell-menu__panel{transform-origin:top left}:host([data-menu-motion=scale]) .shell-menu__panel[data-open=false]{transform:scale(.95)}:host([data-menu-motion=slide]) .shell-menu__panel[data-open=false]{transform:translateY(-8px)}:host([data-menu-motion=none]) .shell-menu__panel{transition-duration:0s}\n"] }]
|
|
1322
|
+
}], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], align: [{ type: i0.Input, args: [{ isSignal: true, alias: "align", required: false }] }], trigger: [{ type: i0.ViewChild, args: ['trigger', { isSignal: true }] }] } });
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Breadcrumb trail, following the WAI-ARIA breadcrumb pattern:
|
|
1326
|
+
* `<nav aria-label>` › `<ol>` › links, the current page as text with
|
|
1327
|
+
* `aria-current="page"`, and CSS separators that screen readers skip.
|
|
1328
|
+
*
|
|
1329
|
+
* Renders the router-derived trail from `ShellBreadcrumbStore`, or `[items]`.
|
|
1330
|
+
* It measures its own width and shortens itself instead of wrapping:
|
|
1331
|
+
* full → middle levels folded into "…" → first › … › parent › current →
|
|
1332
|
+
* a single "‹ Parent" back link.
|
|
1333
|
+
*/
|
|
1334
|
+
class ShellBreadcrumbComponent {
|
|
1335
|
+
/** A manual trail. Leave unset to follow the router. */
|
|
1336
|
+
items = input(null, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
|
|
1337
|
+
/** Overrides `breadcrumbs.maxItems` for this instance. */
|
|
1338
|
+
maxItems = input(null, ...(ngDevMode ? [{ debugName: "maxItems" }] : /* istanbul ignore next */ []));
|
|
1339
|
+
/** Force a layout instead of choosing one from the available width. */
|
|
1340
|
+
display = input('auto', ...(ngDevMode ? [{ debugName: "display" }] : /* istanbul ignore next */ []));
|
|
1341
|
+
/** Custom crumb content; the link and `aria-current` stay the library's. */
|
|
1342
|
+
itemTemplate = input(null, ...(ngDevMode ? [{ debugName: "itemTemplate" }] : /* istanbul ignore next */ []));
|
|
1343
|
+
store = inject(ShellBreadcrumbStore);
|
|
1344
|
+
shell = inject(ShellStore);
|
|
1345
|
+
width = observeWidth(inject(ElementRef));
|
|
1346
|
+
options = computed(() => this.shell.config().breadcrumbs, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
|
|
1347
|
+
separator = computed(() => this.options().separator, ...(ngDevMode ? [{ debugName: "separator" }] : /* istanbul ignore next */ []));
|
|
1348
|
+
labels = computed(() => this.shell.config().labels, ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
|
|
1349
|
+
crumbs = computed(() => this.items() ?? this.store.crumbs(), ...(ngDevMode ? [{ debugName: "crumbs" }] : /* istanbul ignore next */ []));
|
|
1350
|
+
mode = computed(() => {
|
|
1351
|
+
const forced = this.display();
|
|
1352
|
+
if (forced !== 'auto') {
|
|
1353
|
+
return forced;
|
|
1354
|
+
}
|
|
1355
|
+
const width = this.width();
|
|
1356
|
+
if (width === null) {
|
|
1357
|
+
return 'full';
|
|
1358
|
+
}
|
|
1359
|
+
const { backBelow, compactBelow } = this.options();
|
|
1360
|
+
return width < backBelow ? 'back' : width < compactBelow ? 'compact' : 'full';
|
|
1361
|
+
}, ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
|
|
1362
|
+
view = computed(() => {
|
|
1363
|
+
const crumbs = this.crumbs();
|
|
1364
|
+
// compact = first › … › parent › current
|
|
1365
|
+
const limit = this.mode() === 'compact' ? 3 : Math.max(2, this.maxItems() ?? this.options().maxItems);
|
|
1366
|
+
if (crumbs.length <= limit) {
|
|
1367
|
+
return { head: crumbs, hidden: [], tail: [] };
|
|
1368
|
+
}
|
|
1369
|
+
const tailCount = limit - 1;
|
|
1370
|
+
return {
|
|
1371
|
+
head: crumbs.slice(0, 1),
|
|
1372
|
+
hidden: crumbs.slice(1, crumbs.length - tailCount),
|
|
1373
|
+
tail: crumbs.slice(crumbs.length - tailCount),
|
|
1374
|
+
};
|
|
1375
|
+
}, ...(ngDevMode ? [{ debugName: "view" }] : /* istanbul ignore next */ []));
|
|
1376
|
+
/** Target of the "‹ Parent" link: the level just above the current page. */
|
|
1377
|
+
parent = computed(() => {
|
|
1378
|
+
const crumbs = this.crumbs();
|
|
1379
|
+
const current = crumbs.findIndex((crumb) => crumb.current);
|
|
1380
|
+
if (current === -1) {
|
|
1381
|
+
return crumbs.at(-1) ?? null;
|
|
1382
|
+
}
|
|
1383
|
+
return current > 0 ? crumbs[current - 1] : null;
|
|
1384
|
+
}, ...(ngDevMode ? [{ debugName: "parent" }] : /* istanbul ignore next */ []));
|
|
1385
|
+
pending = computed(() => this.crumbs().some((crumb) => crumb.label === null), ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
|
|
1386
|
+
/** Fast labels never flash a skeleton: it only shows after `skeletonDelay`. */
|
|
1387
|
+
delayElapsed = signal(false, ...(ngDevMode ? [{ debugName: "delayElapsed" }] : /* istanbul ignore next */ []));
|
|
1388
|
+
skeletonShown = computed(() => this.pending() && (this.options().skeletonDelay <= 0 || this.delayElapsed()), ...(ngDevMode ? [{ debugName: "skeletonShown" }] : /* istanbul ignore next */ []));
|
|
1389
|
+
constructor() {
|
|
1390
|
+
effect((onCleanup) => {
|
|
1391
|
+
if (!this.pending()) {
|
|
1392
|
+
this.delayElapsed.set(false);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const timer = setTimeout(() => this.delayElapsed.set(true), this.options().skeletonDelay);
|
|
1396
|
+
onCleanup(() => clearTimeout(timer));
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellBreadcrumbComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1400
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: ShellBreadcrumbComponent, isStandalone: true, selector: "shell-breadcrumb", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, maxItems: { classPropertyName: "maxItems", publicName: "maxItems", isSignal: true, isRequired: false, transformFunction: null }, display: { classPropertyName: "display", publicName: "display", isSignal: true, isRequired: false, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.data-display": "mode()", "attr.data-separator": "separator()" } }, ngImport: i0, template: "<nav class=\"crumbs\" [attr.aria-label]=\"labels().breadcrumb\">\n @if (mode() === 'back') {\n @if (parent(); as target) {\n <a\n class=\"crumbs__back\"\n [routerLink]=\"target.url\"\n [attr.aria-label]=\"labels().back + ' ' + (target.label ?? '')\"\n >\n <span class=\"crumbs__back-glyph\" aria-hidden=\"true\"></span>\n <ng-container *ngTemplateOutlet=\"labelTemplate; context: { $implicit: target }\" />\n </a>\n } @else if (crumbs()[0]; as only) {\n <span class=\"crumbs__current\" aria-current=\"page\">\n <ng-container *ngTemplateOutlet=\"labelTemplate; context: { $implicit: only }\" />\n </span>\n }\n } @else {\n @let trail = view();\n <ol class=\"crumbs__list\" [attr.aria-busy]=\"pending() ? 'true' : null\">\n @for (crumb of trail.head; track crumb.url) {\n <li class=\"crumbs__item\">\n <ng-container *ngTemplateOutlet=\"crumbTemplate; context: { $implicit: crumb }\" />\n </li>\n }\n\n @if (trail.hidden.length) {\n <li class=\"crumbs__item crumbs__item--more\">\n <shell-overflow-menu class=\"crumbs__more\" align=\"start\" [label]=\"labels().showPath\">\n @for (crumb of trail.hidden; track crumb.url) {\n <a class=\"crumbs__menu-link\" [routerLink]=\"crumb.url\">{{ crumb.label }}</a>\n }\n </shell-overflow-menu>\n </li>\n }\n\n @for (crumb of trail.tail; track crumb.url) {\n <li class=\"crumbs__item\">\n <ng-container *ngTemplateOutlet=\"crumbTemplate; context: { $implicit: crumb }\" />\n </li>\n }\n </ol>\n }\n\n @if (pending()) {\n <span class=\"crumbs__sr\">{{ labels().loading }}</span>\n }\n</nav>\n\n<ng-template #crumbTemplate let-crumb>\n @if (crumb.current) {\n <span class=\"crumbs__current\" aria-current=\"page\">\n <ng-container *ngTemplateOutlet=\"labelTemplate; context: { $implicit: crumb }\" />\n </span>\n } @else {\n <a class=\"crumbs__link\" [routerLink]=\"crumb.url\">\n <ng-container *ngTemplateOutlet=\"labelTemplate; context: { $implicit: crumb }\" />\n </a>\n }\n</ng-template>\n\n<!--\n A pending label is a placeholder: it reserves its width from the start, and\n only becomes a visible skeleton once `skeletonDelay` has passed.\n-->\n<ng-template #labelTemplate let-crumb>\n @if (crumb.label === null) {\n <span class=\"crumbs__skeleton\" [class.crumbs__skeleton--shown]=\"skeletonShown()\" aria-hidden=\"true\"></span>\n } @else if (itemTemplate(); as custom) {\n <ng-container *ngTemplateOutlet=\"custom; context: { $implicit: crumb, crumb: crumb }\" />\n } @else {\n <span class=\"crumbs__label\">{{ crumb.label }}</span>\n }\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;min-inline-size:0;font-size:var(--shell-breadcrumb-font-size, 1em)}.crumbs{display:flex;align-items:center;min-inline-size:0}.crumbs__list{display:flex;align-items:center;gap:var(--shell-breadcrumb-gap, 4px);min-inline-size:0;margin:0;padding:0;list-style:none;white-space:nowrap}.crumbs__item{display:flex;align-items:center;gap:var(--shell-breadcrumb-gap, 4px);flex:0 3 auto;min-inline-size:0}.crumbs__item:last-child{flex-shrink:1}.crumbs__item+.crumbs__item:before{content:\"\";flex:0 0 auto;inline-size:5px;block-size:5px;margin-inline:2px 3px;border-block-start:1.5px solid var(--shell-breadcrumb-separator-color, var(--shell-fg-muted, #6b6f76));border-right:1.5px solid var(--shell-breadcrumb-separator-color, var(--shell-fg-muted, #6b6f76));transform:rotate(45deg)}:host-context([dir=rtl]) .crumbs__item+.crumbs__item:before{transform:rotate(-135deg)}:host([data-separator=slash]) .crumbs__item+.crumbs__item:before,:host([data-separator=dot]) .crumbs__item+.crumbs__item:before{inline-size:auto;block-size:auto;margin-inline:1px 2px;border:0;color:var(--shell-breadcrumb-separator-color, var(--shell-fg-muted, #6b6f76));line-height:1;transform:none}:host([data-separator=slash]) .crumbs__item+.crumbs__item:before{content:\"/\" / \"\"}:host([data-separator=dot]) .crumbs__item+.crumbs__item:before{content:\"\\b7\" / \"\";font-weight:700}.crumbs__link,.crumbs__current,.crumbs__back{display:inline-flex;align-items:center;gap:6px;min-inline-size:0;max-inline-size:var(--shell-breadcrumb-label-max, 28ch);padding:2px 4px;border-radius:var(--shell-radius-sm, 6px)}.crumbs__link,.crumbs__back{color:var(--shell-breadcrumb-fg, var(--shell-fg-muted, #6b6f76));text-decoration:none;transition-property:color,background-color;transition-duration:calc(var(--shell-subheader-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-subheader-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.crumbs__link,.crumbs__back{transition-duration:1ms}}.crumbs__link:focus-visible,.crumbs__back:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:0}.crumbs__link:hover,.crumbs__back:hover{color:var(--shell-breadcrumb-hover-fg, var(--shell-fg, #1b1c1f));background:var(--shell-breadcrumb-hover-bg, var(--shell-hover-surface, #f0f0f3))}.crumbs__current{color:var(--shell-breadcrumb-current-fg, var(--shell-fg, #1b1c1f));font-weight:var(--shell-breadcrumb-current-weight, 600)}.crumbs__label{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.crumbs__back-glyph{flex:0 0 auto;inline-size:6px;block-size:6px;border-block-end:1.5px solid currentcolor;border-left:1.5px solid currentcolor;transform:rotate(45deg)}:host-context([dir=rtl]) .crumbs__back-glyph{transform:rotate(-135deg)}.crumbs__more{--shell-overflow-trigger-size: 26px}.crumbs__menu-link{display:block;padding:7px 10px;border-radius:var(--shell-radius-sm, 6px);color:var(--shell-fg, #1b1c1f);text-decoration:none;white-space:nowrap}.crumbs__menu-link:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:-2px}.crumbs__menu-link:hover{background:var(--shell-hover-surface, #f0f0f3)}.crumbs__skeleton{display:inline-block;inline-size:9ch;block-size:.85em;border-radius:999px;background:var(--shell-breadcrumb-skeleton-bg, var(--shell-active-surface, #e9e9ee));visibility:hidden}.crumbs__skeleton--shown{visibility:visible;animation:crumbs-pulse calc(1.6s * var(--shell-motion-scale, 1)) ease-in-out infinite}@media(prefers-reduced-motion:reduce){.crumbs__skeleton--shown{animation:none}}@keyframes crumbs-pulse{50%{opacity:.45}}.crumbs__sr{position:absolute;inline-size:1px;block-size:1px;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: ShellOverflowMenuComponent, selector: "shell-overflow-menu", inputs: ["label", "align"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1401
|
+
}
|
|
1402
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellBreadcrumbComponent, decorators: [{
|
|
1403
|
+
type: Component,
|
|
1404
|
+
args: [{ selector: 'shell-breadcrumb', imports: [NgTemplateOutlet, RouterLink, ShellOverflowMenuComponent], host: {
|
|
1405
|
+
'[attr.data-display]': 'mode()',
|
|
1406
|
+
'[attr.data-separator]': 'separator()',
|
|
1407
|
+
}, changeDetection: ChangeDetectionStrategy.OnPush, template: "<nav class=\"crumbs\" [attr.aria-label]=\"labels().breadcrumb\">\n @if (mode() === 'back') {\n @if (parent(); as target) {\n <a\n class=\"crumbs__back\"\n [routerLink]=\"target.url\"\n [attr.aria-label]=\"labels().back + ' ' + (target.label ?? '')\"\n >\n <span class=\"crumbs__back-glyph\" aria-hidden=\"true\"></span>\n <ng-container *ngTemplateOutlet=\"labelTemplate; context: { $implicit: target }\" />\n </a>\n } @else if (crumbs()[0]; as only) {\n <span class=\"crumbs__current\" aria-current=\"page\">\n <ng-container *ngTemplateOutlet=\"labelTemplate; context: { $implicit: only }\" />\n </span>\n }\n } @else {\n @let trail = view();\n <ol class=\"crumbs__list\" [attr.aria-busy]=\"pending() ? 'true' : null\">\n @for (crumb of trail.head; track crumb.url) {\n <li class=\"crumbs__item\">\n <ng-container *ngTemplateOutlet=\"crumbTemplate; context: { $implicit: crumb }\" />\n </li>\n }\n\n @if (trail.hidden.length) {\n <li class=\"crumbs__item crumbs__item--more\">\n <shell-overflow-menu class=\"crumbs__more\" align=\"start\" [label]=\"labels().showPath\">\n @for (crumb of trail.hidden; track crumb.url) {\n <a class=\"crumbs__menu-link\" [routerLink]=\"crumb.url\">{{ crumb.label }}</a>\n }\n </shell-overflow-menu>\n </li>\n }\n\n @for (crumb of trail.tail; track crumb.url) {\n <li class=\"crumbs__item\">\n <ng-container *ngTemplateOutlet=\"crumbTemplate; context: { $implicit: crumb }\" />\n </li>\n }\n </ol>\n }\n\n @if (pending()) {\n <span class=\"crumbs__sr\">{{ labels().loading }}</span>\n }\n</nav>\n\n<ng-template #crumbTemplate let-crumb>\n @if (crumb.current) {\n <span class=\"crumbs__current\" aria-current=\"page\">\n <ng-container *ngTemplateOutlet=\"labelTemplate; context: { $implicit: crumb }\" />\n </span>\n } @else {\n <a class=\"crumbs__link\" [routerLink]=\"crumb.url\">\n <ng-container *ngTemplateOutlet=\"labelTemplate; context: { $implicit: crumb }\" />\n </a>\n }\n</ng-template>\n\n<!--\n A pending label is a placeholder: it reserves its width from the start, and\n only becomes a visible skeleton once `skeletonDelay` has passed.\n-->\n<ng-template #labelTemplate let-crumb>\n @if (crumb.label === null) {\n <span class=\"crumbs__skeleton\" [class.crumbs__skeleton--shown]=\"skeletonShown()\" aria-hidden=\"true\"></span>\n } @else if (itemTemplate(); as custom) {\n <ng-container *ngTemplateOutlet=\"custom; context: { $implicit: crumb, crumb: crumb }\" />\n } @else {\n <span class=\"crumbs__label\">{{ crumb.label }}</span>\n }\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;min-inline-size:0;font-size:var(--shell-breadcrumb-font-size, 1em)}.crumbs{display:flex;align-items:center;min-inline-size:0}.crumbs__list{display:flex;align-items:center;gap:var(--shell-breadcrumb-gap, 4px);min-inline-size:0;margin:0;padding:0;list-style:none;white-space:nowrap}.crumbs__item{display:flex;align-items:center;gap:var(--shell-breadcrumb-gap, 4px);flex:0 3 auto;min-inline-size:0}.crumbs__item:last-child{flex-shrink:1}.crumbs__item+.crumbs__item:before{content:\"\";flex:0 0 auto;inline-size:5px;block-size:5px;margin-inline:2px 3px;border-block-start:1.5px solid var(--shell-breadcrumb-separator-color, var(--shell-fg-muted, #6b6f76));border-right:1.5px solid var(--shell-breadcrumb-separator-color, var(--shell-fg-muted, #6b6f76));transform:rotate(45deg)}:host-context([dir=rtl]) .crumbs__item+.crumbs__item:before{transform:rotate(-135deg)}:host([data-separator=slash]) .crumbs__item+.crumbs__item:before,:host([data-separator=dot]) .crumbs__item+.crumbs__item:before{inline-size:auto;block-size:auto;margin-inline:1px 2px;border:0;color:var(--shell-breadcrumb-separator-color, var(--shell-fg-muted, #6b6f76));line-height:1;transform:none}:host([data-separator=slash]) .crumbs__item+.crumbs__item:before{content:\"/\" / \"\"}:host([data-separator=dot]) .crumbs__item+.crumbs__item:before{content:\"\\b7\" / \"\";font-weight:700}.crumbs__link,.crumbs__current,.crumbs__back{display:inline-flex;align-items:center;gap:6px;min-inline-size:0;max-inline-size:var(--shell-breadcrumb-label-max, 28ch);padding:2px 4px;border-radius:var(--shell-radius-sm, 6px)}.crumbs__link,.crumbs__back{color:var(--shell-breadcrumb-fg, var(--shell-fg-muted, #6b6f76));text-decoration:none;transition-property:color,background-color;transition-duration:calc(var(--shell-subheader-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-subheader-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.crumbs__link,.crumbs__back{transition-duration:1ms}}.crumbs__link:focus-visible,.crumbs__back:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:0}.crumbs__link:hover,.crumbs__back:hover{color:var(--shell-breadcrumb-hover-fg, var(--shell-fg, #1b1c1f));background:var(--shell-breadcrumb-hover-bg, var(--shell-hover-surface, #f0f0f3))}.crumbs__current{color:var(--shell-breadcrumb-current-fg, var(--shell-fg, #1b1c1f));font-weight:var(--shell-breadcrumb-current-weight, 600)}.crumbs__label{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.crumbs__back-glyph{flex:0 0 auto;inline-size:6px;block-size:6px;border-block-end:1.5px solid currentcolor;border-left:1.5px solid currentcolor;transform:rotate(45deg)}:host-context([dir=rtl]) .crumbs__back-glyph{transform:rotate(-135deg)}.crumbs__more{--shell-overflow-trigger-size: 26px}.crumbs__menu-link{display:block;padding:7px 10px;border-radius:var(--shell-radius-sm, 6px);color:var(--shell-fg, #1b1c1f);text-decoration:none;white-space:nowrap}.crumbs__menu-link:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:-2px}.crumbs__menu-link:hover{background:var(--shell-hover-surface, #f0f0f3)}.crumbs__skeleton{display:inline-block;inline-size:9ch;block-size:.85em;border-radius:999px;background:var(--shell-breadcrumb-skeleton-bg, var(--shell-active-surface, #e9e9ee));visibility:hidden}.crumbs__skeleton--shown{visibility:visible;animation:crumbs-pulse calc(1.6s * var(--shell-motion-scale, 1)) ease-in-out infinite}@media(prefers-reduced-motion:reduce){.crumbs__skeleton--shown{animation:none}}@keyframes crumbs-pulse{50%{opacity:.45}}.crumbs__sr{position:absolute;inline-size:1px;block-size:1px;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}\n"] }]
|
|
1408
|
+
}], ctorParameters: () => [], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], maxItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxItems", required: false }] }], display: [{ type: i0.Input, args: [{ isSignal: true, alias: "display", required: false }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: false }] }] } });
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* The breadcrumb row, between the topbar and `main`.
|
|
1412
|
+
*
|
|
1413
|
+
* [breadcrumb | built-in trail] [subheader-start] ········ [subheader-end] [⋯]
|
|
1414
|
+
*
|
|
1415
|
+
* A measured region like the topbar: `subheader-end` templates use
|
|
1416
|
+
* `slotMinSize` / `slotOverflow` against the row's own width and move into
|
|
1417
|
+
* the row's ⋯ menu when they don't fit. Its minimum height is reserved, so
|
|
1418
|
+
* labels and actions that arrive late never shift the page.
|
|
1419
|
+
*/
|
|
1420
|
+
class ShellSubheaderComponent {
|
|
1421
|
+
region = measureRegion(inject(ElementRef));
|
|
1422
|
+
registry = inject(ShellSlotRegistry);
|
|
1423
|
+
config = inject(ShellStore).config;
|
|
1424
|
+
regionName = 'subheader';
|
|
1425
|
+
regionWidth = this.region.width;
|
|
1426
|
+
regionSize = this.region.size;
|
|
1427
|
+
/** An app-provided trail replaces the built-in one. */
|
|
1428
|
+
customTrail = hasSlot('breadcrumb');
|
|
1429
|
+
overflowSlots = computed(() => this.config().subheader.overflowSlots, ...(ngDevMode ? [{ debugName: "overflowSlots" }] : /* istanbul ignore next */ []));
|
|
1430
|
+
hasOverflow = computed(() => this.overflowSlots().some((name) => this.registry.overflowing(name, this.regionSize()).length > 0), ...(ngDevMode ? [{ debugName: "hasOverflow" }] : /* istanbul ignore next */ []));
|
|
1431
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSubheaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1432
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: ShellSubheaderComponent, isStandalone: true, selector: "shell-subheader", host: { properties: { "attr.data-shell-size": "regionSize()", "attr.data-variant": "config().appearance.subheader" } }, providers: [{ provide: SHELL_REGION, useExisting: ShellSubheaderComponent }], ngImport: i0, template: "<div class=\"subheader__start\">\n @if (customTrail()) {\n <shell-slot-outlet name=\"breadcrumb\" class=\"subheader__slot subheader__trail\" />\n } @else {\n <shell-breadcrumb class=\"subheader__trail\" />\n }\n <shell-slot-outlet name=\"subheader-start\" class=\"subheader__slot\" />\n</div>\n\n<div class=\"subheader__end\">\n <shell-slot-outlet name=\"subheader-end\" class=\"subheader__slot\" />\n\n @if (hasOverflow()) {\n <shell-overflow-menu [label]=\"config().labels.moreActions\">\n @for (name of overflowSlots(); track name) {\n <shell-slot-outlet [name]=\"name\" select=\"overflow\" class=\"subheader__overflow-item\" />\n }\n </shell-overflow-menu>\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{container:shell-subheader/inline-size;display:flex;align-items:center;gap:8px 16px;box-sizing:border-box;min-block-size:var(--shell-subheader-height, 44px);margin:var(--shell-subheader-margin, 0);padding-inline:var(--shell-subheader-padding-inline, 24px);border-width:var(--shell-subheader-border-width, 0 0 1px);border-style:var(--shell-subheader-border-style, solid);border-color:var(--shell-subheader-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-subheader-radius, 0px);background:var(--shell-subheader-bg, var(--shell-surface, #ffffff));color:var(--shell-subheader-fg, var(--shell-fg, #1b1c1f));box-shadow:var(--shell-subheader-shadow, none);font-size:var(--shell-subheader-font-size, var(--shell-font-size, 14px));transition-property:background-color,border-color,box-shadow;transition-duration:calc(var(--shell-subheader-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-subheader-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){:host{transition-duration:1ms}}:host([data-variant=transparent]){border-color:var(--shell-subheader-border-color, transparent);background:var(--shell-subheader-bg, transparent)}:host([data-variant=elevated]){border-color:var(--shell-subheader-border-color, transparent);box-shadow:var(--shell-subheader-shadow, 0 1px 2px rgba(0, 0, 0, .04), 0 6px 14px -8px rgba(0, 0, 0, .14))}.subheader__start{display:flex;align-items:center;gap:12px;flex:1 1 auto;min-inline-size:0}.subheader__trail{flex:1 1 auto;min-inline-size:0}.subheader__slot{gap:8px}.subheader__end{display:flex;align-items:center;gap:8px;flex:0 0 auto}.subheader__overflow-item{flex-direction:column;align-items:stretch;gap:4px}\n"], dependencies: [{ kind: "component", type: ShellSlotOutletComponent, selector: "shell-slot-outlet", inputs: ["name", "select", "size"] }, { kind: "component", type: ShellBreadcrumbComponent, selector: "shell-breadcrumb", inputs: ["items", "maxItems", "display", "itemTemplate"] }, { kind: "component", type: ShellOverflowMenuComponent, selector: "shell-overflow-menu", inputs: ["label", "align"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1433
|
+
}
|
|
1434
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellSubheaderComponent, decorators: [{
|
|
1435
|
+
type: Component,
|
|
1436
|
+
args: [{ selector: 'shell-subheader', imports: [ShellSlotOutletComponent, ShellBreadcrumbComponent, ShellOverflowMenuComponent], host: {
|
|
1437
|
+
'[attr.data-shell-size]': 'regionSize()',
|
|
1438
|
+
'[attr.data-variant]': 'config().appearance.subheader',
|
|
1439
|
+
}, providers: [{ provide: SHELL_REGION, useExisting: ShellSubheaderComponent }], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"subheader__start\">\n @if (customTrail()) {\n <shell-slot-outlet name=\"breadcrumb\" class=\"subheader__slot subheader__trail\" />\n } @else {\n <shell-breadcrumb class=\"subheader__trail\" />\n }\n <shell-slot-outlet name=\"subheader-start\" class=\"subheader__slot\" />\n</div>\n\n<div class=\"subheader__end\">\n <shell-slot-outlet name=\"subheader-end\" class=\"subheader__slot\" />\n\n @if (hasOverflow()) {\n <shell-overflow-menu [label]=\"config().labels.moreActions\">\n @for (name of overflowSlots(); track name) {\n <shell-slot-outlet [name]=\"name\" select=\"overflow\" class=\"subheader__overflow-item\" />\n }\n </shell-overflow-menu>\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{container:shell-subheader/inline-size;display:flex;align-items:center;gap:8px 16px;box-sizing:border-box;min-block-size:var(--shell-subheader-height, 44px);margin:var(--shell-subheader-margin, 0);padding-inline:var(--shell-subheader-padding-inline, 24px);border-width:var(--shell-subheader-border-width, 0 0 1px);border-style:var(--shell-subheader-border-style, solid);border-color:var(--shell-subheader-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-subheader-radius, 0px);background:var(--shell-subheader-bg, var(--shell-surface, #ffffff));color:var(--shell-subheader-fg, var(--shell-fg, #1b1c1f));box-shadow:var(--shell-subheader-shadow, none);font-size:var(--shell-subheader-font-size, var(--shell-font-size, 14px));transition-property:background-color,border-color,box-shadow;transition-duration:calc(var(--shell-subheader-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-subheader-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){:host{transition-duration:1ms}}:host([data-variant=transparent]){border-color:var(--shell-subheader-border-color, transparent);background:var(--shell-subheader-bg, transparent)}:host([data-variant=elevated]){border-color:var(--shell-subheader-border-color, transparent);box-shadow:var(--shell-subheader-shadow, 0 1px 2px rgba(0, 0, 0, .04), 0 6px 14px -8px rgba(0, 0, 0, .14))}.subheader__start{display:flex;align-items:center;gap:12px;flex:1 1 auto;min-inline-size:0}.subheader__trail{flex:1 1 auto;min-inline-size:0}.subheader__slot{gap:8px}.subheader__end{display:flex;align-items:center;gap:8px;flex:0 0 auto}.subheader__overflow-item{flex-direction:column;align-items:stretch;gap:4px}\n"] }]
|
|
1440
|
+
}] });
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Topbar with priority-based slot collapsing.
|
|
1444
|
+
*
|
|
1445
|
+
* The bar measures **itself**, not the window. That matters: expanding the
|
|
1446
|
+
* sidebar from a rail to a full panel takes ~200px away from the topbar without
|
|
1447
|
+
* the window ever resizing, and the bar has to respond to that the same way it
|
|
1448
|
+
* responds to a phone.
|
|
1449
|
+
*
|
|
1450
|
+
* Slot order, start to end:
|
|
1451
|
+
*
|
|
1452
|
+
* [toggle] [topbar-start] [brand] [primary-nav] [title] ····· [search] [actions] [⋯] [account] [topbar-end]
|
|
1453
|
+
*
|
|
1454
|
+
* Each slot template declares the room it needs (`slotMinSize` / `slotMaxSize`)
|
|
1455
|
+
* and what happens when it does not get it (`slotOverflow` → move into the ⋯
|
|
1456
|
+
* menu, otherwise drop).
|
|
1457
|
+
*/
|
|
1458
|
+
class ShellTopbarComponent {
|
|
1459
|
+
registry = inject(ShellSlotRegistry);
|
|
1460
|
+
region = measureRegion(inject(ElementRef));
|
|
1461
|
+
store = inject(ShellStore);
|
|
1462
|
+
config = this.store.config;
|
|
1463
|
+
regionName = 'topbar';
|
|
1464
|
+
regionWidth = this.region.width;
|
|
1465
|
+
regionSize = this.region.size;
|
|
1466
|
+
showToggle = computed(() => {
|
|
1467
|
+
switch (this.config().topbar.sidebarToggle) {
|
|
1468
|
+
case 'never':
|
|
1469
|
+
return false;
|
|
1470
|
+
case 'auto': {
|
|
1471
|
+
const mode = this.store.sidebar().mode;
|
|
1472
|
+
return mode === 'overlay' || mode === 'hidden';
|
|
1473
|
+
}
|
|
1474
|
+
default:
|
|
1475
|
+
return true;
|
|
1476
|
+
}
|
|
1477
|
+
}, ...(ngDevMode ? [{ debugName: "showToggle" }] : /* istanbul ignore next */ []));
|
|
1478
|
+
overflowSlots = computed(() => this.config().overflowSlots, ...(ngDevMode ? [{ debugName: "overflowSlots" }] : /* istanbul ignore next */ []));
|
|
1479
|
+
/** The ⋯ menu exists only while something is in it. */
|
|
1480
|
+
hasOverflow = computed(() => this.overflowSlots().some((name) => this.registry.overflowing(name, this.regionSize()).length > 0), ...(ngDevMode ? [{ debugName: "hasOverflow" }] : /* istanbul ignore next */ []));
|
|
1481
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellTopbarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1482
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: ShellTopbarComponent, isStandalone: true, selector: "shell-topbar", host: { attributes: { "role": "banner" }, properties: { "attr.data-shell-size": "regionSize()", "attr.data-overflow": "hasOverflow()", "attr.data-variant": "config().appearance.topbar", "attr.data-menu-motion": "config().motion.overflowMenu" } }, providers: [{ provide: SHELL_REGION, useExisting: ShellTopbarComponent }], ngImport: i0, template: "<div class=\"topbar__group topbar__group--start\">\n @if (showToggle()) {\n <button shellSidebarToggle class=\"topbar__icon-button topbar__toggle\">\n <span class=\"topbar__toggle-glyph\" aria-hidden=\"true\"></span>\n </button>\n }\n\n <shell-slot-outlet name=\"topbar-start\" class=\"topbar__slot\" />\n <shell-slot-outlet name=\"brand\" class=\"topbar__slot topbar__slot--brand\" />\n <shell-slot-outlet name=\"primary-nav\" class=\"topbar__slot topbar__slot--nav\" />\n <shell-slot-outlet name=\"title\" class=\"topbar__slot topbar__slot--title\" />\n</div>\n\n<div class=\"topbar__group topbar__group--end\">\n <shell-slot-outlet name=\"search\" class=\"topbar__slot topbar__slot--search\" />\n <shell-slot-outlet name=\"actions\" class=\"topbar__slot\" />\n\n @if (hasOverflow()) {\n <shell-overflow-menu class=\"topbar__overflow\" [label]=\"config().labels.moreActions\">\n @for (name of overflowSlots(); track name) {\n <shell-slot-outlet [name]=\"name\" select=\"overflow\" class=\"topbar__overflow-item\" />\n }\n </shell-overflow-menu>\n }\n\n <shell-slot-outlet name=\"account\" class=\"topbar__slot\" />\n <shell-slot-outlet name=\"topbar-end\" class=\"topbar__slot\" />\n</div>\n", styles: ["@charset \"UTF-8\";:host{container:shell-topbar/inline-size;display:flex;align-items:center;gap:var(--shell-topbar-gap, 8px);block-size:var(--shell-topbar-height, 64px);padding-inline:var(--shell-topbar-padding-inline, 12px);border-width:var(--shell-topbar-border-width, 0 0 1px);border-style:var(--shell-topbar-border-style, solid);border-color:var(--shell-topbar-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-topbar-radius, 0px);background:var(--shell-topbar-bg, var(--shell-topbar-surface, #ffffff));color:var(--shell-topbar-fg, var(--shell-fg, #1b1c1f));box-shadow:var(--shell-topbar-shadow, none);transition-property:background-color,border-color,box-shadow;transition-duration:calc(var(--shell-topbar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-topbar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){:host{transition-duration:1ms}}:host([data-variant=elevated]){border-color:var(--shell-topbar-border-color, transparent);box-shadow:var(--shell-topbar-shadow, 0 1px 2px rgba(0, 0, 0, .05), 0 6px 16px -8px rgba(0, 0, 0, .18))}:host([data-variant=blur]){border-color:var(--shell-topbar-border-color, color-mix(in srgb, var(--shell-border, #e6e6e9) 60%, transparent));background:var(--shell-topbar-bg, color-mix(in srgb, var(--shell-topbar-surface, #ffffff) 72%, transparent));-webkit-backdrop-filter:blur(var(--shell-topbar-blur, 12px)) saturate(1.5);backdrop-filter:blur(var(--shell-topbar-blur, 12px)) saturate(1.5)}:host([data-variant=transparent]){border-color:var(--shell-topbar-border-color, transparent);background:var(--shell-topbar-bg, transparent)}.topbar__group{display:flex;align-items:center;gap:var(--shell-topbar-gap, 8px);min-inline-size:0}.topbar__group--start{flex:1 1 auto}.topbar__group--end{flex:0 1 auto;margin-inline-start:auto}.topbar__slot{flex:0 0 auto;gap:var(--shell-topbar-gap, 8px)}.topbar__slot--nav,.topbar__slot--title{flex:0 1 auto;min-inline-size:0;overflow:hidden}.topbar__slot--title{font-weight:600;white-space:nowrap}.topbar__slot--search{flex:0 1 clamp(40px,34cqi,420px);min-inline-size:0}.topbar__icon-button{display:grid;place-items:center;flex:0 0 auto;inline-size:38px;block-size:38px;padding:0;border:0;border-radius:var(--shell-radius-sm, 6px);background:transparent;color:inherit;cursor:pointer;transition-property:background-color;transition-duration:calc(var(--shell-topbar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-topbar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.topbar__icon-button{transition-duration:1ms}}.topbar__icon-button:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:2px}.topbar__icon-button:hover{background:var(--shell-hover-surface, #f0f0f3)}.topbar__toggle-glyph{position:relative;inline-size:16px;block-size:2px;border-radius:2px;background:currentcolor}.topbar__toggle-glyph:before,.topbar__toggle-glyph:after{content:\"\";position:absolute;inset-inline-start:0;inline-size:16px;block-size:2px;border-radius:2px;background:currentcolor;transition-property:inline-size;transition-duration:calc(var(--shell-collapse-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-collapse-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.topbar__toggle-glyph:before,.topbar__toggle-glyph:after{transition-duration:1ms}}.topbar__toggle-glyph:before{inset-block-start:-5px}.topbar__toggle-glyph:after{inset-block-start:5px}:host-context(app-shell[data-sidebar-mode=expanded]) .topbar__toggle-glyph:before,:host-context(app-shell[data-sidebar-mode=expanded]) .topbar__toggle-glyph:after{inline-size:10px}.topbar__overflow-item{flex-direction:column;align-items:stretch;gap:4px}\n"], dependencies: [{ kind: "component", type: ShellSlotOutletComponent, selector: "shell-slot-outlet", inputs: ["name", "select", "size"] }, { kind: "directive", type: SidebarToggleDirective, selector: "button[shellSidebarToggle]", exportAs: ["shellSidebarToggle"] }, { kind: "component", type: ShellOverflowMenuComponent, selector: "shell-overflow-menu", inputs: ["label", "align"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1483
|
+
}
|
|
1484
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellTopbarComponent, decorators: [{
|
|
1485
|
+
type: Component,
|
|
1486
|
+
args: [{ selector: 'shell-topbar', imports: [ShellSlotOutletComponent, SidebarToggleDirective, ShellOverflowMenuComponent], host: {
|
|
1487
|
+
role: 'banner',
|
|
1488
|
+
'[attr.data-shell-size]': 'regionSize()',
|
|
1489
|
+
'[attr.data-overflow]': 'hasOverflow()',
|
|
1490
|
+
'[attr.data-variant]': 'config().appearance.topbar',
|
|
1491
|
+
'[attr.data-menu-motion]': 'config().motion.overflowMenu',
|
|
1492
|
+
}, providers: [{ provide: SHELL_REGION, useExisting: ShellTopbarComponent }], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"topbar__group topbar__group--start\">\n @if (showToggle()) {\n <button shellSidebarToggle class=\"topbar__icon-button topbar__toggle\">\n <span class=\"topbar__toggle-glyph\" aria-hidden=\"true\"></span>\n </button>\n }\n\n <shell-slot-outlet name=\"topbar-start\" class=\"topbar__slot\" />\n <shell-slot-outlet name=\"brand\" class=\"topbar__slot topbar__slot--brand\" />\n <shell-slot-outlet name=\"primary-nav\" class=\"topbar__slot topbar__slot--nav\" />\n <shell-slot-outlet name=\"title\" class=\"topbar__slot topbar__slot--title\" />\n</div>\n\n<div class=\"topbar__group topbar__group--end\">\n <shell-slot-outlet name=\"search\" class=\"topbar__slot topbar__slot--search\" />\n <shell-slot-outlet name=\"actions\" class=\"topbar__slot\" />\n\n @if (hasOverflow()) {\n <shell-overflow-menu class=\"topbar__overflow\" [label]=\"config().labels.moreActions\">\n @for (name of overflowSlots(); track name) {\n <shell-slot-outlet [name]=\"name\" select=\"overflow\" class=\"topbar__overflow-item\" />\n }\n </shell-overflow-menu>\n }\n\n <shell-slot-outlet name=\"account\" class=\"topbar__slot\" />\n <shell-slot-outlet name=\"topbar-end\" class=\"topbar__slot\" />\n</div>\n", styles: ["@charset \"UTF-8\";:host{container:shell-topbar/inline-size;display:flex;align-items:center;gap:var(--shell-topbar-gap, 8px);block-size:var(--shell-topbar-height, 64px);padding-inline:var(--shell-topbar-padding-inline, 12px);border-width:var(--shell-topbar-border-width, 0 0 1px);border-style:var(--shell-topbar-border-style, solid);border-color:var(--shell-topbar-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-topbar-radius, 0px);background:var(--shell-topbar-bg, var(--shell-topbar-surface, #ffffff));color:var(--shell-topbar-fg, var(--shell-fg, #1b1c1f));box-shadow:var(--shell-topbar-shadow, none);transition-property:background-color,border-color,box-shadow;transition-duration:calc(var(--shell-topbar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-topbar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){:host{transition-duration:1ms}}:host([data-variant=elevated]){border-color:var(--shell-topbar-border-color, transparent);box-shadow:var(--shell-topbar-shadow, 0 1px 2px rgba(0, 0, 0, .05), 0 6px 16px -8px rgba(0, 0, 0, .18))}:host([data-variant=blur]){border-color:var(--shell-topbar-border-color, color-mix(in srgb, var(--shell-border, #e6e6e9) 60%, transparent));background:var(--shell-topbar-bg, color-mix(in srgb, var(--shell-topbar-surface, #ffffff) 72%, transparent));-webkit-backdrop-filter:blur(var(--shell-topbar-blur, 12px)) saturate(1.5);backdrop-filter:blur(var(--shell-topbar-blur, 12px)) saturate(1.5)}:host([data-variant=transparent]){border-color:var(--shell-topbar-border-color, transparent);background:var(--shell-topbar-bg, transparent)}.topbar__group{display:flex;align-items:center;gap:var(--shell-topbar-gap, 8px);min-inline-size:0}.topbar__group--start{flex:1 1 auto}.topbar__group--end{flex:0 1 auto;margin-inline-start:auto}.topbar__slot{flex:0 0 auto;gap:var(--shell-topbar-gap, 8px)}.topbar__slot--nav,.topbar__slot--title{flex:0 1 auto;min-inline-size:0;overflow:hidden}.topbar__slot--title{font-weight:600;white-space:nowrap}.topbar__slot--search{flex:0 1 clamp(40px,34cqi,420px);min-inline-size:0}.topbar__icon-button{display:grid;place-items:center;flex:0 0 auto;inline-size:38px;block-size:38px;padding:0;border:0;border-radius:var(--shell-radius-sm, 6px);background:transparent;color:inherit;cursor:pointer;transition-property:background-color;transition-duration:calc(var(--shell-topbar-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-topbar-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.topbar__icon-button{transition-duration:1ms}}.topbar__icon-button:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:2px}.topbar__icon-button:hover{background:var(--shell-hover-surface, #f0f0f3)}.topbar__toggle-glyph{position:relative;inline-size:16px;block-size:2px;border-radius:2px;background:currentcolor}.topbar__toggle-glyph:before,.topbar__toggle-glyph:after{content:\"\";position:absolute;inset-inline-start:0;inline-size:16px;block-size:2px;border-radius:2px;background:currentcolor;transition-property:inline-size;transition-duration:calc(var(--shell-collapse-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-collapse-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.topbar__toggle-glyph:before,.topbar__toggle-glyph:after{transition-duration:1ms}}.topbar__toggle-glyph:before{inset-block-start:-5px}.topbar__toggle-glyph:after{inset-block-start:5px}:host-context(app-shell[data-sidebar-mode=expanded]) .topbar__toggle-glyph:before,:host-context(app-shell[data-sidebar-mode=expanded]) .topbar__toggle-glyph:after{inline-size:10px}.topbar__overflow-item{flex-direction:column;align-items:stretch;gap:4px}\n"] }]
|
|
1493
|
+
}] });
|
|
1494
|
+
|
|
1495
|
+
/** `undefined` (attribute absent) means "use the config"; otherwise a boolean attribute. */
|
|
1496
|
+
function optionalBoolean(value) {
|
|
1497
|
+
return value === undefined || value === null ? undefined : booleanAttribute(value);
|
|
1498
|
+
}
|
|
1499
|
+
function isEditable(target) {
|
|
1500
|
+
if (!(target instanceof HTMLElement)) {
|
|
1501
|
+
return false;
|
|
1502
|
+
}
|
|
1503
|
+
return target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName);
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* The navigational shell: sidebar + topbar + breadcrumb row + main + footer.
|
|
1507
|
+
*
|
|
1508
|
+
* Layout is a CSS grid whose tracks come from custom properties, so a host app
|
|
1509
|
+
* restyles it entirely from CSS. The only value TypeScript writes is the
|
|
1510
|
+
* current sidebar track width, which is derived state, not layout maths.
|
|
1511
|
+
*/
|
|
1512
|
+
class AppShellComponent {
|
|
1513
|
+
host = inject(ElementRef);
|
|
1514
|
+
document = inject(DOCUMENT);
|
|
1515
|
+
router = inject(Router, { optional: true });
|
|
1516
|
+
breadcrumbs = inject(ShellBreadcrumbStore);
|
|
1517
|
+
main = viewChild.required('main');
|
|
1518
|
+
frame = viewChild.required('frame');
|
|
1519
|
+
store = inject(ShellStore);
|
|
1520
|
+
config = this.store.config;
|
|
1521
|
+
mainId = SHELL_MAIN_ID;
|
|
1522
|
+
sidebarId = SHELL_SIDEBAR_ID;
|
|
1523
|
+
/** Per-instance overrides. Leave unset to follow the (runtime) config. */
|
|
1524
|
+
layout = input(undefined, ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
|
|
1525
|
+
scroll = input(undefined, ...(ngDevMode ? [{ debugName: "scroll" }] : /* istanbul ignore next */ []));
|
|
1526
|
+
topbar = input(undefined, { ...(ngDevMode ? { debugName: "topbar" } : /* istanbul ignore next */ {}), transform: optionalBoolean });
|
|
1527
|
+
subheader = input(undefined, { ...(ngDevMode ? { debugName: "subheader" } : /* istanbul ignore next */ {}), transform: optionalBoolean });
|
|
1528
|
+
footer = input(undefined, { ...(ngDevMode ? { debugName: "footer" } : /* istanbul ignore next */ {}), transform: optionalBoolean });
|
|
1529
|
+
regionName = 'shell';
|
|
1530
|
+
regionWidth = observeWidth(this.host);
|
|
1531
|
+
regionSize = this.store.size;
|
|
1532
|
+
size = this.store.size;
|
|
1533
|
+
sidebar = this.store.sidebar;
|
|
1534
|
+
hasSubheaderSlots = hasSlot('breadcrumb', 'subheader-start', 'subheader-end');
|
|
1535
|
+
hasFooterSlots = hasSlot('footer', 'footer-start', 'footer-end');
|
|
1536
|
+
resolvedLayout = computed(() => this.layout() ?? this.config().layout, ...(ngDevMode ? [{ debugName: "resolvedLayout" }] : /* istanbul ignore next */ []));
|
|
1537
|
+
resolvedScroll = computed(() => this.scroll() ?? this.config().scroll, ...(ngDevMode ? [{ debugName: "resolvedScroll" }] : /* istanbul ignore next */ []));
|
|
1538
|
+
/** Topbar, main and footer share one rounded card; the sidebar sits on the shell. */
|
|
1539
|
+
inset = computed(() => this.resolvedLayout() === 'inset', ...(ngDevMode ? [{ debugName: "inset" }] : /* istanbul ignore next */ []));
|
|
1540
|
+
showTopbar = computed(() => this.topbar() ?? this.config().topbar.enabled, ...(ngDevMode ? [{ debugName: "showTopbar" }] : /* istanbul ignore next */ []));
|
|
1541
|
+
showFooter = computed(() => this.footer() ?? (this.config().footer.enabled || this.hasFooterSlots()), ...(ngDevMode ? [{ debugName: "showFooter" }] : /* istanbul ignore next */ []));
|
|
1542
|
+
/**
|
|
1543
|
+
* The breadcrumb row. In `'auto'` it follows the page: a route's
|
|
1544
|
+
* `data.subheader` wins; otherwise it shows when the trail is at least two
|
|
1545
|
+
* levels deep (a flat trail is noise) or when a page fills a subheader slot.
|
|
1546
|
+
* Decided per navigation, so it never appears or vanishes within a page.
|
|
1547
|
+
*/
|
|
1548
|
+
showSubheader = computed(() => {
|
|
1549
|
+
const explicit = this.subheader();
|
|
1550
|
+
if (explicit !== undefined) {
|
|
1551
|
+
return explicit;
|
|
1552
|
+
}
|
|
1553
|
+
const mode = this.config().subheader.enabled;
|
|
1554
|
+
if (mode !== 'auto') {
|
|
1555
|
+
return mode;
|
|
1556
|
+
}
|
|
1557
|
+
return (this.breadcrumbs.subheaderOverride() ??
|
|
1558
|
+
(this.breadcrumbs.depth() >= 2 || this.hasSubheaderSlots()));
|
|
1559
|
+
}, ...(ngDevMode ? [{ debugName: "showSubheader" }] : /* istanbul ignore next */ []));
|
|
1560
|
+
/**
|
|
1561
|
+
* Factor every shell duration is multiplied by (see `duration()` in the
|
|
1562
|
+
* token file): 0 turns motion off, 1 / speed scales it.
|
|
1563
|
+
*/
|
|
1564
|
+
motionScale = computed(() => {
|
|
1565
|
+
const { enabled, speed } = this.config().motion;
|
|
1566
|
+
return String(enabled && speed > 0 ? 1 / speed : 0);
|
|
1567
|
+
}, ...(ngDevMode ? [{ debugName: "motionScale" }] : /* istanbul ignore next */ []));
|
|
1568
|
+
constructor() {
|
|
1569
|
+
// Feed the measured width back into the store: everything downstream —
|
|
1570
|
+
// buckets, sidebar mode, slot visibility — is derived from this one value.
|
|
1571
|
+
effect(() => this.store.setWidth(this.regionWidth()));
|
|
1572
|
+
// A modal drawer must not leave the page scrolling underneath it. Done in
|
|
1573
|
+
// TS rather than a global class so the library needs no global stylesheet.
|
|
1574
|
+
effect((onCleanup) => {
|
|
1575
|
+
if (!this.sidebar().modal) {
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const root = this.document.documentElement;
|
|
1579
|
+
const previous = root.style.overflow;
|
|
1580
|
+
root.style.overflow = 'hidden';
|
|
1581
|
+
onCleanup(() => {
|
|
1582
|
+
root.style.overflow = previous;
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
this.router?.events
|
|
1586
|
+
.pipe(filter((event) => event instanceof NavigationEnd), takeUntilDestroyed())
|
|
1587
|
+
.subscribe(() => {
|
|
1588
|
+
const config = this.config();
|
|
1589
|
+
const state = this.store.sidebar();
|
|
1590
|
+
if (config.sidebar.closeOnNavigate && state.mode === 'overlay' && state.open) {
|
|
1591
|
+
this.store.closeSidebar();
|
|
1592
|
+
}
|
|
1593
|
+
if (config.scrollTopOnNavigate) {
|
|
1594
|
+
this.resetScroll();
|
|
1595
|
+
}
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* The window does not scroll in these modes, so the router cannot restore
|
|
1600
|
+
* scroll: `main` scrolls in `scroll: 'main'`, the card in `inset` + `page`.
|
|
1601
|
+
*/
|
|
1602
|
+
resetScroll() {
|
|
1603
|
+
if (this.resolvedScroll() === 'main') {
|
|
1604
|
+
this.main().nativeElement.scrollTo({ top: 0 });
|
|
1605
|
+
}
|
|
1606
|
+
else if (this.inset()) {
|
|
1607
|
+
this.frame().nativeElement.scrollTo({ top: 0 });
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* A plain `href="#id"` would resolve against `<base href>` and trigger a
|
|
1612
|
+
* router navigation, so the jump is done by moving focus instead.
|
|
1613
|
+
*/
|
|
1614
|
+
skipToMain(event) {
|
|
1615
|
+
event.preventDefault();
|
|
1616
|
+
this.document.getElementById(this.mainId)?.focus();
|
|
1617
|
+
}
|
|
1618
|
+
/** Optional Ctrl/⌘ + key toggle, ignored while typing. */
|
|
1619
|
+
onDocumentKeydown(event) {
|
|
1620
|
+
const key = this.config().sidebar.toggleShortcut;
|
|
1621
|
+
if (!key ||
|
|
1622
|
+
event.defaultPrevented ||
|
|
1623
|
+
event.altKey ||
|
|
1624
|
+
event.shiftKey ||
|
|
1625
|
+
!(event.ctrlKey || event.metaKey) ||
|
|
1626
|
+
event.key.toLowerCase() !== key.toLowerCase() ||
|
|
1627
|
+
isEditable(event.target)) {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
event.preventDefault();
|
|
1631
|
+
this.store.toggleSidebar();
|
|
1632
|
+
}
|
|
1633
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: AppShellComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1634
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: AppShellComponent, isStandalone: true, selector: "app-shell", inputs: { layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, scroll: { classPropertyName: "scroll", publicName: "scroll", isSignal: true, isRequired: false, transformFunction: null }, topbar: { classPropertyName: "topbar", publicName: "topbar", isSignal: true, isRequired: false, transformFunction: null }, subheader: { classPropertyName: "subheader", publicName: "subheader", isSignal: true, isRequired: false, transformFunction: null }, footer: { classPropertyName: "footer", publicName: "footer", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "document:keydown": "onDocumentKeydown($event)" }, properties: { "attr.data-layout": "resolvedLayout()", "attr.data-scroll": "resolvedScroll()", "attr.data-topbar": "showTopbar()", "attr.data-subheader": "showSubheader()", "attr.data-shell-size": "size()", "attr.data-sidebar-mode": "sidebar().mode", "attr.data-sidebar-open": "sidebar().open", "attr.data-sidebar-modal": "sidebar().modal", "attr.data-sidebar-peeked": "sidebar().peeked", "attr.data-resizing": "store.resizing() || null", "attr.data-motion-collapse": "config().motion.collapse ? null : \"off\"", "style.--shell-sidebar-track": "sidebar().trackWidth", "style.--shell-motion-scale": "motionScale()" } }, providers: [{ provide: SHELL_REGION, useExisting: AppShellComponent }], viewQueries: [{ propertyName: "main", first: true, predicate: ["main"], descendants: true, isSignal: true }, { propertyName: "frame", first: true, predicate: ["frame"], descendants: true, isSignal: true }], exportAs: ["appShell"], ngImport: i0, template: "<a class=\"shell__skip-link\" [href]=\"'#' + mainId\" (click)=\"skipToMain($event)\">\n {{ config().labels.skipToContent }}\n</a>\n\n<!--\n Outside `inset` the topbar comes first in the DOM, so keyboard order follows\n the visual order (topbar \u2192 sidebar \u2192 breadcrumbs \u2192 main). In `inset` it moves\n into the card. Only one of the two ever renders.\n-->\n@if (showTopbar() && !inset()) {\n <shell-topbar class=\"shell__topbar\" />\n}\n\n<shell-sidebar class=\"shell__sidebar\" [id]=\"sidebarId\" [attr.data-layout]=\"resolvedLayout()\" />\n\n<!--\n The frame wraps topbar, breadcrumb row, main and footer. In `inset` it is the\n rounded card (and, with `scroll: 'page'`, the scroll container); in every\n other layout it is `display: contents`, so its children stay shell grid items.\n-->\n<div #frame class=\"shell__frame\">\n @if (showTopbar() && inset()) {\n <shell-topbar class=\"shell__topbar\" />\n }\n\n <!-- Before <main>, so the skip link jumps past the breadcrumbs too. -->\n @if (showSubheader()) {\n <shell-subheader class=\"shell__subheader\" [attr.data-sticky]=\"config().subheader.sticky || null\" />\n }\n\n <main #main class=\"shell__main\" [id]=\"mainId\" tabindex=\"-1\">\n <div class=\"shell__content\">\n <ng-content />\n </div>\n </main>\n\n @if (showFooter()) {\n <shell-footer class=\"shell__footer\" />\n }\n</div>\n\n<!--\n Scrim for the modal drawer. Always rendered so it can fade out as well as\n in; while closed it is invisible and ignores the pointer. It stacks above\n main content but below the sidebar, which is raised by --shell-z-sidebar.\n-->\n<div\n class=\"shell__scrim\"\n aria-hidden=\"true\"\n [attr.data-open]=\"sidebar().modal\"\n [attr.data-motion]=\"config().motion.scrim\"\n (click)=\"store.closeSidebar()\"\n></div>\n", styles: ["@charset \"UTF-8\";:host{--shell-sidebar-track: var(--shell-sidebar-width, 264px);--shell-sidebar-top-offset: var(--shell-topbar-height, 64px);--shell-subheader-top-offset: var(--shell-topbar-height, 64px);display:grid;grid-template-columns:var(--shell-sidebar-track) minmax(0,1fr);grid-template-rows:auto auto minmax(0,1fr) auto;grid-template-areas:\"topbar topbar\" \"sidebar subheader\" \"sidebar main\" \"sidebar footer\";isolation:isolate;color-scheme:var(--shell-color-scheme, light);color:var(--shell-fg, #1b1c1f);background:var(--shell-bg, var(--shell-surface, #ffffff));font-family:var(--shell-font-family, inherit);font-size:var(--shell-font-size, 14px);transition-property:grid-template-columns;transition-duration:calc(var(--shell-collapse-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-collapse-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){:host{transition-duration:1ms}}:host([data-motion-collapse=off]){--shell-collapse-duration: 0s}:host([data-layout=sidebar-full]){grid-template-areas:\"sidebar topbar\" \"sidebar subheader\" \"sidebar main\" \"sidebar footer\"}:host([data-layout=stacked]){grid-template-areas:\"topbar topbar\" \"sidebar subheader\" \"sidebar main\" \"footer footer\"}:host([data-layout=sidebar-full]),:host([data-topbar=false]){--shell-sidebar-top-offset: 0px}:host([data-topbar=false]){--shell-subheader-top-offset: 0px}:host([data-scroll=main]){--shell-sidebar-block-size: 100%;block-size:100dvh;overflow:clip}:host([data-scroll=page]){--shell-sidebar-block-size: calc(100dvh - var(--shell-sidebar-top-offset));min-block-size:100dvh}.shell__frame{display:contents}:host([data-layout=inset]){--shell-sidebar-top-offset: 0px;--shell-sidebar-block-size: 100%;grid-template-rows:minmax(0,1fr);grid-template-areas:\"sidebar frame\";block-size:100dvh;overflow:clip;background:var(--shell-bg, var(--shell-surface-raised, #f7f7f8))}:host([data-layout=inset]) .shell__frame{grid-area:frame;z-index:var(--shell-z-main, 0);display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;grid-template-areas:\"topbar\" \"subheader\" \"main\" \"footer\";min-inline-size:0;margin:var(--shell-frame-inset, 8px);border-width:var(--shell-frame-border-width, 0);border-style:var(--shell-frame-border-style, solid);border-color:var(--shell-frame-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-frame-radius, 14px);background:var(--shell-frame-bg, var(--shell-surface, #ffffff));box-shadow:var(--shell-frame-shadow, 0 1px 2px rgba(0, 0, 0, .04), 0 8px 24px -8px rgba(0, 0, 0, .14));overflow:clip}:host([data-layout=inset][data-scroll=page]) .shell__frame{grid-template-rows:auto auto 1fr auto;overflow:auto;overscroll-behavior:contain}:host([data-resizing]){cursor:col-resize;-webkit-user-select:none;user-select:none;transition-duration:0s}.shell__topbar{grid-area:topbar;z-index:var(--shell-z-topbar, 20)}:host([data-scroll=page]) .shell__topbar{position:sticky;inset-block-start:0}.shell__subheader{grid-area:subheader;z-index:calc(var(--shell-z-topbar, 20) - 1)}:host([data-scroll=page]) .shell__subheader[data-sticky]{position:sticky;inset-block-start:var(--shell-subheader-top-offset)}.shell__sidebar{grid-area:sidebar;z-index:var(--shell-z-sidebar, 40)}.shell__main{position:relative;grid-area:main;min-inline-size:0;z-index:var(--shell-z-main, 0);margin:var(--shell-main-margin, 0);padding:var(--shell-main-padding, 24px);border-width:var(--shell-main-border-width, 0);border-style:var(--shell-main-border-style, solid);border-color:var(--shell-main-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-main-radius, 0px);background:var(--shell-main-bg, var(--shell-surface, #ffffff));color:var(--shell-main-fg, var(--shell-fg, #1b1c1f));box-shadow:var(--shell-main-shadow, none);container:shell-main/inline-size}.shell__main:focus{outline:none}:host([data-scroll=main]) .shell__main{overflow:auto;overscroll-behavior:contain}.shell__content{max-inline-size:var(--shell-main-max-width, none);margin-inline:auto}.shell__footer{grid-area:footer;z-index:var(--shell-z-topbar, 20)}.shell__scrim{position:fixed;inset:0;z-index:var(--shell-z-scrim, 30);background:var(--shell-drawer-scrim, var(--shell-scrim, rgba(15, 17, 21, .45)));-webkit-backdrop-filter:blur(var(--shell-drawer-scrim-blur, 0px));backdrop-filter:blur(var(--shell-drawer-scrim-blur, 0px));opacity:0;visibility:hidden;pointer-events:none;transition-property:opacity,visibility;transition-duration:calc(var(--shell-drawer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-drawer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.shell__scrim{transition-duration:1ms}}.shell__scrim[data-open=true]{opacity:1;visibility:visible;pointer-events:auto}.shell__scrim[data-motion=none]{transition-duration:0s}.shell__skip-link{position:fixed;inset-block-start:8px;inset-inline-start:8px;z-index:var(--shell-z-skip-link, 60);padding:8px 14px;border-radius:var(--shell-radius-sm, 6px);background:var(--shell-surface, #ffffff);color:var(--shell-fg, #1b1c1f);box-shadow:var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28));text-decoration:none;transform:translateY(calc(-100% - 16px));transition-property:transform;transition-duration:calc(var(--shell-transition-duration, .18s) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))}@media(prefers-reduced-motion:reduce){.shell__skip-link{transition-duration:1ms}}.shell__skip-link:focus-visible{transform:none;outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:2px}\n"], dependencies: [{ kind: "component", type: ShellTopbarComponent, selector: "shell-topbar" }, { kind: "component", type: ShellSidebarComponent, selector: "shell-sidebar", inputs: ["collapseControl"] }, { kind: "component", type: ShellSubheaderComponent, selector: "shell-subheader" }, { kind: "component", type: ShellFooterComponent, selector: "shell-footer" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1635
|
+
}
|
|
1636
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: AppShellComponent, decorators: [{
|
|
1637
|
+
type: Component,
|
|
1638
|
+
args: [{ selector: 'app-shell', imports: [ShellTopbarComponent, ShellSidebarComponent, ShellSubheaderComponent, ShellFooterComponent], host: {
|
|
1639
|
+
'[attr.data-layout]': 'resolvedLayout()',
|
|
1640
|
+
'[attr.data-scroll]': 'resolvedScroll()',
|
|
1641
|
+
'[attr.data-topbar]': 'showTopbar()',
|
|
1642
|
+
'[attr.data-subheader]': 'showSubheader()',
|
|
1643
|
+
'[attr.data-shell-size]': 'size()',
|
|
1644
|
+
'[attr.data-sidebar-mode]': 'sidebar().mode',
|
|
1645
|
+
'[attr.data-sidebar-open]': 'sidebar().open',
|
|
1646
|
+
'[attr.data-sidebar-modal]': 'sidebar().modal',
|
|
1647
|
+
'[attr.data-sidebar-peeked]': 'sidebar().peeked',
|
|
1648
|
+
'[attr.data-resizing]': 'store.resizing() || null',
|
|
1649
|
+
'[attr.data-motion-collapse]': 'config().motion.collapse ? null : "off"',
|
|
1650
|
+
'[style.--shell-sidebar-track]': 'sidebar().trackWidth',
|
|
1651
|
+
'[style.--shell-motion-scale]': 'motionScale()',
|
|
1652
|
+
'(document:keydown)': 'onDocumentKeydown($event)',
|
|
1653
|
+
}, providers: [{ provide: SHELL_REGION, useExisting: AppShellComponent }], exportAs: 'appShell', changeDetection: ChangeDetectionStrategy.OnPush, template: "<a class=\"shell__skip-link\" [href]=\"'#' + mainId\" (click)=\"skipToMain($event)\">\n {{ config().labels.skipToContent }}\n</a>\n\n<!--\n Outside `inset` the topbar comes first in the DOM, so keyboard order follows\n the visual order (topbar \u2192 sidebar \u2192 breadcrumbs \u2192 main). In `inset` it moves\n into the card. Only one of the two ever renders.\n-->\n@if (showTopbar() && !inset()) {\n <shell-topbar class=\"shell__topbar\" />\n}\n\n<shell-sidebar class=\"shell__sidebar\" [id]=\"sidebarId\" [attr.data-layout]=\"resolvedLayout()\" />\n\n<!--\n The frame wraps topbar, breadcrumb row, main and footer. In `inset` it is the\n rounded card (and, with `scroll: 'page'`, the scroll container); in every\n other layout it is `display: contents`, so its children stay shell grid items.\n-->\n<div #frame class=\"shell__frame\">\n @if (showTopbar() && inset()) {\n <shell-topbar class=\"shell__topbar\" />\n }\n\n <!-- Before <main>, so the skip link jumps past the breadcrumbs too. -->\n @if (showSubheader()) {\n <shell-subheader class=\"shell__subheader\" [attr.data-sticky]=\"config().subheader.sticky || null\" />\n }\n\n <main #main class=\"shell__main\" [id]=\"mainId\" tabindex=\"-1\">\n <div class=\"shell__content\">\n <ng-content />\n </div>\n </main>\n\n @if (showFooter()) {\n <shell-footer class=\"shell__footer\" />\n }\n</div>\n\n<!--\n Scrim for the modal drawer. Always rendered so it can fade out as well as\n in; while closed it is invisible and ignores the pointer. It stacks above\n main content but below the sidebar, which is raised by --shell-z-sidebar.\n-->\n<div\n class=\"shell__scrim\"\n aria-hidden=\"true\"\n [attr.data-open]=\"sidebar().modal\"\n [attr.data-motion]=\"config().motion.scrim\"\n (click)=\"store.closeSidebar()\"\n></div>\n", styles: ["@charset \"UTF-8\";:host{--shell-sidebar-track: var(--shell-sidebar-width, 264px);--shell-sidebar-top-offset: var(--shell-topbar-height, 64px);--shell-subheader-top-offset: var(--shell-topbar-height, 64px);display:grid;grid-template-columns:var(--shell-sidebar-track) minmax(0,1fr);grid-template-rows:auto auto minmax(0,1fr) auto;grid-template-areas:\"topbar topbar\" \"sidebar subheader\" \"sidebar main\" \"sidebar footer\";isolation:isolate;color-scheme:var(--shell-color-scheme, light);color:var(--shell-fg, #1b1c1f);background:var(--shell-bg, var(--shell-surface, #ffffff));font-family:var(--shell-font-family, inherit);font-size:var(--shell-font-size, 14px);transition-property:grid-template-columns;transition-duration:calc(var(--shell-collapse-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-collapse-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){:host{transition-duration:1ms}}:host([data-motion-collapse=off]){--shell-collapse-duration: 0s}:host([data-layout=sidebar-full]){grid-template-areas:\"sidebar topbar\" \"sidebar subheader\" \"sidebar main\" \"sidebar footer\"}:host([data-layout=stacked]){grid-template-areas:\"topbar topbar\" \"sidebar subheader\" \"sidebar main\" \"footer footer\"}:host([data-layout=sidebar-full]),:host([data-topbar=false]){--shell-sidebar-top-offset: 0px}:host([data-topbar=false]){--shell-subheader-top-offset: 0px}:host([data-scroll=main]){--shell-sidebar-block-size: 100%;block-size:100dvh;overflow:clip}:host([data-scroll=page]){--shell-sidebar-block-size: calc(100dvh - var(--shell-sidebar-top-offset));min-block-size:100dvh}.shell__frame{display:contents}:host([data-layout=inset]){--shell-sidebar-top-offset: 0px;--shell-sidebar-block-size: 100%;grid-template-rows:minmax(0,1fr);grid-template-areas:\"sidebar frame\";block-size:100dvh;overflow:clip;background:var(--shell-bg, var(--shell-surface-raised, #f7f7f8))}:host([data-layout=inset]) .shell__frame{grid-area:frame;z-index:var(--shell-z-main, 0);display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;grid-template-areas:\"topbar\" \"subheader\" \"main\" \"footer\";min-inline-size:0;margin:var(--shell-frame-inset, 8px);border-width:var(--shell-frame-border-width, 0);border-style:var(--shell-frame-border-style, solid);border-color:var(--shell-frame-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-frame-radius, 14px);background:var(--shell-frame-bg, var(--shell-surface, #ffffff));box-shadow:var(--shell-frame-shadow, 0 1px 2px rgba(0, 0, 0, .04), 0 8px 24px -8px rgba(0, 0, 0, .14));overflow:clip}:host([data-layout=inset][data-scroll=page]) .shell__frame{grid-template-rows:auto auto 1fr auto;overflow:auto;overscroll-behavior:contain}:host([data-resizing]){cursor:col-resize;-webkit-user-select:none;user-select:none;transition-duration:0s}.shell__topbar{grid-area:topbar;z-index:var(--shell-z-topbar, 20)}:host([data-scroll=page]) .shell__topbar{position:sticky;inset-block-start:0}.shell__subheader{grid-area:subheader;z-index:calc(var(--shell-z-topbar, 20) - 1)}:host([data-scroll=page]) .shell__subheader[data-sticky]{position:sticky;inset-block-start:var(--shell-subheader-top-offset)}.shell__sidebar{grid-area:sidebar;z-index:var(--shell-z-sidebar, 40)}.shell__main{position:relative;grid-area:main;min-inline-size:0;z-index:var(--shell-z-main, 0);margin:var(--shell-main-margin, 0);padding:var(--shell-main-padding, 24px);border-width:var(--shell-main-border-width, 0);border-style:var(--shell-main-border-style, solid);border-color:var(--shell-main-border-color, var(--shell-border, #e6e6e9));border-radius:var(--shell-main-radius, 0px);background:var(--shell-main-bg, var(--shell-surface, #ffffff));color:var(--shell-main-fg, var(--shell-fg, #1b1c1f));box-shadow:var(--shell-main-shadow, none);container:shell-main/inline-size}.shell__main:focus{outline:none}:host([data-scroll=main]) .shell__main{overflow:auto;overscroll-behavior:contain}.shell__content{max-inline-size:var(--shell-main-max-width, none);margin-inline:auto}.shell__footer{grid-area:footer;z-index:var(--shell-z-topbar, 20)}.shell__scrim{position:fixed;inset:0;z-index:var(--shell-z-scrim, 30);background:var(--shell-drawer-scrim, var(--shell-scrim, rgba(15, 17, 21, .45)));-webkit-backdrop-filter:blur(var(--shell-drawer-scrim-blur, 0px));backdrop-filter:blur(var(--shell-drawer-scrim-blur, 0px));opacity:0;visibility:hidden;pointer-events:none;transition-property:opacity,visibility;transition-duration:calc(var(--shell-drawer-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-drawer-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.shell__scrim{transition-duration:1ms}}.shell__scrim[data-open=true]{opacity:1;visibility:visible;pointer-events:auto}.shell__scrim[data-motion=none]{transition-duration:0s}.shell__skip-link{position:fixed;inset-block-start:8px;inset-inline-start:8px;z-index:var(--shell-z-skip-link, 60);padding:8px 14px;border-radius:var(--shell-radius-sm, 6px);background:var(--shell-surface, #ffffff);color:var(--shell-fg, #1b1c1f);box-shadow:var(--shell-shadow-panel, 0 12px 32px -12px rgba(15, 17, 21, .28));text-decoration:none;transform:translateY(calc(-100% - 16px));transition-property:transform;transition-duration:calc(var(--shell-transition-duration, .18s) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1))}@media(prefers-reduced-motion:reduce){.shell__skip-link{transition-duration:1ms}}.shell__skip-link:focus-visible{transform:none;outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:2px}\n"] }]
|
|
1654
|
+
}], ctorParameters: () => [], propDecorators: { main: [{ type: i0.ViewChild, args: ['main', { isSignal: true }] }], frame: [{ type: i0.ViewChild, args: ['frame', { isSignal: true }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], scroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "scroll", required: false }] }], topbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "topbar", required: false }] }], subheader: [{ type: i0.Input, args: [{ isSignal: true, alias: "subheader", required: false }] }], footer: [{ type: i0.Input, args: [{ isSignal: true, alias: "footer", required: false }] }] } });
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* A single navigation entry: a link, or an expandable section when it has
|
|
1658
|
+
* children.
|
|
1659
|
+
*
|
|
1660
|
+
* Renders `<a>` for anything navigable and `<button>` for a pure section
|
|
1661
|
+
* header. Those are different semantics for assistive tech, and only one of
|
|
1662
|
+
* them belongs in the tab order as a link. A section whose subtree contains the
|
|
1663
|
+
* active route opens itself and is marked, so deep links never land in a
|
|
1664
|
+
* collapsed tree.
|
|
1665
|
+
*/
|
|
1666
|
+
class ShellNavItemComponent {
|
|
1667
|
+
item = input.required(...(ngDevMode ? [{ debugName: "item" }] : /* istanbul ignore next */ []));
|
|
1668
|
+
depth = input(0, ...(ngDevMode ? [{ debugName: "depth" }] : /* istanbul ignore next */ []));
|
|
1669
|
+
/** False on an icon rail: labels and badges give way to icons and tooltips. */
|
|
1670
|
+
labels = input(true, ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
|
|
1671
|
+
iconTemplate = input(null, ...(ngDevMode ? [{ debugName: "iconTemplate" }] : /* istanbul ignore next */ []));
|
|
1672
|
+
/** Replaces the row content; see `ShellNavComponent.itemTemplate`. */
|
|
1673
|
+
itemTemplate = input(null, ...(ngDevMode ? [{ debugName: "itemTemplate" }] : /* istanbul ignore next */ []));
|
|
1674
|
+
router = inject(Router, { optional: true });
|
|
1675
|
+
/** Current URL, so "contains the active route" re-evaluates on navigation. */
|
|
1676
|
+
url = this.router
|
|
1677
|
+
? toSignal(this.router.events.pipe(filter((event) => event instanceof NavigationEnd), map((event) => event.urlAfterRedirects)), { initialValue: this.router.url })
|
|
1678
|
+
: signal('');
|
|
1679
|
+
children = computed(() => this.item().children ?? [], ...(ngDevMode ? [{ debugName: "children" }] : /* istanbul ignore next */ []));
|
|
1680
|
+
hasChildren = computed(() => this.children().length > 0, ...(ngDevMode ? [{ debugName: "hasChildren" }] : /* istanbul ignore next */ []));
|
|
1681
|
+
isLink = computed(() => {
|
|
1682
|
+
const item = this.item();
|
|
1683
|
+
return !!(item.route || item.href) && !item.disabled;
|
|
1684
|
+
}, ...(ngDevMode ? [{ debugName: "isLink" }] : /* istanbul ignore next */ []));
|
|
1685
|
+
childActive = computed(() => {
|
|
1686
|
+
this.url();
|
|
1687
|
+
return this.hasChildren() && this.containsActive(this.children());
|
|
1688
|
+
}, ...(ngDevMode ? [{ debugName: "childActive" }] : /* istanbul ignore next */ []));
|
|
1689
|
+
/**
|
|
1690
|
+
* User-toggleable, but re-derives when the data or the active route changes —
|
|
1691
|
+
* so a server-driven `expanded` flag or a deep link still wins, without
|
|
1692
|
+
* clobbering a click the moment anything else re-renders.
|
|
1693
|
+
*/
|
|
1694
|
+
expandedState = linkedSignal(() => (this.item().expanded ?? false) || this.childActive(), ...(ngDevMode ? [{ debugName: "expandedState" }] : /* istanbul ignore next */ []));
|
|
1695
|
+
expanded = computed(() => this.hasChildren() && this.expandedState(), ...(ngDevMode ? [{ debugName: "expanded" }] : /* istanbul ignore next */ []));
|
|
1696
|
+
/** Tooltip only when the label is not rendered — otherwise it is noise. */
|
|
1697
|
+
title = computed(() => (this.labels() ? null : this.item().label), ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
|
|
1698
|
+
/** Stand-in for a missing icon on a rail, so the row is never blank. */
|
|
1699
|
+
initial = computed(() => this.item().label.trim().charAt(0).toUpperCase(), ...(ngDevMode ? [{ debugName: "initial" }] : /* istanbul ignore next */ []));
|
|
1700
|
+
itemContext = computed(() => {
|
|
1701
|
+
const item = this.item();
|
|
1702
|
+
return {
|
|
1703
|
+
$implicit: item,
|
|
1704
|
+
item,
|
|
1705
|
+
depth: this.depth(),
|
|
1706
|
+
labels: this.labels(),
|
|
1707
|
+
expanded: this.expanded(),
|
|
1708
|
+
};
|
|
1709
|
+
}, ...(ngDevMode ? [{ debugName: "itemContext" }] : /* istanbul ignore next */ []));
|
|
1710
|
+
toggle() {
|
|
1711
|
+
this.expandedState.update((open) => !open);
|
|
1712
|
+
}
|
|
1713
|
+
/** `readonly unknown[]` is friendlier as an API than RouterLink's `any[]`. */
|
|
1714
|
+
routerTarget(item) {
|
|
1715
|
+
const route = item.route;
|
|
1716
|
+
if (route === undefined) {
|
|
1717
|
+
return [];
|
|
1718
|
+
}
|
|
1719
|
+
return typeof route === 'string' ? route : [...route];
|
|
1720
|
+
}
|
|
1721
|
+
containsActive(items) {
|
|
1722
|
+
const router = this.router;
|
|
1723
|
+
if (!router) {
|
|
1724
|
+
return false;
|
|
1725
|
+
}
|
|
1726
|
+
return items.some((item) => {
|
|
1727
|
+
if (item.route !== undefined) {
|
|
1728
|
+
const tree = typeof item.route === 'string'
|
|
1729
|
+
? router.parseUrl(item.route)
|
|
1730
|
+
: router.createUrlTree([...item.route]);
|
|
1731
|
+
const options = {
|
|
1732
|
+
paths: item.exact ? 'exact' : 'subset',
|
|
1733
|
+
queryParams: 'ignored',
|
|
1734
|
+
fragment: 'ignored',
|
|
1735
|
+
matrixParams: 'ignored',
|
|
1736
|
+
};
|
|
1737
|
+
if (router.isActive(tree, options)) {
|
|
1738
|
+
return true;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return this.containsActive(item.children ?? []);
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellNavItemComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1745
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: ShellNavItemComponent, isStandalone: true, selector: "shell-nav-item", inputs: { item: { classPropertyName: "item", publicName: "item", isSignal: true, isRequired: true, transformFunction: null }, depth: { classPropertyName: "depth", publicName: "depth", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null }, iconTemplate: { classPropertyName: "iconTemplate", publicName: "iconTemplate", isSignal: true, isRequired: false, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.data-depth": "depth()", "attr.data-expanded": "expanded()", "attr.data-labels": "labels()", "attr.data-child-active": "childActive() || null", "attr.data-disabled": "item().disabled || null", "style.--shell-nav-depth": "depth()" } }, ngImport: i0, template: "@let entry = item();\n@let icon = entry.icon;\n@let badge = entry.badge;\n\n@if (isLink()) {\n @if (entry.route) {\n <a\n class=\"nav-item__row\"\n [routerLink]=\"routerTarget(entry)\"\n routerLinkActive=\"nav-item__row--active\"\n [routerLinkActiveOptions]=\"{ exact: !!entry.exact }\"\n ariaCurrentWhenActive=\"page\"\n [attr.title]=\"title()\"\n [attr.target]=\"entry.target\"\n >\n <ng-container *ngTemplateOutlet=\"row\" />\n </a>\n } @else {\n <a\n class=\"nav-item__row\"\n [href]=\"entry.href\"\n [attr.title]=\"title()\"\n [attr.target]=\"entry.target\"\n [attr.rel]=\"entry.target === '_blank' ? 'noopener' : null\"\n >\n <ng-container *ngTemplateOutlet=\"row\" />\n </a>\n }\n} @else if (hasChildren()) {\n <button\n type=\"button\"\n class=\"nav-item__row nav-item__row--section\"\n [attr.aria-expanded]=\"expanded()\"\n [attr.title]=\"title()\"\n [disabled]=\"entry.disabled\"\n (click)=\"toggle()\"\n >\n <ng-container *ngTemplateOutlet=\"row\" />\n <span class=\"nav-item__chevron\" aria-hidden=\"true\"></span>\n </button>\n} @else {\n <span class=\"nav-item__row nav-item__row--static\" [attr.title]=\"title()\">\n <ng-container *ngTemplateOutlet=\"row\" />\n </span>\n}\n\n@if (hasChildren()) {\n <!--\n Height animation via grid-template-rows 0fr \u2192 1fr: no JS measurement, and\n it degrades to an instant show/hide under prefers-reduced-motion.\n `inert` keeps collapsed children out of the tab order and the a11y tree.\n -->\n <div class=\"nav-item__children\">\n <ul class=\"nav-item__child-list\" [attr.inert]=\"expanded() ? null : true\">\n @for (child of children(); track child.id ?? child.label) {\n <li>\n <shell-nav-item\n [item]=\"child\"\n [depth]=\"depth() + 1\"\n [labels]=\"labels()\"\n [iconTemplate]=\"iconTemplate()\"\n [itemTemplate]=\"itemTemplate()\"\n />\n </li>\n }\n </ul>\n </div>\n}\n\n<ng-template #row>\n @if (itemTemplate(); as custom) {\n <!--\n Custom content inside the library's row element: link semantics, active\n state and sections still come from here; the template owns the rest.\n -->\n <ng-container *ngTemplateOutlet=\"custom; context: itemContext()\" />\n } @else {\n @if (icon) {\n <span class=\"nav-item__icon\" aria-hidden=\"true\">\n @if (iconTemplate()) {\n <ng-container\n *ngTemplateOutlet=\"\n iconTemplate()!;\n context: { $implicit: icon, icon: icon, item: entry }\n \"\n />\n } @else {\n {{ icon }}\n }\n </span>\n } @else if (!labels()) {\n <!-- Without an icon a rail row would be empty: show the label's initial. -->\n <span class=\"nav-item__glyph\" aria-hidden=\"true\">{{ initial() }}</span>\n }\n\n @if (labels()) {\n <span class=\"nav-item__label\">{{ entry.label }}</span>\n\n @if (badge !== undefined && badge !== null) {\n <span class=\"nav-item__badge\">{{ badge }}</span>\n }\n } @else {\n <!-- The label still has to reach screen readers on an icon rail. -->\n <span class=\"nav-item__sr-label\">{{ entry.label }}</span>\n\n @if (badge !== undefined && badge !== null) {\n <span class=\"nav-item__dot\" aria-hidden=\"true\"></span>\n }\n }\n }\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block}.nav-item__row{position:relative;display:flex;align-items:center;gap:var(--shell-nav-item-gap, 10px);box-sizing:border-box;inline-size:100%;min-block-size:var(--shell-nav-item-height, 36px);padding-inline:10px;padding-inline-start:calc(10px + var(--shell-nav-depth, 0) * 14px);border:0;border-radius:var(--shell-nav-item-radius, var(--shell-radius-sm, 6px));background:transparent;color:var(--shell-nav-item-fg, var(--shell-fg, #1b1c1f));font:inherit;text-align:start;text-decoration:none;cursor:pointer;transition-property:background-color,color;transition-duration:calc(var(--shell-nav-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav-item__row{transition-duration:1ms}}.nav-item__row:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:-2px}.nav-item__row:hover{background:var(--shell-nav-item-hover-bg, var(--shell-hover-surface, #f0f0f3))}.nav-item__row--static{cursor:default;color:var(--shell-fg-muted, #6b6f76)}.nav-item__row--static:hover{background:transparent}.nav-item__row--active{background:var(--shell-nav-item-active-bg, var(--shell-accent-surface, #eef0fe));color:var(--shell-nav-item-active-fg, var(--shell-accent, #4f46e5));font-weight:600}.nav-item__row--active:hover{background:var(--shell-nav-item-active-bg, var(--shell-accent-surface, #eef0fe))}:host-context(shell-nav[data-indicator=bar]) .nav-item__row--active,:host-context(shell-nav[data-indicator=none]) .nav-item__row--active{background:var(--shell-nav-item-active-bg, transparent)}:host-context(shell-nav[data-indicator=bar]) .nav-item__row--active:hover,:host-context(shell-nav[data-indicator=none]) .nav-item__row--active:hover{background:var(--shell-nav-item-hover-bg, var(--shell-hover-surface, #f0f0f3))}:host-context(shell-nav[data-indicator=bar]) .nav-item__row--active:before{content:\"\";position:absolute;inset-block:7px;inset-inline-start:0;inline-size:var(--shell-nav-indicator-size, 3px);border-radius:999px;background:var(--shell-nav-indicator-color, var(--shell-accent, #4f46e5))}:host([data-child-active])>.nav-item__row--section{color:var(--shell-nav-item-active-fg, var(--shell-accent, #4f46e5))}:host([data-disabled]) .nav-item__row{opacity:.5;pointer-events:none}.nav-item__icon{display:grid;place-items:center;flex:0 0 auto;inline-size:var(--shell-nav-icon-size, 18px);block-size:var(--shell-nav-icon-size, 18px);font-size:var(--shell-nav-icon-size, 18px);line-height:1}.nav-item__glyph{display:grid;place-items:center;flex:0 0 auto;inline-size:var(--shell-nav-icon-size, 18px);block-size:var(--shell-nav-icon-size, 18px);border-radius:5px;background:var(--shell-active-surface, #e9e9ee);color:var(--shell-fg-muted, #6b6f76);font-size:11px;font-weight:700;line-height:1}.nav-item__row--active .nav-item__glyph{background:var(--shell-accent, #4f46e5);color:var(--shell-accent-fg, #ffffff)}.nav-item__label{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;flex:1 1 auto}.nav-item__badge{flex:0 0 auto;min-inline-size:20px;padding-inline:6px;border-radius:999px;background:var(--shell-active-surface, #e9e9ee);color:var(--shell-fg-muted, #6b6f76);font-size:var(--shell-font-size-sm, 12.5px);font-weight:600;line-height:20px;text-align:center}.nav-item__dot{position:absolute;inset-block-start:6px;inset-inline-end:8px;inline-size:7px;block-size:7px;border-radius:50%;background:var(--shell-accent, #4f46e5)}.nav-item__sr-label{position:absolute;inline-size:1px;block-size:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.nav-item__chevron{flex:0 0 auto;inline-size:6px;block-size:6px;margin-inline-end:4px;border-inline-end:2px solid currentcolor;border-block-end:2px solid currentcolor;color:var(--shell-fg-muted, #6b6f76);transform:rotate(-45deg);transition-property:transform;transition-duration:calc(var(--shell-nav-expand-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-expand-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav-item__chevron{transition-duration:1ms}}:host-context([dir=rtl]) .nav-item__chevron{transform:rotate(135deg)}:host([data-expanded=true])>.nav-item__row>.nav-item__chevron{transform:rotate(45deg)}:host([data-labels=false]) .nav-item__row{justify-content:center;padding-inline:0}:host([data-labels=false]) .nav-item__chevron{display:none}.nav-item__children{display:grid;grid-template-rows:0fr;transition-property:grid-template-rows;transition-duration:calc(var(--shell-nav-expand-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-expand-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav-item__children{transition-duration:1ms}}:host([data-expanded=true])>.nav-item__children{grid-template-rows:1fr}.nav-item__child-list{display:flex;flex-direction:column;gap:var(--shell-sidebar-gap, 2px);min-block-size:0;margin:0;padding:0;overflow:hidden;list-style:none}:host([data-expanded=true])>.nav-item__children>.nav-item__child-list{padding-block-start:var(--shell-sidebar-gap, 2px)}\n"], dependencies: [{ kind: "component", type: ShellNavItemComponent, selector: "shell-nav-item", inputs: ["item", "depth", "labels", "iconTemplate", "itemTemplate"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "directive", type: RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1746
|
+
}
|
|
1747
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellNavItemComponent, decorators: [{
|
|
1748
|
+
type: Component,
|
|
1749
|
+
args: [{ selector: 'shell-nav-item', imports: [NgTemplateOutlet, RouterLink, RouterLinkActive, ShellNavItemComponent], host: {
|
|
1750
|
+
'[attr.data-depth]': 'depth()',
|
|
1751
|
+
'[attr.data-expanded]': 'expanded()',
|
|
1752
|
+
'[attr.data-labels]': 'labels()',
|
|
1753
|
+
'[attr.data-child-active]': 'childActive() || null',
|
|
1754
|
+
'[attr.data-disabled]': 'item().disabled || null',
|
|
1755
|
+
'[style.--shell-nav-depth]': 'depth()',
|
|
1756
|
+
}, changeDetection: ChangeDetectionStrategy.OnPush, template: "@let entry = item();\n@let icon = entry.icon;\n@let badge = entry.badge;\n\n@if (isLink()) {\n @if (entry.route) {\n <a\n class=\"nav-item__row\"\n [routerLink]=\"routerTarget(entry)\"\n routerLinkActive=\"nav-item__row--active\"\n [routerLinkActiveOptions]=\"{ exact: !!entry.exact }\"\n ariaCurrentWhenActive=\"page\"\n [attr.title]=\"title()\"\n [attr.target]=\"entry.target\"\n >\n <ng-container *ngTemplateOutlet=\"row\" />\n </a>\n } @else {\n <a\n class=\"nav-item__row\"\n [href]=\"entry.href\"\n [attr.title]=\"title()\"\n [attr.target]=\"entry.target\"\n [attr.rel]=\"entry.target === '_blank' ? 'noopener' : null\"\n >\n <ng-container *ngTemplateOutlet=\"row\" />\n </a>\n }\n} @else if (hasChildren()) {\n <button\n type=\"button\"\n class=\"nav-item__row nav-item__row--section\"\n [attr.aria-expanded]=\"expanded()\"\n [attr.title]=\"title()\"\n [disabled]=\"entry.disabled\"\n (click)=\"toggle()\"\n >\n <ng-container *ngTemplateOutlet=\"row\" />\n <span class=\"nav-item__chevron\" aria-hidden=\"true\"></span>\n </button>\n} @else {\n <span class=\"nav-item__row nav-item__row--static\" [attr.title]=\"title()\">\n <ng-container *ngTemplateOutlet=\"row\" />\n </span>\n}\n\n@if (hasChildren()) {\n <!--\n Height animation via grid-template-rows 0fr \u2192 1fr: no JS measurement, and\n it degrades to an instant show/hide under prefers-reduced-motion.\n `inert` keeps collapsed children out of the tab order and the a11y tree.\n -->\n <div class=\"nav-item__children\">\n <ul class=\"nav-item__child-list\" [attr.inert]=\"expanded() ? null : true\">\n @for (child of children(); track child.id ?? child.label) {\n <li>\n <shell-nav-item\n [item]=\"child\"\n [depth]=\"depth() + 1\"\n [labels]=\"labels()\"\n [iconTemplate]=\"iconTemplate()\"\n [itemTemplate]=\"itemTemplate()\"\n />\n </li>\n }\n </ul>\n </div>\n}\n\n<ng-template #row>\n @if (itemTemplate(); as custom) {\n <!--\n Custom content inside the library's row element: link semantics, active\n state and sections still come from here; the template owns the rest.\n -->\n <ng-container *ngTemplateOutlet=\"custom; context: itemContext()\" />\n } @else {\n @if (icon) {\n <span class=\"nav-item__icon\" aria-hidden=\"true\">\n @if (iconTemplate()) {\n <ng-container\n *ngTemplateOutlet=\"\n iconTemplate()!;\n context: { $implicit: icon, icon: icon, item: entry }\n \"\n />\n } @else {\n {{ icon }}\n }\n </span>\n } @else if (!labels()) {\n <!-- Without an icon a rail row would be empty: show the label's initial. -->\n <span class=\"nav-item__glyph\" aria-hidden=\"true\">{{ initial() }}</span>\n }\n\n @if (labels()) {\n <span class=\"nav-item__label\">{{ entry.label }}</span>\n\n @if (badge !== undefined && badge !== null) {\n <span class=\"nav-item__badge\">{{ badge }}</span>\n }\n } @else {\n <!-- The label still has to reach screen readers on an icon rail. -->\n <span class=\"nav-item__sr-label\">{{ entry.label }}</span>\n\n @if (badge !== undefined && badge !== null) {\n <span class=\"nav-item__dot\" aria-hidden=\"true\"></span>\n }\n }\n }\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block}.nav-item__row{position:relative;display:flex;align-items:center;gap:var(--shell-nav-item-gap, 10px);box-sizing:border-box;inline-size:100%;min-block-size:var(--shell-nav-item-height, 36px);padding-inline:10px;padding-inline-start:calc(10px + var(--shell-nav-depth, 0) * 14px);border:0;border-radius:var(--shell-nav-item-radius, var(--shell-radius-sm, 6px));background:transparent;color:var(--shell-nav-item-fg, var(--shell-fg, #1b1c1f));font:inherit;text-align:start;text-decoration:none;cursor:pointer;transition-property:background-color,color;transition-duration:calc(var(--shell-nav-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav-item__row{transition-duration:1ms}}.nav-item__row:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:-2px}.nav-item__row:hover{background:var(--shell-nav-item-hover-bg, var(--shell-hover-surface, #f0f0f3))}.nav-item__row--static{cursor:default;color:var(--shell-fg-muted, #6b6f76)}.nav-item__row--static:hover{background:transparent}.nav-item__row--active{background:var(--shell-nav-item-active-bg, var(--shell-accent-surface, #eef0fe));color:var(--shell-nav-item-active-fg, var(--shell-accent, #4f46e5));font-weight:600}.nav-item__row--active:hover{background:var(--shell-nav-item-active-bg, var(--shell-accent-surface, #eef0fe))}:host-context(shell-nav[data-indicator=bar]) .nav-item__row--active,:host-context(shell-nav[data-indicator=none]) .nav-item__row--active{background:var(--shell-nav-item-active-bg, transparent)}:host-context(shell-nav[data-indicator=bar]) .nav-item__row--active:hover,:host-context(shell-nav[data-indicator=none]) .nav-item__row--active:hover{background:var(--shell-nav-item-hover-bg, var(--shell-hover-surface, #f0f0f3))}:host-context(shell-nav[data-indicator=bar]) .nav-item__row--active:before{content:\"\";position:absolute;inset-block:7px;inset-inline-start:0;inline-size:var(--shell-nav-indicator-size, 3px);border-radius:999px;background:var(--shell-nav-indicator-color, var(--shell-accent, #4f46e5))}:host([data-child-active])>.nav-item__row--section{color:var(--shell-nav-item-active-fg, var(--shell-accent, #4f46e5))}:host([data-disabled]) .nav-item__row{opacity:.5;pointer-events:none}.nav-item__icon{display:grid;place-items:center;flex:0 0 auto;inline-size:var(--shell-nav-icon-size, 18px);block-size:var(--shell-nav-icon-size, 18px);font-size:var(--shell-nav-icon-size, 18px);line-height:1}.nav-item__glyph{display:grid;place-items:center;flex:0 0 auto;inline-size:var(--shell-nav-icon-size, 18px);block-size:var(--shell-nav-icon-size, 18px);border-radius:5px;background:var(--shell-active-surface, #e9e9ee);color:var(--shell-fg-muted, #6b6f76);font-size:11px;font-weight:700;line-height:1}.nav-item__row--active .nav-item__glyph{background:var(--shell-accent, #4f46e5);color:var(--shell-accent-fg, #ffffff)}.nav-item__label{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;flex:1 1 auto}.nav-item__badge{flex:0 0 auto;min-inline-size:20px;padding-inline:6px;border-radius:999px;background:var(--shell-active-surface, #e9e9ee);color:var(--shell-fg-muted, #6b6f76);font-size:var(--shell-font-size-sm, 12.5px);font-weight:600;line-height:20px;text-align:center}.nav-item__dot{position:absolute;inset-block-start:6px;inset-inline-end:8px;inline-size:7px;block-size:7px;border-radius:50%;background:var(--shell-accent, #4f46e5)}.nav-item__sr-label{position:absolute;inline-size:1px;block-size:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.nav-item__chevron{flex:0 0 auto;inline-size:6px;block-size:6px;margin-inline-end:4px;border-inline-end:2px solid currentcolor;border-block-end:2px solid currentcolor;color:var(--shell-fg-muted, #6b6f76);transform:rotate(-45deg);transition-property:transform;transition-duration:calc(var(--shell-nav-expand-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-expand-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav-item__chevron{transition-duration:1ms}}:host-context([dir=rtl]) .nav-item__chevron{transform:rotate(135deg)}:host([data-expanded=true])>.nav-item__row>.nav-item__chevron{transform:rotate(45deg)}:host([data-labels=false]) .nav-item__row{justify-content:center;padding-inline:0}:host([data-labels=false]) .nav-item__chevron{display:none}.nav-item__children{display:grid;grid-template-rows:0fr;transition-property:grid-template-rows;transition-duration:calc(var(--shell-nav-expand-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-expand-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav-item__children{transition-duration:1ms}}:host([data-expanded=true])>.nav-item__children{grid-template-rows:1fr}.nav-item__child-list{display:flex;flex-direction:column;gap:var(--shell-sidebar-gap, 2px);min-block-size:0;margin:0;padding:0;overflow:hidden;list-style:none}:host([data-expanded=true])>.nav-item__children>.nav-item__child-list{padding-block-start:var(--shell-sidebar-gap, 2px)}\n"] }]
|
|
1757
|
+
}], propDecorators: { item: [{ type: i0.Input, args: [{ isSignal: true, alias: "item", required: true }] }], depth: [{ type: i0.Input, args: [{ isSignal: true, alias: "depth", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], iconTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "iconTemplate", required: false }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: false }] }] } });
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Lets code other than the owner of the nav array add entries to it — a
|
|
1761
|
+
* feature library adding "Roadmap" to the app's "Workspace" group, or a
|
|
1762
|
+
* section adding context links while it is open.
|
|
1763
|
+
*
|
|
1764
|
+
* Contributions target a group by `ShellNavGroup.id` and are merged into every
|
|
1765
|
+
* `<shell-nav>` that renders a group with that id. The app keeps control of
|
|
1766
|
+
* structure and order: it declares the groups (an empty group is fine — empty
|
|
1767
|
+
* groups are not rendered), features only fill them.
|
|
1768
|
+
*/
|
|
1769
|
+
class ShellNavRegistry {
|
|
1770
|
+
contributions = signal([], ...(ngDevMode ? [{ debugName: "contributions" }] : /* istanbul ignore next */ []));
|
|
1771
|
+
sequence = 0;
|
|
1772
|
+
/**
|
|
1773
|
+
* Add items to the group with this id. Returns a function that removes them
|
|
1774
|
+
* again. Prefer `contributeNavItems()` / `provideShellNavItems()`, which
|
|
1775
|
+
* remove them automatically.
|
|
1776
|
+
*/
|
|
1777
|
+
contribute(groupId, items, options = {}) {
|
|
1778
|
+
const entry = {
|
|
1779
|
+
groupId,
|
|
1780
|
+
items,
|
|
1781
|
+
order: options.order ?? 0,
|
|
1782
|
+
sequence: this.sequence++,
|
|
1783
|
+
};
|
|
1784
|
+
this.contributions.update((current) => [...current, entry]);
|
|
1785
|
+
return () => this.contributions.update((current) => current.filter((candidate) => candidate !== entry));
|
|
1786
|
+
}
|
|
1787
|
+
/** Items contributed to a group, in order. Reactive when read in a signal context. */
|
|
1788
|
+
itemsFor(groupId) {
|
|
1789
|
+
return this.contributions()
|
|
1790
|
+
.filter((contribution) => contribution.groupId === groupId)
|
|
1791
|
+
.sort((a, b) => a.order - b.order || a.sequence - b.sequence)
|
|
1792
|
+
.flatMap((contribution) => isSignal(contribution.items) ? contribution.items() : contribution.items);
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* `groups` with contributed items appended to the groups whose `id` matches.
|
|
1796
|
+
* Returns the input untouched when there is nothing to add, so object
|
|
1797
|
+
* identity stays stable for the common case. Reactive.
|
|
1798
|
+
*/
|
|
1799
|
+
merge(groups) {
|
|
1800
|
+
if (!this.contributions().length) {
|
|
1801
|
+
return groups;
|
|
1802
|
+
}
|
|
1803
|
+
return groups.map((group) => {
|
|
1804
|
+
const extra = group.id === undefined ? [] : this.itemsFor(group.id);
|
|
1805
|
+
return extra.length ? { ...group, items: [...group.items, ...extra] } : group;
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellNavRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1809
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellNavRegistry, providedIn: 'root' });
|
|
1810
|
+
}
|
|
1811
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellNavRegistry, decorators: [{
|
|
1812
|
+
type: Injectable,
|
|
1813
|
+
args: [{ providedIn: 'root' }]
|
|
1814
|
+
}] });
|
|
1815
|
+
/**
|
|
1816
|
+
* Contribute nav items for the lifetime of the calling component, directive or
|
|
1817
|
+
* service. Call from an injection context.
|
|
1818
|
+
*
|
|
1819
|
+
* ```ts
|
|
1820
|
+
* export class ProjectPage {
|
|
1821
|
+
* constructor() {
|
|
1822
|
+
* contributeNavItems('workspace', [{ label: 'Project settings', route: '/settings' }]);
|
|
1823
|
+
* }
|
|
1824
|
+
* }
|
|
1825
|
+
* ```
|
|
1826
|
+
*/
|
|
1827
|
+
function contributeNavItems(groupId, items, options) {
|
|
1828
|
+
const remove = inject(ShellNavRegistry).contribute(groupId, items, options);
|
|
1829
|
+
inject(DestroyRef).onDestroy(remove);
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Contribute nav items for the lifetime of an environment injector. In the app
|
|
1833
|
+
* config the entries exist from startup — the usual way for a feature to own
|
|
1834
|
+
* its link:
|
|
1835
|
+
*
|
|
1836
|
+
* ```ts
|
|
1837
|
+
* providers: [provideShellNavItems('workspace', [{ label: 'Roadmap', route: '/roadmap' }])]
|
|
1838
|
+
* ```
|
|
1839
|
+
*
|
|
1840
|
+
* In a lazy route's `providers` they only appear once that route has loaded,
|
|
1841
|
+
* so do not use that to add the link *to* the lazy route itself.
|
|
1842
|
+
*/
|
|
1843
|
+
function provideShellNavItems(groupId, items, options) {
|
|
1844
|
+
return provideEnvironmentInitializer(() => contributeNavItems(groupId, items, options));
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* Renders grouped navigation. Data in, markup out — so the same array feeds
|
|
1849
|
+
* the sidebar, the mobile drawer and anything else that needs the nav tree.
|
|
1850
|
+
*
|
|
1851
|
+
* ```html
|
|
1852
|
+
* <ng-template shellSlot="sidebar-nav">
|
|
1853
|
+
* <shell-nav [groups]="navigation" [iconTemplate]="icon" />
|
|
1854
|
+
* </ng-template>
|
|
1855
|
+
* ```
|
|
1856
|
+
*
|
|
1857
|
+
* `labels` follows the sidebar by default, so a rail needs no configuration.
|
|
1858
|
+
* Items contributed through `ShellNavRegistry` are merged into the groups with
|
|
1859
|
+
* a matching `id`; groups that end up empty are not rendered.
|
|
1860
|
+
*/
|
|
1861
|
+
class ShellNavComponent {
|
|
1862
|
+
/** Groups, or a flat item list which is wrapped in one unlabelled group. */
|
|
1863
|
+
groups = input([], ...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
|
|
1864
|
+
items = input([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
|
|
1865
|
+
/** Override the sidebar's label state — useful when reusing `shell-nav`. */
|
|
1866
|
+
labels = input(null, ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
|
|
1867
|
+
/** Hide group headings even when labels show (a denser, Slack-like list). */
|
|
1868
|
+
headings = input(true, { ...(ngDevMode ? { debugName: "headings" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1869
|
+
/** Renders `ShellNavItem.icon` keys. Without it, the key is shown as text. */
|
|
1870
|
+
iconTemplate = input(null, ...(ngDevMode ? [{ debugName: "iconTemplate" }] : /* istanbul ignore next */ []));
|
|
1871
|
+
/**
|
|
1872
|
+
* Replaces the *content* of every row (icon, label, badge). The row element
|
|
1873
|
+
* itself — link, section button, active state, children — stays the
|
|
1874
|
+
* library's, so routing and accessibility keep working.
|
|
1875
|
+
*/
|
|
1876
|
+
itemTemplate = input(null, ...(ngDevMode ? [{ debugName: "itemTemplate" }] : /* istanbul ignore next */ []));
|
|
1877
|
+
store = inject(ShellStore);
|
|
1878
|
+
registry = inject(ShellNavRegistry);
|
|
1879
|
+
/** Per-group overrides on top of the data's own `collapsed` flag. */
|
|
1880
|
+
groupOverrides = signal(new Map(), ...(ngDevMode ? [{ debugName: "groupOverrides" }] : /* istanbul ignore next */ []));
|
|
1881
|
+
showLabels = computed(() => this.labels() ?? this.store.sidebar().labels, ...(ngDevMode ? [{ debugName: "showLabels" }] : /* istanbul ignore next */ []));
|
|
1882
|
+
resolvedGroups = computed(() => {
|
|
1883
|
+
const groups = this.groups();
|
|
1884
|
+
const items = this.items();
|
|
1885
|
+
const base = groups.length ? groups : items.length ? [{ items }] : [];
|
|
1886
|
+
return this.registry.merge(base).filter((group) => group.items.length > 0);
|
|
1887
|
+
}, ...(ngDevMode ? [{ debugName: "resolvedGroups" }] : /* istanbul ignore next */ []));
|
|
1888
|
+
groupKey(group, index) {
|
|
1889
|
+
return group.id ?? group.label ?? `group-${index}`;
|
|
1890
|
+
}
|
|
1891
|
+
isCollapsed(group, index) {
|
|
1892
|
+
return this.groupOverrides().get(this.groupKey(group, index)) ?? !!group.collapsed;
|
|
1893
|
+
}
|
|
1894
|
+
toggleGroup(group, index) {
|
|
1895
|
+
const key = this.groupKey(group, index);
|
|
1896
|
+
const collapsed = this.isCollapsed(group, index);
|
|
1897
|
+
this.groupOverrides.update((current) => new Map(current).set(key, !collapsed));
|
|
1898
|
+
}
|
|
1899
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellNavComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1900
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: ShellNavComponent, isStandalone: true, selector: "shell-nav", inputs: { groups: { classPropertyName: "groups", publicName: "groups", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null }, headings: { classPropertyName: "headings", publicName: "headings", isSignal: true, isRequired: false, transformFunction: null }, iconTemplate: { classPropertyName: "iconTemplate", publicName: "iconTemplate", isSignal: true, isRequired: false, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.data-labels": "showLabels()", "attr.data-indicator": "store.config().appearance.navIndicator", "attr.data-motion-expand": "store.config().motion.navExpand ? null : \"off\"" } }, ngImport: i0, template: "@for (group of resolvedGroups(); track groupKey(group, $index); let index = $index) {\n @let collapsed = isCollapsed(group, index);\n @let showHeading = showLabels() && headings() && !!group.label;\n\n <div class=\"nav__group\" [attr.data-collapsed]=\"collapsed\">\n @if (showHeading) {\n @if (group.collapsible) {\n <button\n type=\"button\"\n class=\"nav__heading nav__heading--button\"\n [attr.aria-expanded]=\"!collapsed\"\n (click)=\"toggleGroup(group, index)\"\n >\n <span class=\"nav__heading-text\">{{ group.label }}</span>\n <span class=\"nav__heading-chevron\" aria-hidden=\"true\"></span>\n </button>\n } @else {\n <h2 class=\"nav__heading\">{{ group.label }}</h2>\n }\n } @else if (index > 0) {\n <!-- Without a heading, a rule keeps the groups legible on a rail. -->\n <hr class=\"nav__divider\" />\n }\n\n @if (!collapsed) {\n <ul class=\"nav__list\">\n @for (item of group.items; track item.id ?? item.label) {\n <li>\n <shell-nav-item\n [item]=\"item\"\n [labels]=\"showLabels()\"\n [iconTemplate]=\"iconTemplate()\"\n [itemTemplate]=\"itemTemplate()\"\n />\n </li>\n }\n </ul>\n }\n </div>\n}\n", styles: ["@charset \"UTF-8\";:host{display:flex;flex-direction:column;gap:12px}:host([data-motion-expand=off]){--shell-nav-expand-duration: 0s}.nav__group{display:flex;flex-direction:column;gap:2px}.nav__heading{display:flex;align-items:center;gap:6px;margin:0;padding:6px 10px 2px;border:0;background:transparent;color:var(--shell-nav-heading-fg, var(--shell-fg-muted, #6b6f76));font-size:var(--shell-nav-heading-size, var(--shell-font-size-sm, 12.5px));font-weight:600;letter-spacing:.01em}.nav__heading--button{border-radius:var(--shell-radius-sm, 6px);cursor:pointer;transition-property:color;transition-duration:calc(var(--shell-nav-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav__heading--button{transition-duration:1ms}}.nav__heading--button:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:-2px}.nav__heading--button:hover{color:var(--shell-nav-item-fg, var(--shell-fg, #1b1c1f))}.nav__heading-text{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;flex:1 1 auto;text-align:start}.nav__heading-chevron{flex:0 0 auto;inline-size:6px;block-size:6px;border-inline-end:2px solid currentcolor;border-block-end:2px solid currentcolor;transform:rotate(45deg);transition-property:transform;transition-duration:calc(var(--shell-nav-expand-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-expand-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav__heading-chevron{transition-duration:1ms}}.nav__group[data-collapsed=true] .nav__heading-chevron{transform:rotate(-45deg)}.nav__divider{margin:6px 8px;border:0;border-block-start:1px solid var(--shell-sidebar-border-color, var(--shell-border, #e6e6e9))}.nav__list{display:flex;flex-direction:column;gap:var(--shell-sidebar-gap, 2px);margin:0;padding:0;list-style:none}\n"], dependencies: [{ kind: "component", type: ShellNavItemComponent, selector: "shell-nav-item", inputs: ["item", "depth", "labels", "iconTemplate", "itemTemplate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1901
|
+
}
|
|
1902
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: ShellNavComponent, decorators: [{
|
|
1903
|
+
type: Component,
|
|
1904
|
+
args: [{ selector: 'shell-nav', imports: [ShellNavItemComponent], host: {
|
|
1905
|
+
'[attr.data-labels]': 'showLabels()',
|
|
1906
|
+
'[attr.data-indicator]': 'store.config().appearance.navIndicator',
|
|
1907
|
+
'[attr.data-motion-expand]': 'store.config().motion.navExpand ? null : "off"',
|
|
1908
|
+
}, changeDetection: ChangeDetectionStrategy.OnPush, template: "@for (group of resolvedGroups(); track groupKey(group, $index); let index = $index) {\n @let collapsed = isCollapsed(group, index);\n @let showHeading = showLabels() && headings() && !!group.label;\n\n <div class=\"nav__group\" [attr.data-collapsed]=\"collapsed\">\n @if (showHeading) {\n @if (group.collapsible) {\n <button\n type=\"button\"\n class=\"nav__heading nav__heading--button\"\n [attr.aria-expanded]=\"!collapsed\"\n (click)=\"toggleGroup(group, index)\"\n >\n <span class=\"nav__heading-text\">{{ group.label }}</span>\n <span class=\"nav__heading-chevron\" aria-hidden=\"true\"></span>\n </button>\n } @else {\n <h2 class=\"nav__heading\">{{ group.label }}</h2>\n }\n } @else if (index > 0) {\n <!-- Without a heading, a rule keeps the groups legible on a rail. -->\n <hr class=\"nav__divider\" />\n }\n\n @if (!collapsed) {\n <ul class=\"nav__list\">\n @for (item of group.items; track item.id ?? item.label) {\n <li>\n <shell-nav-item\n [item]=\"item\"\n [labels]=\"showLabels()\"\n [iconTemplate]=\"iconTemplate()\"\n [itemTemplate]=\"itemTemplate()\"\n />\n </li>\n }\n </ul>\n }\n </div>\n}\n", styles: ["@charset \"UTF-8\";:host{display:flex;flex-direction:column;gap:12px}:host([data-motion-expand=off]){--shell-nav-expand-duration: 0s}.nav__group{display:flex;flex-direction:column;gap:2px}.nav__heading{display:flex;align-items:center;gap:6px;margin:0;padding:6px 10px 2px;border:0;background:transparent;color:var(--shell-nav-heading-fg, var(--shell-fg-muted, #6b6f76));font-size:var(--shell-nav-heading-size, var(--shell-font-size-sm, 12.5px));font-weight:600;letter-spacing:.01em}.nav__heading--button{border-radius:var(--shell-radius-sm, 6px);cursor:pointer;transition-property:color;transition-duration:calc(var(--shell-nav-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav__heading--button{transition-duration:1ms}}.nav__heading--button:focus-visible{outline:2px solid var(--shell-focus-ring, #4f46e5);outline-offset:-2px}.nav__heading--button:hover{color:var(--shell-nav-item-fg, var(--shell-fg, #1b1c1f))}.nav__heading-text{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;flex:1 1 auto;text-align:start}.nav__heading-chevron{flex:0 0 auto;inline-size:6px;block-size:6px;border-inline-end:2px solid currentcolor;border-block-end:2px solid currentcolor;transform:rotate(45deg);transition-property:transform;transition-duration:calc(var(--shell-nav-expand-duration, var(--shell-transition-duration, .18s)) * var(--shell-motion-scale, 1));transition-timing-function:var(--shell-nav-expand-easing, var(--shell-transition-easing, cubic-bezier(.2, 0, 0, 1)))}@media(prefers-reduced-motion:reduce){.nav__heading-chevron{transition-duration:1ms}}.nav__group[data-collapsed=true] .nav__heading-chevron{transform:rotate(-45deg)}.nav__divider{margin:6px 8px;border:0;border-block-start:1px solid var(--shell-sidebar-border-color, var(--shell-border, #e6e6e9))}.nav__list{display:flex;flex-direction:column;gap:var(--shell-sidebar-gap, 2px);margin:0;padding:0;list-style:none}\n"] }]
|
|
1909
|
+
}], propDecorators: { groups: [{ type: i0.Input, args: [{ isSignal: true, alias: "groups", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], headings: [{ type: i0.Input, args: [{ isSignal: true, alias: "headings", required: false }] }], iconTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "iconTemplate", required: false }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: false }] }] } });
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Add to a component's `imports` to get the whole shell vocabulary:
|
|
1913
|
+
*
|
|
1914
|
+
* ```ts
|
|
1915
|
+
* @Component({ imports: [APP_SHELL], … })
|
|
1916
|
+
* ```
|
|
1917
|
+
*
|
|
1918
|
+
* Tree-shaking still applies per component, so unused pieces are dropped.
|
|
1919
|
+
*/
|
|
1920
|
+
const APP_SHELL = [
|
|
1921
|
+
AppShellComponent,
|
|
1922
|
+
ShellSlotDirective,
|
|
1923
|
+
ShellSlotOutletComponent,
|
|
1924
|
+
ShellNavComponent,
|
|
1925
|
+
ShellNavItemComponent,
|
|
1926
|
+
ShellBreadcrumbComponent,
|
|
1927
|
+
SidebarToggleDirective,
|
|
1928
|
+
ContainerSizeDirective,
|
|
1929
|
+
];
|
|
1930
|
+
|
|
1931
|
+
/*
|
|
1932
|
+
* Public API of the app shell.
|
|
1933
|
+
*
|
|
1934
|
+
* Everything a host application needs, and nothing it should not reach for:
|
|
1935
|
+
* internals such as the focus trap live behind the barrel.
|
|
1936
|
+
*/
|
|
1937
|
+
// Configuration & types
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* Generated bundle index. Do not edit.
|
|
1941
|
+
*/
|
|
1942
|
+
|
|
1943
|
+
export { APP_SHELL, AppShellComponent, BREADCRUMB_DATA_KEY, ContainerSizeDirective, DEFAULT_BREAKPOINTS, DEFAULT_SHELL_CONFIG, SHELL_CONFIG, SHELL_MAIN_ID, SHELL_REGION, SHELL_SIDEBAR_ID, SHELL_SIZES, SHELL_STATE_STORAGE, SIDEBAR_BEHAVIOR, SUBHEADER_DATA_KEY, ShellBreadcrumbComponent, ShellBreadcrumbStore, ShellFooterComponent, ShellNavComponent, ShellNavItemComponent, ShellNavRegistry, ShellSidebarComponent, ShellSlotDirective, ShellSlotOutletComponent, ShellSlotRegistry, ShellStore, ShellSubheaderComponent, ShellTopbarComponent, SidebarToggleDirective, atLeast, atMost, contributeBreadcrumbLabel, contributeNavItems, drawerSidebarBehavior, drawerState, hasSlot, hiddenState, isBelow, localShellStateStorage, manualSidebarBehavior, measureRegion, mergeShellConfig, observeWidth, panelState, provideAppShell, provideShellNavItems, railState, resolveSize, responsiveSidebarBehavior, sizeRank };
|
|
1944
|
+
//# sourceMappingURL=danjelp-ngx-app-shell.mjs.map
|