@styloviz/breadcrumb 0.1.1
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 +64 -0
- package/fesm2022/styloviz-breadcrumb.mjs +466 -0
- package/fesm2022/styloviz-breadcrumb.mjs.map +1 -0
- package/package.json +43 -0
- package/types/styloviz-breadcrumb.d.ts +212 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StyloViz
|
|
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,64 @@
|
|
|
1
|
+
# @styloviz/breadcrumb
|
|
2
|
+
|
|
3
|
+
> 4 visual variants, 5 separator styles, a sibling-page dropdown switcher, badges, disabled crumbs, collapsible middle crumbs, a loading skeleton, and full keyboard support.
|
|
4
|
+
|
|
5
|
+
Part of the **StyloViz UI Kit** — a premium Angular 21 + Tailwind CSS 4 dashboard component library. Standalone, `OnPush`, strongly typed, dark-mode ready.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @styloviz/core @styloviz/breadcrumb
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires `@angular/core` and `@angular/common` >= 21. `@styloviz/core` is a peer dependency shared by every component. Prefer everything at once? `@styloviz/all` installs the whole free tier in one command.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { SvBreadcrumbComponent } from '@styloviz/breadcrumb';
|
|
19
|
+
|
|
20
|
+
@Component({
|
|
21
|
+
standalone: true,
|
|
22
|
+
imports: [SvBreadcrumbComponent],
|
|
23
|
+
template: `
|
|
24
|
+
<sv-breadcrumb [items]="crumbs" />
|
|
25
|
+
`,
|
|
26
|
+
})
|
|
27
|
+
export class DemoComponent {}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Inputs
|
|
31
|
+
|
|
32
|
+
| Input | Type | Default | Description |
|
|
33
|
+
| --- | --- | --- | --- |
|
|
34
|
+
| `items` | `BreadcrumbItem[]` | `[]` | Ordered list of crumbs. The last item is treated as the current page. |
|
|
35
|
+
| `separator` | `BreadcrumbSeparator` | `'chevron'` | Separator style between crumbs. Use `custom` with `customSeparatorPath`. |
|
|
36
|
+
| `customSeparatorPath` | `string` | `''` | SVG `<path d="...">` used when `separator="custom"`. |
|
|
37
|
+
| `variant` | `BreadcrumbVariant` | `'plain'` | Visual style of the crumb trail. |
|
|
38
|
+
| `size` | `BreadcrumbSize` | `'md'` | Size of the breadcrumb text and icons. |
|
|
39
|
+
| `maxVisible` | `number` | `0` | Maximum number of crumbs to show before collapsing the middle ones. Set to `0` to always show all. |
|
|
40
|
+
| `expandable` | `boolean` | `true` | Allow the collapsed ellipsis to be clicked to reveal the hidden middle crumbs. When `false` the ellipsis is purely decorative. |
|
|
41
|
+
| `showHomeIcon` | `boolean` | `false` | Prepends a home icon to the first crumb when it has no explicit `iconPath`. |
|
|
42
|
+
| `truncateLabels` | `boolean` | `false` | Clips long labels to a fixed width with an ellipsis and a title tooltip. |
|
|
43
|
+
| `collapseOnMobile` | `boolean` | `false` | Hides middle crumbs below the `sm` breakpoint (pure CSS) to keep long trails compact on phones. |
|
|
44
|
+
| `loading` | `boolean` | `false` | Shows an animated skeleton in place of the crumb trail while data is loading. |
|
|
45
|
+
| `skeletonCount` | `number` | `3` | Number of skeleton segments rendered when `loading` is `true`. |
|
|
46
|
+
| `ariaLabel` | `string` | `'Breadcrumb'` | Accessible label for the breadcrumb navigation landmark. |
|
|
47
|
+
| `customClass` | `string` | `''` | Additional CSS classes on the root `<nav>` element. |
|
|
48
|
+
|
|
49
|
+
## Outputs
|
|
50
|
+
|
|
51
|
+
| Output | Type | Description |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `crumbClick` | `BreadcrumbItem` | Emitted when a crumb with a `routerLink` is clicked. |
|
|
54
|
+
| `expand` | `void` | Emitted when the collapsed ellipsis is expanded to reveal hidden crumbs. |
|
|
55
|
+
| `collapse` | `void` | Emitted when an expanded trail is collapsed back via the toggle. |
|
|
56
|
+
| `menuItemSelect` | `BreadcrumbMenuItem` | Emitted when a sibling page is chosen from a crumb's dropdown switcher. |
|
|
57
|
+
|
|
58
|
+
## Documentation
|
|
59
|
+
|
|
60
|
+
Full API reference and live demos: https://styloviz.dev/docs/breadcrumb
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, ElementRef, ApplicationRef, input, output, signal, viewChildren, computed, TemplateRef, HostListener, ViewChild, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { NgClass, NgTemplateOutlet } from '@angular/common';
|
|
4
|
+
import { RouterLink } from '@angular/router';
|
|
5
|
+
|
|
6
|
+
// ─── Separator SVG paths ──────────────────────────────────────────────────────
|
|
7
|
+
const SEPARATOR_PATHS = {
|
|
8
|
+
slash: null, // rendered as a text character
|
|
9
|
+
chevron: 'M9 18l6-6-6-6',
|
|
10
|
+
dot: null, // rendered as a text character
|
|
11
|
+
arrow: 'M5 12h14M12 5l7 7-7 7',
|
|
12
|
+
};
|
|
13
|
+
/** Default "home" glyph used by `showHomeIcon` on the first crumb. */
|
|
14
|
+
const HOME_ICON_PATH = 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 ' +
|
|
15
|
+
'1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 ' +
|
|
16
|
+
'1.125-.504 1.125-1.125V9.75M8.25 21h8.25';
|
|
17
|
+
/** Chevron-down glyph used by the sibling-menu trigger button. */
|
|
18
|
+
const MENU_CARET_PATH = 'M6 9l6 6 6-6';
|
|
19
|
+
/** Fixed sibling-menu panel width in px — matches the panel's CSS min-width. */
|
|
20
|
+
const MENU_PANEL_W = 192;
|
|
21
|
+
/** Approximate max panel height (px) — used only for the vertical flip decision. */
|
|
22
|
+
const MENU_PANEL_H = 240;
|
|
23
|
+
/** Gap between trigger edge and panel (px). */
|
|
24
|
+
const GAP = 4;
|
|
25
|
+
let uidCounter = 0;
|
|
26
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* Breadcrumb — A navigation trail showing the user's location within the app.
|
|
29
|
+
*
|
|
30
|
+
* Features:
|
|
31
|
+
* - Accepts flat `items` array — no routing knowledge required
|
|
32
|
+
* - 5 separator styles: slash · chevron · dot · arrow · custom (bring your own SVG path)
|
|
33
|
+
* - 4 visual variants: plain · contained · pills · underline
|
|
34
|
+
* - Optional icon before each crumb label, plus an automatic home icon on the first crumb
|
|
35
|
+
* - Per-crumb badge, disabled state, and a sibling-page dropdown switcher (ARIA menu-button)
|
|
36
|
+
* - Sibling-menu panel is rendered in a true `document.body` portal so it always escapes
|
|
37
|
+
* ancestor `overflow:hidden`/scroll/transform containment (see dropdown-instruction.md)
|
|
38
|
+
* - Automatic "current page" ARIA on the last crumb
|
|
39
|
+
* - Collapsible middle crumbs when more than `maxVisible` items — the ellipsis is a real
|
|
40
|
+
* open/close toggle (`toggleCollapse`), not a one-way reveal, with `expand`/`collapse` outputs
|
|
41
|
+
* - Optional label truncation with a tooltip, and CSS-only mobile collapse
|
|
42
|
+
* - Loading skeleton state for async breadcrumb sources
|
|
43
|
+
* - 3 sizes: sm · md · lg
|
|
44
|
+
* - Full dark-mode support
|
|
45
|
+
* - Accessible: `<nav aria-label>`, `<ol>`, `aria-current="page"`, `aria-disabled`,
|
|
46
|
+
* full keyboard support (Tab, Enter, Escape, ↑ ↓ Home End inside the sibling menu)
|
|
47
|
+
*/
|
|
48
|
+
class SvBreadcrumbComponent {
|
|
49
|
+
host = inject((ElementRef));
|
|
50
|
+
appRef = inject(ApplicationRef);
|
|
51
|
+
uid = `sv-breadcrumb-${uidCounter++}`;
|
|
52
|
+
// ── Inputs ────────────────────────────────────────────────────────────────
|
|
53
|
+
/** Ordered list of crumbs. The last item is treated as the current page. */
|
|
54
|
+
items = input([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
|
|
55
|
+
/** Separator style between crumbs. Use `custom` with `customSeparatorPath`. @default 'chevron' */
|
|
56
|
+
separator = input('chevron', ...(ngDevMode ? [{ debugName: "separator" }] : /* istanbul ignore next */ []));
|
|
57
|
+
/** SVG `<path d="...">` used when `separator="custom"`. */
|
|
58
|
+
customSeparatorPath = input('', ...(ngDevMode ? [{ debugName: "customSeparatorPath" }] : /* istanbul ignore next */ []));
|
|
59
|
+
/** Visual style of the crumb trail. @default 'plain' */
|
|
60
|
+
variant = input('plain', ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
|
|
61
|
+
/** Size of the breadcrumb text and icons. @default 'md' */
|
|
62
|
+
size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
|
|
63
|
+
/**
|
|
64
|
+
* Maximum number of crumbs to show before collapsing the middle ones.
|
|
65
|
+
* Set to `0` to always show all. @default 0
|
|
66
|
+
*/
|
|
67
|
+
maxVisible = input(0, ...(ngDevMode ? [{ debugName: "maxVisible" }] : /* istanbul ignore next */ []));
|
|
68
|
+
/**
|
|
69
|
+
* Allow the collapsed ellipsis to be clicked to reveal the hidden middle
|
|
70
|
+
* crumbs. When `false` the ellipsis is purely decorative. @default true
|
|
71
|
+
*/
|
|
72
|
+
expandable = input(true, ...(ngDevMode ? [{ debugName: "expandable" }] : /* istanbul ignore next */ []));
|
|
73
|
+
/** Prepends a home icon to the first crumb when it has no explicit `iconPath`. @default false */
|
|
74
|
+
showHomeIcon = input(false, ...(ngDevMode ? [{ debugName: "showHomeIcon" }] : /* istanbul ignore next */ []));
|
|
75
|
+
/** Clips long labels to a fixed width with an ellipsis and a title tooltip. @default false */
|
|
76
|
+
truncateLabels = input(false, ...(ngDevMode ? [{ debugName: "truncateLabels" }] : /* istanbul ignore next */ []));
|
|
77
|
+
/** Hides middle crumbs below the `sm` breakpoint (pure CSS) to keep long trails compact on phones. @default false */
|
|
78
|
+
collapseOnMobile = input(false, ...(ngDevMode ? [{ debugName: "collapseOnMobile" }] : /* istanbul ignore next */ []));
|
|
79
|
+
/** Shows an animated skeleton in place of the crumb trail while data is loading. @default false */
|
|
80
|
+
loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
81
|
+
/** Number of skeleton segments rendered when `loading` is `true`. @default 3 */
|
|
82
|
+
skeletonCount = input(3, ...(ngDevMode ? [{ debugName: "skeletonCount" }] : /* istanbul ignore next */ []));
|
|
83
|
+
/** Accessible label for the breadcrumb navigation landmark. @default 'Breadcrumb' */
|
|
84
|
+
ariaLabel = input('Breadcrumb', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
85
|
+
/** Additional CSS classes on the root `<nav>` element. */
|
|
86
|
+
customClass = input('', ...(ngDevMode ? [{ debugName: "customClass" }] : /* istanbul ignore next */ []));
|
|
87
|
+
// ── Outputs ───────────────────────────────────────────────────────────────
|
|
88
|
+
/** Emitted when a crumb with a `routerLink` is clicked. */
|
|
89
|
+
crumbClick = output();
|
|
90
|
+
/** Emitted when the collapsed ellipsis is expanded to reveal hidden crumbs. */
|
|
91
|
+
expand = output();
|
|
92
|
+
/** Emitted when an expanded trail is collapsed back via the toggle. */
|
|
93
|
+
collapse = output();
|
|
94
|
+
/** Emitted when a sibling page is chosen from a crumb's dropdown switcher. */
|
|
95
|
+
menuItemSelect = output();
|
|
96
|
+
// ── Internal state ──────────────────────────────────────────────────────────
|
|
97
|
+
/** Whether the user has expanded the collapsed middle crumbs. */
|
|
98
|
+
isExpanded = signal(false, ...(ngDevMode ? [{ debugName: "isExpanded" }] : /* istanbul ignore next */ []));
|
|
99
|
+
/** Index (within `visibleItems`) of the crumb whose sibling menu is open, or `null`. */
|
|
100
|
+
openMenuIndex = signal(null, ...(ngDevMode ? [{ debugName: "openMenuIndex" }] : /* istanbul ignore next */ []));
|
|
101
|
+
/** Roving-tabindex focus position inside the currently open sibling menu. */
|
|
102
|
+
activeMenuItemIndex = signal(0, ...(ngDevMode ? [{ debugName: "activeMenuItemIndex" }] : /* istanbul ignore next */ []));
|
|
103
|
+
/** Anchor to restore focus to when the sibling menu closes. */
|
|
104
|
+
triggerEl = null;
|
|
105
|
+
menuItemRefs = viewChildren('menuItemEl', ...(ngDevMode ? [{ debugName: "menuItemRefs" }] : /* istanbul ignore next */ []));
|
|
106
|
+
/** Whether the open sibling menu should render above (vs. below) its trigger. */
|
|
107
|
+
menuAbove = signal(false, ...(ngDevMode ? [{ debugName: "menuAbove" }] : /* istanbul ignore next */ []));
|
|
108
|
+
/**
|
|
109
|
+
* Inline style object applied to the portaled menu panel. Uses
|
|
110
|
+
* `position: fixed` with exact viewport coordinates so the panel escapes
|
|
111
|
+
* all parent `overflow`/transform containment (dropdown-instruction.md §1–2).
|
|
112
|
+
*/
|
|
113
|
+
menuPanelStyle = signal({}, ...(ngDevMode ? [{ debugName: "menuPanelStyle" }] : /* istanbul ignore next */ []));
|
|
114
|
+
// ── Body-portal state (dropdown-instruction.md §1) ────────────────────────
|
|
115
|
+
portalEl = null;
|
|
116
|
+
portalView = null;
|
|
117
|
+
/** The ng-template holding the sibling-menu panel markup. */
|
|
118
|
+
menuPanelTpl;
|
|
119
|
+
/**
|
|
120
|
+
* Capture-phase scroll listener — fires for ANY scrollable ancestor, not
|
|
121
|
+
* just the window, so the panel tracks its trigger through nested
|
|
122
|
+
* scroll containers (dropdown-instruction.md §6).
|
|
123
|
+
*/
|
|
124
|
+
_rafId = null;
|
|
125
|
+
_scrollHandler = () => {
|
|
126
|
+
if (this.openMenuIndex() === null)
|
|
127
|
+
return;
|
|
128
|
+
if (this._rafId !== null)
|
|
129
|
+
return;
|
|
130
|
+
this._rafId = requestAnimationFrame(() => {
|
|
131
|
+
this._rafId = null;
|
|
132
|
+
if (this.openMenuIndex() !== null) {
|
|
133
|
+
this._calcMenuPos();
|
|
134
|
+
this.portalView?.detectChanges();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
// ── Computed ──────────────────────────────────────────────────────────────
|
|
139
|
+
/** Whether `maxVisible` would collapse this trail at all, regardless of expanded state. */
|
|
140
|
+
isCollapsible = computed(() => {
|
|
141
|
+
const max = this.maxVisible();
|
|
142
|
+
return max > 0 && this.items().length > max;
|
|
143
|
+
}, ...(ngDevMode ? [{ debugName: "isCollapsible" }] : /* istanbul ignore next */ []));
|
|
144
|
+
/** Number of crumbs currently hidden behind the ellipsis (0 when expanded or not collapsible). */
|
|
145
|
+
hiddenCount = computed(() => {
|
|
146
|
+
if (!this.isCollapsible() || this.isExpanded())
|
|
147
|
+
return 0;
|
|
148
|
+
return this.items().length - 2; // all but first + last
|
|
149
|
+
}, ...(ngDevMode ? [{ debugName: "hiddenCount" }] : /* istanbul ignore next */ []));
|
|
150
|
+
/** How many crumbs the toggle would hide/reveal, regardless of its current state. */
|
|
151
|
+
toggleCount = computed(() => this.isCollapsible() ? this.items().length - 2 : 0, ...(ngDevMode ? [{ debugName: "toggleCount" }] : /* istanbul ignore next */ []));
|
|
152
|
+
/**
|
|
153
|
+
* Crumbs actually rendered. When collapsible, a `'__ellipsis__'` toggle
|
|
154
|
+
* token always sits right after the first crumb — collapsed it hides the
|
|
155
|
+
* middle crumbs, expanded it re-collapses them, so the trail can be
|
|
156
|
+
* toggled open and closed rather than only ever revealed once.
|
|
157
|
+
*/
|
|
158
|
+
visibleItems = computed(() => {
|
|
159
|
+
const all = this.items();
|
|
160
|
+
if (!this.isCollapsible())
|
|
161
|
+
return all;
|
|
162
|
+
const first = all[0];
|
|
163
|
+
const last = all[all.length - 1];
|
|
164
|
+
if (!this.isExpanded())
|
|
165
|
+
return [first, '__ellipsis__', last];
|
|
166
|
+
return [first, '__ellipsis__', ...all.slice(1, -1), last];
|
|
167
|
+
}, ...(ngDevMode ? [{ debugName: "visibleItems" }] : /* istanbul ignore next */ []));
|
|
168
|
+
/** Whether there are collapsed crumbs. */
|
|
169
|
+
hasEllipsis = computed(() => this.visibleItems().includes('__ellipsis__'), ...(ngDevMode ? [{ debugName: "hasEllipsis" }] : /* istanbul ignore next */ []));
|
|
170
|
+
/** SVG path for the current separator (null means text character). */
|
|
171
|
+
separatorPath = computed(() => {
|
|
172
|
+
const sep = this.separator();
|
|
173
|
+
if (sep === 'custom')
|
|
174
|
+
return this.customSeparatorPath() || SEPARATOR_PATHS.chevron;
|
|
175
|
+
return SEPARATOR_PATHS[sep];
|
|
176
|
+
}, ...(ngDevMode ? [{ debugName: "separatorPath" }] : /* istanbul ignore next */ []));
|
|
177
|
+
/** Text character used as separator (only for slash / dot). */
|
|
178
|
+
separatorChar = computed(() => {
|
|
179
|
+
switch (this.separator()) {
|
|
180
|
+
case 'slash': return '/';
|
|
181
|
+
case 'dot': return '·';
|
|
182
|
+
default: return '';
|
|
183
|
+
}
|
|
184
|
+
}, ...(ngDevMode ? [{ debugName: "separatorChar" }] : /* istanbul ignore next */ []));
|
|
185
|
+
/** Whether the separator is rendered as SVG (true) or a text character (false). */
|
|
186
|
+
isSvgSeparator = computed(() => this.separatorPath() !== null, ...(ngDevMode ? [{ debugName: "isSvgSeparator" }] : /* istanbul ignore next */ []));
|
|
187
|
+
/** Numbered array driving the loading skeleton's segment count. */
|
|
188
|
+
skeletonItems = computed(() => Array.from({ length: Math.max(1, this.skeletonCount()) }, (_, i) => i), ...(ngDevMode ? [{ debugName: "skeletonItems" }] : /* istanbul ignore next */ []));
|
|
189
|
+
/** The crumb whose sibling menu is currently open, or `null`. Drives the portaled panel. */
|
|
190
|
+
openMenuItem = computed(() => {
|
|
191
|
+
const index = this.openMenuIndex();
|
|
192
|
+
if (index === null)
|
|
193
|
+
return null;
|
|
194
|
+
const item = this.visibleItems()[index];
|
|
195
|
+
return item && this.isBreadcrumbItem(item) ? item : null;
|
|
196
|
+
}, ...(ngDevMode ? [{ debugName: "openMenuItem" }] : /* istanbul ignore next */ []));
|
|
197
|
+
// ── Size maps ─────────────────────────────────────────────────────────────
|
|
198
|
+
textClass = computed(() => ({
|
|
199
|
+
sm: 'text-xs',
|
|
200
|
+
md: 'text-sm',
|
|
201
|
+
lg: 'text-base',
|
|
202
|
+
})[this.size()], ...(ngDevMode ? [{ debugName: "textClass" }] : /* istanbul ignore next */ []));
|
|
203
|
+
iconClass = computed(() => ({
|
|
204
|
+
sm: 'h-3 w-3',
|
|
205
|
+
md: 'h-3.5 w-3.5',
|
|
206
|
+
lg: 'h-4 w-4',
|
|
207
|
+
})[this.size()], ...(ngDevMode ? [{ debugName: "iconClass" }] : /* istanbul ignore next */ []));
|
|
208
|
+
separatorClass = computed(() => ({
|
|
209
|
+
sm: 'h-3 w-3',
|
|
210
|
+
md: 'h-3.5 w-3.5',
|
|
211
|
+
lg: 'h-4 w-4',
|
|
212
|
+
})[this.size()], ...(ngDevMode ? [{ debugName: "separatorClass" }] : /* istanbul ignore next */ []));
|
|
213
|
+
// ── Variant class maps ────────────────────────────────────────────────────
|
|
214
|
+
/** Extra classes applied to the root `<nav>` for container-style variants. */
|
|
215
|
+
navWrapperClass = computed(() => this.variant() === 'contained'
|
|
216
|
+
? 'inline-flex items-center rounded-lg border border-gray-200/80 bg-gray-50/70 px-3 py-1.5 dark:border-gray-800 dark:bg-gray-800/40'
|
|
217
|
+
: '', ...(ngDevMode ? [{ debugName: "navWrapperClass" }] : /* istanbul ignore next */ []));
|
|
218
|
+
/** Classes for a clickable (routerLink) crumb. */
|
|
219
|
+
linkClasses = computed(() => {
|
|
220
|
+
const base = 'flex items-center gap-1 text-gray-500 transition-colors hover:text-gray-700 ' +
|
|
221
|
+
'dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus-visible:rounded ' +
|
|
222
|
+
'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1';
|
|
223
|
+
switch (this.variant()) {
|
|
224
|
+
case 'pills': return `${base} rounded-full px-2.5 py-1 hover:bg-gray-100 dark:hover:bg-gray-800/70`;
|
|
225
|
+
case 'underline': return `${base} border-b-2 border-transparent pb-0.5 hover:border-gray-300 dark:hover:border-gray-600`;
|
|
226
|
+
default: return base;
|
|
227
|
+
}
|
|
228
|
+
}, ...(ngDevMode ? [{ debugName: "linkClasses" }] : /* istanbul ignore next */ []));
|
|
229
|
+
/** Classes for the current-page (last) crumb. */
|
|
230
|
+
currentClasses = computed(() => {
|
|
231
|
+
const base = 'flex items-center gap-1 font-medium text-gray-900 dark:text-white';
|
|
232
|
+
switch (this.variant()) {
|
|
233
|
+
case 'pills': return `${base} rounded-full bg-primary-50 px-2.5 py-1 text-primary-700 dark:bg-primary-500/10 dark:text-primary-400`;
|
|
234
|
+
case 'underline': return `${base} border-b-2 border-primary-600 pb-0.5 text-primary-700 dark:border-primary-400 dark:text-primary-400`;
|
|
235
|
+
default: return base;
|
|
236
|
+
}
|
|
237
|
+
}, ...(ngDevMode ? [{ debugName: "currentClasses" }] : /* istanbul ignore next */ []));
|
|
238
|
+
/** Classes for a non-linked middle crumb (no routerLink, not disabled). */
|
|
239
|
+
middleClasses = computed(() => {
|
|
240
|
+
const base = 'flex items-center gap-1 text-gray-500 dark:text-gray-400';
|
|
241
|
+
return this.variant() === 'pills' ? `${base} rounded-full px-2.5 py-1` : base;
|
|
242
|
+
}, ...(ngDevMode ? [{ debugName: "middleClasses" }] : /* istanbul ignore next */ []));
|
|
243
|
+
/** Classes for a disabled crumb. */
|
|
244
|
+
disabledClasses = computed(() => {
|
|
245
|
+
const base = 'flex items-center gap-1 cursor-not-allowed text-gray-300 opacity-60 dark:text-gray-600';
|
|
246
|
+
return this.variant() === 'pills' ? `${base} rounded-full px-2.5 py-1` : base;
|
|
247
|
+
}, ...(ngDevMode ? [{ debugName: "disabledClasses" }] : /* istanbul ignore next */ []));
|
|
248
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
249
|
+
isLastItem(item) {
|
|
250
|
+
const all = this.visibleItems();
|
|
251
|
+
return all[all.length - 1] === item;
|
|
252
|
+
}
|
|
253
|
+
isFirstItem(item) {
|
|
254
|
+
return this.visibleItems()[0] === item;
|
|
255
|
+
}
|
|
256
|
+
isBreadcrumbItem(item) {
|
|
257
|
+
return item !== '__ellipsis__';
|
|
258
|
+
}
|
|
259
|
+
/** True for crumbs strictly between the first and last — used to CSS-hide on mobile. */
|
|
260
|
+
isMiddleItem(item) {
|
|
261
|
+
if (!this.isBreadcrumbItem(item))
|
|
262
|
+
return false;
|
|
263
|
+
return !this.isFirstItem(item) && !this.isLastItem(item);
|
|
264
|
+
}
|
|
265
|
+
/** Resolves the icon shown before a crumb's label, falling back to the home icon. */
|
|
266
|
+
iconPathFor(item) {
|
|
267
|
+
if (item.iconPath)
|
|
268
|
+
return item.iconPath;
|
|
269
|
+
if (this.showHomeIcon() && this.isFirstItem(item))
|
|
270
|
+
return HOME_ICON_PATH;
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
onCrumbClick(item) {
|
|
274
|
+
if (item.disabled)
|
|
275
|
+
return;
|
|
276
|
+
if (item.routerLink) {
|
|
277
|
+
this.crumbClick.emit(item);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Toggle the collapsed middle crumbs open or closed. Unlike a one-way
|
|
282
|
+
* "reveal", calling this again while expanded collapses the trail back —
|
|
283
|
+
* the ellipsis token stays clickable in both states (see `visibleItems`).
|
|
284
|
+
*/
|
|
285
|
+
toggleCollapse() {
|
|
286
|
+
if (!this.expandable() || !this.isCollapsible())
|
|
287
|
+
return;
|
|
288
|
+
const next = !this.isExpanded();
|
|
289
|
+
this.isExpanded.set(next);
|
|
290
|
+
if (next)
|
|
291
|
+
this.expand.emit();
|
|
292
|
+
else
|
|
293
|
+
this.collapse.emit();
|
|
294
|
+
}
|
|
295
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
296
|
+
ngOnInit() {
|
|
297
|
+
// Capture phase catches scroll from any scrollable ancestor, not just window
|
|
298
|
+
// (dropdown-instruction.md §6).
|
|
299
|
+
window.addEventListener('scroll', this._scrollHandler, { passive: true, capture: true });
|
|
300
|
+
}
|
|
301
|
+
ngOnDestroy() {
|
|
302
|
+
window.removeEventListener('scroll', this._scrollHandler, true);
|
|
303
|
+
if (this._rafId !== null)
|
|
304
|
+
cancelAnimationFrame(this._rafId);
|
|
305
|
+
this._destroyMenuPortal();
|
|
306
|
+
}
|
|
307
|
+
// ── Sibling dropdown menu (ARIA menu-button + body-portal pattern) ───────
|
|
308
|
+
/** Stable DOM id for the (single, reused) sibling-menu panel. */
|
|
309
|
+
menuPanelId = `${this.uid}-menu`;
|
|
310
|
+
toggleMenu(index, event) {
|
|
311
|
+
event.stopPropagation();
|
|
312
|
+
if (this.openMenuIndex() === index) {
|
|
313
|
+
this.closeMenu();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (this.openMenuIndex() !== null)
|
|
317
|
+
this._destroyMenuPortal();
|
|
318
|
+
this.triggerEl = event.currentTarget;
|
|
319
|
+
this._calcMenuPos();
|
|
320
|
+
// Mount the panel into document.body via a true portal so it always
|
|
321
|
+
// escapes ancestor overflow/transform clipping (dropdown-instruction.md §1).
|
|
322
|
+
this.portalEl = document.createElement('div');
|
|
323
|
+
this.portalEl.className = 'sv-breadcrumb-menu-portal';
|
|
324
|
+
document.body.appendChild(this.portalEl);
|
|
325
|
+
this.portalView = this.menuPanelTpl.createEmbeddedView({});
|
|
326
|
+
this.appRef.attachView(this.portalView);
|
|
327
|
+
this.portalView.rootNodes.forEach((n) => this.portalEl.appendChild(n));
|
|
328
|
+
this.openMenuIndex.set(index);
|
|
329
|
+
this.activeMenuItemIndex.set(0);
|
|
330
|
+
this.portalView.detectChanges();
|
|
331
|
+
queueMicrotask(() => this.menuItemRefs()[0]?.nativeElement.focus());
|
|
332
|
+
}
|
|
333
|
+
closeMenu() {
|
|
334
|
+
if (this.openMenuIndex() === null)
|
|
335
|
+
return;
|
|
336
|
+
this._destroyMenuPortal();
|
|
337
|
+
this.openMenuIndex.set(null);
|
|
338
|
+
this.triggerEl?.focus();
|
|
339
|
+
this.triggerEl = null;
|
|
340
|
+
}
|
|
341
|
+
selectMenuItem(item) {
|
|
342
|
+
this.menuItemSelect.emit(item);
|
|
343
|
+
this.closeMenu();
|
|
344
|
+
}
|
|
345
|
+
onMenuKeydown(event) {
|
|
346
|
+
const refs = this.menuItemRefs();
|
|
347
|
+
if (!refs.length)
|
|
348
|
+
return;
|
|
349
|
+
switch (event.key) {
|
|
350
|
+
case 'ArrowDown':
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
this.activeMenuItemIndex.update(i => Math.min(i + 1, refs.length - 1));
|
|
353
|
+
refs[this.activeMenuItemIndex()].nativeElement.focus();
|
|
354
|
+
break;
|
|
355
|
+
case 'ArrowUp':
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
this.activeMenuItemIndex.update(i => Math.max(i - 1, 0));
|
|
358
|
+
refs[this.activeMenuItemIndex()].nativeElement.focus();
|
|
359
|
+
break;
|
|
360
|
+
case 'Home':
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
this.activeMenuItemIndex.set(0);
|
|
363
|
+
refs[0].nativeElement.focus();
|
|
364
|
+
break;
|
|
365
|
+
case 'End':
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
this.activeMenuItemIndex.set(refs.length - 1);
|
|
368
|
+
refs[refs.length - 1].nativeElement.focus();
|
|
369
|
+
break;
|
|
370
|
+
case 'Escape':
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
this.closeMenu();
|
|
373
|
+
break;
|
|
374
|
+
case 'Tab':
|
|
375
|
+
this.closeMenu();
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** Position the portaled panel against `triggerEl`, flipping to fit the viewport. */
|
|
380
|
+
_calcMenuPos() {
|
|
381
|
+
const trigger = this.triggerEl ?? this.host.nativeElement;
|
|
382
|
+
const rect = trigger.getBoundingClientRect();
|
|
383
|
+
const vw = window.innerWidth;
|
|
384
|
+
const vh = window.innerHeight;
|
|
385
|
+
const spaceBelow = vh - rect.bottom;
|
|
386
|
+
const spaceAbove = rect.top;
|
|
387
|
+
const above = spaceBelow < MENU_PANEL_H && spaceAbove > spaceBelow;
|
|
388
|
+
const right = rect.left + MENU_PANEL_W > vw;
|
|
389
|
+
this.menuAbove.set(above);
|
|
390
|
+
const style = {
|
|
391
|
+
position: 'fixed',
|
|
392
|
+
zIndex: '9999',
|
|
393
|
+
minWidth: `${MENU_PANEL_W}px`,
|
|
394
|
+
};
|
|
395
|
+
style[above ? 'bottom' : 'top'] = above
|
|
396
|
+
? `${vh - rect.top + GAP}px`
|
|
397
|
+
: `${rect.bottom + GAP}px`;
|
|
398
|
+
style[right ? 'right' : 'left'] = right
|
|
399
|
+
? `${vw - rect.right}px`
|
|
400
|
+
: `${rect.left}px`;
|
|
401
|
+
this.menuPanelStyle.set(style);
|
|
402
|
+
}
|
|
403
|
+
_destroyMenuPortal() {
|
|
404
|
+
if (this.portalView) {
|
|
405
|
+
this.appRef.detachView(this.portalView);
|
|
406
|
+
this.portalView.destroy();
|
|
407
|
+
this.portalView = null;
|
|
408
|
+
}
|
|
409
|
+
this.portalEl?.remove();
|
|
410
|
+
this.portalEl = null;
|
|
411
|
+
}
|
|
412
|
+
// ── Global listeners ──────────────────────────────────────────────────────
|
|
413
|
+
/**
|
|
414
|
+
* Click-outside close. The panel lives in `document.body`, not inside this
|
|
415
|
+
* component's DOM subtree, so both the host AND the portal element must be
|
|
416
|
+
* checked (dropdown-instruction.md §7).
|
|
417
|
+
*/
|
|
418
|
+
onDocumentMousedown(event) {
|
|
419
|
+
if (this.openMenuIndex() === null)
|
|
420
|
+
return;
|
|
421
|
+
const target = event.target;
|
|
422
|
+
if (this.host.nativeElement.contains(target))
|
|
423
|
+
return;
|
|
424
|
+
if (this.portalEl?.contains(target))
|
|
425
|
+
return;
|
|
426
|
+
this.closeMenu();
|
|
427
|
+
}
|
|
428
|
+
/** Viewport resize invalidates the computed coordinates — close rather than reposition
|
|
429
|
+
* with stale numbers (dropdown-instruction.md §8). */
|
|
430
|
+
onWindowResize() {
|
|
431
|
+
if (this.openMenuIndex() !== null)
|
|
432
|
+
this.closeMenu();
|
|
433
|
+
}
|
|
434
|
+
/** Scoped to the host so it only fires while focus/interaction is on the trigger;
|
|
435
|
+
* focus inside the portaled panel is handled by `onMenuKeydown` (dropdown-instruction.md §9). */
|
|
436
|
+
onHostEscape() {
|
|
437
|
+
if (this.openMenuIndex() !== null)
|
|
438
|
+
this.closeMenu();
|
|
439
|
+
}
|
|
440
|
+
menuCaretPath = MENU_CARET_PATH;
|
|
441
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: SvBreadcrumbComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
442
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", type: SvBreadcrumbComponent, isStandalone: true, selector: "sv-breadcrumb", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, separator: { classPropertyName: "separator", publicName: "separator", isSignal: true, isRequired: false, transformFunction: null }, customSeparatorPath: { classPropertyName: "customSeparatorPath", publicName: "customSeparatorPath", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, maxVisible: { classPropertyName: "maxVisible", publicName: "maxVisible", isSignal: true, isRequired: false, transformFunction: null }, expandable: { classPropertyName: "expandable", publicName: "expandable", isSignal: true, isRequired: false, transformFunction: null }, showHomeIcon: { classPropertyName: "showHomeIcon", publicName: "showHomeIcon", isSignal: true, isRequired: false, transformFunction: null }, truncateLabels: { classPropertyName: "truncateLabels", publicName: "truncateLabels", isSignal: true, isRequired: false, transformFunction: null }, collapseOnMobile: { classPropertyName: "collapseOnMobile", publicName: "collapseOnMobile", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, skeletonCount: { classPropertyName: "skeletonCount", publicName: "skeletonCount", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, customClass: { classPropertyName: "customClass", publicName: "customClass", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { crumbClick: "crumbClick", expand: "expand", collapse: "collapse", menuItemSelect: "menuItemSelect" }, host: { listeners: { "document:mousedown": "onDocumentMousedown($event)", "window:resize": "onWindowResize()", "keydown.escape": "onHostEscape()" } }, viewQueries: [{ propertyName: "menuItemRefs", predicate: ["menuItemEl"], descendants: true, isSignal: true }, { propertyName: "menuPanelTpl", first: true, predicate: ["menuPanelTpl"], descendants: true, read: TemplateRef }], ngImport: i0, template: "<!-- \u2500\u2500 Reusable fragments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n\n<ng-template #iconTpl let-item>\n @if (iconPathFor(item); as path) {\n <svg\n [ngClass]=\"iconClass()\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path [attr.d]=\"path\" />\n </svg>\n }\n</ng-template>\n\n<ng-template #badgeTpl let-item>\n @if (item.badge !== undefined && item.badge !== null) {\n <span\n class=\"inline-flex items-center justify-center rounded-full bg-gray-200 px-1.5 py-0.5\n text-[10px] font-semibold leading-none text-gray-700 dark:bg-gray-700 dark:text-gray-200\"\n >\n {{ item.badge }}\n </span>\n }\n</ng-template>\n\n<ng-template #labelTpl let-item>\n @if (truncateLabels()) {\n <span\n class=\"inline-block max-w-[10rem] truncate align-bottom\"\n [attr.title]=\"item.title ?? item.label\"\n >{{ item.label }}</span>\n } @else {\n {{ item.label }}\n }\n</ng-template>\n\n<ng-template #menuTriggerTpl let-item let-idx=\"idx\">\n @if (item.menu?.length && !item.disabled) {\n <button\n type=\"button\"\n data-trigger\n class=\"flex items-center rounded p-0.5 text-gray-400 transition-colors hover:text-gray-700\n focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500\n focus-visible:ring-offset-1 dark:text-gray-500 dark:hover:text-gray-200\"\n aria-haspopup=\"menu\"\n [attr.aria-expanded]=\"openMenuIndex() === idx\"\n [attr.aria-controls]=\"menuPanelId\"\n [attr.aria-label]=\"'Switch page under ' + item.label\"\n (click)=\"toggleMenu(idx, $event)\"\n >\n <svg class=\"h-3 w-3\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"menuCaretPath\" />\n </svg>\n </button>\n }\n</ng-template>\n\n<!-- \u2500\u2500 Sibling-menu panel \u2014 rendered into document.body via a true body portal\n (dropdown-instruction.md \u00A71) so it always escapes ancestor overflow/transform\n clipping. Never rendered in place; only instantiated on open by toggleMenu(). \u2500\u2500 -->\n<ng-template #menuPanelTpl>\n @if (openMenuItem(); as item) {\n <div\n [id]=\"menuPanelId\"\n role=\"menu\"\n [attr.aria-label]=\"item.label + ' pages'\"\n class=\"overflow-hidden rounded-lg border border-gray-100 bg-white py-1 shadow-xl\n dark:border-gray-800 dark:bg-gray-900\"\n [style.position]=\"menuPanelStyle()['position']\"\n [style.zIndex]=\"menuPanelStyle()['zIndex']\"\n [style.minWidth]=\"menuPanelStyle()['minWidth']\"\n [style.top]=\"menuPanelStyle()['top']\"\n [style.bottom]=\"menuPanelStyle()['bottom']\"\n [style.left]=\"menuPanelStyle()['left']\"\n [style.right]=\"menuPanelStyle()['right']\"\n (keydown)=\"onMenuKeydown($event)\"\n >\n @for (mi of item.menu; track mi.label) {\n <a\n role=\"menuitem\"\n tabindex=\"-1\"\n #menuItemEl\n [routerLink]=\"mi.routerLink\"\n class=\"flex items-center gap-2 px-3 py-1.5 text-xs text-gray-600 transition-colors\n hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus-visible:bg-gray-50\n dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:bg-gray-800\"\n (click)=\"selectMenuItem(mi)\"\n >\n @if (mi.iconPath) {\n <svg class=\"h-3.5 w-3.5 shrink-0\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"mi.iconPath\" />\n </svg>\n }\n {{ mi.label }}\n </a>\n }\n </div>\n }\n</ng-template>\n\n<!-- \u2500\u2500 Root \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n\n<nav\n [ngClass]=\"['flex', navWrapperClass(), customClass()]\"\n [attr.aria-label]=\"ariaLabel()\"\n>\n @if (loading()) {\n <div\n class=\"flex items-center gap-2\"\n [ngClass]=\"textClass()\"\n role=\"status\"\n aria-live=\"polite\"\n aria-busy=\"true\"\n >\n <span class=\"sr-only\">Loading breadcrumb\u2026</span>\n @for (i of skeletonItems(); track i; let last = $last) {\n <span\n class=\"h-3.5 animate-pulse rounded bg-gray-200 dark:bg-gray-700\"\n [style.width.px]=\"40 + (i % 3) * 24\"\n aria-hidden=\"true\"\n ></span>\n @if (!last) {\n <span class=\"select-none text-gray-300 dark:text-gray-600\" aria-hidden=\"true\">/</span>\n }\n }\n </div>\n } @else {\n <ol\n role=\"list\"\n [ngClass]=\"['flex flex-wrap items-center gap-1', textClass()]\"\n >\n @for (item of visibleItems(); track $index; let last = $last, idx = $index) {\n\n <li\n [ngClass]=\"[\n 'flex items-center gap-1',\n (collapseOnMobile() && isMiddleItem(item)) ? 'hidden sm:flex' : ''\n ]\"\n >\n\n <!-- \u2500\u2500 Crumb \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @if (isBreadcrumbItem(item)) {\n\n @if (item.disabled) {\n <!-- Disabled crumb \u2014 non-interactive -->\n <span [ngClass]=\"disabledClasses()\" aria-disabled=\"true\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n\n } @else if (last) {\n <!-- Current page \u2014 plain text, aria-current -->\n <span [ngClass]=\"currentClasses()\" aria-current=\"page\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n\n } @else if (item.routerLink) {\n <!-- Linked crumb -->\n <a\n [routerLink]=\"item.routerLink\"\n [ngClass]=\"linkClasses()\"\n (click)=\"onCrumbClick(item)\"\n >\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </a>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n\n } @else {\n <!-- Non-linked crumb (middle, no routerLink) -->\n <span [ngClass]=\"middleClasses()\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n }\n\n } @else {\n <!-- Ellipsis / collapse toggle \u2014 a real open\u2194close button when expandable -->\n @if (expandable()) {\n <button\n type=\"button\"\n class=\"flex items-center gap-0.5 rounded px-1 py-0.5 text-gray-400 transition-colors\n hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200\n focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500\n focus-visible:ring-offset-1\"\n [attr.aria-expanded]=\"isExpanded()\"\n [attr.aria-label]=\"isExpanded()\n ? 'Hide ' + toggleCount() + ' page' + (toggleCount() === 1 ? '' : 's')\n : 'Show ' + toggleCount() + ' hidden page' + (toggleCount() === 1 ? '' : 's')\"\n (click)=\"toggleCollapse()\"\n >\n @if (isExpanded()) {\n <svg class=\"h-3 w-3\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5\" />\n </svg>\n } @else {\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n }\n </button>\n } @else {\n <span\n class=\"flex items-center gap-0.5 text-gray-500 dark:text-gray-400\"\n aria-label=\"More pages\"\n >\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n </span>\n }\n }\n\n <!-- \u2500\u2500 Separator (between items, not after the last) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @if (!last) {\n @if (isSvgSeparator()) {\n <svg\n [ngClass]=\"[separatorClass(), 'shrink-0 text-gray-300 dark:text-gray-600']\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path [attr.d]=\"separatorPath()\" />\n </svg>\n } @else {\n <span\n class=\"shrink-0 select-none text-gray-300 dark:text-gray-600\"\n aria-hidden=\"true\"\n >\n {{ separatorChar() }}\n </span>\n }\n }\n\n </li>\n\n }\n </ol>\n }\n</nav>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
443
|
+
}
|
|
444
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: SvBreadcrumbComponent, decorators: [{
|
|
445
|
+
type: Component,
|
|
446
|
+
args: [{ selector: 'sv-breadcrumb', standalone: true, imports: [NgClass, NgTemplateOutlet, RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- \u2500\u2500 Reusable fragments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n\n<ng-template #iconTpl let-item>\n @if (iconPathFor(item); as path) {\n <svg\n [ngClass]=\"iconClass()\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path [attr.d]=\"path\" />\n </svg>\n }\n</ng-template>\n\n<ng-template #badgeTpl let-item>\n @if (item.badge !== undefined && item.badge !== null) {\n <span\n class=\"inline-flex items-center justify-center rounded-full bg-gray-200 px-1.5 py-0.5\n text-[10px] font-semibold leading-none text-gray-700 dark:bg-gray-700 dark:text-gray-200\"\n >\n {{ item.badge }}\n </span>\n }\n</ng-template>\n\n<ng-template #labelTpl let-item>\n @if (truncateLabels()) {\n <span\n class=\"inline-block max-w-[10rem] truncate align-bottom\"\n [attr.title]=\"item.title ?? item.label\"\n >{{ item.label }}</span>\n } @else {\n {{ item.label }}\n }\n</ng-template>\n\n<ng-template #menuTriggerTpl let-item let-idx=\"idx\">\n @if (item.menu?.length && !item.disabled) {\n <button\n type=\"button\"\n data-trigger\n class=\"flex items-center rounded p-0.5 text-gray-400 transition-colors hover:text-gray-700\n focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500\n focus-visible:ring-offset-1 dark:text-gray-500 dark:hover:text-gray-200\"\n aria-haspopup=\"menu\"\n [attr.aria-expanded]=\"openMenuIndex() === idx\"\n [attr.aria-controls]=\"menuPanelId\"\n [attr.aria-label]=\"'Switch page under ' + item.label\"\n (click)=\"toggleMenu(idx, $event)\"\n >\n <svg class=\"h-3 w-3\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"menuCaretPath\" />\n </svg>\n </button>\n }\n</ng-template>\n\n<!-- \u2500\u2500 Sibling-menu panel \u2014 rendered into document.body via a true body portal\n (dropdown-instruction.md \u00A71) so it always escapes ancestor overflow/transform\n clipping. Never rendered in place; only instantiated on open by toggleMenu(). \u2500\u2500 -->\n<ng-template #menuPanelTpl>\n @if (openMenuItem(); as item) {\n <div\n [id]=\"menuPanelId\"\n role=\"menu\"\n [attr.aria-label]=\"item.label + ' pages'\"\n class=\"overflow-hidden rounded-lg border border-gray-100 bg-white py-1 shadow-xl\n dark:border-gray-800 dark:bg-gray-900\"\n [style.position]=\"menuPanelStyle()['position']\"\n [style.zIndex]=\"menuPanelStyle()['zIndex']\"\n [style.minWidth]=\"menuPanelStyle()['minWidth']\"\n [style.top]=\"menuPanelStyle()['top']\"\n [style.bottom]=\"menuPanelStyle()['bottom']\"\n [style.left]=\"menuPanelStyle()['left']\"\n [style.right]=\"menuPanelStyle()['right']\"\n (keydown)=\"onMenuKeydown($event)\"\n >\n @for (mi of item.menu; track mi.label) {\n <a\n role=\"menuitem\"\n tabindex=\"-1\"\n #menuItemEl\n [routerLink]=\"mi.routerLink\"\n class=\"flex items-center gap-2 px-3 py-1.5 text-xs text-gray-600 transition-colors\n hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus-visible:bg-gray-50\n dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:bg-gray-800\"\n (click)=\"selectMenuItem(mi)\"\n >\n @if (mi.iconPath) {\n <svg class=\"h-3.5 w-3.5 shrink-0\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"mi.iconPath\" />\n </svg>\n }\n {{ mi.label }}\n </a>\n }\n </div>\n }\n</ng-template>\n\n<!-- \u2500\u2500 Root \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n\n<nav\n [ngClass]=\"['flex', navWrapperClass(), customClass()]\"\n [attr.aria-label]=\"ariaLabel()\"\n>\n @if (loading()) {\n <div\n class=\"flex items-center gap-2\"\n [ngClass]=\"textClass()\"\n role=\"status\"\n aria-live=\"polite\"\n aria-busy=\"true\"\n >\n <span class=\"sr-only\">Loading breadcrumb\u2026</span>\n @for (i of skeletonItems(); track i; let last = $last) {\n <span\n class=\"h-3.5 animate-pulse rounded bg-gray-200 dark:bg-gray-700\"\n [style.width.px]=\"40 + (i % 3) * 24\"\n aria-hidden=\"true\"\n ></span>\n @if (!last) {\n <span class=\"select-none text-gray-300 dark:text-gray-600\" aria-hidden=\"true\">/</span>\n }\n }\n </div>\n } @else {\n <ol\n role=\"list\"\n [ngClass]=\"['flex flex-wrap items-center gap-1', textClass()]\"\n >\n @for (item of visibleItems(); track $index; let last = $last, idx = $index) {\n\n <li\n [ngClass]=\"[\n 'flex items-center gap-1',\n (collapseOnMobile() && isMiddleItem(item)) ? 'hidden sm:flex' : ''\n ]\"\n >\n\n <!-- \u2500\u2500 Crumb \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @if (isBreadcrumbItem(item)) {\n\n @if (item.disabled) {\n <!-- Disabled crumb \u2014 non-interactive -->\n <span [ngClass]=\"disabledClasses()\" aria-disabled=\"true\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n\n } @else if (last) {\n <!-- Current page \u2014 plain text, aria-current -->\n <span [ngClass]=\"currentClasses()\" aria-current=\"page\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n\n } @else if (item.routerLink) {\n <!-- Linked crumb -->\n <a\n [routerLink]=\"item.routerLink\"\n [ngClass]=\"linkClasses()\"\n (click)=\"onCrumbClick(item)\"\n >\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </a>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n\n } @else {\n <!-- Non-linked crumb (middle, no routerLink) -->\n <span [ngClass]=\"middleClasses()\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n }\n\n } @else {\n <!-- Ellipsis / collapse toggle \u2014 a real open\u2194close button when expandable -->\n @if (expandable()) {\n <button\n type=\"button\"\n class=\"flex items-center gap-0.5 rounded px-1 py-0.5 text-gray-400 transition-colors\n hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200\n focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500\n focus-visible:ring-offset-1\"\n [attr.aria-expanded]=\"isExpanded()\"\n [attr.aria-label]=\"isExpanded()\n ? 'Hide ' + toggleCount() + ' page' + (toggleCount() === 1 ? '' : 's')\n : 'Show ' + toggleCount() + ' hidden page' + (toggleCount() === 1 ? '' : 's')\"\n (click)=\"toggleCollapse()\"\n >\n @if (isExpanded()) {\n <svg class=\"h-3 w-3\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5\" />\n </svg>\n } @else {\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n }\n </button>\n } @else {\n <span\n class=\"flex items-center gap-0.5 text-gray-500 dark:text-gray-400\"\n aria-label=\"More pages\"\n >\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n </span>\n }\n }\n\n <!-- \u2500\u2500 Separator (between items, not after the last) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @if (!last) {\n @if (isSvgSeparator()) {\n <svg\n [ngClass]=\"[separatorClass(), 'shrink-0 text-gray-300 dark:text-gray-600']\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path [attr.d]=\"separatorPath()\" />\n </svg>\n } @else {\n <span\n class=\"shrink-0 select-none text-gray-300 dark:text-gray-600\"\n aria-hidden=\"true\"\n >\n {{ separatorChar() }}\n </span>\n }\n }\n\n </li>\n\n }\n </ol>\n }\n</nav>\n" }]
|
|
447
|
+
}], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], separator: [{ type: i0.Input, args: [{ isSignal: true, alias: "separator", required: false }] }], customSeparatorPath: [{ type: i0.Input, args: [{ isSignal: true, alias: "customSeparatorPath", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], maxVisible: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxVisible", required: false }] }], expandable: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandable", required: false }] }], showHomeIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "showHomeIcon", required: false }] }], truncateLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "truncateLabels", required: false }] }], collapseOnMobile: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapseOnMobile", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], skeletonCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "skeletonCount", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], customClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "customClass", required: false }] }], crumbClick: [{ type: i0.Output, args: ["crumbClick"] }], expand: [{ type: i0.Output, args: ["expand"] }], collapse: [{ type: i0.Output, args: ["collapse"] }], menuItemSelect: [{ type: i0.Output, args: ["menuItemSelect"] }], menuItemRefs: [{ type: i0.ViewChildren, args: ['menuItemEl', { isSignal: true }] }], menuPanelTpl: [{
|
|
448
|
+
type: ViewChild,
|
|
449
|
+
args: ['menuPanelTpl', { read: TemplateRef }]
|
|
450
|
+
}], onDocumentMousedown: [{
|
|
451
|
+
type: HostListener,
|
|
452
|
+
args: ['document:mousedown', ['$event']]
|
|
453
|
+
}], onWindowResize: [{
|
|
454
|
+
type: HostListener,
|
|
455
|
+
args: ['window:resize']
|
|
456
|
+
}], onHostEscape: [{
|
|
457
|
+
type: HostListener,
|
|
458
|
+
args: ['keydown.escape']
|
|
459
|
+
}] } });
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Generated bundle index. Do not edit.
|
|
463
|
+
*/
|
|
464
|
+
|
|
465
|
+
export { SvBreadcrumbComponent };
|
|
466
|
+
//# sourceMappingURL=styloviz-breadcrumb.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"styloviz-breadcrumb.mjs","sources":["../../../../projects/breadcrumb/src/lib/breadcrumb.component.ts","../../../../projects/breadcrumb/src/lib/breadcrumb.component.html","../../../../projects/breadcrumb/src/styloviz-breadcrumb.ts"],"sourcesContent":["import {\n ApplicationRef,\n ChangeDetectionStrategy,\n Component,\n ElementRef,\n EmbeddedViewRef,\n HostListener,\n OnDestroy,\n OnInit,\n TemplateRef,\n ViewChild,\n computed,\n inject,\n input,\n output,\n signal,\n viewChildren,\n} from '@angular/core';\nimport { NgClass, NgTemplateOutlet } from '@angular/common';\nimport { RouterLink } from '@angular/router';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\n/** A sibling page offered from a crumb's dropdown switcher. */\nexport interface BreadcrumbMenuItem {\n /** Display label. */\n label: string;\n /** Router link (string or array). */\n routerLink?: string | string[];\n /** Optional SVG `<path d=\"...\">` shown before the label. */\n iconPath?: string;\n}\n\n/** A single crumb in the breadcrumb trail. */\nexport interface BreadcrumbItem {\n /** Display label. */\n label: string;\n /**\n * Router link (string or array).\n * Omit for the current / last crumb (renders as plain text).\n */\n routerLink?: string | string[];\n /** Optional SVG `<path d=\"...\">` shown before the label. */\n iconPath?: string;\n /** Renders the crumb as non-interactive (e.g. permission-gated section). @default false */\n disabled?: boolean;\n /** Small count/status pill rendered after the label. */\n badge?: string | number;\n /** Sibling pages at this level — renders a dropdown-switcher trigger next to the crumb. */\n menu?: BreadcrumbMenuItem[];\n /** Full label shown as a tooltip when `truncateLabels` clips the visible text. */\n title?: string;\n}\n\nexport type BreadcrumbSeparator = 'slash' | 'chevron' | 'dot' | 'arrow' | 'custom';\nexport type BreadcrumbVariant = 'plain' | 'contained' | 'pills' | 'underline';\nexport type BreadcrumbSize = 'sm' | 'md' | 'lg';\n\n// ─── Separator SVG paths ──────────────────────────────────────────────────────\n\nconst SEPARATOR_PATHS: Record<Exclude<BreadcrumbSeparator, 'custom'>, string | null> = {\n slash: null, // rendered as a text character\n chevron: 'M9 18l6-6-6-6',\n dot: null, // rendered as a text character\n arrow: 'M5 12h14M12 5l7 7-7 7',\n};\n\n/** Default \"home\" glyph used by `showHomeIcon` on the first crumb. */\nconst HOME_ICON_PATH =\n 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 ' +\n '1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 ' +\n '1.125-.504 1.125-1.125V9.75M8.25 21h8.25';\n\n/** Chevron-down glyph used by the sibling-menu trigger button. */\nconst MENU_CARET_PATH = 'M6 9l6 6 6-6';\n\n/** Fixed sibling-menu panel width in px — matches the panel's CSS min-width. */\nconst MENU_PANEL_W = 192;\n/** Approximate max panel height (px) — used only for the vertical flip decision. */\nconst MENU_PANEL_H = 240;\n/** Gap between trigger edge and panel (px). */\nconst GAP = 4;\n\nlet uidCounter = 0;\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\n/**\n * Breadcrumb — A navigation trail showing the user's location within the app.\n *\n * Features:\n * - Accepts flat `items` array — no routing knowledge required\n * - 5 separator styles: slash · chevron · dot · arrow · custom (bring your own SVG path)\n * - 4 visual variants: plain · contained · pills · underline\n * - Optional icon before each crumb label, plus an automatic home icon on the first crumb\n * - Per-crumb badge, disabled state, and a sibling-page dropdown switcher (ARIA menu-button)\n * - Sibling-menu panel is rendered in a true `document.body` portal so it always escapes\n * ancestor `overflow:hidden`/scroll/transform containment (see dropdown-instruction.md)\n * - Automatic \"current page\" ARIA on the last crumb\n * - Collapsible middle crumbs when more than `maxVisible` items — the ellipsis is a real\n * open/close toggle (`toggleCollapse`), not a one-way reveal, with `expand`/`collapse` outputs\n * - Optional label truncation with a tooltip, and CSS-only mobile collapse\n * - Loading skeleton state for async breadcrumb sources\n * - 3 sizes: sm · md · lg\n * - Full dark-mode support\n * - Accessible: `<nav aria-label>`, `<ol>`, `aria-current=\"page\"`, `aria-disabled`,\n * full keyboard support (Tab, Enter, Escape, ↑ ↓ Home End inside the sibling menu)\n */\n@Component({\n selector: 'sv-breadcrumb',\n standalone: true,\n imports: [NgClass, NgTemplateOutlet, RouterLink],\n templateUrl: './breadcrumb.component.html',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SvBreadcrumbComponent implements OnInit, OnDestroy {\n private readonly host = inject(ElementRef<HTMLElement>);\n private readonly appRef = inject(ApplicationRef);\n private readonly uid = `sv-breadcrumb-${uidCounter++}`;\n\n // ── Inputs ────────────────────────────────────────────────────────────────\n\n /** Ordered list of crumbs. The last item is treated as the current page. */\n items = input<BreadcrumbItem[]>([]);\n\n /** Separator style between crumbs. Use `custom` with `customSeparatorPath`. @default 'chevron' */\n separator = input<BreadcrumbSeparator>('chevron');\n\n /** SVG `<path d=\"...\">` used when `separator=\"custom\"`. */\n customSeparatorPath = input<string>('');\n\n /** Visual style of the crumb trail. @default 'plain' */\n variant = input<BreadcrumbVariant>('plain');\n\n /** Size of the breadcrumb text and icons. @default 'md' */\n size = input<BreadcrumbSize>('md');\n\n /**\n * Maximum number of crumbs to show before collapsing the middle ones.\n * Set to `0` to always show all. @default 0\n */\n maxVisible = input<number>(0);\n\n /**\n * Allow the collapsed ellipsis to be clicked to reveal the hidden middle\n * crumbs. When `false` the ellipsis is purely decorative. @default true\n */\n expandable = input<boolean>(true);\n\n /** Prepends a home icon to the first crumb when it has no explicit `iconPath`. @default false */\n showHomeIcon = input<boolean>(false);\n\n /** Clips long labels to a fixed width with an ellipsis and a title tooltip. @default false */\n truncateLabels = input<boolean>(false);\n\n /** Hides middle crumbs below the `sm` breakpoint (pure CSS) to keep long trails compact on phones. @default false */\n collapseOnMobile = input<boolean>(false);\n\n /** Shows an animated skeleton in place of the crumb trail while data is loading. @default false */\n loading = input<boolean>(false);\n\n /** Number of skeleton segments rendered when `loading` is `true`. @default 3 */\n skeletonCount = input<number>(3);\n\n /** Accessible label for the breadcrumb navigation landmark. @default 'Breadcrumb' */\n ariaLabel = input<string>('Breadcrumb');\n\n /** Additional CSS classes on the root `<nav>` element. */\n customClass = input<string>('');\n\n // ── Outputs ───────────────────────────────────────────────────────────────\n\n /** Emitted when a crumb with a `routerLink` is clicked. */\n crumbClick = output<BreadcrumbItem>();\n\n /** Emitted when the collapsed ellipsis is expanded to reveal hidden crumbs. */\n expand = output<void>();\n\n /** Emitted when an expanded trail is collapsed back via the toggle. */\n collapse = output<void>();\n\n /** Emitted when a sibling page is chosen from a crumb's dropdown switcher. */\n menuItemSelect = output<BreadcrumbMenuItem>();\n\n // ── Internal state ──────────────────────────────────────────────────────────\n\n /** Whether the user has expanded the collapsed middle crumbs. */\n readonly isExpanded = signal(false);\n\n /** Index (within `visibleItems`) of the crumb whose sibling menu is open, or `null`. */\n readonly openMenuIndex = signal<number | null>(null);\n\n /** Roving-tabindex focus position inside the currently open sibling menu. */\n private readonly activeMenuItemIndex = signal(0);\n\n /** Anchor to restore focus to when the sibling menu closes. */\n private triggerEl: HTMLElement | null = null;\n\n private readonly menuItemRefs = viewChildren<ElementRef<HTMLAnchorElement>>('menuItemEl');\n\n /** Whether the open sibling menu should render above (vs. below) its trigger. */\n readonly menuAbove = signal(false);\n\n /**\n * Inline style object applied to the portaled menu panel. Uses\n * `position: fixed` with exact viewport coordinates so the panel escapes\n * all parent `overflow`/transform containment (dropdown-instruction.md §1–2).\n */\n readonly menuPanelStyle = signal<Record<string, string>>({});\n\n // ── Body-portal state (dropdown-instruction.md §1) ────────────────────────\n\n private portalEl: HTMLElement | null = null;\n private portalView: EmbeddedViewRef<object> | null = null;\n\n /** The ng-template holding the sibling-menu panel markup. */\n @ViewChild('menuPanelTpl', { read: TemplateRef })\n private readonly menuPanelTpl!: TemplateRef<object>;\n\n /**\n * Capture-phase scroll listener — fires for ANY scrollable ancestor, not\n * just the window, so the panel tracks its trigger through nested\n * scroll containers (dropdown-instruction.md §6).\n */\n private _rafId: number | null = null;\n private readonly _scrollHandler = (): void => {\n if (this.openMenuIndex() === null) return;\n if (this._rafId !== null) return;\n this._rafId = requestAnimationFrame(() => {\n this._rafId = null;\n if (this.openMenuIndex() !== null) {\n this._calcMenuPos();\n this.portalView?.detectChanges();\n }\n });\n };\n\n // ── Computed ──────────────────────────────────────────────────────────────\n\n /** Whether `maxVisible` would collapse this trail at all, regardless of expanded state. */\n isCollapsible = computed<boolean>(() => {\n const max = this.maxVisible();\n return max > 0 && this.items().length > max;\n });\n\n /** Number of crumbs currently hidden behind the ellipsis (0 when expanded or not collapsible). */\n hiddenCount = computed<number>(() => {\n if (!this.isCollapsible() || this.isExpanded()) return 0;\n return this.items().length - 2; // all but first + last\n });\n\n /** How many crumbs the toggle would hide/reveal, regardless of its current state. */\n toggleCount = computed<number>(() =>\n this.isCollapsible() ? this.items().length - 2 : 0,\n );\n\n /**\n * Crumbs actually rendered. When collapsible, a `'__ellipsis__'` toggle\n * token always sits right after the first crumb — collapsed it hides the\n * middle crumbs, expanded it re-collapses them, so the trail can be\n * toggled open and closed rather than only ever revealed once.\n */\n visibleItems = computed<Array<BreadcrumbItem | '__ellipsis__'>>(() => {\n const all = this.items();\n if (!this.isCollapsible()) return all;\n\n const first = all[0];\n const last = all[all.length - 1];\n if (!this.isExpanded()) return [first, '__ellipsis__', last];\n return [first, '__ellipsis__', ...all.slice(1, -1), last];\n });\n\n /** Whether there are collapsed crumbs. */\n hasEllipsis = computed(() => this.visibleItems().includes('__ellipsis__'));\n\n /** SVG path for the current separator (null means text character). */\n separatorPath = computed<string | null>(() => {\n const sep = this.separator();\n if (sep === 'custom') return this.customSeparatorPath() || SEPARATOR_PATHS.chevron;\n return SEPARATOR_PATHS[sep];\n });\n\n /** Text character used as separator (only for slash / dot). */\n separatorChar = computed<string>(() => {\n switch (this.separator()) {\n case 'slash': return '/';\n case 'dot': return '·';\n default: return '';\n }\n });\n\n /** Whether the separator is rendered as SVG (true) or a text character (false). */\n isSvgSeparator = computed(() => this.separatorPath() !== null);\n\n /** Numbered array driving the loading skeleton's segment count. */\n skeletonItems = computed<number[]>(() =>\n Array.from({ length: Math.max(1, this.skeletonCount()) }, (_, i) => i),\n );\n\n /** The crumb whose sibling menu is currently open, or `null`. Drives the portaled panel. */\n openMenuItem = computed<BreadcrumbItem | null>(() => {\n const index = this.openMenuIndex();\n if (index === null) return null;\n const item = this.visibleItems()[index];\n return item && this.isBreadcrumbItem(item) ? item : null;\n });\n\n // ── Size maps ─────────────────────────────────────────────────────────────\n\n textClass = computed<string>(() => ({\n sm: 'text-xs',\n md: 'text-sm',\n lg: 'text-base',\n })[this.size()]);\n\n iconClass = computed<string>(() => ({\n sm: 'h-3 w-3',\n md: 'h-3.5 w-3.5',\n lg: 'h-4 w-4',\n })[this.size()]);\n\n separatorClass = computed<string>(() => ({\n sm: 'h-3 w-3',\n md: 'h-3.5 w-3.5',\n lg: 'h-4 w-4',\n })[this.size()]);\n\n // ── Variant class maps ────────────────────────────────────────────────────\n\n /** Extra classes applied to the root `<nav>` for container-style variants. */\n navWrapperClass = computed<string>(() =>\n this.variant() === 'contained'\n ? 'inline-flex items-center rounded-lg border border-gray-200/80 bg-gray-50/70 px-3 py-1.5 dark:border-gray-800 dark:bg-gray-800/40'\n : '',\n );\n\n /** Classes for a clickable (routerLink) crumb. */\n linkClasses = computed<string>(() => {\n const base = 'flex items-center gap-1 text-gray-500 transition-colors hover:text-gray-700 ' +\n 'dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus-visible:rounded ' +\n 'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1';\n switch (this.variant()) {\n case 'pills': return `${base} rounded-full px-2.5 py-1 hover:bg-gray-100 dark:hover:bg-gray-800/70`;\n case 'underline': return `${base} border-b-2 border-transparent pb-0.5 hover:border-gray-300 dark:hover:border-gray-600`;\n default: return base;\n }\n });\n\n /** Classes for the current-page (last) crumb. */\n currentClasses = computed<string>(() => {\n const base = 'flex items-center gap-1 font-medium text-gray-900 dark:text-white';\n switch (this.variant()) {\n case 'pills': return `${base} rounded-full bg-primary-50 px-2.5 py-1 text-primary-700 dark:bg-primary-500/10 dark:text-primary-400`;\n case 'underline': return `${base} border-b-2 border-primary-600 pb-0.5 text-primary-700 dark:border-primary-400 dark:text-primary-400`;\n default: return base;\n }\n });\n\n /** Classes for a non-linked middle crumb (no routerLink, not disabled). */\n middleClasses = computed<string>(() => {\n const base = 'flex items-center gap-1 text-gray-500 dark:text-gray-400';\n return this.variant() === 'pills' ? `${base} rounded-full px-2.5 py-1` : base;\n });\n\n /** Classes for a disabled crumb. */\n disabledClasses = computed<string>(() => {\n const base = 'flex items-center gap-1 cursor-not-allowed text-gray-300 opacity-60 dark:text-gray-600';\n return this.variant() === 'pills' ? `${base} rounded-full px-2.5 py-1` : base;\n });\n\n // ── Helpers ───────────────────────────────────────────────────────────────\n\n isLastItem(item: BreadcrumbItem | '__ellipsis__'): boolean {\n const all = this.visibleItems();\n return all[all.length - 1] === item;\n }\n\n isFirstItem(item: BreadcrumbItem | '__ellipsis__'): boolean {\n return this.visibleItems()[0] === item;\n }\n\n isBreadcrumbItem(item: BreadcrumbItem | '__ellipsis__'): item is BreadcrumbItem {\n return item !== '__ellipsis__';\n }\n\n /** True for crumbs strictly between the first and last — used to CSS-hide on mobile. */\n isMiddleItem(item: BreadcrumbItem | '__ellipsis__'): boolean {\n if (!this.isBreadcrumbItem(item)) return false;\n return !this.isFirstItem(item) && !this.isLastItem(item);\n }\n\n /** Resolves the icon shown before a crumb's label, falling back to the home icon. */\n iconPathFor(item: BreadcrumbItem): string | undefined {\n if (item.iconPath) return item.iconPath;\n if (this.showHomeIcon() && this.isFirstItem(item)) return HOME_ICON_PATH;\n return undefined;\n }\n\n onCrumbClick(item: BreadcrumbItem): void {\n if (item.disabled) return;\n if (item.routerLink) {\n this.crumbClick.emit(item);\n }\n }\n\n /**\n * Toggle the collapsed middle crumbs open or closed. Unlike a one-way\n * \"reveal\", calling this again while expanded collapses the trail back —\n * the ellipsis token stays clickable in both states (see `visibleItems`).\n */\n toggleCollapse(): void {\n if (!this.expandable() || !this.isCollapsible()) return;\n const next = !this.isExpanded();\n this.isExpanded.set(next);\n if (next) this.expand.emit();\n else this.collapse.emit();\n }\n\n // ── Lifecycle ─────────────────────────────────────────────────────────────\n\n ngOnInit(): void {\n // Capture phase catches scroll from any scrollable ancestor, not just window\n // (dropdown-instruction.md §6).\n window.addEventListener('scroll', this._scrollHandler, { passive: true, capture: true });\n }\n\n ngOnDestroy(): void {\n window.removeEventListener('scroll', this._scrollHandler, true);\n if (this._rafId !== null) cancelAnimationFrame(this._rafId);\n this._destroyMenuPortal();\n }\n\n // ── Sibling dropdown menu (ARIA menu-button + body-portal pattern) ───────\n\n /** Stable DOM id for the (single, reused) sibling-menu panel. */\n readonly menuPanelId = `${this.uid}-menu`;\n\n toggleMenu(index: number, event: MouseEvent): void {\n event.stopPropagation();\n if (this.openMenuIndex() === index) {\n this.closeMenu();\n return;\n }\n if (this.openMenuIndex() !== null) this._destroyMenuPortal();\n\n this.triggerEl = event.currentTarget as HTMLElement;\n this._calcMenuPos();\n\n // Mount the panel into document.body via a true portal so it always\n // escapes ancestor overflow/transform clipping (dropdown-instruction.md §1).\n this.portalEl = document.createElement('div');\n this.portalEl.className = 'sv-breadcrumb-menu-portal';\n document.body.appendChild(this.portalEl);\n\n this.portalView = this.menuPanelTpl.createEmbeddedView({});\n this.appRef.attachView(this.portalView);\n this.portalView.rootNodes.forEach((n: Node) => this.portalEl!.appendChild(n));\n\n this.openMenuIndex.set(index);\n this.activeMenuItemIndex.set(0);\n this.portalView.detectChanges();\n\n queueMicrotask(() => this.menuItemRefs()[0]?.nativeElement.focus());\n }\n\n closeMenu(): void {\n if (this.openMenuIndex() === null) return;\n this._destroyMenuPortal();\n this.openMenuIndex.set(null);\n this.triggerEl?.focus();\n this.triggerEl = null;\n }\n\n selectMenuItem(item: BreadcrumbMenuItem): void {\n this.menuItemSelect.emit(item);\n this.closeMenu();\n }\n\n onMenuKeydown(event: KeyboardEvent): void {\n const refs = this.menuItemRefs();\n if (!refs.length) return;\n\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.activeMenuItemIndex.update(i => Math.min(i + 1, refs.length - 1));\n refs[this.activeMenuItemIndex()].nativeElement.focus();\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.activeMenuItemIndex.update(i => Math.max(i - 1, 0));\n refs[this.activeMenuItemIndex()].nativeElement.focus();\n break;\n case 'Home':\n event.preventDefault();\n this.activeMenuItemIndex.set(0);\n refs[0].nativeElement.focus();\n break;\n case 'End':\n event.preventDefault();\n this.activeMenuItemIndex.set(refs.length - 1);\n refs[refs.length - 1].nativeElement.focus();\n break;\n case 'Escape':\n event.preventDefault();\n this.closeMenu();\n break;\n case 'Tab':\n this.closeMenu();\n break;\n }\n }\n\n /** Position the portaled panel against `triggerEl`, flipping to fit the viewport. */\n private _calcMenuPos(): void {\n const trigger = this.triggerEl ?? this.host.nativeElement;\n const rect = trigger.getBoundingClientRect();\n const vw = window.innerWidth;\n const vh = window.innerHeight;\n\n const spaceBelow = vh - rect.bottom;\n const spaceAbove = rect.top;\n const above = spaceBelow < MENU_PANEL_H && spaceAbove > spaceBelow;\n const right = rect.left + MENU_PANEL_W > vw;\n\n this.menuAbove.set(above);\n\n const style: Record<string, string> = {\n position: 'fixed',\n zIndex: '9999',\n minWidth: `${MENU_PANEL_W}px`,\n };\n style[above ? 'bottom' : 'top'] = above\n ? `${vh - rect.top + GAP}px`\n : `${rect.bottom + GAP}px`;\n style[right ? 'right' : 'left'] = right\n ? `${vw - rect.right}px`\n : `${rect.left}px`;\n\n this.menuPanelStyle.set(style);\n }\n\n private _destroyMenuPortal(): void {\n if (this.portalView) {\n this.appRef.detachView(this.portalView);\n this.portalView.destroy();\n this.portalView = null;\n }\n this.portalEl?.remove();\n this.portalEl = null;\n }\n\n // ── Global listeners ──────────────────────────────────────────────────────\n\n /**\n * Click-outside close. The panel lives in `document.body`, not inside this\n * component's DOM subtree, so both the host AND the portal element must be\n * checked (dropdown-instruction.md §7).\n */\n @HostListener('document:mousedown', ['$event'])\n onDocumentMousedown(event: MouseEvent): void {\n if (this.openMenuIndex() === null) return;\n const target = event.target as Node;\n if (this.host.nativeElement.contains(target)) return;\n if (this.portalEl?.contains(target)) return;\n this.closeMenu();\n }\n\n /** Viewport resize invalidates the computed coordinates — close rather than reposition\n * with stale numbers (dropdown-instruction.md §8). */\n @HostListener('window:resize')\n onWindowResize(): void {\n if (this.openMenuIndex() !== null) this.closeMenu();\n }\n\n /** Scoped to the host so it only fires while focus/interaction is on the trigger;\n * focus inside the portaled panel is handled by `onMenuKeydown` (dropdown-instruction.md §9). */\n @HostListener('keydown.escape')\n onHostEscape(): void {\n if (this.openMenuIndex() !== null) this.closeMenu();\n }\n\n readonly menuCaretPath = MENU_CARET_PATH;\n}\n","<!-- ── Reusable fragments ──────────────────────────────────────────────────── -->\n\n<ng-template #iconTpl let-item>\n @if (iconPathFor(item); as path) {\n <svg\n [ngClass]=\"iconClass()\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path [attr.d]=\"path\" />\n </svg>\n }\n</ng-template>\n\n<ng-template #badgeTpl let-item>\n @if (item.badge !== undefined && item.badge !== null) {\n <span\n class=\"inline-flex items-center justify-center rounded-full bg-gray-200 px-1.5 py-0.5\n text-[10px] font-semibold leading-none text-gray-700 dark:bg-gray-700 dark:text-gray-200\"\n >\n {{ item.badge }}\n </span>\n }\n</ng-template>\n\n<ng-template #labelTpl let-item>\n @if (truncateLabels()) {\n <span\n class=\"inline-block max-w-[10rem] truncate align-bottom\"\n [attr.title]=\"item.title ?? item.label\"\n >{{ item.label }}</span>\n } @else {\n {{ item.label }}\n }\n</ng-template>\n\n<ng-template #menuTriggerTpl let-item let-idx=\"idx\">\n @if (item.menu?.length && !item.disabled) {\n <button\n type=\"button\"\n data-trigger\n class=\"flex items-center rounded p-0.5 text-gray-400 transition-colors hover:text-gray-700\n focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500\n focus-visible:ring-offset-1 dark:text-gray-500 dark:hover:text-gray-200\"\n aria-haspopup=\"menu\"\n [attr.aria-expanded]=\"openMenuIndex() === idx\"\n [attr.aria-controls]=\"menuPanelId\"\n [attr.aria-label]=\"'Switch page under ' + item.label\"\n (click)=\"toggleMenu(idx, $event)\"\n >\n <svg class=\"h-3 w-3\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"menuCaretPath\" />\n </svg>\n </button>\n }\n</ng-template>\n\n<!-- ── Sibling-menu panel — rendered into document.body via a true body portal\n (dropdown-instruction.md §1) so it always escapes ancestor overflow/transform\n clipping. Never rendered in place; only instantiated on open by toggleMenu(). ── -->\n<ng-template #menuPanelTpl>\n @if (openMenuItem(); as item) {\n <div\n [id]=\"menuPanelId\"\n role=\"menu\"\n [attr.aria-label]=\"item.label + ' pages'\"\n class=\"overflow-hidden rounded-lg border border-gray-100 bg-white py-1 shadow-xl\n dark:border-gray-800 dark:bg-gray-900\"\n [style.position]=\"menuPanelStyle()['position']\"\n [style.zIndex]=\"menuPanelStyle()['zIndex']\"\n [style.minWidth]=\"menuPanelStyle()['minWidth']\"\n [style.top]=\"menuPanelStyle()['top']\"\n [style.bottom]=\"menuPanelStyle()['bottom']\"\n [style.left]=\"menuPanelStyle()['left']\"\n [style.right]=\"menuPanelStyle()['right']\"\n (keydown)=\"onMenuKeydown($event)\"\n >\n @for (mi of item.menu; track mi.label) {\n <a\n role=\"menuitem\"\n tabindex=\"-1\"\n #menuItemEl\n [routerLink]=\"mi.routerLink\"\n class=\"flex items-center gap-2 px-3 py-1.5 text-xs text-gray-600 transition-colors\n hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus-visible:bg-gray-50\n dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:bg-gray-800\"\n (click)=\"selectMenuItem(mi)\"\n >\n @if (mi.iconPath) {\n <svg class=\"h-3.5 w-3.5 shrink-0\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path [attr.d]=\"mi.iconPath\" />\n </svg>\n }\n {{ mi.label }}\n </a>\n }\n </div>\n }\n</ng-template>\n\n<!-- ── Root ─────────────────────────────────────────────────────────────────── -->\n\n<nav\n [ngClass]=\"['flex', navWrapperClass(), customClass()]\"\n [attr.aria-label]=\"ariaLabel()\"\n>\n @if (loading()) {\n <div\n class=\"flex items-center gap-2\"\n [ngClass]=\"textClass()\"\n role=\"status\"\n aria-live=\"polite\"\n aria-busy=\"true\"\n >\n <span class=\"sr-only\">Loading breadcrumb…</span>\n @for (i of skeletonItems(); track i; let last = $last) {\n <span\n class=\"h-3.5 animate-pulse rounded bg-gray-200 dark:bg-gray-700\"\n [style.width.px]=\"40 + (i % 3) * 24\"\n aria-hidden=\"true\"\n ></span>\n @if (!last) {\n <span class=\"select-none text-gray-300 dark:text-gray-600\" aria-hidden=\"true\">/</span>\n }\n }\n </div>\n } @else {\n <ol\n role=\"list\"\n [ngClass]=\"['flex flex-wrap items-center gap-1', textClass()]\"\n >\n @for (item of visibleItems(); track $index; let last = $last, idx = $index) {\n\n <li\n [ngClass]=\"[\n 'flex items-center gap-1',\n (collapseOnMobile() && isMiddleItem(item)) ? 'hidden sm:flex' : ''\n ]\"\n >\n\n <!-- ── Crumb ─────────────────────────────────────────────────── -->\n @if (isBreadcrumbItem(item)) {\n\n @if (item.disabled) {\n <!-- Disabled crumb — non-interactive -->\n <span [ngClass]=\"disabledClasses()\" aria-disabled=\"true\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n\n } @else if (last) {\n <!-- Current page — plain text, aria-current -->\n <span [ngClass]=\"currentClasses()\" aria-current=\"page\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n\n } @else if (item.routerLink) {\n <!-- Linked crumb -->\n <a\n [routerLink]=\"item.routerLink\"\n [ngClass]=\"linkClasses()\"\n (click)=\"onCrumbClick(item)\"\n >\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </a>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n\n } @else {\n <!-- Non-linked crumb (middle, no routerLink) -->\n <span [ngClass]=\"middleClasses()\">\n <ng-container *ngTemplateOutlet=\"iconTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"labelTpl; context: { $implicit: item }\" />\n <ng-container *ngTemplateOutlet=\"badgeTpl; context: { $implicit: item }\" />\n </span>\n <ng-container *ngTemplateOutlet=\"menuTriggerTpl; context: { $implicit: item, idx: idx }\" />\n }\n\n } @else {\n <!-- Ellipsis / collapse toggle — a real open↔close button when expandable -->\n @if (expandable()) {\n <button\n type=\"button\"\n class=\"flex items-center gap-0.5 rounded px-1 py-0.5 text-gray-400 transition-colors\n hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200\n focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500\n focus-visible:ring-offset-1\"\n [attr.aria-expanded]=\"isExpanded()\"\n [attr.aria-label]=\"isExpanded()\n ? 'Hide ' + toggleCount() + ' page' + (toggleCount() === 1 ? '' : 's')\n : 'Show ' + toggleCount() + ' hidden page' + (toggleCount() === 1 ? '' : 's')\"\n (click)=\"toggleCollapse()\"\n >\n @if (isExpanded()) {\n <svg class=\"h-3 w-3\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5\" />\n </svg>\n } @else {\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n }\n </button>\n } @else {\n <span\n class=\"flex items-center gap-0.5 text-gray-500 dark:text-gray-400\"\n aria-label=\"More pages\"\n >\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n <span class=\"h-1 w-1 rounded-full bg-current\"></span>\n </span>\n }\n }\n\n <!-- ── Separator (between items, not after the last) ─────────── -->\n @if (!last) {\n @if (isSvgSeparator()) {\n <svg\n [ngClass]=\"[separatorClass(), 'shrink-0 text-gray-300 dark:text-gray-600']\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path [attr.d]=\"separatorPath()\" />\n </svg>\n } @else {\n <span\n class=\"shrink-0 select-none text-gray-300 dark:text-gray-600\"\n aria-hidden=\"true\"\n >\n {{ separatorChar() }}\n </span>\n }\n }\n\n </li>\n\n }\n </ol>\n }\n</nav>\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@styloviz/breadcrumb",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "4 visual variants, 5 separator styles, a sibling-page dropdown switcher, badges, disabled crumbs, collapsible middle crumbs, a loading skeleton, and full keyboard support.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "sazzad-bs23",
|
|
7
|
+
"homepage": "https://styloviz.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/rhossain/angular-tailwind-dashboard-components.git",
|
|
11
|
+
"directory": "projects/breadcrumb"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/rhossain/angular-tailwind-dashboard-components/issues"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@angular/common": ">=21.0.0",
|
|
18
|
+
"@angular/core": ">=21.0.0",
|
|
19
|
+
"@angular/router": ">=21.0.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"module": "fesm2022/styloviz-breadcrumb.mjs",
|
|
29
|
+
"typings": "types/styloviz-breadcrumb.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
"./package.json": {
|
|
32
|
+
"default": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./types/styloviz-breadcrumb.d.ts",
|
|
36
|
+
"default": "./fesm2022/styloviz-breadcrumb.mjs"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"type": "module",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"tslib": "^2.3.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as _angular_core from '@angular/core';
|
|
2
|
+
import { OnInit, OnDestroy } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
/** A sibling page offered from a crumb's dropdown switcher. */
|
|
5
|
+
interface BreadcrumbMenuItem {
|
|
6
|
+
/** Display label. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Router link (string or array). */
|
|
9
|
+
routerLink?: string | string[];
|
|
10
|
+
/** Optional SVG `<path d="...">` shown before the label. */
|
|
11
|
+
iconPath?: string;
|
|
12
|
+
}
|
|
13
|
+
/** A single crumb in the breadcrumb trail. */
|
|
14
|
+
interface BreadcrumbItem {
|
|
15
|
+
/** Display label. */
|
|
16
|
+
label: string;
|
|
17
|
+
/**
|
|
18
|
+
* Router link (string or array).
|
|
19
|
+
* Omit for the current / last crumb (renders as plain text).
|
|
20
|
+
*/
|
|
21
|
+
routerLink?: string | string[];
|
|
22
|
+
/** Optional SVG `<path d="...">` shown before the label. */
|
|
23
|
+
iconPath?: string;
|
|
24
|
+
/** Renders the crumb as non-interactive (e.g. permission-gated section). @default false */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Small count/status pill rendered after the label. */
|
|
27
|
+
badge?: string | number;
|
|
28
|
+
/** Sibling pages at this level — renders a dropdown-switcher trigger next to the crumb. */
|
|
29
|
+
menu?: BreadcrumbMenuItem[];
|
|
30
|
+
/** Full label shown as a tooltip when `truncateLabels` clips the visible text. */
|
|
31
|
+
title?: string;
|
|
32
|
+
}
|
|
33
|
+
type BreadcrumbSeparator = 'slash' | 'chevron' | 'dot' | 'arrow' | 'custom';
|
|
34
|
+
type BreadcrumbVariant = 'plain' | 'contained' | 'pills' | 'underline';
|
|
35
|
+
type BreadcrumbSize = 'sm' | 'md' | 'lg';
|
|
36
|
+
/**
|
|
37
|
+
* Breadcrumb — A navigation trail showing the user's location within the app.
|
|
38
|
+
*
|
|
39
|
+
* Features:
|
|
40
|
+
* - Accepts flat `items` array — no routing knowledge required
|
|
41
|
+
* - 5 separator styles: slash · chevron · dot · arrow · custom (bring your own SVG path)
|
|
42
|
+
* - 4 visual variants: plain · contained · pills · underline
|
|
43
|
+
* - Optional icon before each crumb label, plus an automatic home icon on the first crumb
|
|
44
|
+
* - Per-crumb badge, disabled state, and a sibling-page dropdown switcher (ARIA menu-button)
|
|
45
|
+
* - Sibling-menu panel is rendered in a true `document.body` portal so it always escapes
|
|
46
|
+
* ancestor `overflow:hidden`/scroll/transform containment (see dropdown-instruction.md)
|
|
47
|
+
* - Automatic "current page" ARIA on the last crumb
|
|
48
|
+
* - Collapsible middle crumbs when more than `maxVisible` items — the ellipsis is a real
|
|
49
|
+
* open/close toggle (`toggleCollapse`), not a one-way reveal, with `expand`/`collapse` outputs
|
|
50
|
+
* - Optional label truncation with a tooltip, and CSS-only mobile collapse
|
|
51
|
+
* - Loading skeleton state for async breadcrumb sources
|
|
52
|
+
* - 3 sizes: sm · md · lg
|
|
53
|
+
* - Full dark-mode support
|
|
54
|
+
* - Accessible: `<nav aria-label>`, `<ol>`, `aria-current="page"`, `aria-disabled`,
|
|
55
|
+
* full keyboard support (Tab, Enter, Escape, ↑ ↓ Home End inside the sibling menu)
|
|
56
|
+
*/
|
|
57
|
+
declare class SvBreadcrumbComponent implements OnInit, OnDestroy {
|
|
58
|
+
private readonly host;
|
|
59
|
+
private readonly appRef;
|
|
60
|
+
private readonly uid;
|
|
61
|
+
/** Ordered list of crumbs. The last item is treated as the current page. */
|
|
62
|
+
items: _angular_core.InputSignal<BreadcrumbItem[]>;
|
|
63
|
+
/** Separator style between crumbs. Use `custom` with `customSeparatorPath`. @default 'chevron' */
|
|
64
|
+
separator: _angular_core.InputSignal<BreadcrumbSeparator>;
|
|
65
|
+
/** SVG `<path d="...">` used when `separator="custom"`. */
|
|
66
|
+
customSeparatorPath: _angular_core.InputSignal<string>;
|
|
67
|
+
/** Visual style of the crumb trail. @default 'plain' */
|
|
68
|
+
variant: _angular_core.InputSignal<BreadcrumbVariant>;
|
|
69
|
+
/** Size of the breadcrumb text and icons. @default 'md' */
|
|
70
|
+
size: _angular_core.InputSignal<BreadcrumbSize>;
|
|
71
|
+
/**
|
|
72
|
+
* Maximum number of crumbs to show before collapsing the middle ones.
|
|
73
|
+
* Set to `0` to always show all. @default 0
|
|
74
|
+
*/
|
|
75
|
+
maxVisible: _angular_core.InputSignal<number>;
|
|
76
|
+
/**
|
|
77
|
+
* Allow the collapsed ellipsis to be clicked to reveal the hidden middle
|
|
78
|
+
* crumbs. When `false` the ellipsis is purely decorative. @default true
|
|
79
|
+
*/
|
|
80
|
+
expandable: _angular_core.InputSignal<boolean>;
|
|
81
|
+
/** Prepends a home icon to the first crumb when it has no explicit `iconPath`. @default false */
|
|
82
|
+
showHomeIcon: _angular_core.InputSignal<boolean>;
|
|
83
|
+
/** Clips long labels to a fixed width with an ellipsis and a title tooltip. @default false */
|
|
84
|
+
truncateLabels: _angular_core.InputSignal<boolean>;
|
|
85
|
+
/** Hides middle crumbs below the `sm` breakpoint (pure CSS) to keep long trails compact on phones. @default false */
|
|
86
|
+
collapseOnMobile: _angular_core.InputSignal<boolean>;
|
|
87
|
+
/** Shows an animated skeleton in place of the crumb trail while data is loading. @default false */
|
|
88
|
+
loading: _angular_core.InputSignal<boolean>;
|
|
89
|
+
/** Number of skeleton segments rendered when `loading` is `true`. @default 3 */
|
|
90
|
+
skeletonCount: _angular_core.InputSignal<number>;
|
|
91
|
+
/** Accessible label for the breadcrumb navigation landmark. @default 'Breadcrumb' */
|
|
92
|
+
ariaLabel: _angular_core.InputSignal<string>;
|
|
93
|
+
/** Additional CSS classes on the root `<nav>` element. */
|
|
94
|
+
customClass: _angular_core.InputSignal<string>;
|
|
95
|
+
/** Emitted when a crumb with a `routerLink` is clicked. */
|
|
96
|
+
crumbClick: _angular_core.OutputEmitterRef<BreadcrumbItem>;
|
|
97
|
+
/** Emitted when the collapsed ellipsis is expanded to reveal hidden crumbs. */
|
|
98
|
+
expand: _angular_core.OutputEmitterRef<void>;
|
|
99
|
+
/** Emitted when an expanded trail is collapsed back via the toggle. */
|
|
100
|
+
collapse: _angular_core.OutputEmitterRef<void>;
|
|
101
|
+
/** Emitted when a sibling page is chosen from a crumb's dropdown switcher. */
|
|
102
|
+
menuItemSelect: _angular_core.OutputEmitterRef<BreadcrumbMenuItem>;
|
|
103
|
+
/** Whether the user has expanded the collapsed middle crumbs. */
|
|
104
|
+
readonly isExpanded: _angular_core.WritableSignal<boolean>;
|
|
105
|
+
/** Index (within `visibleItems`) of the crumb whose sibling menu is open, or `null`. */
|
|
106
|
+
readonly openMenuIndex: _angular_core.WritableSignal<number | null>;
|
|
107
|
+
/** Roving-tabindex focus position inside the currently open sibling menu. */
|
|
108
|
+
private readonly activeMenuItemIndex;
|
|
109
|
+
/** Anchor to restore focus to when the sibling menu closes. */
|
|
110
|
+
private triggerEl;
|
|
111
|
+
private readonly menuItemRefs;
|
|
112
|
+
/** Whether the open sibling menu should render above (vs. below) its trigger. */
|
|
113
|
+
readonly menuAbove: _angular_core.WritableSignal<boolean>;
|
|
114
|
+
/**
|
|
115
|
+
* Inline style object applied to the portaled menu panel. Uses
|
|
116
|
+
* `position: fixed` with exact viewport coordinates so the panel escapes
|
|
117
|
+
* all parent `overflow`/transform containment (dropdown-instruction.md §1–2).
|
|
118
|
+
*/
|
|
119
|
+
readonly menuPanelStyle: _angular_core.WritableSignal<Record<string, string>>;
|
|
120
|
+
private portalEl;
|
|
121
|
+
private portalView;
|
|
122
|
+
/** The ng-template holding the sibling-menu panel markup. */
|
|
123
|
+
private readonly menuPanelTpl;
|
|
124
|
+
/**
|
|
125
|
+
* Capture-phase scroll listener — fires for ANY scrollable ancestor, not
|
|
126
|
+
* just the window, so the panel tracks its trigger through nested
|
|
127
|
+
* scroll containers (dropdown-instruction.md §6).
|
|
128
|
+
*/
|
|
129
|
+
private _rafId;
|
|
130
|
+
private readonly _scrollHandler;
|
|
131
|
+
/** Whether `maxVisible` would collapse this trail at all, regardless of expanded state. */
|
|
132
|
+
isCollapsible: _angular_core.Signal<boolean>;
|
|
133
|
+
/** Number of crumbs currently hidden behind the ellipsis (0 when expanded or not collapsible). */
|
|
134
|
+
hiddenCount: _angular_core.Signal<number>;
|
|
135
|
+
/** How many crumbs the toggle would hide/reveal, regardless of its current state. */
|
|
136
|
+
toggleCount: _angular_core.Signal<number>;
|
|
137
|
+
/**
|
|
138
|
+
* Crumbs actually rendered. When collapsible, a `'__ellipsis__'` toggle
|
|
139
|
+
* token always sits right after the first crumb — collapsed it hides the
|
|
140
|
+
* middle crumbs, expanded it re-collapses them, so the trail can be
|
|
141
|
+
* toggled open and closed rather than only ever revealed once.
|
|
142
|
+
*/
|
|
143
|
+
visibleItems: _angular_core.Signal<(BreadcrumbItem | "__ellipsis__")[]>;
|
|
144
|
+
/** Whether there are collapsed crumbs. */
|
|
145
|
+
hasEllipsis: _angular_core.Signal<boolean>;
|
|
146
|
+
/** SVG path for the current separator (null means text character). */
|
|
147
|
+
separatorPath: _angular_core.Signal<string | null>;
|
|
148
|
+
/** Text character used as separator (only for slash / dot). */
|
|
149
|
+
separatorChar: _angular_core.Signal<string>;
|
|
150
|
+
/** Whether the separator is rendered as SVG (true) or a text character (false). */
|
|
151
|
+
isSvgSeparator: _angular_core.Signal<boolean>;
|
|
152
|
+
/** Numbered array driving the loading skeleton's segment count. */
|
|
153
|
+
skeletonItems: _angular_core.Signal<number[]>;
|
|
154
|
+
/** The crumb whose sibling menu is currently open, or `null`. Drives the portaled panel. */
|
|
155
|
+
openMenuItem: _angular_core.Signal<BreadcrumbItem | null>;
|
|
156
|
+
textClass: _angular_core.Signal<string>;
|
|
157
|
+
iconClass: _angular_core.Signal<string>;
|
|
158
|
+
separatorClass: _angular_core.Signal<string>;
|
|
159
|
+
/** Extra classes applied to the root `<nav>` for container-style variants. */
|
|
160
|
+
navWrapperClass: _angular_core.Signal<string>;
|
|
161
|
+
/** Classes for a clickable (routerLink) crumb. */
|
|
162
|
+
linkClasses: _angular_core.Signal<string>;
|
|
163
|
+
/** Classes for the current-page (last) crumb. */
|
|
164
|
+
currentClasses: _angular_core.Signal<string>;
|
|
165
|
+
/** Classes for a non-linked middle crumb (no routerLink, not disabled). */
|
|
166
|
+
middleClasses: _angular_core.Signal<string>;
|
|
167
|
+
/** Classes for a disabled crumb. */
|
|
168
|
+
disabledClasses: _angular_core.Signal<string>;
|
|
169
|
+
isLastItem(item: BreadcrumbItem | '__ellipsis__'): boolean;
|
|
170
|
+
isFirstItem(item: BreadcrumbItem | '__ellipsis__'): boolean;
|
|
171
|
+
isBreadcrumbItem(item: BreadcrumbItem | '__ellipsis__'): item is BreadcrumbItem;
|
|
172
|
+
/** True for crumbs strictly between the first and last — used to CSS-hide on mobile. */
|
|
173
|
+
isMiddleItem(item: BreadcrumbItem | '__ellipsis__'): boolean;
|
|
174
|
+
/** Resolves the icon shown before a crumb's label, falling back to the home icon. */
|
|
175
|
+
iconPathFor(item: BreadcrumbItem): string | undefined;
|
|
176
|
+
onCrumbClick(item: BreadcrumbItem): void;
|
|
177
|
+
/**
|
|
178
|
+
* Toggle the collapsed middle crumbs open or closed. Unlike a one-way
|
|
179
|
+
* "reveal", calling this again while expanded collapses the trail back —
|
|
180
|
+
* the ellipsis token stays clickable in both states (see `visibleItems`).
|
|
181
|
+
*/
|
|
182
|
+
toggleCollapse(): void;
|
|
183
|
+
ngOnInit(): void;
|
|
184
|
+
ngOnDestroy(): void;
|
|
185
|
+
/** Stable DOM id for the (single, reused) sibling-menu panel. */
|
|
186
|
+
readonly menuPanelId: string;
|
|
187
|
+
toggleMenu(index: number, event: MouseEvent): void;
|
|
188
|
+
closeMenu(): void;
|
|
189
|
+
selectMenuItem(item: BreadcrumbMenuItem): void;
|
|
190
|
+
onMenuKeydown(event: KeyboardEvent): void;
|
|
191
|
+
/** Position the portaled panel against `triggerEl`, flipping to fit the viewport. */
|
|
192
|
+
private _calcMenuPos;
|
|
193
|
+
private _destroyMenuPortal;
|
|
194
|
+
/**
|
|
195
|
+
* Click-outside close. The panel lives in `document.body`, not inside this
|
|
196
|
+
* component's DOM subtree, so both the host AND the portal element must be
|
|
197
|
+
* checked (dropdown-instruction.md §7).
|
|
198
|
+
*/
|
|
199
|
+
onDocumentMousedown(event: MouseEvent): void;
|
|
200
|
+
/** Viewport resize invalidates the computed coordinates — close rather than reposition
|
|
201
|
+
* with stale numbers (dropdown-instruction.md §8). */
|
|
202
|
+
onWindowResize(): void;
|
|
203
|
+
/** Scoped to the host so it only fires while focus/interaction is on the trigger;
|
|
204
|
+
* focus inside the portaled panel is handled by `onMenuKeydown` (dropdown-instruction.md §9). */
|
|
205
|
+
onHostEscape(): void;
|
|
206
|
+
readonly menuCaretPath = "M6 9l6 6 6-6";
|
|
207
|
+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SvBreadcrumbComponent, never>;
|
|
208
|
+
static ɵcmp: _angular_core.ɵɵComponentDeclaration<SvBreadcrumbComponent, "sv-breadcrumb", never, { "items": { "alias": "items"; "required": false; "isSignal": true; }; "separator": { "alias": "separator"; "required": false; "isSignal": true; }; "customSeparatorPath": { "alias": "customSeparatorPath"; "required": false; "isSignal": true; }; "variant": { "alias": "variant"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "maxVisible": { "alias": "maxVisible"; "required": false; "isSignal": true; }; "expandable": { "alias": "expandable"; "required": false; "isSignal": true; }; "showHomeIcon": { "alias": "showHomeIcon"; "required": false; "isSignal": true; }; "truncateLabels": { "alias": "truncateLabels"; "required": false; "isSignal": true; }; "collapseOnMobile": { "alias": "collapseOnMobile"; "required": false; "isSignal": true; }; "loading": { "alias": "loading"; "required": false; "isSignal": true; }; "skeletonCount": { "alias": "skeletonCount"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "ariaLabel"; "required": false; "isSignal": true; }; "customClass": { "alias": "customClass"; "required": false; "isSignal": true; }; }, { "crumbClick": "crumbClick"; "expand": "expand"; "collapse": "collapse"; "menuItemSelect": "menuItemSelect"; }, never, never, true, never>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { SvBreadcrumbComponent };
|
|
212
|
+
export type { BreadcrumbItem, BreadcrumbMenuItem, BreadcrumbSeparator, BreadcrumbSize, BreadcrumbVariant };
|