maverick-wave 3.3.0 → 3.3.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.
Files changed (77) hide show
  1. package/.claude/skills/maverick-wave/SKILL.md +208 -0
  2. package/.claude/skills/maverick-wave/examples/angular-form.md +275 -0
  3. package/.claude/skills/maverick-wave/examples/angular-list-page.md +304 -0
  4. package/.claude/skills/maverick-wave/examples/angular-services.md +348 -0
  5. package/.claude/skills/maverick-wave/examples/static-landing-page.md +362 -0
  6. package/.claude/skills/maverick-wave/references/components.md +812 -0
  7. package/.claude/skills/maverick-wave/references/forms.md +287 -0
  8. package/.claude/skills/maverick-wave/references/javascript.md +70 -0
  9. package/.claude/skills/maverick-wave/references/layout.md +300 -0
  10. package/.claude/skills/maverick-wave/references/theming.md +192 -0
  11. package/.github/workflows/ci.yml +41 -0
  12. package/CHANGELOG.md +13 -0
  13. package/README.md +115 -21
  14. package/index.html +15 -9
  15. package/maverick-wave.min.css +1 -1
  16. package/maverick-wave.min.js +1 -1
  17. package/package.json +3 -1
  18. package/scripts/verify.js +137 -0
  19. package/src/js/main.js +23 -11
  20. package/src/partials/accordions-container.html +22 -27
  21. package/src/partials/alerts-container.html +26 -0
  22. package/src/partials/avatars-container.html +22 -0
  23. package/src/partials/buttons-container.html +11 -15
  24. package/src/partials/cards-container.html +8 -8
  25. package/src/partials/colors-container.html +33 -20
  26. package/src/partials/documentation-container.html +3 -0
  27. package/src/partials/form-field-container.html +16 -12
  28. package/src/partials/get-started-container.html +93 -35
  29. package/src/partials/header-utilities-container.html +64 -6
  30. package/src/partials/input-group-container.html +5 -3
  31. package/src/partials/page-header-container.html +78 -0
  32. package/src/partials/tables-container.html +130 -0
  33. package/src/partials/tabs-container.html +20 -16
  34. package/src/partials/tags-container.html +55 -1
  35. package/src/scss/abstracts/_index.scss +4 -1
  36. package/src/scss/abstracts/_variables.scss +128 -106
  37. package/src/scss/base/_base.scss +32 -136
  38. package/src/scss/base/_reset.scss +1 -1
  39. package/src/scss/base/_typography.scss +0 -92
  40. package/src/scss/components/_accordions.scss +3 -3
  41. package/src/scss/components/_avatars.scss +28 -1
  42. package/src/scss/components/_blog-post.scss +1 -1
  43. package/src/scss/components/_button-bar.scss +6 -6
  44. package/src/scss/components/_buttons.scss +33 -10
  45. package/src/scss/components/_cards.scss +7 -7
  46. package/src/scss/components/_coming-soon.scss +4 -4
  47. package/src/scss/components/_gallery.scss +2 -2
  48. package/src/scss/components/_index.scss +1 -0
  49. package/src/scss/components/_info.scss +20 -3
  50. package/src/scss/components/_lists.scss +11 -10
  51. package/src/scss/components/_modals.scss +10 -3
  52. package/src/scss/components/_pagination.scss +3 -3
  53. package/src/scss/components/_panels.scss +4 -4
  54. package/src/scss/components/_progress.scss +1 -1
  55. package/src/scss/components/_ratings.scss +1 -1
  56. package/src/scss/components/_spinners.scss +2 -2
  57. package/src/scss/components/_stepper.scss +3 -2
  58. package/src/scss/components/_tables.scss +42 -1
  59. package/src/scss/components/_tabs.scss +5 -5
  60. package/src/scss/components/_tags.scss +43 -27
  61. package/src/scss/components/_tiles.scss +3 -3
  62. package/src/scss/components/_timelines.scss +2 -2
  63. package/src/scss/components/_toasts.scss +92 -0
  64. package/src/scss/form-elements/_form.scss +0 -1
  65. package/src/scss/form-elements/_input.scss +24 -0
  66. package/src/scss/form-elements/_toggle.scss +1 -1
  67. package/src/scss/layout/_footer.scss +4 -4
  68. package/src/scss/layout/_header.scss +78 -4
  69. package/src/scss/layout/_index.scss +1 -0
  70. package/src/scss/layout/_main.scss +1 -1
  71. package/src/scss/layout/_page-header.scss +48 -0
  72. package/src/scss/layout/_section.scss +15 -14
  73. package/src/scss/main.scss +4 -0
  74. package/src/scss/utilities/_flex.scss +4 -0
  75. package/src/scss/utilities/_index.scss +1 -0
  76. package/src/scss/utilities/_text.scss +105 -0
  77. package/.claude/commands/mw.md +0 -595
