@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 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.6.0-next.1",
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,10 @@
1
+ :host {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 0.25rem;
5
+
6
+ @media (width <= 48rem) {
7
+ flex-direction: row;
8
+ align-items: stretch;
9
+ }
10
+ }
@@ -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,8 @@
1
+ :host {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: space-between;
5
+ gap: 0.5rem;
6
+ min-block-size: var(--zen-sidenav-item-height);
7
+ min-inline-size: 0;
8
+ }
@@ -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,9 @@
1
+ <a
2
+ routerLinkActive="active"
3
+ [attr.aria-label]="ariaLabel() || label()"
4
+ [attr.title]="ariaLabel() || label()"
5
+ [routerLink]="routerLink()"
6
+ >
7
+ <zen-icon [icon]="icon()" [size]="size()" />
8
+ <span class="sidenav-label">{{ label() }}</span>
9
+ </a>
@@ -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"]}
@@ -51,6 +51,7 @@
51
51
  "pin",
52
52
  "popover",
53
53
  "radio",
54
+ "sidenav",
54
55
  "skeleton",
55
56
  "switch",
56
57
  "textarea"
@@ -23,6 +23,7 @@ export type UiType =
23
23
  | 'pin'
24
24
  | 'popover'
25
25
  | 'radio'
26
+ | 'sidenav'
26
27
  | 'skeleton'
27
28
  | 'switch'
28
29
  | 'textarea';