@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danjel Peqini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
# @danjelp/ngx-app-shell
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@danjelp/ngx-app-shell)
|
|
4
|
+
[](https://github.com/dpeqini/ngx-app-shell/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
A reusable Angular navigational shell: **sidebar + topbar + main + footer**.
|
|
7
|
+
|
|
8
|
+
- Layout comes from CSS custom properties.
|
|
9
|
+
- Chrome comes from named slots that react to the space *they* have, not the window.
|
|
10
|
+
- The sidebar's collapse policy is a swappable strategy that can change at runtime.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
projects/app-shell/
|
|
14
|
+
├── styles/_tokens.scss public Sass entry (@forward)
|
|
15
|
+
└── src/
|
|
16
|
+
├── public-api.ts the only import surface
|
|
17
|
+
└── lib/
|
|
18
|
+
├── app-shell.imports.ts APP_SHELL convenience array
|
|
19
|
+
├── core/
|
|
20
|
+
│ ├── shell.types.ts SidebarState, ShellSlotName, ShellApi …
|
|
21
|
+
│ ├── shell.config.ts ShellConfig, provideAppShell(), mergeShellConfig()
|
|
22
|
+
│ ├── shell.store.ts the one source of truth (root, signals)
|
|
23
|
+
│ ├── shell-state-storage.ts persistence port (localStorage by default)
|
|
24
|
+
│ └── shell.ids.ts
|
|
25
|
+
├── sidebar/
|
|
26
|
+
│ ├── sidebar-behavior.ts SidebarBehavior interface + DI token
|
|
27
|
+
│ ├── sidebar-states.ts drawerState / railState / panelState / hiddenState
|
|
28
|
+
│ ├── responsive-sidebar-behavior.ts default policy
|
|
29
|
+
│ ├── manual-sidebar-behavior.ts user decides, never auto-collapses
|
|
30
|
+
│ └── drawer-sidebar-behavior.ts always an off-canvas drawer
|
|
31
|
+
├── responsive/
|
|
32
|
+
│ ├── shell-size.ts buckets, resolveSize, atLeast/atMost/isBelow
|
|
33
|
+
│ ├── observe-width.ts ResizeObserver → signal
|
|
34
|
+
│ ├── measure-region.ts width + bucket for any element
|
|
35
|
+
│ ├── shell-region.ts SHELL_REGION: "the space I actually have"
|
|
36
|
+
│ └── container-size.directive.ts [shellContainerSize] for your own UI
|
|
37
|
+
├── slots/ shellSlot directive, registry, outlet, hasSlot()
|
|
38
|
+
├── layout/ app-shell, topbar, sidebar, subheader, footer, overflow menu
|
|
39
|
+
├── nav/ shell-nav, shell-nav-item, ShellNavRegistry, types
|
|
40
|
+
├── breadcrumbs/ ShellBreadcrumbStore, shell-breadcrumb, types
|
|
41
|
+
├── directives/sidebar-toggle.directive.ts
|
|
42
|
+
├── a11y/focus-trap.ts
|
|
43
|
+
└── styles/_tokens.scss the token map (single source of truth)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install @danjelp/ngx-app-shell
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The peer dependencies are `@angular/core`, `@angular/common` and `@angular/router` (19–22), plus
|
|
53
|
+
`rxjs` 7.
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// app.config.ts
|
|
59
|
+
providers: [
|
|
60
|
+
provideRouter(routes),
|
|
61
|
+
provideAppShell({
|
|
62
|
+
layout: 'topbar-full',
|
|
63
|
+
sidebar: { collapseBelow: 'lg', overlayBelow: 'md', resizable: true, toggleShortcut: 'b' },
|
|
64
|
+
}),
|
|
65
|
+
];
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```scss
|
|
69
|
+
// styles.scss — optional: every token has an inline fallback
|
|
70
|
+
@use '@danjelp/ngx-app-shell/styles/tokens' as shell;
|
|
71
|
+
@include shell.tokens;
|
|
72
|
+
@include shell.dark-tokens(':root[data-theme="dark"]');
|
|
73
|
+
|
|
74
|
+
:root {
|
|
75
|
+
--shell-topbar-height: 64px;
|
|
76
|
+
--shell-sidebar-width: 250px;
|
|
77
|
+
--shell-main-padding: 24px;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```html
|
|
82
|
+
<!-- app.html -->
|
|
83
|
+
<app-shell>
|
|
84
|
+
<ng-template shellSlot="brand"><a routerLink="/">Acme</a></ng-template>
|
|
85
|
+
<ng-template shellSlot="sidebar-nav">
|
|
86
|
+
<shell-nav [groups]="navigation" [iconTemplate]="icon" />
|
|
87
|
+
</ng-template>
|
|
88
|
+
<ng-template #icon let-name><my-icon [name]="name" /></ng-template>
|
|
89
|
+
|
|
90
|
+
<router-outlet />
|
|
91
|
+
</app-shell>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
@Component({ imports: [APP_SHELL, RouterOutlet, RouterLink], … })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Mapping of the original layout variables
|
|
99
|
+
|
|
100
|
+
| original | shell |
|
|
101
|
+
| -------------------------- | ---------------------------------------------------------------------- |
|
|
102
|
+
| `--topbar-height` | `--shell-topbar-height` |
|
|
103
|
+
| `--sidebar-width` | `--shell-sidebar-width` |
|
|
104
|
+
| `--sidebar-top-offset` | derived from the layout (`--shell-sidebar-top-offset`, read-only) |
|
|
105
|
+
| `--topbar-left-offset` | derived from the layout — the topbar is its own grid area |
|
|
106
|
+
| `--main-container-padding` | `--shell-main-padding`, no topbar compensation — `main` is its own row |
|
|
107
|
+
|
|
108
|
+
## 1. Layout: a grid driven by custom properties
|
|
109
|
+
|
|
110
|
+
There are three named grid areas and one runtime value. Switching arrangement swaps only
|
|
111
|
+
`grid-template-areas`, so no component needs to know where it sits.
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
topbar-full sidebar-full stacked
|
|
115
|
+
┌───────────────┐ ┌────┬──────────┐ ┌───────────────┐
|
|
116
|
+
│ topbar │ │ s │ topbar │ │ topbar │
|
|
117
|
+
├────┬──────────┤ │ i ├──────────┤ ├────┬──────────┤
|
|
118
|
+
│ sb │ main │ │ d │ main │ │ sb │ main │
|
|
119
|
+
├────┼──────────┤ │ e ├──────────┤ ├────┴──────────┤
|
|
120
|
+
│ sb │ footer │ │ b │ footer │ │ footer │
|
|
121
|
+
└────┴──────────┘ └────┴──────────┘ └───────────────┘
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`inset` puts the topbar, content and footer together in one rounded card (the *frame*). The
|
|
125
|
+
sidebar sits directly on the shell background, with no surface, border or shadow; the drawer
|
|
126
|
+
keeps its own.
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
inset
|
|
130
|
+
┌────┬───────────────────┐
|
|
131
|
+
│ │╭─────────────────╮│
|
|
132
|
+
│ sb ││ topbar ││
|
|
133
|
+
│ ││ main ││
|
|
134
|
+
│ ││ footer ││
|
|
135
|
+
│ │╰─────────────────╯│
|
|
136
|
+
└────┴───────────────────┘
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Inside the card, `scroll` chooses what moves:
|
|
140
|
+
- `'main'`: only the content scrolls, between a pinned topbar and footer.
|
|
141
|
+
- `'page'`: the card itself scrolls. The topbar sticks to the top of the card, and the footer
|
|
142
|
+
follows the content.
|
|
143
|
+
|
|
144
|
+
The card never leaves the screen and the sidebar never moves. Style the card with the `frame-*`
|
|
145
|
+
tokens, and the area around it with `--shell-bg`.
|
|
146
|
+
|
|
147
|
+
The only layout value TypeScript writes is `--shell-sidebar-track`, bound from the resolved
|
|
148
|
+
sidebar state. With `scroll: 'main'` only `main` scrolls, and the shell scrolls it back to the
|
|
149
|
+
top after each navigation (`scrollTopOnNavigate`). With `scroll: 'page'` the document scrolls,
|
|
150
|
+
and the topbar and sidebar stick.
|
|
151
|
+
|
|
152
|
+
## 2. Adding items: slots and nav data
|
|
153
|
+
|
|
154
|
+
There are two mechanisms, for two kinds of content.
|
|
155
|
+
|
|
156
|
+
### Slots — templates that register themselves
|
|
157
|
+
|
|
158
|
+
`<ng-content select>` can't move content between places, and a shell needs exactly that:
|
|
159
|
+
search collapses to an icon, and actions move into an overflow menu. So each slot is an
|
|
160
|
+
`ng-template` that registers with a root `ShellSlotRegistry` when it is created and unregisters
|
|
161
|
+
when it is destroyed. Each region renders the slots it owns with `<shell-slot-outlet>`.
|
|
162
|
+
|
|
163
|
+
```html
|
|
164
|
+
<ng-template shellSlot="actions" slotOrder="1" slotMinSize="md" slotOverflow let-overflow="overflow">
|
|
165
|
+
<button [class]="overflow ? 'menu-row' : 'icon-btn'">Notifications</button>
|
|
166
|
+
</ng-template>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| input | meaning |
|
|
170
|
+
| -------------- | ------------------------------------------------------------------------ |
|
|
171
|
+
| `shellSlot` | target slot name |
|
|
172
|
+
| `slotOrder` | ascending order within the slot (several templates per slot is fine) |
|
|
173
|
+
| `slotMinSize` | render only when the *region* is at least this size |
|
|
174
|
+
| `slotMaxSize` | render only up to this size (narrow-only affordances) |
|
|
175
|
+
| `slotOverflow` | when it doesn't fit, move it to the ⋯ menu instead of dropping it (only slots in `config.overflowSlots`; dev mode warns otherwise) |
|
|
176
|
+
|
|
177
|
+
Template context: `let-sidebar` (resolved `SidebarState`), `let-size` (shell size),
|
|
178
|
+
`let-region` (this region's size), `let-overflow` (true in the ⋯ menu) and `let-shell`
|
|
179
|
+
(`ShellApi`).
|
|
180
|
+
|
|
181
|
+
Because registration is a service call rather than content projection, a **routed page can
|
|
182
|
+
contribute chrome** — its own actions or title — and it disappears when the page is destroyed.
|
|
183
|
+
|
|
184
|
+
### Navigation — data in, markup out
|
|
185
|
+
|
|
186
|
+
`<shell-nav>` renders `ShellNavGroup[]` (or a flat `ShellNavItem[]`) and handles:
|
|
187
|
+
- rail labels: hidden, but still read by screen readers and shown as tooltips
|
|
188
|
+
- badges turning into dots on the rail
|
|
189
|
+
- nested sections that open themselves when they contain the active route
|
|
190
|
+
- collapsible groups and `aria-current`
|
|
191
|
+
|
|
192
|
+
The same array can feed a command palette.
|
|
193
|
+
|
|
194
|
+
| input | purpose |
|
|
195
|
+
| ---------------- | --------------------------------------------------------------------------- |
|
|
196
|
+
| `groups`/`items` | the data |
|
|
197
|
+
| `iconTemplate` | turns `item.icon` keys into icons (`let-name`) |
|
|
198
|
+
| `itemTemplate` | replaces the **content** of each row. The row element — link, section button, active state, children — stays the library's. Context: `item`, `depth`, `labels`, `expanded` |
|
|
199
|
+
| `labels` | overrides the sidebar's label state |
|
|
200
|
+
| `headings` | `false` for a dense list without group headings |
|
|
201
|
+
|
|
202
|
+
On the rail, an item without an icon shows the first letter of its label, so no row is ever
|
|
203
|
+
blank.
|
|
204
|
+
|
|
205
|
+
### Feature-contributed nav items — `ShellNavRegistry`
|
|
206
|
+
|
|
207
|
+
The app declares the groups, and features fill them by group `id`. A group with no items and no
|
|
208
|
+
contributions isn't rendered, so an app can reserve an empty group for plugins.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// app-lifetime: feature-owned link, registered at startup
|
|
212
|
+
providers: [provideShellNavItems('workspace', [{ label: 'Roadmap', route: '/roadmap' }], { order: 10 })];
|
|
213
|
+
|
|
214
|
+
// component-lifetime: context links while a page is open
|
|
215
|
+
contributeNavItems('workspace', computed(() => [{ label: 'Inbox', route: '/inbox', badge: unread() }]));
|
|
216
|
+
|
|
217
|
+
// merged tree for your own consumers (palette, breadcrumbs)
|
|
218
|
+
inject(ShellNavRegistry).merge(groups);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## 3. Regions measure themselves, not the window
|
|
222
|
+
|
|
223
|
+
Every region measures its own width and provides `SHELL_REGION` with it. **Expanding the sidebar
|
|
224
|
+
from a rail to a full panel takes ~200px away from the topbar without the window resizing.** The
|
|
225
|
+
topbar notices, drops to a smaller size, and collapses its slots exactly as it would on a phone.
|
|
226
|
+
|
|
227
|
+
The sizes are based on container width, not device width: `xs 0` · `sm 520` · `md 768` · `lg 1080` · `xl 1440`.
|
|
228
|
+
Every region also exposes `data-shell-size` and a named CSS container (`shell-topbar`,
|
|
229
|
+
`shell-main`, `shell-footer`), so plain CSS can react too:
|
|
230
|
+
|
|
231
|
+
```scss
|
|
232
|
+
@container shell-topbar (max-width: 820px) { .search__kbd { display: none; } }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`[shellContainerSize]` gives your own components the same trick. The sidebar is the deliberate
|
|
236
|
+
exception: it reports a size based on what it shows — `xs` on a rail, `md` when labels show —
|
|
237
|
+
so `slotMinSize="sm"` in the sidebar means "only when labels show".
|
|
238
|
+
|
|
239
|
+
## 4. The sidebar's behaviour is a strategy
|
|
240
|
+
|
|
241
|
+
The state is computed from four inputs: the shell size, the user's **intent** (`auto`,
|
|
242
|
+
`expanded`, `collapsed` or `hidden`; persisted), a temporary **drawer-open** flag (never
|
|
243
|
+
persisted, and reset whenever the size changes), and the peek flag.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
export const railFirst: SidebarBehavior = {
|
|
247
|
+
resolve({ size, intent, drawerOpen, peeking, config: { sidebar } }) {
|
|
248
|
+
if (isBelow(size, sidebar.overlayBelow)) return drawerState(drawerOpen, sidebar);
|
|
249
|
+
if (intent === 'hidden') return hiddenState();
|
|
250
|
+
return intent === 'expanded' ? panelState(sidebar) : railState(peeking, sidebar);
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
provideAppShell({}, { behavior: railFirst }); // at startup
|
|
255
|
+
inject(ShellStore).setBehavior(railFirst); // or at runtime
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Shipped policies: `responsiveSidebarBehavior` (default), `manualSidebarBehavior` and
|
|
259
|
+
`drawerSidebarBehavior`.
|
|
260
|
+
|
|
261
|
+
A drawer toggles open and closed. A persistent panel asks `nextIntent`, which defaults to
|
|
262
|
+
expanded ⇄ collapsed.
|
|
263
|
+
|
|
264
|
+
| mode | open | modal | labels | notes |
|
|
265
|
+
| ----------- | ---- | ----- | ------- | ---------------------------------------------- |
|
|
266
|
+
| `expanded` | ✓ | | ✓ | in-flow panel, optional drag-to-resize |
|
|
267
|
+
| `collapsed` | ✓ | | on peek | icon rail; peek widens it over the content |
|
|
268
|
+
| `overlay` | ✗/✓ | ✓ | ✓ | drawer, scrim, focus trap, Escape |
|
|
269
|
+
| `hidden` | ✗ | | | not displayed, zero-width track |
|
|
270
|
+
|
|
271
|
+
`trackWidth` and `panelWidth` are separate so that peek doesn't reflow the page under the
|
|
272
|
+
pointer.
|
|
273
|
+
|
|
274
|
+
`<button shellSidebarToggle>` works anywhere and keeps `aria-expanded`, `aria-controls` and its
|
|
275
|
+
label in sync with the mode.
|
|
276
|
+
|
|
277
|
+
## 5. The breadcrumb row
|
|
278
|
+
|
|
279
|
+
A row between the topbar and `main`, holding the trail on the start side and page-level actions
|
|
280
|
+
on the end side:
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
Projects › Mobile app v3 › Settings [subheader-start] ········ Updated 2m ago · Share · [New issue] ⋯
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**The trail comes from your routes.** Each route that consumes URL segments is one level. Its
|
|
287
|
+
label comes from, in order:
|
|
288
|
+
1. a page override
|
|
289
|
+
2. the route's own `data.breadcrumb` (a string, or produced by `resolve: { breadcrumb }`)
|
|
290
|
+
3. the route's own `title`
|
|
291
|
+
|
|
292
|
+
`data: { breadcrumb: false }` skips a level, and empty-path routes never add one. Only what a
|
|
293
|
+
route declares itself counts; values Angular passes down from a parent don't.
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
{ path: 'projects', data: { breadcrumb: 'Projects' }, children: [
|
|
297
|
+
{ path: '', component: ProjectsPage },
|
|
298
|
+
{ path: ':id', component: ProjectPage, children: [
|
|
299
|
+
{ path: 'settings', title: 'Settings', component: ProjectSettings },
|
|
300
|
+
]},
|
|
301
|
+
]}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**The placeholder.** For names only the page knows, call `contributeBreadcrumbLabel(nameSignal)`
|
|
305
|
+
in a routed component. It labels *that component's* level, including while a child tab is
|
|
306
|
+
current.
|
|
307
|
+
- While the signal is `undefined`, the crumb is a skeleton and the list is `aria-busy`.
|
|
308
|
+
- The skeleton appears only after `skeletonDelay` (400ms), so fast labels never flash.
|
|
309
|
+
- The row's height is reserved, so nothing moves when the name arrives.
|
|
310
|
+
- Labels from resolvers are ready before the page renders and never need a placeholder.
|
|
311
|
+
|
|
312
|
+
**When the row shows** (`subheader.enabled: 'auto'`):
|
|
313
|
+
- when the page is at least two levels deep, or a page fills a subheader slot
|
|
314
|
+
- `data: { subheader: true | false }` on a route forces it
|
|
315
|
+
- the decision is made per navigation, so the row never appears or disappears within a page
|
|
316
|
+
|
|
317
|
+
**The end side.**
|
|
318
|
+
- `subheader-start` sits right after the trail; `subheader-end` holds actions.
|
|
319
|
+
- `slotOverflow` on `subheader-end` moves actions into the row's own ⋯ menu.
|
|
320
|
+
- Keep to one primary action and up to three secondary ones, and only actions that apply to the
|
|
321
|
+
whole page.
|
|
322
|
+
|
|
323
|
+
**Narrow widths.** The trail measures its own width:
|
|
324
|
+
1. the full trail; beyond `maxItems`, the middle levels fold into a "…" menu
|
|
325
|
+
2. below `compactBelow`: first › … › parent › current
|
|
326
|
+
3. below `backBelow`: a single "‹ Parent" link
|
|
327
|
+
|
|
328
|
+
**Scrolling.**
|
|
329
|
+
- With `scroll: 'main'` the row is pinned.
|
|
330
|
+
- With `scroll: 'page'` it sticks under the topbar, unless `subheader.sticky` is `false`.
|
|
331
|
+
- In `inset` it sits inside the card.
|
|
332
|
+
|
|
333
|
+
`<shell-breadcrumb>` also works on its own, for example inside a page: `[items]`, `[display]`,
|
|
334
|
+
`[maxItems]`, `[itemTemplate]`.
|
|
335
|
+
|
|
336
|
+
## Slot map
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
340
|
+
│ ☰ topbar-start brand primary-nav title ··· search actions ⋯ account topbar-end│
|
|
341
|
+
├──────────────────┬───────────────────────────────────────────────────────────┤
|
|
342
|
+
│ sidebar-header │ breadcrumb · subheader-start ······· subheader-end ⋯ │
|
|
343
|
+
│ sidebar-action ├───────────────────────────────────────────────────────────┤
|
|
344
|
+
│ sidebar-search │ main (<ng-content>) │
|
|
345
|
+
│ sidebar-nav ↕ │ │
|
|
346
|
+
│ sidebar-secondary│ │
|
|
347
|
+
│ sidebar-footer ‹ │ │
|
|
348
|
+
├──────────────────┴───────────────────────────────────────────────────────────┤
|
|
349
|
+
│ footer-start footer footer-end │
|
|
350
|
+
└──────────────────────────────────────────────────────────────────────────────┘
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
The sidebar slots follow what Linear, GitHub, Slack, Notion, Vercel, VS Code, Gmail, Jira and
|
|
354
|
+
Shopify agree on:
|
|
355
|
+
1. identity or workspace switcher
|
|
356
|
+
2. one primary action
|
|
357
|
+
3. quick find (usually a command-palette launcher)
|
|
358
|
+
4. grouped, scrollable navigation — the only part that scrolls
|
|
359
|
+
5. secondary links (settings, help, upgrade)
|
|
360
|
+
6. the user card, sharing its row with the collapse control
|
|
361
|
+
|
|
362
|
+
## Tokens
|
|
363
|
+
|
|
364
|
+
The tokens are defined once as a Sass map. They're emitted as CSS custom properties (`@include shell.tokens`) **and**
|
|
365
|
+
inlined as `var()` fallbacks, so the shell renders without any global stylesheet. You can override
|
|
366
|
+
them anywhere in the cascade. `shell.dark-tokens($selector)` emits a dark palette.
|
|
367
|
+
|
|
368
|
+
| group | tokens |
|
|
369
|
+
| --------- | ------ |
|
|
370
|
+
| structure | `topbar-height` `topbar-padding-inline` `topbar-gap` `sidebar-width` `sidebar-collapsed-width` `sidebar-overlay-width` `sidebar-padding` `sidebar-gap` `footer-min-height` `main-padding` `main-max-width` |
|
|
371
|
+
| colour | `color-scheme` `surface` `surface-raised` `sidebar-surface` `topbar-surface` `fg` `fg-muted` `border` `hover-surface` `active-surface` `accent` `accent-fg` `accent-surface` `focus-ring` `scrim` `shadow-panel` |
|
|
372
|
+
| shape | `radius` `radius-sm` `nav-item-height` `nav-item-gap` `nav-icon-size` `font-family` `font-size` `font-size-sm` |
|
|
373
|
+
| motion | `transition-duration` `transition-easing` |
|
|
374
|
+
| stacking | `z-main` `z-topbar` `z-scrim` `z-sidebar` `z-flyout` `z-skip-link` |
|
|
375
|
+
|
|
376
|
+
## Theming each part
|
|
377
|
+
|
|
378
|
+
There are three layers, and each one overrides the layer below it:
|
|
379
|
+
|
|
380
|
+
1. **Global tokens** — `--shell-surface`, `--shell-accent` and the rest of the table above.
|
|
381
|
+
2. **Component tokens** — `--shell-<part>-<property>`. Each defaults to a global token, so you
|
|
382
|
+
only set what's different:
|
|
383
|
+
|
|
384
|
+
| part | tokens |
|
|
385
|
+
| ------------- | ------ |
|
|
386
|
+
| shell | `bg` (the container behind every part — shows around inset or transparent parts) |
|
|
387
|
+
| topbar | `topbar-bg` `topbar-fg` `topbar-border-width` `topbar-border-style` `topbar-border-color` `topbar-radius` `topbar-shadow` `topbar-blur` |
|
|
388
|
+
| sidebar | `sidebar-bg` `sidebar-fg` `sidebar-border-color` `sidebar-shadow` `sidebar-radius` `sidebar-inset` `sidebar-peek-shadow` |
|
|
389
|
+
| drawer | `drawer-bg` `drawer-shadow` `drawer-radius` `drawer-scrim` `drawer-scrim-blur` |
|
|
390
|
+
| nav | `nav-item-fg` `nav-item-hover-bg` `nav-item-active-bg` `nav-item-active-fg` `nav-item-radius` `nav-indicator-color` `nav-indicator-size` `nav-heading-fg` `nav-heading-size` |
|
|
391
|
+
| ⋯ menu | `menu-bg` `menu-border-color` `menu-shadow` `menu-radius` |
|
|
392
|
+
| main | `main-bg` `main-fg` `main-border-width` `main-border-style` `main-border-color` `main-radius` `main-shadow` `main-margin` |
|
|
393
|
+
| footer | `footer-bg` `footer-fg` `footer-border-color` `footer-shadow` `footer-font-size` |
|
|
394
|
+
| frame (`inset` card) | `frame-bg` `frame-border-width` `frame-border-style` `frame-border-color` `frame-radius` `frame-shadow` `frame-inset` |
|
|
395
|
+
| subheader (breadcrumb row) | `subheader-height` (a minimum) `subheader-bg` `subheader-fg` `subheader-border-width` `subheader-border-style` `subheader-border-color` `subheader-radius` `subheader-margin` `subheader-padding-inline` `subheader-shadow` `subheader-font-size` |
|
|
396
|
+
| breadcrumb | `breadcrumb-fg` `breadcrumb-hover-fg` `breadcrumb-hover-bg` `breadcrumb-current-fg` `breadcrumb-current-weight` `breadcrumb-separator-color` `breadcrumb-gap` `breadcrumb-font-size` `breadcrumb-label-max` `breadcrumb-skeleton-bg` |
|
|
397
|
+
|
|
398
|
+
```scss
|
|
399
|
+
:root {
|
|
400
|
+
--shell-topbar-bg: #0b1020;
|
|
401
|
+
--shell-topbar-fg: #f5f6fa;
|
|
402
|
+
--shell-sidebar-shadow: 0 10px 30px -12px rgb(0 0 0 / 30%);
|
|
403
|
+
--shell-drawer-duration: 260ms;
|
|
404
|
+
|
|
405
|
+
/* Border tokens take the CSS shorthand: 1–4 values, top right bottom left. */
|
|
406
|
+
--shell-topbar-border-width: 0 0 2px; /* bottom only (the default is 0 0 1px) */
|
|
407
|
+
--shell-topbar-border-color: #6366f1;
|
|
408
|
+
--shell-main-border-width: 1px; /* all four sides */
|
|
409
|
+
--shell-main-border-color: #e4e4e7;
|
|
410
|
+
--shell-main-radius: 12px;
|
|
411
|
+
--shell-main-margin: 0 8px 8px 0; /* inset "card" look */
|
|
412
|
+
--shell-topbar-radius: 0 0 12px 12px; /* bottom corners only */
|
|
413
|
+
--shell-bg: #eef0f4; /* behind everything, shows around the card */
|
|
414
|
+
|
|
415
|
+
/* Breadcrumb row as an inset pill, with a bolder current page. */
|
|
416
|
+
--shell-subheader-margin: 8px 8px 0;
|
|
417
|
+
--shell-subheader-radius: 10px;
|
|
418
|
+
--shell-subheader-border-width: 1px;
|
|
419
|
+
--shell-breadcrumb-current-fg: #4f46e5;
|
|
420
|
+
--shell-breadcrumb-current-weight: 700;
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Give them concrete values. Don't alias them on `:root`, as in
|
|
425
|
+
`--shell-topbar-bg: var(--shell-surface)`. An alias is resolved where it's declared, so it
|
|
426
|
+
would stop following theme changes made lower in the page. The shell keeps its own fallbacks
|
|
427
|
+
inside each component for exactly this reason.
|
|
428
|
+
3. **Presets** — the `appearance` config picks a look for each part. Presets only change the
|
|
429
|
+
tokens' *defaults*, so a component token always wins.
|
|
430
|
+
|
|
431
|
+
### Motion per part
|
|
432
|
+
|
|
433
|
+
- **Timing:** `--shell-<scope>-duration` and `--shell-<scope>-easing`. Each falls back to
|
|
434
|
+
`--shell-transition-duration` / `-easing`. The scopes are:
|
|
435
|
+
- `topbar`, `sidebar`, `footer`
|
|
436
|
+
- `collapse` (rail ⇄ panel)
|
|
437
|
+
- `drawer` (drawer and scrim)
|
|
438
|
+
- `nav` and `nav-expand`
|
|
439
|
+
- `menu`
|
|
440
|
+
- `subheader` (breadcrumb row and trail)
|
|
441
|
+
- **Kind and on/off:** the `motion` config.
|
|
442
|
+
- **One multiplier:** every duration is multiplied by `--shell-motion-scale`. That's `1 / speed`,
|
|
443
|
+
or 0 when `motion.enabled` is false, so the switch also covers durations you set yourself.
|
|
444
|
+
`prefers-reduced-motion` still wins.
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
provideAppShell({
|
|
448
|
+
appearance: { topbar: 'elevated', sidebar: 'floating', navIndicator: 'bar' },
|
|
449
|
+
motion: { drawer: 'fade', overflowMenu: 'scale', speed: 1.5, navExpand: false },
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
The scrim and the ⋯ menu stay in the page and toggle `data-open`, so they animate out as well as
|
|
454
|
+
in. This is plain CSS, with no animation library.
|
|
455
|
+
|
|
456
|
+
## Configuration
|
|
457
|
+
|
|
458
|
+
`provideAppShell(config, { behavior })` at startup; `ShellStore.configure(patch)` at runtime.
|
|
459
|
+
|
|
460
|
+
| key | default | notes |
|
|
461
|
+
| -------------------------- | ---------------------------------- | ---------------------------------------------- |
|
|
462
|
+
| `layout` | `topbar-full` | `sidebar-full`, `stacked`, `inset` (card) |
|
|
463
|
+
| `scroll` | `main` | `page` makes the document scroll |
|
|
464
|
+
| `scrollTopOnNavigate` | `true` | resets `main` scroll after navigation |
|
|
465
|
+
| `initialSize` | `lg` | assumed before the first measurement and on SSR |
|
|
466
|
+
| `breakpoints` | see above | container widths |
|
|
467
|
+
| `topbar.enabled` | `true` | |
|
|
468
|
+
| `topbar.sidebarToggle` | `always` | `auto` = only for drawer/hidden, `never` |
|
|
469
|
+
| `footer.enabled` | `false` | the footer also shows when a footer slot is filled |
|
|
470
|
+
| `sidebar.collapseBelow` | `lg` | rail below this size |
|
|
471
|
+
| `sidebar.overlayBelow` | `md` | drawer below this size |
|
|
472
|
+
| `sidebar.peek`/`peekDelay` | `true` / `180` | hover/focus widens the rail |
|
|
473
|
+
| `sidebar.closeOnNavigate` | `true` | drawer closes on `NavigationEnd` |
|
|
474
|
+
| `sidebar.resizable` | `false` | drag or keyboard; `minWidth`/`maxWidth`; double-click resets |
|
|
475
|
+
| `sidebar.toggleShortcut` | `null` | e.g. `'b'` → Ctrl/⌘+B, ignored while typing |
|
|
476
|
+
| `sidebar.persist` | `true` | intent + width under `<storageKey>.intent/.width` |
|
|
477
|
+
| `appearance.topbar` | `solid` | `elevated`, `blur`, `transparent` (blur/transparent need `scroll: 'page'`) |
|
|
478
|
+
| `appearance.sidebar` | `solid` | `floating`, `borderless` |
|
|
479
|
+
| `appearance.footer` | `solid` | `minimal` |
|
|
480
|
+
| `appearance.navIndicator` | `pill` | `bar`, `none` |
|
|
481
|
+
| `appearance.subheader` | `solid` | `transparent` (blends into the page), `elevated` (shadow instead of the rule) |
|
|
482
|
+
| `motion.enabled` / `speed` | `true` / `1` | master switch; 2 = twice as fast |
|
|
483
|
+
| `motion.drawer` | `slide` | `fade`, `scale`, `none` |
|
|
484
|
+
| `motion.overflowMenu` | `fade` | `scale`, `slide`, `none` |
|
|
485
|
+
| `motion.scrim` | `fade` | `none` |
|
|
486
|
+
| `motion.collapse` | `true` | animate rail ⇄ panel |
|
|
487
|
+
| `motion.navExpand` | `true` | animate nav sections |
|
|
488
|
+
| `subheader.enabled` | `'auto'` | `true` / `false` force it; auto = at least two levels deep, or a filled subheader slot |
|
|
489
|
+
| `subheader.sticky` | `true` | stick under the topbar with `scroll: 'page'` |
|
|
490
|
+
| `subheader.overflowSlots` | `subheader-end` | slots the row's ⋯ menu serves |
|
|
491
|
+
| `breadcrumbs.includeHome` / `homeLabel` / `homeUrl` | `false` / `Home` / `/` | prepend a home crumb |
|
|
492
|
+
| `breadcrumbs.includeCurrent` | `true` | `false` ends the trail at the parent |
|
|
493
|
+
| `breadcrumbs.maxItems` | `4` | beyond it, the middle folds into "…" |
|
|
494
|
+
| `breadcrumbs.separator` | `chevron` | `slash`, `dot` — always skipped by screen readers |
|
|
495
|
+
| `breadcrumbs.compactBelow` / `backBelow` | `480` / `280` | px of the trail's own width |
|
|
496
|
+
| `breadcrumbs.useRouteTitle` / `skeletonDelay` | `true` / `400` | title fallback; placeholder delay in ms |
|
|
497
|
+
| `overflowSlots` | `primary-nav`, `search`, `actions` | slots the topbar's ⋯ menu serves |
|
|
498
|
+
| `labels.*` | English | every string the shell renders |
|
|
499
|
+
|
|
500
|
+
Per-instance overrides: `<app-shell layout="sidebar-full" scroll="page" footer>`.
|
|
501
|
+
|
|
502
|
+
## `ShellStore` (root, inject anywhere)
|
|
503
|
+
|
|
504
|
+
`size()`, `shellWidth()`, `sidebar()`, `sidebarIntent()`, `sidebarWidth()`, `config()`,
|
|
505
|
+
`behavior()` · `toggleSidebar()`, `openSidebar()`, `closeSidebar()`, `setSidebarIntent()`,
|
|
506
|
+
`setSidebarWidth(px | null)`, `configure(patch)`, `setBehavior(behavior)`.
|
|
507
|
+
|
|
508
|
+
## Accessibility
|
|
509
|
+
|
|
510
|
+
- A skip link focuses `main`; it doesn't navigate, so it can't trigger the router.
|
|
511
|
+
- Landmarks: `role="banner"` on the topbar, `<nav aria-label>` in the sidebar, `main`, and
|
|
512
|
+
`role="contentinfo"` on the footer.
|
|
513
|
+
- The drawer is `role="dialog" aria-modal="true"`. It traps focus, restores focus on close, and
|
|
514
|
+
closes on Escape, a scrim click or navigation. The page behind it can't scroll.
|
|
515
|
+
- A closed drawer and collapsed nav children are `inert`.
|
|
516
|
+
- Rail items keep a visually hidden label and a tooltip.
|
|
517
|
+
- The breadcrumb follows the WAI-ARIA pattern:
|
|
518
|
+
- a `<nav aria-label="Breadcrumb">` containing an ordered list
|
|
519
|
+
- the current page is text with `aria-current="page"`
|
|
520
|
+
- the separators are CSS, so screen readers don't read them
|
|
521
|
+
- the row sits before `main`, so the skip link jumps past it
|
|
522
|
+
- The resize handle is a `role="separator"` with arrow keys, Home and End.
|
|
523
|
+
- `prefers-reduced-motion` is respected, and the layout supports RTL via logical properties.
|
|
524
|
+
- SSR-safe: `localStorage` sits behind a swappable port, and unmeasured regions render at
|
|
525
|
+
`initialSize`.
|
|
526
|
+
|
|
527
|
+
## Limits
|
|
528
|
+
|
|
529
|
+
- **One shell per application.** The store, the registries and the element ids are app-wide
|
|
530
|
+
singletons.
|
|
531
|
+
- **Topbar fitting uses the sizes you declare; it doesn't measure content.** The topbar doesn't
|
|
532
|
+
measure each item's width, so unexpectedly long content (translations, many links) can clip.
|
|
533
|
+
There's no "fit as many as possible" algorithm.
|
|
534
|
+
- **Overflow moves whole templates.** For per-link overflow, use one template per link. The
|
|
535
|
+
template must style its own overflow version (`let-overflow`). The ⋯ panel is a simple
|
|
536
|
+
show/hide panel, not an ARIA menu with arrow-key navigation.
|
|
537
|
+
- **Contributions add, they never replace.** A page can't hide the app's contribution to the same
|
|
538
|
+
slot. `slotOrder` numbers are global, so features must agree on them.
|
|
539
|
+
- **Components in a slot template resolve dependencies from where the template is written, not
|
|
540
|
+
where it's rendered.** `inject(SHELL_REGION)` inside a slot template doesn't return the topbar.
|
|
541
|
+
Use `let-region`.
|
|
542
|
+
- **Nav contributions only land in groups with a matching `id`.** Lazy-route providers only run
|
|
543
|
+
after the route has loaded. With `itemTemplate`, the template handles the rail case (`labels`
|
|
544
|
+
false).
|
|
545
|
+
- **Group and section open state isn't persisted.** Active-section detection treats string routes
|
|
546
|
+
as absolute paths.
|
|
547
|
+
- **Breadcrumbs follow the primary router outlet only.** Call `contributeBreadcrumbLabel()` from
|
|
548
|
+
a routed component: it labels that component's route level.
|
|
549
|
+
- **Server-side rendering can shift the layout.** Regions render at `initialSize` until the
|
|
550
|
+
browser measures them.
|
|
551
|
+
|
|
552
|
+
## Requirements
|
|
553
|
+
|
|
554
|
+
Angular ≥ 19 (`linkedSignal`, `@let`, signal inputs, `provideEnvironmentInitializer`). The
|
|
555
|
+
router is a peer dependency, injected optionally.
|
|
556
|
+
|
|
557
|
+
## License
|
|
558
|
+
|
|
559
|
+
MIT © Danjel Peqini
|