@@ -0,0 +1,304 @@
1
+ # Example: list page with filters, table, modal and toasts
2
+
3
+ A complete application page built only from framework classes: page header with
4
+ counters and actions, a filter panel, a data table with status chips and a
5
+ sticky header, loading and empty states, a delete confirmation modal and a toast
6
+ stack.
7
+
8
+ The Angular parts are signals, the new control flow and `inject()` - swap them
9
+ for any other framework, the markup does not change.
10
+
11
+ ## Component
12
+
13
+ ```ts
14
+ import {
15
+ ChangeDetectionStrategy,
16
+ Component,
17
+ computed,
18
+ inject,
19
+ signal,
20
+ } from '@angular/core';
21
+ import { CurrencyPipe, DatePipe, TitleCasePipe } from '@angular/common';
22
+ import { FormsModule } from '@angular/forms';
23
+ import { RouterLink } from '@angular/router';
24
+ import { InvoiceService } from './invoice.service';
25
+ import { ToastService } from '../shared/toast.service';
26
+
27
+ type Status = 'paid' | 'open' | 'cancelled' | 'draft';
28
+
29
+ interface Invoice {
30
+ id: string;
31
+ number: string;
32
+ customer: string;
33
+ issuedAt: Date;
34
+ total: number;
35
+ status: Status;
36
+ }
37
+
38
+ @Component({
39
+ selector: 'app-invoice-list',
40
+ imports: [FormsModule, RouterLink, CurrencyPipe, DatePipe, TitleCasePipe],
41
+ templateUrl: './invoice-list.component.html',
42
+ changeDetection: ChangeDetectionStrategy.OnPush,
43
+ })
44
+ export class InvoiceListComponent {
45
+ private readonly invoices = inject(InvoiceService);
46
+ private readonly toasts = inject(ToastService);
47
+
48
+ protected readonly loading = this.invoices.loading;
49
+ protected readonly search = signal('');
50
+ protected readonly status = signal<Status | 'all'>('all');
51
+ protected readonly toDelete = signal<Invoice | null>(null);
52
+
53
+ protected readonly filtered = computed(() => {
54
+ const term = this.search().trim().toLowerCase();
55
+ const status = this.status();
56
+ return this.invoices
57
+ .all()
58
+ .filter(
59
+ (invoice) =>
60
+ (status === 'all' || invoice.status === status) &&
61
+ (term === '' ||
62
+ invoice.number.toLowerCase().includes(term) ||
63
+ invoice.customer.toLowerCase().includes(term))
64
+ );
65
+ });
66
+
67
+ protected readonly drafts = computed(
68
+ () => this.invoices.all().filter((i) => i.status === 'draft').length
69
+ );
70
+
71
+ // Status → tag variant. Keep this mapping in TS, not in the template -
72
+ // it is the only place that knows which colour means what.
73
+ protected readonly tagClass: Record<Status, string> = {
74
+ paid: 'mw-tag-success',
75
+ open: 'mw-tag-warning',
76
+ cancelled: 'mw-tag-danger',
77
+ draft: 'mw-tag-muted',
78
+ };
79
+
80
+ protected resetFilters(): void {
81
+ this.search.set('');
82
+ this.status.set('all');
83
+ }
84
+
85
+ protected confirmDelete(): void {
86
+ const invoice = this.toDelete();
87
+ if (!invoice) return;
88
+
89
+ this.invoices.remove(invoice.id).subscribe({
90
+ next: () => {
91
+ this.toasts.success(`Invoice ${invoice.number} deleted.`);
92
+ this.toDelete.set(null);
93
+ },
94
+ error: () => this.toasts.error('Deleting failed. Please try again.'),
95
+ });
96
+ }
97
+ }
98
+ ```
99
+
100
+ ## Template
101
+
102
+ ```html
103
+ <!-- Page header: title, counters, actions -->
104
+ <header class="mw-page-header">
105
+ <div>
106
+ <h1>Invoices</h1>
107
+ <div class="mw-meta-header">
108
+ <div class="mw-meta-item">
109
+ <i class="fas fa-list"></i><span>Total: {{ filtered().length }}</span>
110
+ </div>
111
+ <div class="mw-meta-item">
112
+ <i class="fas fa-pen"></i><span>Drafts: {{ drafts() }}</span>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ <div class="mw-page-header-actions">
118
+ <button
119
+ type="button"
120
+ class="mw-btn mw-btn-outline mw-btn-sm"
121
+ (click)="resetFilters()"
122
+ >
123
+ <i class="fas fa-rotate-left"></i> Reset
124
+ </button>
125
+ <button
126
+ type="button"
127
+ class="mw-btn mw-btn-primary mw-btn-sm"
128
+ routerLink="new"
129
+ >
130
+ <i class="fas fa-plus"></i> New invoice
131
+ </button>
132
+ </div>
133
+ </header>
134
+
135
+ <!-- Filter bar -->
136
+ <div class="mw-panel mw-mb-6">
137
+ <h4 class="mw-panel-header">Filter</h4>
138
+ <div class="mw-panel-body">
139
+ <div class="mw-form">
140
+ <div class="mw-grid-2">
141
+ <div class="mw-field">
142
+ <label class="mw-field-label" for="search">Search</label>
143
+ <div class="mw-input-group">
144
+ <span class="mw-input-group-prefix"
145
+ ><i class="fas fa-search"></i
146
+ ></span>
147
+ <input
148
+ id="search"
149
+ type="text"
150
+ class="mw-input"
151
+ placeholder="Number or customer…"
152
+ [ngModel]="search()"
153
+ (ngModelChange)="search.set($event)"
154
+ />
155
+ </div>
156
+ </div>
157
+
158
+ <div class="mw-field">
159
+ <label class="mw-field-label" for="status">Status</label>
160
+ <select
161
+ id="status"
162
+ class="mw-select"
163
+ [ngModel]="status()"
164
+ (ngModelChange)="status.set($event)"
165
+ >
166
+ <option value="all">All</option>
167
+ <option value="open">Open</option>
168
+ <option value="paid">Paid</option>
169
+ <option value="cancelled">Cancelled</option>
170
+ <option value="draft">Draft</option>
171
+ </select>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ @if (loading()) {
179
+ <!-- Loading -->
180
+ <div class="mw-skeleton-card">
181
+ <span class="mw-skeleton mw-skeleton-title"></span>
182
+ <span class="mw-skeleton mw-skeleton-text"></span>
183
+ <span class="mw-skeleton mw-skeleton-text"></span>
184
+ <span class="mw-skeleton mw-skeleton-text"></span>
185
+ </div>
186
+ } @else if (filtered().length === 0) {
187
+ <!-- Empty -->
188
+ <div class="mw-empty-state mw-empty-state-primary">
189
+ <div class="mw-empty-state-icon"><i class="fas fa-file-invoice"></i></div>
190
+ <p class="mw-empty-state-title">No invoices found</p>
191
+ <p class="mw-empty-state-desc">Adjust the filters or create a new invoice.</p>
192
+ <button type="button" class="mw-btn mw-btn-outline" (click)="resetFilters()">
193
+ Reset filters
194
+ </button>
195
+ </div>
196
+ } @else {
197
+ <!-- Table -->
198
+ <div class="mw-table-responsive-scroll" style="--mw-table-scroll-height: 520px">
199
+ <table class="mw-table mw-table-subtle mw-table-sticky-head mw-table-cards">
200
+ <thead>
201
+ <tr>
202
+ <th>Number</th>
203
+ <th>Customer</th>
204
+ <th>Date</th>
205
+ <th>Status</th>
206
+ <th class="mw-text-numeric">Total</th>
207
+ <th></th>
208
+ </tr>
209
+ </thead>
210
+ <tbody>
211
+ @for (invoice of filtered(); track invoice.id) {
212
+ <tr>
213
+ <td data-label="Number">{{ invoice.number }}</td>
214
+ <td data-label="Customer">{{ invoice.customer }}</td>
215
+ <td data-label="Date">{{ invoice.issuedAt | date: 'dd.MM.yyyy' }}</td>
216
+ <td data-label="Status">
217
+ <span class="mw-tag" [class]="tagClass[invoice.status]">
218
+ {{ invoice.status | titlecase }}
219
+ </span>
220
+ </td>
221
+ <td data-label="Total" class="mw-text-numeric">
222
+ {{ invoice.total | currency: 'EUR' : 'symbol' : '1.2-2' }}
223
+ </td>
224
+ <td data-label="">
225
+ <div class="mw-d-flex mw-gap-2 mw-justify-end">
226
+ <button
227
+ type="button"
228
+ class="mw-btn-mini mw-btn-mini-primary"
229
+ data-tooltip="Edit"
230
+ [routerLink]="[invoice.id]"
231
+ >
232
+ <i class="fas fa-pen"></i>
233
+ </button>
234
+ <button
235
+ type="button"
236
+ class="mw-btn-mini mw-btn-mini-danger"
237
+ data-tooltip="Delete"
238
+ (click)="toDelete.set(invoice)"
239
+ >
240
+ <i class="fas fa-trash"></i>
241
+ </button>
242
+ </div>
243
+ </td>
244
+ </tr>
245
+ }
246
+ </tbody>
247
+ </table>
248
+ </div>
249
+ }
250
+
251
+ <!-- Delete confirmation -->
252
+ <div class="mw-modal-overlay" [class.mw-modal-open]="toDelete() !== null">
253
+ <div class="mw-modal mw-modal-sm">
254
+ <div class="mw-modal-header">
255
+ <h4 class="mw-modal-title">Delete invoice</h4>
256
+ <button type="button" class="mw-modal-close" (click)="toDelete.set(null)">
257
+ &#120299;
258
+ </button>
259
+ </div>
260
+ <div class="mw-modal-body">
261
+ <p class="mw-text-center">
262
+ <strong>Delete {{ toDelete()?.number }}?</strong>
263
+ </p>
264
+ <p class="mw-text-center mw-text-muted">This action cannot be undone.</p>
265
+ </div>
266
+ <div class="mw-modal-footer">
267
+ <button
268
+ type="button"
269
+ class="mw-btn mw-btn-outline"
270
+ (click)="toDelete.set(null)"
271
+ >
272
+ Cancel
273
+ </button>
274
+ <button
275
+ type="button"
276
+ class="mw-btn mw-btn-danger"
277
+ (click)="confirmDelete()"
278
+ >
279
+ <i class="fas fa-trash"></i> Delete
280
+ </button>
281
+ </div>
282
+ </div>
283
+ <div class="mw-modal-backdrop" (click)="toDelete.set(null)"></div>
284
+ </div>
285
+ ```
286
+
287
+ ## Notes
288
+
289
+ - **`mw-table-subtle` for data tables.** The default primary header bar is a
290
+ landing-page look; over 200 rows it shouts louder than the data.
291
+ - **Sticky needs a height cap.** `mw-table-sticky-head` only works inside
292
+ `mw-table-responsive-scroll`; the height comes from
293
+ `--mw-table-scroll-height` and can be set per table inline.
294
+ - **`mw-table-cards` needs `data-label` on every cell**, including the action
295
+ column (empty string is fine) - below `md` each row becomes a card and the
296
+ label is rendered as the cell heading.
297
+ - **`mw-text-numeric` on the header cell as well**, otherwise the column heading
298
+ sits left while the figures sit right.
299
+ - **`mw-tag`, not `mw-tags`**, for a single status chip in a cell.
300
+ - **`mw-btn-danger` for the destructive action** in the modal footer.
301
+ - The modal is always in the DOM and toggled through `mw-modal-open`; the body
302
+ scroll lock happens automatically via `body:has(.mw-modal-open)`.
303
+ - The toast stack lives in the app shell, not on this page - see
304
+ `angular-services.md`.
@@ -0,0 +1,348 @@
1
+ # Example: replacing `main.js` in a SPA
2
+
3
+ The shipped JavaScript must not be loaded in a SPA
4
+ (`references/javascript.md` explains why). These are the replacements - a
5
+ service or a component per behaviour, none of them longer than a screen.
6
+
7
+ ## Theme service
8
+
9
+ The framework only needs one class on `<body>`. Everything else - persistence,
10
+ the toggle UI, the initial value - belongs to the application.
11
+
12
+ ```ts
13
+ import { Injectable, effect, signal } from '@angular/core';
14
+
15
+ const STORAGE_KEY = 'mw-theme';
16
+
17
+ @Injectable({ providedIn: 'root' })
18
+ export class ThemeService {
19
+ readonly light = signal(localStorage.getItem(STORAGE_KEY) === 'light');
20
+
21
+ constructor() {
22
+ effect(() => {
23
+ const light = this.light();
24
+ document.body.classList.toggle('mw-theme-light', light);
25
+ localStorage.setItem(STORAGE_KEY, light ? 'light' : 'dark');
26
+ });
27
+ }
28
+
29
+ toggle(): void {
30
+ this.light.update((value) => !value);
31
+ }
32
+ }
33
+ ```
34
+
35
+ The framework's own toggle markup, driven by the service:
36
+
37
+ ```html
38
+ <div
39
+ class="mw-theme-toggle"
40
+ [class.active]="theme.light()"
41
+ (click)="theme.toggle()"
42
+ >
43
+ <div class="mw-theme-toggle-slider">
44
+ <div class="mw-theme-toggle-icon">
45
+ <i
46
+ class="fas"
47
+ [class.fa-sun]="theme.light()"
48
+ [class.fa-moon]="!theme.light()"
49
+ ></i>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ ```
54
+
55
+ If the stylesheet was compiled with `$mw-theme-mode: 'dark'` or `'light'`, the
56
+ class does nothing - hide the toggle in that case
57
+ (`getComputedStyle(document.documentElement).getPropertyValue('--mw-internal-theme-mode')`).
58
+
59
+ ## Toast service + outlet
60
+
61
+ ```ts
62
+ import { Injectable, signal } from '@angular/core';
63
+
64
+ export type ToastKind = 'success' | 'warning' | 'danger' | 'info';
65
+
66
+ export interface Toast {
67
+ id: number;
68
+ kind: ToastKind;
69
+ text: string;
70
+ }
71
+
72
+ @Injectable({ providedIn: 'root' })
73
+ export class ToastService {
74
+ private nextId = 0;
75
+ readonly toasts = signal<Toast[]>([]);
76
+
77
+ success(text: string) {
78
+ this.push('success', text);
79
+ }
80
+ warning(text: string) {
81
+ this.push('warning', text);
82
+ }
83
+ error(text: string) {
84
+ this.push('danger', text);
85
+ }
86
+ info(text: string) {
87
+ this.push('info', text);
88
+ }
89
+
90
+ dismiss(id: number): void {
91
+ this.toasts.update((list) => list.filter((toast) => toast.id !== id));
92
+ }
93
+
94
+ private push(kind: ToastKind, text: string): void {
95
+ const toast = { id: this.nextId++, kind, text };
96
+ this.toasts.update((list) => [...list, toast]);
97
+ setTimeout(() => this.dismiss(toast.id), 5000);
98
+ }
99
+ }
100
+ ```
101
+
102
+ ```ts
103
+ @Component({
104
+ selector: 'app-toast-outlet',
105
+ template: `
106
+ <div class="mw-toast-stack mw-toast-stack-top-right">
107
+ @for (toast of toasts.toasts(); track toast.id) {
108
+ <div class="mw-alert" [class]="'mw-alert-' + toast.kind">
109
+ <div class="mw-alert-icon">
110
+ <i class="fas" [class]="icon[toast.kind]"></i>
111
+ </div>
112
+ <div class="mw-alert-content">{{ toast.text }}</div>
113
+ <button
114
+ type="button"
115
+ class="mw-btn-mini mw-btn-mini-danger mw-alert-close"
116
+ (click)="toasts.dismiss(toast.id)"
117
+ >
118
+ <i class="fas fa-times"></i>
119
+ </button>
120
+ </div>
121
+ }
122
+ </div>
123
+ `,
124
+ changeDetection: ChangeDetectionStrategy.OnPush,
125
+ })
126
+ export class ToastOutletComponent {
127
+ protected readonly toasts = inject(ToastService);
128
+ protected readonly icon: Record<ToastKind, string> = {
129
+ success: 'fa-check-circle',
130
+ warning: 'fa-exclamation-triangle',
131
+ danger: 'fa-times-circle',
132
+ info: 'fa-info-circle',
133
+ };
134
+ }
135
+ ```
136
+
137
+ Place `<app-toast-outlet />` once in `app.component.html`. The stack is
138
+ `position: fixed` and lets clicks through everywhere except on a toast, so it
139
+ can sit anywhere in the tree. Auto-dismiss is the `setTimeout` above - the
140
+ framework does not provide one. Do not use `mw-alert-closed`; removing the item
141
+ from the signal is cleaner.
142
+
143
+ ## Modal
144
+
145
+ No service needed - the overlay is toggled by one class and the body scroll lock
146
+ is pure CSS (`body:has(.mw-modal-open)`).
147
+
148
+ ```ts
149
+ @Component({
150
+ selector: 'app-confirm-dialog',
151
+ template: `
152
+ <div class="mw-modal-overlay" [class.mw-modal-open]="open()">
153
+ <div class="mw-modal mw-modal-sm">
154
+ <div class="mw-modal-header">
155
+ <h4 class="mw-modal-title">{{ title() }}</h4>
156
+ <button
157
+ type="button"
158
+ class="mw-modal-close"
159
+ (click)="cancelled.emit()"
160
+ >
161
+ &#120299;
162
+ </button>
163
+ </div>
164
+ <div class="mw-modal-body"><ng-content /></div>
165
+ <div class="mw-modal-footer">
166
+ <button
167
+ type="button"
168
+ class="mw-btn mw-btn-outline"
169
+ (click)="cancelled.emit()"
170
+ >
171
+ Cancel
172
+ </button>
173
+ <button
174
+ type="button"
175
+ class="mw-btn mw-btn-danger"
176
+ (click)="confirmed.emit()"
177
+ >
178
+ Delete
179
+ </button>
180
+ </div>
181
+ </div>
182
+ <div class="mw-modal-backdrop" (click)="cancelled.emit()"></div>
183
+ </div>
184
+ `,
185
+ changeDetection: ChangeDetectionStrategy.OnPush,
186
+ })
187
+ export class ConfirmDialogComponent {
188
+ readonly open = input(false);
189
+ readonly title = input('Are you sure?');
190
+ readonly confirmed = output<void>();
191
+ readonly cancelled = output<void>();
192
+ }
193
+ ```
194
+
195
+ ## Accordion
196
+
197
+ `active` on the header **and** the content - no prefix.
198
+
199
+ ```ts
200
+ @Component({
201
+ selector: 'app-faq',
202
+ template: `
203
+ <div class="mw-accordion">
204
+ @for (item of items(); track item.id; let i = $index) {
205
+ <div class="mw-accordion-item">
206
+ <div
207
+ class="mw-accordion-header"
208
+ [class.active]="openIndex() === i"
209
+ (click)="toggle(i)"
210
+ >
211
+ <h3>{{ item.question }}</h3>
212
+ <i class="fas fa-chevron-down mw-accordion-icon"></i>
213
+ </div>
214
+ <div class="mw-accordion-content" [class.active]="openIndex() === i">
215
+ <div class="mw-accordion-content-inner">{{ item.answer }}</div>
216
+ </div>
217
+ </div>
218
+ }
219
+ </div>
220
+ `,
221
+ changeDetection: ChangeDetectionStrategy.OnPush,
222
+ })
223
+ export class FaqComponent {
224
+ readonly items =
225
+ input.required<{ id: string; question: string; answer: string }[]>();
226
+ protected readonly openIndex = signal(0);
227
+
228
+ protected toggle(index: number): void {
229
+ this.openIndex.update((current) => (current === index ? -1 : index));
230
+ }
231
+ }
232
+ ```
233
+
234
+ ## Tabs
235
+
236
+ `data-tab`/`id` pairing is only for the shipped script - bind `active` instead.
237
+
238
+ ```html
239
+ <div class="mw-tabs mw-tabs-pills">
240
+ <div class="mw-tabs-nav">
241
+ @for (tab of tabs; track tab.key) {
242
+ <div
243
+ class="mw-tabs-nav-item"
244
+ [class.active]="active() === tab.key"
245
+ (click)="active.set(tab.key)"
246
+ >
247
+ {{ tab.label }}
248
+ </div>
249
+ }
250
+ </div>
251
+ <div class="mw-tabs-content">
252
+ <div class="mw-tabs-panel" [class.active]="active() === 'details'">…</div>
253
+ <div class="mw-tabs-panel" [class.active]="active() === 'history'">…</div>
254
+ </div>
255
+ </div>
256
+ ```
257
+
258
+ ## Header with mobile navigation
259
+
260
+ The drawer needs `open` on **both** the burger button and the navbar. Close it
261
+ on every navigation.
262
+
263
+ ```ts
264
+ @Component({
265
+ selector: 'app-header',
266
+ imports: [RouterLink, RouterLinkActive],
267
+ template: `
268
+ <header class="mw-header">
269
+ <div class="mw-container">
270
+ <div class="mw-logo">
271
+ <button type="button" routerLink="/">
272
+ <img src="/logo.svg" alt="Home" />
273
+ </button>
274
+ </div>
275
+
276
+ <div class="mw-header-actions">
277
+ <nav class="mw-navbar mw-navbar-medium" [class.open]="menuOpen()">
278
+ <ul class="mw-navbar-list">
279
+ @for (item of nav; track item.path) {
280
+ <li class="mw-navbar-item">
281
+ <a
282
+ class="mw-navbar-link"
283
+ [routerLink]="item.path"
284
+ routerLinkActive="active"
285
+ >
286
+ {{ item.label }}
287
+ </a>
288
+ </li>
289
+ }
290
+ </ul>
291
+ </nav>
292
+
293
+ <button class="mw-profile-btn" type="button" (click)="openProfile()">
294
+ <span class="mw-avatar mw-avatar-initials mw-avatar-xs">{{
295
+ initials()
296
+ }}</span>
297
+ <span class="mw-profile-btn-name">{{ user().name }}</span>
298
+ </button>
299
+
300
+ <div
301
+ class="mw-menu-btn"
302
+ [class.open]="menuOpen()"
303
+ (click)="menuOpen.set(!menuOpen())"
304
+ >
305
+ <div class="mw-menu-btn-burger"></div>
306
+ </div>
307
+ </div>
308
+ </div>
309
+ </header>
310
+ `,
311
+ changeDetection: ChangeDetectionStrategy.OnPush,
312
+ })
313
+ export class HeaderComponent {
314
+ protected readonly menuOpen = signal(false);
315
+
316
+ constructor(router: Router) {
317
+ router.events
318
+ .pipe(
319
+ filter((e) => e instanceof NavigationEnd),
320
+ takeUntilDestroyed()
321
+ )
322
+ .subscribe(() => this.menuOpen.set(false));
323
+ }
324
+ }
325
+ ```
326
+
327
+ `routerLinkActive="active"` replaces the scroll spy - the framework only cares
328
+ about the class name.
329
+
330
+ ## Progress bar
331
+
332
+ ```html
333
+ <div class="mw-progress-container">
334
+ <div class="mw-progress-barinfo">
335
+ <span class="mw-progress-label">Upload</span>
336
+ <span class="mw-progress-value mw-progress-percent">{{ percent() }}</span>
337
+ </div>
338
+ <div class="mw-progress-bar">
339
+ <div
340
+ class="mw-progress-fill mw-progress-success"
341
+ [style.width.%]="percent()"
342
+ ></div>
343
+ </div>
344
+ </div>
345
+ ```
346
+
347
+ `mw-progress-percent` only appends the `%` sign - the number comes from you, and
348
+ so does the width.