maverick-wave 5.29.0 → 5.31.0
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 +2 -2
- package/maverick-wave.min.css +2 -2
- package/package.json +8 -2
- package/src/scss/components/_avatars.scss +35 -3
- package/src/scss/components/_dropdown.scss +24 -1
- package/src/scss/components/_lang-switch.scss +4 -8
- package/src/scss/components/_modals.scss +33 -5
- package/.claude/settings.local.json +0 -43
- package/.claude/skills/mw-maverick-wave/SKILL.md +0 -532
- package/.claude/skills/mw-maverick-wave/examples/angular-form.md +0 -279
- package/.claude/skills/mw-maverick-wave/examples/angular-list-page.md +0 -309
- package/.claude/skills/mw-maverick-wave/examples/angular-services.md +0 -538
- package/.claude/skills/mw-maverick-wave/examples/static-landing-page.md +0 -530
- package/.claude/skills/mw-maverick-wave/references/components.md +0 -2169
- package/.claude/skills/mw-maverick-wave/references/forms.md +0 -358
- package/.claude/skills/mw-maverick-wave/references/javascript.md +0 -164
- package/.claude/skills/mw-maverick-wave/references/layout.md +0 -764
- package/.claude/skills/mw-maverick-wave/references/theming.md +0 -528
- package/.github/workflows/ci.yml +0 -41
- package/.idea/codeStyles/Project.xml +0 -59
- package/.idea/codeStyles/codeStyleConfig.xml +0 -5
- package/.idea/maverick-wave.iml +0 -12
- package/.idea/modules.xml +0 -8
- package/.idea/prettier.xml +0 -6
- package/.idea/vcs.xml +0 -6
- package/.prettierignore +0 -8
- package/.prettierrc.json +0 -7
- package/CHANGELOG.md +0 -950
- package/CLAUDE.md +0 -60
- package/gulpfile.js +0 -167
- package/index.html +0 -1730
- package/release.sh +0 -31
- package/scripts/verify.js +0 -252
- package/src/assets/favicon/apple-touch-icon.png +0 -0
- package/src/assets/favicon/favicon-96x96.png +0 -0
- package/src/assets/favicon/favicon.svg +0 -1
- package/src/assets/favicon/site.webmanifest +0 -37
- package/src/assets/favicon/web-app-manifest-192x192.png +0 -0
- package/src/assets/favicon/web-app-manifest-512x512.png +0 -0
- package/src/assets/favicon.ico +0 -0
- package/src/assets/header-logo.svg +0 -24
- package/src/assets/images/gallery-city.svg +0 -49
- package/src/assets/images/gallery-desert.svg +0 -25
- package/src/assets/images/gallery-forest.svg +0 -46
- package/src/assets/images/gallery-lake.svg +0 -26
- package/src/assets/images/gallery-mountain.svg +0 -19
- package/src/assets/images/gallery-ocean.svg +0 -26
- package/src/assets/images/gallery-oldtown.svg +0 -41
- package/src/assets/images/gallery-snow.svg +0 -39
- package/src/assets/images/photo-balloon.svg +0 -35
- package/src/assets/images/photo-forest-path.svg +0 -31
- package/src/assets/images/photo-lighthouse.svg +0 -42
- package/src/assets/images/photo-palm.svg +0 -27
- package/src/assets/images/photo-peak.svg +0 -27
- package/src/assets/images/photo-tower.svg +0 -34
- package/src/assets/images/photo-waterfall.svg +0 -32
- package/src/assets/images/slider-field.svg +0 -124
- package/src/assets/images/slider-forest.svg +0 -33
- package/src/assets/images/slider-lake.svg +0 -35
- package/src/assets/images/slider-mountains.svg +0 -29
- package/src/assets/images/slider-trees.svg +0 -41
- package/src/assets/images/story-aurora.svg +0 -36
- package/src/assets/images/story-beach.svg +0 -34
- package/src/assets/images/story-stars.svg +0 -35
- package/src/assets/images/tile-analytics.svg +0 -22
- package/src/assets/images/tile-cloud.svg +0 -19
- package/src/assets/images/tile-ecommerce.svg +0 -14
- package/src/assets/images/tile-enterprise.svg +0 -22
- package/src/assets/images/tile-marketing.svg +0 -16
- package/src/assets/media/demo-chime.wav +0 -0
- package/src/assets/media/story-sunrise.jpg +0 -0
- package/src/assets/media/story-sunrise.mp4 +0 -0
- package/src/partials/accordions-container.html +0 -179
- package/src/partials/alerts-container.html +0 -151
- package/src/partials/announcement-container.html +0 -67
- package/src/partials/avatars-container.html +0 -229
- package/src/partials/badges-container.html +0 -114
- package/src/partials/blog-posts-container.html +0 -158
- package/src/partials/breadcrumbs-container.html +0 -126
- package/src/partials/button-bar-container.html +0 -175
- package/src/partials/buttons-container.html +0 -272
- package/src/partials/calendar-container.html +0 -203
- package/src/partials/cards-container.html +0 -727
- package/src/partials/code-container.html +0 -194
- package/src/partials/colors-container.html +0 -263
- package/src/partials/coming-soon-container.html +0 -10
- package/src/partials/contact-container.html +0 -85
- package/src/partials/content-slider-container.html +0 -36
- package/src/partials/divider-container.html +0 -70
- package/src/partials/documentation-container.html +0 -204
- package/src/partials/dropdown-container.html +0 -119
- package/src/partials/empty-state-container.html +0 -79
- package/src/partials/feed-container.html +0 -506
- package/src/partials/footer-container.html +0 -113
- package/src/partials/form-container.html +0 -262
- package/src/partials/form-elements-container.html +0 -616
- package/src/partials/form-field-container.html +0 -166
- package/src/partials/gallery-container.html +0 -62
- package/src/partials/get-started-container.html +0 -423
- package/src/partials/grid-container.html +0 -191
- package/src/partials/header-container.html +0 -69
- package/src/partials/header-utilities-container.html +0 -331
- package/src/partials/highlights-container.html +0 -71
- package/src/partials/history-container.html +0 -67
- package/src/partials/home-container.html +0 -31
- package/src/partials/html-lists-container.html +0 -63
- package/src/partials/info-container.html +0 -104
- package/src/partials/input-group-container.html +0 -299
- package/src/partials/item-lists-container.html +0 -368
- package/src/partials/kanban-container.html +0 -459
- package/src/partials/kbd-container.html +0 -63
- package/src/partials/leader-row-container.html +0 -139
- package/src/partials/login-container.html +0 -65
- package/src/partials/lunch-menu-container.html +0 -341
- package/src/partials/media-container.html +0 -27
- package/src/partials/meta-info-container.html +0 -21
- package/src/partials/modals-container.html +0 -199
- package/src/partials/mosaic-container.html +0 -173
- package/src/partials/page-header-container.html +0 -78
- package/src/partials/pagination-container.html +0 -64
- package/src/partials/palette-container.html +0 -480
- package/src/partials/panels-container.html +0 -126
- package/src/partials/parallax-container.html +0 -147
- package/src/partials/portrait-gallery-container.html +0 -175
- package/src/partials/preview-container.html +0 -136
- package/src/partials/pricing-container.html +0 -523
- package/src/partials/progress-container.html +0 -230
- package/src/partials/prose-container.html +0 -43
- package/src/partials/ratings-container.html +0 -104
- package/src/partials/section-head-container.html +0 -77
- package/src/partials/segmented-container.html +0 -66
- package/src/partials/skeleton-container.html +0 -87
- package/src/partials/spinners-container.html +0 -96
- package/src/partials/stepper-container.html +0 -111
- package/src/partials/stories-container.html +0 -182
- package/src/partials/tables-container.html +0 -309
- package/src/partials/tabs-container.html +0 -290
- package/src/partials/tags-container.html +0 -242
- package/src/partials/techstack-bucket-container.html +0 -187
- package/src/partials/testimonials-container.html +0 -88
- package/src/partials/tiles-container.html +0 -247
- package/src/partials/timelines-container.html +0 -196
- package/src/partials/typography-container.html +0 -74
- package/src/partials/utilities-container.html +0 -553
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
# Example: reactive form
|
|
2
|
-
|
|
3
|
-
Every control type in one form, with the error handling the framework expects:
|
|
4
|
-
`mw-field-has-error` on the wrapper, `mw-field-error` for the message. Nothing
|
|
5
|
-
reacts to `ng-invalid` on its own - the bindings below are the whole mechanism.
|
|
6
|
-
|
|
7
|
-
## Component
|
|
8
|
-
|
|
9
|
-
```ts
|
|
10
|
-
import {
|
|
11
|
-
ChangeDetectionStrategy,
|
|
12
|
-
Component,
|
|
13
|
-
inject,
|
|
14
|
-
signal,
|
|
15
|
-
} from '@angular/core';
|
|
16
|
-
import { TitleCasePipe } from '@angular/common';
|
|
17
|
-
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
18
|
-
|
|
19
|
-
@Component({
|
|
20
|
-
selector: 'app-project-form',
|
|
21
|
-
imports: [ReactiveFormsModule, TitleCasePipe],
|
|
22
|
-
templateUrl: './project-form.component.html',
|
|
23
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
24
|
-
})
|
|
25
|
-
export class ProjectFormComponent {
|
|
26
|
-
private readonly fb = inject(FormBuilder);
|
|
27
|
-
|
|
28
|
-
protected readonly saving = signal(false);
|
|
29
|
-
|
|
30
|
-
protected readonly form = this.fb.nonNullable.group({
|
|
31
|
-
name: ['', [Validators.required, Validators.maxLength(80)]],
|
|
32
|
-
email: ['', [Validators.required, Validators.email]],
|
|
33
|
-
budget: [''], // text + inputmode="decimal", parsed on submit
|
|
34
|
-
type: ['', Validators.required],
|
|
35
|
-
priority: ['normal'],
|
|
36
|
-
description: [''],
|
|
37
|
-
teamSize: [3],
|
|
38
|
-
notify: [true],
|
|
39
|
-
terms: [false, Validators.requiredTrue],
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
protected readonly f = this.form.controls;
|
|
43
|
-
|
|
44
|
-
// A control shows its error once the user has touched it
|
|
45
|
-
protected invalid(name: keyof typeof this.f): boolean {
|
|
46
|
-
const control = this.f[name];
|
|
47
|
-
return control.invalid && (control.touched || control.dirty);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
protected submit(): void {
|
|
51
|
-
if (this.form.invalid) {
|
|
52
|
-
this.form.markAllAsTouched();
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
this.saving.set(true);
|
|
56
|
-
// …persist, then this.saving.set(false)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Template
|
|
62
|
-
|
|
63
|
-
```html
|
|
64
|
-
<form class="mw-form" [formGroup]="form" (ngSubmit)="submit()">
|
|
65
|
-
<!-- Group: base data -->
|
|
66
|
-
<div class="mw-form-group">
|
|
67
|
-
<p class="mw-text-muted mw-mb-3">Base data</p>
|
|
68
|
-
|
|
69
|
-
<div class="mw-grid-2">
|
|
70
|
-
<div class="mw-field" [class.mw-field-has-error]="invalid('name')">
|
|
71
|
-
<label class="mw-field-label mw-required" for="name"
|
|
72
|
-
>Project name</label
|
|
73
|
-
>
|
|
74
|
-
<input id="name" type="text" class="mw-input" formControlName="name" />
|
|
75
|
-
@if (invalid('name')) {
|
|
76
|
-
<span class="mw-field-error">
|
|
77
|
-
<i class="fas fa-exclamation-circle"></i>
|
|
78
|
-
@if (f.name.hasError('required')) { A name is required. } @else { At
|
|
79
|
-
most 80 characters. }
|
|
80
|
-
</span>
|
|
81
|
-
} @else {
|
|
82
|
-
<span class="mw-field-hint">Shown in the project list.</span>
|
|
83
|
-
}
|
|
84
|
-
</div>
|
|
85
|
-
|
|
86
|
-
<div class="mw-field" [class.mw-field-has-error]="invalid('email')">
|
|
87
|
-
<label class="mw-field-label mw-required" for="email">Contact</label>
|
|
88
|
-
<div class="mw-input-group">
|
|
89
|
-
<span class="mw-input-group-prefix"
|
|
90
|
-
><i class="fas fa-envelope"></i
|
|
91
|
-
></span>
|
|
92
|
-
<input
|
|
93
|
-
id="email"
|
|
94
|
-
type="email"
|
|
95
|
-
class="mw-input"
|
|
96
|
-
formControlName="email"
|
|
97
|
-
/>
|
|
98
|
-
</div>
|
|
99
|
-
@if (invalid('email')) {
|
|
100
|
-
<span class="mw-field-error">
|
|
101
|
-
<i class="fas fa-exclamation-circle"></i> Please enter a valid email
|
|
102
|
-
address.
|
|
103
|
-
</span>
|
|
104
|
-
}
|
|
105
|
-
</div>
|
|
106
|
-
|
|
107
|
-
<!-- Money: text + inputmode, never type="number" -->
|
|
108
|
-
<div class="mw-field">
|
|
109
|
-
<label class="mw-field-label" for="budget">Budget</label>
|
|
110
|
-
<div class="mw-input-group">
|
|
111
|
-
<span class="mw-input-group-prefix"
|
|
112
|
-
><i class="fas fa-euro-sign"></i
|
|
113
|
-
></span>
|
|
114
|
-
<input
|
|
115
|
-
id="budget"
|
|
116
|
-
type="text"
|
|
117
|
-
inputmode="decimal"
|
|
118
|
-
class="mw-input mw-input-numeric"
|
|
119
|
-
placeholder="0,00"
|
|
120
|
-
formControlName="budget"
|
|
121
|
-
/>
|
|
122
|
-
<span class="mw-input-group-suffix">EUR</span>
|
|
123
|
-
</div>
|
|
124
|
-
<span class="mw-field-hint">Comma as decimal separator.</span>
|
|
125
|
-
</div>
|
|
126
|
-
|
|
127
|
-
<div class="mw-field" [class.mw-field-has-error]="invalid('type')">
|
|
128
|
-
<label class="mw-field-label mw-required" for="type">Type</label>
|
|
129
|
-
<select id="type" class="mw-select" formControlName="type">
|
|
130
|
-
<option value="" disabled>Choose…</option>
|
|
131
|
-
<option value="internal">Internal</option>
|
|
132
|
-
<option value="customer">Customer project</option>
|
|
133
|
-
</select>
|
|
134
|
-
@if (invalid('type')) {
|
|
135
|
-
<span class="mw-field-error">
|
|
136
|
-
<i class="fas fa-exclamation-circle"></i> Please choose a type.
|
|
137
|
-
</span>
|
|
138
|
-
}
|
|
139
|
-
</div>
|
|
140
|
-
|
|
141
|
-
<!-- Full width inside the two-column grid -->
|
|
142
|
-
<div class="mw-field" style="grid-column: 1 / -1">
|
|
143
|
-
<label class="mw-field-label" for="description">Description</label>
|
|
144
|
-
<textarea
|
|
145
|
-
id="description"
|
|
146
|
-
class="mw-textarea mw-textarea-resizable-vertical"
|
|
147
|
-
rows="4"
|
|
148
|
-
formControlName="description"
|
|
149
|
-
></textarea>
|
|
150
|
-
<span class="mw-field-hint">Markdown is allowed.</span>
|
|
151
|
-
</div>
|
|
152
|
-
</div>
|
|
153
|
-
</div>
|
|
154
|
-
|
|
155
|
-
<!-- Group: settings -->
|
|
156
|
-
<div class="mw-form-group">
|
|
157
|
-
<p class="mw-text-muted mw-mb-3">Settings</p>
|
|
158
|
-
|
|
159
|
-
<div class="mw-grid-2">
|
|
160
|
-
<div>
|
|
161
|
-
<p class="mw-text-muted mw-text-sm mw-mb-2">Priority</p>
|
|
162
|
-
<div class="mw-radio-group mw-radio-group-inline">
|
|
163
|
-
@for (option of ['low', 'normal', 'high']; track option) {
|
|
164
|
-
<label class="mw-radio">
|
|
165
|
-
<input type="radio" [value]="option" formControlName="priority" />
|
|
166
|
-
<div class="mw-radio-button"></div>
|
|
167
|
-
<div class="mw-radio-label">{{ option | titlecase }}</div>
|
|
168
|
-
</label>
|
|
169
|
-
}
|
|
170
|
-
</div>
|
|
171
|
-
</div>
|
|
172
|
-
|
|
173
|
-
<div>
|
|
174
|
-
<p class="mw-text-muted mw-text-sm mw-mb-2">Notifications</p>
|
|
175
|
-
<div class="mw-toggle-group">
|
|
176
|
-
<label class="mw-toggle mw-toggle-success">
|
|
177
|
-
<input type="checkbox" formControlName="notify" />
|
|
178
|
-
<div class="mw-toggle-track"></div>
|
|
179
|
-
<span class="mw-toggle-label">Notify me about changes</span>
|
|
180
|
-
</label>
|
|
181
|
-
</div>
|
|
182
|
-
</div>
|
|
183
|
-
|
|
184
|
-
<!-- Slider: both bindings are needed, the framework computes nothing -->
|
|
185
|
-
<div class="mw-field" style="grid-column: 1 / -1">
|
|
186
|
-
<label class="mw-field-label" for="team">Team size</label>
|
|
187
|
-
<div class="mw-slider-container">
|
|
188
|
-
<div
|
|
189
|
-
class="mw-slider-value mw-slider-numeric"
|
|
190
|
-
[attr.data-value]="f.teamSize.value"
|
|
191
|
-
></div>
|
|
192
|
-
<input
|
|
193
|
-
id="team"
|
|
194
|
-
type="range"
|
|
195
|
-
class="mw-slider mw-slider-primary"
|
|
196
|
-
min="1"
|
|
197
|
-
max="10"
|
|
198
|
-
formControlName="teamSize"
|
|
199
|
-
[style.--value.%]="f.teamSize.value * 10"
|
|
200
|
-
/>
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
</div>
|
|
204
|
-
</div>
|
|
205
|
-
|
|
206
|
-
<!-- Single control without a field wrapper -->
|
|
207
|
-
<div
|
|
208
|
-
class="mw-checkbox-group"
|
|
209
|
-
[class.mw-form-element-error]="invalid('terms')"
|
|
210
|
-
>
|
|
211
|
-
<label class="mw-checkbox">
|
|
212
|
-
<input type="checkbox" formControlName="terms" />
|
|
213
|
-
<div class="mw-checkbox-box"></div>
|
|
214
|
-
<div class="mw-checkbox-label">I accept the terms.</div>
|
|
215
|
-
</label>
|
|
216
|
-
</div>
|
|
217
|
-
|
|
218
|
-
<div class="mw-form-actions">
|
|
219
|
-
<p class="mw-actions-note">Fields marked with * are required.</p>
|
|
220
|
-
<button type="button" class="mw-btn mw-btn-outline" (click)="form.reset()">
|
|
221
|
-
Reset
|
|
222
|
-
</button>
|
|
223
|
-
<button type="submit" class="mw-btn mw-btn-primary" [disabled]="saving()">
|
|
224
|
-
@if (saving()) {
|
|
225
|
-
<span class="mw-spinner-border mw-spinner-sm"></span> Saving… } @else {
|
|
226
|
-
<i class="fas fa-save"></i> Save }
|
|
227
|
-
</button>
|
|
228
|
-
</div>
|
|
229
|
-
</form>
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
## Notes
|
|
233
|
-
|
|
234
|
-
- **The error state is two bindings**: `mw-field-has-error` on the wrapper
|
|
235
|
-
(border and halo) and the `mw-field-error` element (the message). Hint and
|
|
236
|
-
error should not be visible at the same time.
|
|
237
|
-
- **For a control without a wrapper** - a checkbox group, radio group or slider
|
|
238
|
-
container - use `mw-form-element-error` instead.
|
|
239
|
-
- **`disabled`/`readonly` are attributes**, not classes. With reactive forms use
|
|
240
|
-
`control.disable()`; the muted look comes from the `:disabled` selector.
|
|
241
|
-
- **Prefilled values** take `mw-prefilled` on the control, not on the wrapper.
|
|
242
|
-
In an edit form that starts from `patchValue()`, bind it to your own flag:
|
|
243
|
-
`[class.mw-prefilled]="loaded() && !form.controls.name.dirty"`. Repeat the
|
|
244
|
-
information in the `mw-field-hint`, the marker itself is decoration.
|
|
245
|
-
- **The slider needs two bindings**: `data-value` for the badge text,
|
|
246
|
-
`--value` for the filled part of the track.
|
|
247
|
-
- **A spinner inside a button** needs its shape class (`mw-spinner-border`) plus
|
|
248
|
-
a size; the button already provides the gap.
|
|
249
|
-
- Multi-column layout comes from `mw-grid-2` inside the group, a full-width
|
|
250
|
-
field from `style="grid-column: 1 / -1"`.
|
|
251
|
-
|
|
252
|
-
## Number formatting
|
|
253
|
-
|
|
254
|
-
`mw-text-numeric` aligns from the right edge, so it works with both `1,204.50`
|
|
255
|
-
and `1.204,50` - what matters is a constant number of decimals per column. Let
|
|
256
|
-
the pipe do it:
|
|
257
|
-
|
|
258
|
-
```html
|
|
259
|
-
<td class="mw-text-numeric">
|
|
260
|
-
{{ invoice.total | currency: 'EUR' : 'symbol' : '1.2-2' }}
|
|
261
|
-
</td>
|
|
262
|
-
<td class="mw-text-numeric">{{ item.amount | number: '1.2-2' }}</td>
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
```ts
|
|
266
|
-
// app.config.ts
|
|
267
|
-
import { registerLocaleData } from '@angular/common';
|
|
268
|
-
import localeDe from '@angular/common/locales/de';
|
|
269
|
-
|
|
270
|
-
registerLocaleData(localeDe);
|
|
271
|
-
|
|
272
|
-
export const appConfig: ApplicationConfig = {
|
|
273
|
-
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
|
274
|
-
};
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
`'1.2-2'` is the part that keeps the column aligned - it forces exactly two
|
|
278
|
-
decimals. `tabular-nums` only takes effect if the loaded font ships tabular
|
|
279
|
-
figures; the framework bundles no font.
|
|
@@ -1,309 +0,0 @@
|
|
|
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 - ModalDirective is in angular-services.md -->
|
|
252
|
-
<dialog
|
|
253
|
-
class="mw-modal mw-modal-sm"
|
|
254
|
-
closedby="any"
|
|
255
|
-
appModal
|
|
256
|
-
[isOpen]="toDelete() !== null"
|
|
257
|
-
(dismiss)="toDelete.set(null)"
|
|
258
|
-
aria-labelledby="delete-title"
|
|
259
|
-
>
|
|
260
|
-
<div class="mw-modal-header">
|
|
261
|
-
<h4 class="mw-modal-title" id="delete-title">Delete invoice</h4>
|
|
262
|
-
<button type="button" class="mw-modal-close" (click)="toDelete.set(null)">
|
|
263
|
-
𝗫
|
|
264
|
-
</button>
|
|
265
|
-
</div>
|
|
266
|
-
<div class="mw-modal-body">
|
|
267
|
-
<p class="mw-text-center">
|
|
268
|
-
<strong>Delete {{ toDelete()?.number }}?</strong>
|
|
269
|
-
</p>
|
|
270
|
-
<p class="mw-text-center mw-text-muted">This action cannot be undone.</p>
|
|
271
|
-
</div>
|
|
272
|
-
<div class="mw-modal-footer">
|
|
273
|
-
<button
|
|
274
|
-
type="button"
|
|
275
|
-
class="mw-btn mw-btn-outline"
|
|
276
|
-
(click)="toDelete.set(null)"
|
|
277
|
-
>
|
|
278
|
-
Cancel
|
|
279
|
-
</button>
|
|
280
|
-
<button
|
|
281
|
-
type="button"
|
|
282
|
-
class="mw-btn mw-btn-danger"
|
|
283
|
-
(click)="confirmDelete()"
|
|
284
|
-
>
|
|
285
|
-
<i class="fas fa-trash"></i> Delete
|
|
286
|
-
</button>
|
|
287
|
-
</div>
|
|
288
|
-
</dialog>
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
## Notes
|
|
292
|
-
|
|
293
|
-
- **`mw-table-subtle` for data tables.** The default primary header bar is a
|
|
294
|
-
landing-page look; over 200 rows it shouts louder than the data.
|
|
295
|
-
- **Sticky needs a height cap.** `mw-table-sticky-head` only works inside
|
|
296
|
-
`mw-table-responsive-scroll`; the height comes from
|
|
297
|
-
`--mw-table-scroll-height` and can be set per table inline.
|
|
298
|
-
- **`mw-table-cards` needs `data-label` on every cell**, including the action
|
|
299
|
-
column (empty string is fine) - below `md` each row becomes a card and the
|
|
300
|
-
label is rendered as the cell heading.
|
|
301
|
-
- **`mw-text-numeric` on the header cell as well**, otherwise the column heading
|
|
302
|
-
sits left while the figures sit right.
|
|
303
|
-
- **`mw-tag`, not `mw-tags`**, for a single status chip in a cell.
|
|
304
|
-
- **`mw-btn-danger` for the destructive action** in the modal footer.
|
|
305
|
-
- The modal is always in the DOM; `ModalDirective` calls `showModal()` when the
|
|
306
|
-
signal turns true, and Escape, the focus trap and the scroll lock follow from
|
|
307
|
-
the element.
|
|
308
|
-
- The toast stack lives in the app shell, not on this page - see
|
|
309
|
-
`angular-services.md`.
|