@ng-zen/cli 22.6.0-next.1 → 22.7.0-next.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/README.md +6 -0
- package/package.json +2 -1
- package/schematics/ui/files/sidenav/content/sidenav-content.scss +29 -0
- package/schematics/ui/files/sidenav/content/sidenav-content.ts +13 -0
- package/schematics/ui/files/sidenav/footer/sidenav-footer.scss +10 -0
- package/schematics/ui/files/sidenav/footer/sidenav-footer.ts +13 -0
- package/schematics/ui/files/sidenav/header/sidenav-header.scss +8 -0
- package/schematics/ui/files/sidenav/header/sidenav-header.ts +13 -0
- package/schematics/ui/files/sidenav/index.ts +6 -0
- package/schematics/ui/files/sidenav/item/sidenav-item.html +9 -0
- package/schematics/ui/files/sidenav/item/sidenav-item.scss +85 -0
- package/schematics/ui/files/sidenav/item/sidenav-item.ts +40 -0
- package/schematics/ui/files/sidenav/sidenav.html +15 -0
- package/schematics/ui/files/sidenav/sidenav.scss +110 -0
- package/schematics/ui/files/sidenav/sidenav.spec.ts +101 -0
- package/schematics/ui/files/sidenav/sidenav.stories.ts +110 -0
- package/schematics/ui/files/sidenav/sidenav.ts +99 -0
- package/schematics/ui/files/sidenav/toggle/sidenav-toggle.scss +26 -0
- package/schematics/ui/files/sidenav/toggle/sidenav-toggle.ts +52 -0
- package/schematics/ui/schema.js.map +1 -1
- package/schematics/ui/schema.json +1 -0
- package/schematics/ui/schema.ts +1 -0
package/README.md
CHANGED
|
@@ -171,6 +171,12 @@ ng generate @ng-zen/cli:ui --ui button input --stories
|
|
|
171
171
|
| **Pin** | Anchor-positioned overlay | CSS Anchor Positioning, TemplateRef content, position and offset controls |
|
|
172
172
|
| **Popover** | Native Popover API directive | Template/string content, placements, click toggle, CSS anchor positioning |
|
|
173
173
|
|
|
174
|
+
### Navigation
|
|
175
|
+
|
|
176
|
+
| Component | Description | Features |
|
|
177
|
+
| ----------- | --------------------------- | ---------------------------------------------------------------- |
|
|
178
|
+
| **Sidenav** | Responsive navigation shell | Collapsible rail, mobile bottom bar, header/content/footer slots |
|
|
179
|
+
|
|
174
180
|
## 🎨 Customization
|
|
175
181
|
|
|
176
182
|
Every generated component utilizes CSS variables for immediate theming without touching the core logic. Overwrite them globally or locally:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ng-zen/cli",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.7.0-next.1",
|
|
4
4
|
"description": "Angular CLI generator for creating fully customizable UI components.",
|
|
5
5
|
"license": "BSD-2-Clause",
|
|
6
6
|
"private": false,
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"pin",
|
|
64
64
|
"popover",
|
|
65
65
|
"radio",
|
|
66
|
+
"sidenav",
|
|
66
67
|
"skeleton",
|
|
67
68
|
"switch",
|
|
68
69
|
"textarea"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--zen-sidenav-gap);
|
|
5
|
+
min-block-size: 0;
|
|
6
|
+
min-inline-size: 0;
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 0;
|
|
9
|
+
overflow: hidden auto;
|
|
10
|
+
scrollbar-width: thin;
|
|
11
|
+
scrollbar-color: var(--zen-sidenav-border) transparent;
|
|
12
|
+
container-type: inline-size;
|
|
13
|
+
container-name: sidenav-nav;
|
|
14
|
+
|
|
15
|
+
@media (width <= 48rem) {
|
|
16
|
+
flex-direction: row;
|
|
17
|
+
align-items: stretch;
|
|
18
|
+
gap: 0.25rem;
|
|
19
|
+
overflow: auto hidden;
|
|
20
|
+
scrollbar-width: none;
|
|
21
|
+
scroll-snap-type: x proximity;
|
|
22
|
+
overscroll-behavior-inline: contain;
|
|
23
|
+
-webkit-overflow-scrolling: touch;
|
|
24
|
+
|
|
25
|
+
&::-webkit-scrollbar {
|
|
26
|
+
display: none;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scrollable vertical navigation container.
|
|
5
|
+
* Usage: `<nav zenSidenavContent>...</nav>`
|
|
6
|
+
*/
|
|
7
|
+
@Component({
|
|
8
|
+
// eslint-disable-next-line @angular-eslint/component-selector
|
|
9
|
+
selector: '[zenSidenavContent]',
|
|
10
|
+
template: '<ng-content />',
|
|
11
|
+
styleUrl: './sidenav-content.scss',
|
|
12
|
+
})
|
|
13
|
+
export class ZenSidenavContent {}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Footer slot for the sidenav - contains secondary actions (settings, logout, profile).
|
|
5
|
+
* Usage: `<div zenSidenavFooter>...</div>`
|
|
6
|
+
*/
|
|
7
|
+
@Component({
|
|
8
|
+
// eslint-disable-next-line @angular-eslint/component-selector
|
|
9
|
+
selector: '[zenSidenavFooter]',
|
|
10
|
+
template: '<ng-content />',
|
|
11
|
+
styleUrl: './sidenav-footer.scss',
|
|
12
|
+
})
|
|
13
|
+
export class ZenSidenavFooter {}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Header slot for the sidenav - contains logo/brand and optional collapse toggle.
|
|
5
|
+
* Usage: `<div zenSidenavHeader>...</div>`
|
|
6
|
+
*/
|
|
7
|
+
@Component({
|
|
8
|
+
// eslint-disable-next-line @angular-eslint/component-selector
|
|
9
|
+
selector: '[zenSidenavHeader]',
|
|
10
|
+
template: '<ng-content />',
|
|
11
|
+
styleUrl: './sidenav-header.scss',
|
|
12
|
+
})
|
|
13
|
+
export class ZenSidenavHeader {}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ZenSidenavContent } from './content/sidenav-content';
|
|
2
|
+
export { ZenSidenavFooter } from './footer/sidenav-footer';
|
|
3
|
+
export { ZenSidenavHeader } from './header/sidenav-header';
|
|
4
|
+
export { ZenSidenavItem } from './item/sidenav-item';
|
|
5
|
+
export { ZenSidenav } from './sidenav';
|
|
6
|
+
export { ZenSidenavToggle } from './toggle/sidenav-toggle';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
min-inline-size: 0;
|
|
4
|
+
|
|
5
|
+
@media (width <= 48rem) {
|
|
6
|
+
flex: 0 0 auto;
|
|
7
|
+
scroll-snap-align: start;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
a {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: row;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: 0.75rem;
|
|
16
|
+
padding: 0.5rem;
|
|
17
|
+
border-radius: var(--zen-sidenav-item-radius);
|
|
18
|
+
text-decoration: none;
|
|
19
|
+
color: inherit;
|
|
20
|
+
white-space: nowrap;
|
|
21
|
+
transition:
|
|
22
|
+
background 120ms ease,
|
|
23
|
+
color 120ms ease;
|
|
24
|
+
|
|
25
|
+
&:hover {
|
|
26
|
+
background: color-mix(in oklch, var(--zen-sidenav-hover-background) 80%, transparent);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
&:focus-visible {
|
|
30
|
+
outline: 2px solid var(--zen-sidenav-focus-color);
|
|
31
|
+
outline-offset: 2px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
&.active {
|
|
35
|
+
background: var(--zen-sidenav-active-background);
|
|
36
|
+
font-weight: 600;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@media (width <= 48rem) {
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
gap: 0.125rem;
|
|
44
|
+
min-inline-size: 4rem;
|
|
45
|
+
padding-block: 0.25rem;
|
|
46
|
+
padding-inline: 0.5rem;
|
|
47
|
+
white-space: normal;
|
|
48
|
+
text-align: center;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@media (prefers-reduced-motion: reduce) {
|
|
52
|
+
transition: none;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.sidenav-label {
|
|
57
|
+
font-size: 0.875rem;
|
|
58
|
+
line-height: 1.25;
|
|
59
|
+
transition:
|
|
60
|
+
opacity 150ms ease,
|
|
61
|
+
width 150ms ease,
|
|
62
|
+
margin 150ms ease;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
|
|
65
|
+
@media (width <= 48rem) {
|
|
66
|
+
font-size: 0.625rem;
|
|
67
|
+
line-height: 1.2;
|
|
68
|
+
white-space: nowrap;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
text-overflow: ellipsis;
|
|
71
|
+
max-inline-size: 100%;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@media (prefers-reduced-motion: reduce) {
|
|
75
|
+
transition: none;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Desktop collapsed: hide label, show only icon with tooltip via title/aria-label */
|
|
80
|
+
:host-context([data-collapsed='true']) .sidenav-label {
|
|
81
|
+
width: 0;
|
|
82
|
+
opacity: 0;
|
|
83
|
+
margin-inline: 0;
|
|
84
|
+
pointer-events: none;
|
|
85
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
import { RouterLink, RouterLinkActive } from '@angular/router';
|
|
3
|
+
|
|
4
|
+
import { IconSvgObject, ZenIcon } from '../../icon';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Individual navigation item with icon, label, active state, and responsive behavior.
|
|
8
|
+
*
|
|
9
|
+
* Desktop (expanded): Shows icon + label horizontally
|
|
10
|
+
* Desktop (collapsed): Shows icon only, label hidden with tooltip/aria-label
|
|
11
|
+
* Mobile: Shows icon stacked above label in column layout
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```html
|
|
15
|
+
* <zen-sidenav-item [icon]="homeIcon" label="Home" routerLink="/home" />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
@Component({
|
|
19
|
+
selector: 'zen-sidenav-item',
|
|
20
|
+
templateUrl: './sidenav-item.html',
|
|
21
|
+
styleUrl: './sidenav-item.scss',
|
|
22
|
+
imports: [RouterLink, RouterLinkActive, ZenIcon],
|
|
23
|
+
host: {
|
|
24
|
+
'[attr.data-active]': 'isActive()',
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
export class ZenSidenavItem {
|
|
28
|
+
/** SVG icon definition object (from `@hugeicons/core-free-icons`). */
|
|
29
|
+
readonly icon = input.required<IconSvgObject>();
|
|
30
|
+
/** Visible label text. Hidden when the sidenav is collapsed or on mobile overflow. */
|
|
31
|
+
readonly label = input.required<string>();
|
|
32
|
+
/** Router path the item navigates to. */
|
|
33
|
+
readonly routerLink = input.required<string>();
|
|
34
|
+
/** Accessible label and tooltip. Falls back to `label` when omitted. */
|
|
35
|
+
readonly ariaLabel = input<string>();
|
|
36
|
+
/** Manual active state (in addition to `routerLinkActive`). */
|
|
37
|
+
readonly isActive = input(false);
|
|
38
|
+
/** Icon size in pixels. */
|
|
39
|
+
readonly size = input<number>(24);
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<aside class="sidenav-container">
|
|
2
|
+
<header class="sidenav-header">
|
|
3
|
+
<ng-content select="[zenSidenavHeader]" />
|
|
4
|
+
</header>
|
|
5
|
+
|
|
6
|
+
<div class="sidenav-content">
|
|
7
|
+
<ng-content select="[zenSidenavContent]" />
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<footer class="sidenav-footer">
|
|
11
|
+
<ng-content select="[zenSidenavFooter]" />
|
|
12
|
+
</footer>
|
|
13
|
+
</aside>
|
|
14
|
+
|
|
15
|
+
<ng-content select="zen-sidenav-toggle" />
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
--zen-sidenav-width: 15rem;
|
|
3
|
+
--zen-sidenav-collapsed-width: 4.5rem;
|
|
4
|
+
--zen-sidenav-padding-inline: 0.875rem;
|
|
5
|
+
--zen-sidenav-padding-block: 1rem;
|
|
6
|
+
--zen-sidenav-item-height: 2.5rem;
|
|
7
|
+
--zen-sidenav-item-radius: 0.5rem;
|
|
8
|
+
--zen-sidenav-gap: 0.5rem;
|
|
9
|
+
--zen-sidenav-background: #fafafa;
|
|
10
|
+
--zen-sidenav-border: #e0e0e0;
|
|
11
|
+
--zen-sidenav-hover-background: #eee;
|
|
12
|
+
--zen-sidenav-active-background: #e0e0e0;
|
|
13
|
+
--zen-sidenav-focus-color: #2563eb;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* stylelint-disable-next-line no-duplicate-selectors -- separate variables and styles */
|
|
17
|
+
:host {
|
|
18
|
+
display: block;
|
|
19
|
+
inline-size: var(--zen-sidenav-width);
|
|
20
|
+
block-size: 100dvh;
|
|
21
|
+
flex: 0 0 auto;
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
transition: inline-size 180ms ease;
|
|
24
|
+
z-index: 1000;
|
|
25
|
+
position: relative;
|
|
26
|
+
|
|
27
|
+
@media (prefers-reduced-motion: reduce) {
|
|
28
|
+
transition: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// In-flow bottom bar: part of the layout, never above content
|
|
32
|
+
@media (width <= 48rem) {
|
|
33
|
+
inline-size: 100%;
|
|
34
|
+
block-size: auto;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.sidenav-container {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
inline-size: 100%;
|
|
42
|
+
block-size: 100%;
|
|
43
|
+
box-sizing: border-box;
|
|
44
|
+
padding-block: var(--zen-sidenav-padding-block);
|
|
45
|
+
padding-inline: var(--zen-sidenav-padding-inline);
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
background: var(--zen-sidenav-background);
|
|
48
|
+
border-inline-end: 1px solid var(--zen-sidenav-border);
|
|
49
|
+
|
|
50
|
+
@media (width <= 48rem) {
|
|
51
|
+
display: grid;
|
|
52
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
53
|
+
grid-template-areas: 'content footer';
|
|
54
|
+
align-items: stretch;
|
|
55
|
+
block-size: auto;
|
|
56
|
+
padding-block: 0.25rem max(0.25rem, env(safe-area-inset-bottom));
|
|
57
|
+
padding-inline: 0.25rem;
|
|
58
|
+
border-block-start: 1px solid var(--zen-sidenav-border);
|
|
59
|
+
border-inline-end: 0;
|
|
60
|
+
overflow: hidden;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
:host([data-collapsed='true']) {
|
|
65
|
+
inline-size: var(--zen-sidenav-collapsed-width);
|
|
66
|
+
|
|
67
|
+
.sidenav-header {
|
|
68
|
+
justify-content: center;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.sidenav-header {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
min-block-size: var(--zen-sidenav-item-height);
|
|
76
|
+
margin-block-end: var(--zen-sidenav-gap);
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
|
|
79
|
+
@media (width <= 48rem) {
|
|
80
|
+
display: none;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.sidenav-content {
|
|
85
|
+
min-block-size: 0;
|
|
86
|
+
overflow: hidden;
|
|
87
|
+
|
|
88
|
+
@media (width <= 48rem) {
|
|
89
|
+
grid-area: content;
|
|
90
|
+
min-inline-size: 0;
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.sidenav-footer {
|
|
96
|
+
margin-block-start: auto;
|
|
97
|
+
padding-block-start: 0.75rem;
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
|
|
100
|
+
@media (width <= 48rem) {
|
|
101
|
+
grid-area: footer;
|
|
102
|
+
margin-block-start: 0;
|
|
103
|
+
padding-block-start: 0;
|
|
104
|
+
padding-inline-start: 0.25rem;
|
|
105
|
+
border-inline-start: 1px solid var(--zen-sidenav-border);
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: stretch;
|
|
108
|
+
min-inline-size: 4rem;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Component, provideZonelessChangeDetection, signal } from '@angular/core';
|
|
2
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
3
|
+
import { By } from '@angular/platform-browser';
|
|
4
|
+
import { provideRouter } from '@angular/router';
|
|
5
|
+
import { HomeIcon } from '@hugeicons/core-free-icons';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { ZenSidenavContent } from './content/sidenav-content';
|
|
9
|
+
import { ZenSidenavFooter } from './footer/sidenav-footer';
|
|
10
|
+
import { ZenSidenavHeader } from './header/sidenav-header';
|
|
11
|
+
import { ZenSidenavItem } from './item/sidenav-item';
|
|
12
|
+
import { ZenSidenav } from './sidenav';
|
|
13
|
+
import { ZenSidenavToggle } from './toggle/sidenav-toggle';
|
|
14
|
+
|
|
15
|
+
@Component({
|
|
16
|
+
template: `
|
|
17
|
+
<zen-sidenav [collapsed]="collapsed()">
|
|
18
|
+
<div zenSidenavHeader>Brand</div>
|
|
19
|
+
<nav zenSidenavContent>
|
|
20
|
+
<zen-sidenav-item label="Home" routerLink="/home" [icon]="icon" />
|
|
21
|
+
</nav>
|
|
22
|
+
<div zenSidenavFooter>Footer</div>
|
|
23
|
+
<zen-sidenav-toggle [collapsed]="collapsed()" (collapsedChange)="toggle()" />
|
|
24
|
+
</zen-sidenav>
|
|
25
|
+
`,
|
|
26
|
+
standalone: true,
|
|
27
|
+
imports: [ZenSidenav, ZenSidenavHeader, ZenSidenavContent, ZenSidenavFooter, ZenSidenavItem, ZenSidenavToggle],
|
|
28
|
+
})
|
|
29
|
+
class SidenavTestComponent {
|
|
30
|
+
readonly collapsed = signal(false);
|
|
31
|
+
readonly icon = HomeIcon;
|
|
32
|
+
readonly toggle = vi.fn(() => this.collapsed.update(v => !v));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('ZenSidenav', () => {
|
|
36
|
+
async function setup(): Promise<ComponentFixture<SidenavTestComponent>> {
|
|
37
|
+
await TestBed.configureTestingModule({
|
|
38
|
+
imports: [SidenavTestComponent],
|
|
39
|
+
providers: [provideZonelessChangeDetection(), provideRouter([])],
|
|
40
|
+
}).compileComponents();
|
|
41
|
+
const fixture = TestBed.createComponent(SidenavTestComponent);
|
|
42
|
+
fixture.detectChanges();
|
|
43
|
+
return fixture;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('should create with header, content, footer and toggle slots', async () => {
|
|
47
|
+
const fixture = await setup();
|
|
48
|
+
expect(fixture.nativeElement.querySelector('zen-sidenav')).toBeTruthy();
|
|
49
|
+
expect(fixture.nativeElement.querySelector('[zenSidenavHeader]')?.textContent).toBe('Brand');
|
|
50
|
+
expect(fixture.nativeElement.querySelector('[zenSidenavFooter]')?.textContent).toBe('Footer');
|
|
51
|
+
expect(fixture.nativeElement.querySelector('zen-sidenav-toggle')).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should reflect collapsed state on data-collapsed', async () => {
|
|
55
|
+
const fixture = await setup();
|
|
56
|
+
const host = fixture.nativeElement.querySelector('zen-sidenav');
|
|
57
|
+
|
|
58
|
+
expect(host.getAttribute('data-collapsed')).toBe('false');
|
|
59
|
+
|
|
60
|
+
fixture.componentInstance.collapsed.set(true);
|
|
61
|
+
fixture.detectChanges();
|
|
62
|
+
|
|
63
|
+
expect(host.getAttribute('data-collapsed')).toBe('true');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should render item label, icon link and aria fallback', async () => {
|
|
67
|
+
const fixture = await setup();
|
|
68
|
+
const link = fixture.nativeElement.querySelector('zen-sidenav-item a');
|
|
69
|
+
|
|
70
|
+
expect(link.textContent).toContain('Home');
|
|
71
|
+
expect(link.getAttribute('aria-label')).toBe('Home');
|
|
72
|
+
expect(link.getAttribute('title')).toBe('Home');
|
|
73
|
+
expect(link.getAttribute('href')).toBe('/home');
|
|
74
|
+
expect(fixture.nativeElement.querySelector('zen-sidenav-item zen-icon')).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should emit collapsedChange when toggle is clicked', async () => {
|
|
78
|
+
const fixture = await setup();
|
|
79
|
+
const toggle = fixture.debugElement.query(By.directive(ZenSidenavToggle));
|
|
80
|
+
|
|
81
|
+
toggle.query(By.css('button')).nativeElement.click();
|
|
82
|
+
fixture.detectChanges();
|
|
83
|
+
|
|
84
|
+
expect(fixture.componentInstance.toggle).toHaveBeenCalled();
|
|
85
|
+
expect(fixture.componentInstance.collapsed()).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should swap toggle label and icon with collapsed state', async () => {
|
|
89
|
+
const fixture = await setup();
|
|
90
|
+
const button = (): HTMLButtonElement => fixture.nativeElement.querySelector('zen-sidenav-toggle button');
|
|
91
|
+
|
|
92
|
+
expect(button().getAttribute('aria-label')).toBe('Collapse sidenav');
|
|
93
|
+
expect(button().getAttribute('aria-expanded')).toBe('true');
|
|
94
|
+
|
|
95
|
+
fixture.componentInstance.collapsed.set(true);
|
|
96
|
+
fixture.detectChanges();
|
|
97
|
+
|
|
98
|
+
expect(button().getAttribute('aria-label')).toBe('Expand sidenav');
|
|
99
|
+
expect(button().getAttribute('aria-expanded')).toBe('false');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { signal } from '@angular/core';
|
|
2
|
+
import { provideRouter } from '@angular/router';
|
|
3
|
+
import { HomeIcon, Logout01Icon, Setting06Icon } from '@hugeicons/core-free-icons';
|
|
4
|
+
import { applicationConfig, Meta, moduleMetadata, StoryObj } from '@storybook/angular';
|
|
5
|
+
|
|
6
|
+
import { ZenButton } from '../button';
|
|
7
|
+
import { ZenIcon } from '../icon';
|
|
8
|
+
import { ZenSidenavContent } from './content/sidenav-content';
|
|
9
|
+
import { ZenSidenavFooter } from './footer/sidenav-footer';
|
|
10
|
+
import { ZenSidenavHeader } from './header/sidenav-header';
|
|
11
|
+
import { ZenSidenavItem } from './item/sidenav-item';
|
|
12
|
+
import { ZenSidenav } from './sidenav';
|
|
13
|
+
import { ZenSidenavToggle } from './toggle/sidenav-toggle';
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj<ZenSidenav>;
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
title: 'UI/Sidenav',
|
|
19
|
+
component: ZenSidenav,
|
|
20
|
+
tags: ['autodocs'],
|
|
21
|
+
parameters: {
|
|
22
|
+
layout: 'fullscreen',
|
|
23
|
+
},
|
|
24
|
+
decorators: [
|
|
25
|
+
applicationConfig({ providers: [provideRouter([])] }),
|
|
26
|
+
moduleMetadata({
|
|
27
|
+
imports: [
|
|
28
|
+
ZenButton,
|
|
29
|
+
ZenIcon,
|
|
30
|
+
ZenSidenav,
|
|
31
|
+
ZenSidenavContent,
|
|
32
|
+
ZenSidenavFooter,
|
|
33
|
+
ZenSidenavHeader,
|
|
34
|
+
ZenSidenavItem,
|
|
35
|
+
ZenSidenavToggle,
|
|
36
|
+
],
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
argTypes: {
|
|
40
|
+
collapsed: { control: 'boolean', table: { category: 'inputs', defaultValue: { summary: 'false' } } },
|
|
41
|
+
},
|
|
42
|
+
args: {
|
|
43
|
+
collapsed: false,
|
|
44
|
+
},
|
|
45
|
+
} satisfies Meta<ZenSidenav>;
|
|
46
|
+
|
|
47
|
+
export default meta;
|
|
48
|
+
|
|
49
|
+
const navTemplate = `
|
|
50
|
+
<zen-sidenav [collapsed]="collapsed()">
|
|
51
|
+
<div zenSidenavHeader>
|
|
52
|
+
<span style="font-weight: 700;">Zen</span>
|
|
53
|
+
</div>
|
|
54
|
+
<nav aria-label="Main navigation" zenSidenavContent>
|
|
55
|
+
<zen-sidenav-item [icon]="homeIcon" label="Home" routerLink="/home" />
|
|
56
|
+
<zen-sidenav-item [icon]="settingsIcon" label="Settings" routerLink="/settings" />
|
|
57
|
+
</nav>
|
|
58
|
+
<div zenSidenavFooter>
|
|
59
|
+
<button type="button" variant="ghost" zen-button>
|
|
60
|
+
<zen-icon [icon]="logoutIcon" [size]="22" />
|
|
61
|
+
@if (!collapsed()) {
|
|
62
|
+
<span class="sidenav-label">Logout</span>
|
|
63
|
+
}
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<zen-sidenav-toggle [collapsed]="collapsed()" (collapsedChange)="collapsed.set(!collapsed())" />
|
|
67
|
+
</zen-sidenav>
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
function navProps(collapsed: boolean): object {
|
|
71
|
+
return {
|
|
72
|
+
collapsed: signal(collapsed),
|
|
73
|
+
homeIcon: HomeIcon,
|
|
74
|
+
settingsIcon: Setting06Icon,
|
|
75
|
+
logoutIcon: Logout01Icon,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Default: Story = {
|
|
80
|
+
render: () => ({
|
|
81
|
+
props: navProps(false),
|
|
82
|
+
template: navTemplate,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const Collapsed: Story = {
|
|
87
|
+
render: () => ({
|
|
88
|
+
props: navProps(true),
|
|
89
|
+
template: navTemplate,
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const Mobile: Story = {
|
|
94
|
+
tags: ['!autodocs'],
|
|
95
|
+
globals: {
|
|
96
|
+
viewport: { value: 'mobile1', isRotated: false },
|
|
97
|
+
},
|
|
98
|
+
render: () => ({
|
|
99
|
+
props: navProps(false),
|
|
100
|
+
template: `
|
|
101
|
+
<div style="display: flex; flex-direction: column; min-height: 100dvh;">
|
|
102
|
+
<main style="flex: 1; padding: 1rem;">
|
|
103
|
+
<h1 style="font-size: 1.25rem;">Page content</h1>
|
|
104
|
+
<p>The sidenav below is an in-flow bottom bar — part of the layout, never covering content.</p>
|
|
105
|
+
</main>
|
|
106
|
+
${navTemplate}
|
|
107
|
+
</div>
|
|
108
|
+
`,
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ZenSidenav is a presentational responsive navigation shell. No business logic —
|
|
5
|
+
* nav items, auth and i18n live in the consumer.
|
|
6
|
+
*
|
|
7
|
+
* Slots:
|
|
8
|
+
* - `[zenSidenavHeader]` — brand + optional collapse toggle
|
|
9
|
+
* - `[zenSidenavContent]` — nav items (scrollable)
|
|
10
|
+
* - `[zenSidenavFooter]` — secondary actions (logout, profile)
|
|
11
|
+
* - `zen-sidenav-toggle` — floating desktop collapse toggle (direct
|
|
12
|
+
* child, straddles the inline-end border, hidden on mobile)
|
|
13
|
+
*
|
|
14
|
+
* Layout:
|
|
15
|
+
* ```
|
|
16
|
+
* Desktop, expanded (15rem rail):
|
|
17
|
+
* ┌────────────┬─────────────────┐
|
|
18
|
+
* │ Brand ○ │
|
|
19
|
+
* ├────────────┤ │
|
|
20
|
+
* │ ⌂ Home │ page content │
|
|
21
|
+
* │ ⚙ Settings │ │
|
|
22
|
+
* ├────────────┤ │
|
|
23
|
+
* │ ⎋ Logout │ │
|
|
24
|
+
* └────────────┴─────────────────┘
|
|
25
|
+
*
|
|
26
|
+
* Desktop, collapsed (4.5rem icon rail, labels hidden):
|
|
27
|
+
* ┌────┬──────────────────────────┐
|
|
28
|
+
* │ B ○ │
|
|
29
|
+
* ├────┤ │
|
|
30
|
+
* │ ⌂ │ page content │
|
|
31
|
+
* │ ⚙ │ │
|
|
32
|
+
* ├────┤ │
|
|
33
|
+
* │ ⎋ │ │
|
|
34
|
+
* └────┴──────────────────────────┘
|
|
35
|
+
*
|
|
36
|
+
* Mobile (<= 48rem, in-flow bottom bar, header hidden):
|
|
37
|
+
* ┌───────────────────────────┐
|
|
38
|
+
* │ page content │
|
|
39
|
+
* │ │
|
|
40
|
+
* ├───────────────────────────┤
|
|
41
|
+
* │ ⌂ Home │ ⚙ Settin… │ ⎋ │
|
|
42
|
+
* └───────────────────────────┘
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* Desktop: 15rem rail, 4.5rem icon-only when `collapsed` (labels hide,
|
|
46
|
+
* `title`/`aria-label` keep tooltips). Mobile (<= 48rem): in-flow bottom
|
|
47
|
+
* bar — horizontal scroll nav + pinned footer, header hidden. The bar is
|
|
48
|
+
* part of the layout, never fixed, so it cannot cover content.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```html
|
|
52
|
+
* <zen-sidenav [collapsed]="collapsed()">
|
|
53
|
+
* <div zenSidenavHeader>…</div>
|
|
54
|
+
* <nav zenSidenavContent>…</nav>
|
|
55
|
+
* <div zenSidenavFooter>…</div>
|
|
56
|
+
* </zen-sidenav>
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* ### CSS Custom Properties
|
|
60
|
+
*
|
|
61
|
+
* You can customize the component using CSS custom properties:
|
|
62
|
+
* ```css
|
|
63
|
+
* :root {
|
|
64
|
+
* --zen-sidenav-width: 15rem;
|
|
65
|
+
* --zen-sidenav-collapsed-width: 4.5rem;
|
|
66
|
+
* --zen-sidenav-padding-inline: 0.875rem;
|
|
67
|
+
* --zen-sidenav-padding-block: 1rem;
|
|
68
|
+
* --zen-sidenav-item-height: 2.5rem;
|
|
69
|
+
* --zen-sidenav-item-radius: 0.5rem;
|
|
70
|
+
* --zen-sidenav-gap: 0.5rem;
|
|
71
|
+
* --zen-sidenav-background: #fafafa;
|
|
72
|
+
* --zen-sidenav-border: #e0e0e0;
|
|
73
|
+
* --zen-sidenav-hover-background: #eee;
|
|
74
|
+
* --zen-sidenav-active-background: #e0e0e0;
|
|
75
|
+
* --zen-sidenav-focus-color: #2563eb;
|
|
76
|
+
* }
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* @author Konrad Stępień
|
|
80
|
+
* @license {@link https://github.com/kstepien3/ng-zen/blob/master/LICENSE|BSD-2-Clause}
|
|
81
|
+
* @see [GitHub](https://github.com/kstepien3/ng-zen)
|
|
82
|
+
*/
|
|
83
|
+
@Component({
|
|
84
|
+
selector: 'zen-sidenav',
|
|
85
|
+
templateUrl: './sidenav.html',
|
|
86
|
+
styleUrl: './sidenav.scss',
|
|
87
|
+
host: {
|
|
88
|
+
'[attr.data-collapsed]': 'collapsed()',
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
export class ZenSidenav {
|
|
92
|
+
/**
|
|
93
|
+
* Whether the desktop sidenav is collapsed.
|
|
94
|
+
*
|
|
95
|
+
* The component does not manage this state itself.
|
|
96
|
+
* The parent owns the state and may provide its own toggle control.
|
|
97
|
+
*/
|
|
98
|
+
readonly collapsed = input(false);
|
|
99
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: center;
|
|
5
|
+
flex-shrink: 0;
|
|
6
|
+
|
|
7
|
+
// Floating edge affordance against the nearest positioned ancestor (zen-sidenav host)
|
|
8
|
+
position: absolute;
|
|
9
|
+
inset-block-start: 1rem;
|
|
10
|
+
inset-inline-end: -0.75rem;
|
|
11
|
+
z-index: 1;
|
|
12
|
+
|
|
13
|
+
@media (width <= 48rem) {
|
|
14
|
+
display: none;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
:host button {
|
|
19
|
+
inline-size: 1.5rem;
|
|
20
|
+
block-size: 1.5rem;
|
|
21
|
+
min-inline-size: 1.5rem;
|
|
22
|
+
padding: 0;
|
|
23
|
+
border-radius: 999px;
|
|
24
|
+
background: var(--zen-sidenav-background);
|
|
25
|
+
border: 1px solid var(--zen-sidenav-border);
|
|
26
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Component, input, output } from '@angular/core';
|
|
2
|
+
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from '@hugeicons/core-free-icons';
|
|
3
|
+
|
|
4
|
+
import { ZenButton } from '../../button';
|
|
5
|
+
import { IconSvgObject, ZenIcon } from '../../icon';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Accessible toggle button for collapsing/expanding the sidenav.
|
|
9
|
+
*
|
|
10
|
+
* Project as a direct child of `zen-sidenav` — it positions itself
|
|
11
|
+
* straddling the inline-end border and hides itself on mobile.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```html
|
|
15
|
+
* <zen-sidenav [collapsed]="collapsed()">
|
|
16
|
+
* <zen-sidenav-toggle [collapsed]="collapsed()" (collapsedChange)="collapsed.set(!collapsed())" />
|
|
17
|
+
* </zen-sidenav>
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
@Component({
|
|
21
|
+
selector: 'zen-sidenav-toggle',
|
|
22
|
+
template: `
|
|
23
|
+
<button
|
|
24
|
+
size="sm"
|
|
25
|
+
type="button"
|
|
26
|
+
variant="ghost"
|
|
27
|
+
zen-button
|
|
28
|
+
[attr.aria-expanded]="!collapsed()"
|
|
29
|
+
[attr.aria-label]="collapsed() ? expandLabel() : collapseLabel()"
|
|
30
|
+
(click)="collapsedChange.emit()"
|
|
31
|
+
>
|
|
32
|
+
<zen-icon [icon]="collapsed() ? expandIcon() : collapseIcon()" [size]="20" />
|
|
33
|
+
</button>
|
|
34
|
+
`,
|
|
35
|
+
styleUrl: './sidenav-toggle.scss',
|
|
36
|
+
imports: [ZenIcon, ZenButton],
|
|
37
|
+
})
|
|
38
|
+
export class ZenSidenavToggle {
|
|
39
|
+
/** Current collapsed state of the parent sidenav. Decides icon and label. */
|
|
40
|
+
readonly collapsed = input.required<boolean>();
|
|
41
|
+
/** Emitted when the toggle is clicked. The parent owns the state. */
|
|
42
|
+
readonly collapsedChange = output<void>();
|
|
43
|
+
|
|
44
|
+
/** Icon shown when expanded (clicking collapses). */
|
|
45
|
+
readonly collapseIcon = input<IconSvgObject>(PanelLeftOpenIcon);
|
|
46
|
+
/** Icon shown when collapsed (clicking expands). */
|
|
47
|
+
readonly expandIcon = input<IconSvgObject>(PanelLeftCloseIcon);
|
|
48
|
+
/** Accessible label of the collapse action. */
|
|
49
|
+
readonly collapseLabel = input('Collapse sidenav');
|
|
50
|
+
/** Accessible label of the expand action. */
|
|
51
|
+
readonly expandLabel = input('Expand sidenav');
|
|
52
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../../../src/schematics/ui/schema.ts"],"names":[],"mappings":"","sourcesContent":["import { Path } from '@angular-devkit/core';\n\nexport interface GeneratorSchemaBase {\n currentDirectory: Path;\n path?: Path;\n stories: boolean;\n project?: string;\n}\n\nexport type UiType =\n | 'alert'\n | 'avatar'\n | 'badge'\n | 'button'\n | 'card'\n | 'checkbox'\n | 'dialog'\n | 'divider'\n | 'drawer'\n | 'form-control'\n | 'icon'\n | 'input'\n | 'pin'\n | 'popover'\n | 'radio'\n | 'skeleton'\n | 'switch'\n | 'textarea';\n\nexport interface Schema extends GeneratorSchemaBase {\n ui: UiType[];\n}\n"]}
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../../../src/schematics/ui/schema.ts"],"names":[],"mappings":"","sourcesContent":["import { Path } from '@angular-devkit/core';\n\nexport interface GeneratorSchemaBase {\n currentDirectory: Path;\n path?: Path;\n stories: boolean;\n project?: string;\n}\n\nexport type UiType =\n | 'alert'\n | 'avatar'\n | 'badge'\n | 'button'\n | 'card'\n | 'checkbox'\n | 'dialog'\n | 'divider'\n | 'drawer'\n | 'form-control'\n | 'icon'\n | 'input'\n | 'pin'\n | 'popover'\n | 'radio'\n | 'sidenav'\n | 'skeleton'\n | 'switch'\n | 'textarea';\n\nexport interface Schema extends GeneratorSchemaBase {\n ui: UiType[];\n}\n"]}
|