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.
- package/.claude/skills/maverick-wave/SKILL.md +208 -0
- package/.claude/skills/maverick-wave/examples/angular-form.md +275 -0
- package/.claude/skills/maverick-wave/examples/angular-list-page.md +304 -0
- package/.claude/skills/maverick-wave/examples/angular-services.md +348 -0
- package/.claude/skills/maverick-wave/examples/static-landing-page.md +362 -0
- package/.claude/skills/maverick-wave/references/components.md +812 -0
- package/.claude/skills/maverick-wave/references/forms.md +287 -0
- package/.claude/skills/maverick-wave/references/javascript.md +70 -0
- package/.claude/skills/maverick-wave/references/layout.md +300 -0
- package/.claude/skills/maverick-wave/references/theming.md +192 -0
- package/.github/workflows/ci.yml +41 -0
- package/CHANGELOG.md +13 -0
- package/README.md +115 -21
- package/index.html +15 -9
- package/maverick-wave.min.css +1 -1
- package/maverick-wave.min.js +1 -1
- package/package.json +3 -1
- package/scripts/verify.js +137 -0
- package/src/js/main.js +23 -11
- package/src/partials/accordions-container.html +22 -27
- package/src/partials/alerts-container.html +26 -0
- package/src/partials/avatars-container.html +22 -0
- package/src/partials/buttons-container.html +11 -15
- package/src/partials/cards-container.html +8 -8
- package/src/partials/colors-container.html +33 -20
- package/src/partials/documentation-container.html +3 -0
- package/src/partials/form-field-container.html +16 -12
- package/src/partials/get-started-container.html +93 -35
- package/src/partials/header-utilities-container.html +64 -6
- package/src/partials/input-group-container.html +5 -3
- package/src/partials/page-header-container.html +78 -0
- package/src/partials/tables-container.html +130 -0
- package/src/partials/tabs-container.html +20 -16
- package/src/partials/tags-container.html +55 -1
- package/src/scss/abstracts/_index.scss +4 -1
- package/src/scss/abstracts/_variables.scss +128 -106
- package/src/scss/base/_base.scss +32 -136
- package/src/scss/base/_reset.scss +1 -1
- package/src/scss/base/_typography.scss +0 -92
- package/src/scss/components/_accordions.scss +3 -3
- package/src/scss/components/_avatars.scss +28 -1
- package/src/scss/components/_blog-post.scss +1 -1
- package/src/scss/components/_button-bar.scss +6 -6
- package/src/scss/components/_buttons.scss +33 -10
- package/src/scss/components/_cards.scss +7 -7
- package/src/scss/components/_coming-soon.scss +4 -4
- package/src/scss/components/_gallery.scss +2 -2
- package/src/scss/components/_index.scss +1 -0
- package/src/scss/components/_info.scss +20 -3
- package/src/scss/components/_lists.scss +11 -10
- package/src/scss/components/_modals.scss +10 -3
- package/src/scss/components/_pagination.scss +3 -3
- package/src/scss/components/_panels.scss +4 -4
- package/src/scss/components/_progress.scss +1 -1
- package/src/scss/components/_ratings.scss +1 -1
- package/src/scss/components/_spinners.scss +2 -2
- package/src/scss/components/_stepper.scss +3 -2
- package/src/scss/components/_tables.scss +42 -1
- package/src/scss/components/_tabs.scss +5 -5
- package/src/scss/components/_tags.scss +43 -27
- package/src/scss/components/_tiles.scss +3 -3
- package/src/scss/components/_timelines.scss +2 -2
- package/src/scss/components/_toasts.scss +92 -0
- package/src/scss/form-elements/_form.scss +0 -1
- package/src/scss/form-elements/_input.scss +24 -0
- package/src/scss/form-elements/_toggle.scss +1 -1
- package/src/scss/layout/_footer.scss +4 -4
- package/src/scss/layout/_header.scss +78 -4
- package/src/scss/layout/_index.scss +1 -0
- package/src/scss/layout/_main.scss +1 -1
- package/src/scss/layout/_page-header.scss +48 -0
- package/src/scss/layout/_section.scss +15 -14
- package/src/scss/main.scss +4 -0
- package/src/scss/utilities/_flex.scss +4 -0
- package/src/scss/utilities/_index.scss +1 -0
- package/src/scss/utilities/_text.scss +105 -0
- 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
|
+
𝗫
|
|
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
|
+
𝗫
|
|
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.
|