maverick-wave 5.30.0 → 5.32.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/abstracts/_variables.scss +2 -0
- package/src/scss/components/_modals.scss +23 -9
- package/src/scss/components/_testimonial.scss +2 -0
- package/src/scss/layout/_home.scss +46 -0
- package/src/scss/layout/_main.scss +20 -0
- package/src/scss/layout/_parallax.scss +2 -2
- package/src/scss/main-lean.scss +8 -3
- 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 -2170
- 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 -530
- 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 -956
- 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,538 +0,0 @@
|
|
|
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. The second class,
|
|
11
|
-
`mw-theme-switching` on `<html>`, is not optional on a page of any size: without
|
|
12
|
-
it the flip starts a transition on every element that changes colour.
|
|
13
|
-
|
|
14
|
-
```ts
|
|
15
|
-
import { Injectable, effect, signal } from '@angular/core';
|
|
16
|
-
|
|
17
|
-
const STORAGE_KEY = 'mw-theme';
|
|
18
|
-
|
|
19
|
-
@Injectable({ providedIn: 'root' })
|
|
20
|
-
export class ThemeService {
|
|
21
|
-
// With nothing stored the stylesheet already follows the OS, so the initial
|
|
22
|
-
// value only has to agree with what is on screen
|
|
23
|
-
readonly light = signal(
|
|
24
|
-
localStorage.getItem(STORAGE_KEY)
|
|
25
|
-
? localStorage.getItem(STORAGE_KEY) === 'light'
|
|
26
|
-
: matchMedia('(prefers-color-scheme: light)').matches
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
constructor() {
|
|
30
|
-
effect(() => {
|
|
31
|
-
const light = this.light();
|
|
32
|
-
const root = document.documentElement;
|
|
33
|
-
|
|
34
|
-
root.classList.add('mw-theme-switching');
|
|
35
|
-
document.body.classList.toggle('mw-theme-light', light);
|
|
36
|
-
// the explicit counterpart - without it a dark choice on a light machine
|
|
37
|
-
// falls back to the OS preference
|
|
38
|
-
document.body.classList.toggle('mw-theme-dark', !light);
|
|
39
|
-
void root.offsetHeight; // commit the colours with transitions off
|
|
40
|
-
root.classList.remove('mw-theme-switching');
|
|
41
|
-
|
|
42
|
-
localStorage.setItem(STORAGE_KEY, light ? 'light' : 'dark');
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
toggle(): void {
|
|
47
|
-
this.light.update((value) => !value);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
The framework's own toggle markup, driven by the service:
|
|
53
|
-
|
|
54
|
-
```html
|
|
55
|
-
<div
|
|
56
|
-
class="mw-theme-toggle"
|
|
57
|
-
[class.mw-active]="theme.light()"
|
|
58
|
-
(click)="theme.toggle()"
|
|
59
|
-
>
|
|
60
|
-
<div class="mw-theme-toggle-slider">
|
|
61
|
-
<div class="mw-theme-toggle-icon">
|
|
62
|
-
<i
|
|
63
|
-
class="fas"
|
|
64
|
-
[class.fa-sun]="theme.light()"
|
|
65
|
-
[class.fa-moon]="!theme.light()"
|
|
66
|
-
></i>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
If the stylesheet was compiled with `$mw-theme-mode: 'dark'` or `'light'`, the
|
|
73
|
-
class does nothing - hide the toggle in that case
|
|
74
|
-
(`getComputedStyle(document.documentElement).getPropertyValue('--mw-internal-theme-mode')`).
|
|
75
|
-
|
|
76
|
-
## Toast service + outlet
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
import { Injectable, signal } from '@angular/core';
|
|
80
|
-
|
|
81
|
-
export type ToastKind = 'success' | 'warning' | 'danger' | 'info';
|
|
82
|
-
|
|
83
|
-
export interface Toast {
|
|
84
|
-
id: number;
|
|
85
|
-
kind: ToastKind;
|
|
86
|
-
text: string;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
@Injectable({ providedIn: 'root' })
|
|
90
|
-
export class ToastService {
|
|
91
|
-
private nextId = 0;
|
|
92
|
-
readonly toasts = signal<Toast[]>([]);
|
|
93
|
-
|
|
94
|
-
success(text: string) {
|
|
95
|
-
this.push('success', text);
|
|
96
|
-
}
|
|
97
|
-
warning(text: string) {
|
|
98
|
-
this.push('warning', text);
|
|
99
|
-
}
|
|
100
|
-
error(text: string) {
|
|
101
|
-
this.push('danger', text);
|
|
102
|
-
}
|
|
103
|
-
info(text: string) {
|
|
104
|
-
this.push('info', text);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
dismiss(id: number): void {
|
|
108
|
-
this.toasts.update((list) => list.filter((toast) => toast.id !== id));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
private push(kind: ToastKind, text: string): void {
|
|
112
|
-
const toast = { id: this.nextId++, kind, text };
|
|
113
|
-
this.toasts.update((list) => [...list, toast]);
|
|
114
|
-
setTimeout(() => this.dismiss(toast.id), 5000);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
@Component({
|
|
121
|
-
selector: 'app-toast-outlet',
|
|
122
|
-
template: `
|
|
123
|
-
<div class="mw-toast-stack mw-toast-stack-top-right">
|
|
124
|
-
@for (toast of toasts.toasts(); track toast.id) {
|
|
125
|
-
<div class="mw-alert" [class]="'mw-alert-' + toast.kind">
|
|
126
|
-
<div class="mw-alert-icon">
|
|
127
|
-
<i class="fas" [class]="icon[toast.kind]"></i>
|
|
128
|
-
</div>
|
|
129
|
-
<div class="mw-alert-content">{{ toast.text }}</div>
|
|
130
|
-
<button
|
|
131
|
-
type="button"
|
|
132
|
-
class="mw-btn-mini mw-btn-mini-danger mw-alert-close"
|
|
133
|
-
(click)="toasts.dismiss(toast.id)"
|
|
134
|
-
>
|
|
135
|
-
<i class="fas fa-times"></i>
|
|
136
|
-
</button>
|
|
137
|
-
</div>
|
|
138
|
-
}
|
|
139
|
-
</div>
|
|
140
|
-
`,
|
|
141
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
142
|
-
})
|
|
143
|
-
export class ToastOutletComponent {
|
|
144
|
-
protected readonly toasts = inject(ToastService);
|
|
145
|
-
protected readonly icon: Record<ToastKind, string> = {
|
|
146
|
-
success: 'fa-check-circle',
|
|
147
|
-
warning: 'fa-exclamation-triangle',
|
|
148
|
-
danger: 'fa-times-circle',
|
|
149
|
-
info: 'fa-info-circle',
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Place `<app-toast-outlet />` once in `app.component.html`. The stack is
|
|
155
|
-
`position: fixed` and lets clicks through everywhere except on a toast, so it
|
|
156
|
-
can sit anywhere in the tree. Auto-dismiss is the `setTimeout` above - the
|
|
157
|
-
framework does not provide one. Do not use `mw-alert-closed`; removing the item
|
|
158
|
-
from the signal is cleaner.
|
|
159
|
-
|
|
160
|
-
## Modal
|
|
161
|
-
|
|
162
|
-
A `<dialog>` with the same `mw-modal` classes. Escape, the focus trap, the inert
|
|
163
|
-
page behind it and the body scroll lock all come from the element, so what is
|
|
164
|
-
left to write is opening it.
|
|
165
|
-
|
|
166
|
-
`[open]="isOpen()"` does **not** work: the attribute opens a non-modal dialog -
|
|
167
|
-
no top layer, no backdrop, no focus trap. It has to be `showModal()`, which
|
|
168
|
-
needs a directive.
|
|
169
|
-
|
|
170
|
-
```ts
|
|
171
|
-
@Directive({
|
|
172
|
-
selector: 'dialog[appModal]',
|
|
173
|
-
host: {
|
|
174
|
-
'(close)': 'onClose()',
|
|
175
|
-
'(click)': 'onClick($event)',
|
|
176
|
-
},
|
|
177
|
-
})
|
|
178
|
-
export class ModalDirective {
|
|
179
|
-
readonly isOpen = input(false);
|
|
180
|
-
/** the dialog closed itself - Escape, a backdrop click or a form submit */
|
|
181
|
-
readonly dismiss = output<void>();
|
|
182
|
-
|
|
183
|
-
private readonly host =
|
|
184
|
-
inject<ElementRef<HTMLDialogElement>>(ElementRef).nativeElement;
|
|
185
|
-
|
|
186
|
-
constructor() {
|
|
187
|
-
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
188
|
-
|
|
189
|
-
effect(() => {
|
|
190
|
-
const open = this.isOpen();
|
|
191
|
-
if (!isBrowser) return;
|
|
192
|
-
if (open && !this.host.open) this.host.showModal();
|
|
193
|
-
else if (!open && this.host.open) this.host.close();
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Only when the dialog closed itself - closing it through the signal already
|
|
198
|
-
// went through whatever set the signal, and reporting that back would run the
|
|
199
|
-
// caller's dismiss handler a second time.
|
|
200
|
-
protected onClose(): void {
|
|
201
|
-
if (this.isOpen()) this.dismiss.emit();
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// A click on the dialog element is a click on its backdrop; the content sits
|
|
205
|
-
// in .mw-modal-header/body/footer inside it. Stands in for `closedby="any"`
|
|
206
|
-
// where that is not supported yet.
|
|
207
|
-
protected onClick(event: MouseEvent): void {
|
|
208
|
-
if (event.target === this.host) this.host.close();
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
```ts
|
|
214
|
-
@Component({
|
|
215
|
-
selector: 'app-confirm-dialog',
|
|
216
|
-
imports: [ModalDirective],
|
|
217
|
-
template: `
|
|
218
|
-
<dialog
|
|
219
|
-
class="mw-modal mw-modal-sm"
|
|
220
|
-
closedby="any"
|
|
221
|
-
appModal
|
|
222
|
-
[isOpen]="open()"
|
|
223
|
-
(dismiss)="cancelled.emit()"
|
|
224
|
-
aria-labelledby="confirm-title"
|
|
225
|
-
>
|
|
226
|
-
<div class="mw-modal-header">
|
|
227
|
-
<h4 class="mw-modal-title" id="confirm-title">{{ title() }}</h4>
|
|
228
|
-
<button type="button" class="mw-modal-close" (click)="cancelled.emit()">
|
|
229
|
-
𝗫
|
|
230
|
-
</button>
|
|
231
|
-
</div>
|
|
232
|
-
<div class="mw-modal-body"><ng-content /></div>
|
|
233
|
-
<div class="mw-modal-footer">
|
|
234
|
-
<button
|
|
235
|
-
type="button"
|
|
236
|
-
class="mw-btn mw-btn-outline"
|
|
237
|
-
(click)="cancelled.emit()"
|
|
238
|
-
>
|
|
239
|
-
Cancel
|
|
240
|
-
</button>
|
|
241
|
-
<button
|
|
242
|
-
type="button"
|
|
243
|
-
class="mw-btn mw-btn-danger"
|
|
244
|
-
(click)="confirmed.emit()"
|
|
245
|
-
>
|
|
246
|
-
Delete
|
|
247
|
-
</button>
|
|
248
|
-
</div>
|
|
249
|
-
</dialog>
|
|
250
|
-
`,
|
|
251
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
252
|
-
})
|
|
253
|
-
export class ConfirmDialogComponent {
|
|
254
|
-
readonly open = input(false);
|
|
255
|
-
readonly title = input('Are you sure?');
|
|
256
|
-
readonly confirmed = output<void>();
|
|
257
|
-
readonly cancelled = output<void>();
|
|
258
|
-
}
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
`role="dialog"` and `aria-modal` come from `showModal()`; the title is named
|
|
262
|
-
with `aria-labelledby`. Without `autofocus` on something the browser focuses the
|
|
263
|
-
first focusable element, usually the close button - put it on the field or the
|
|
264
|
-
safe action instead.
|
|
265
|
-
|
|
266
|
-
In tests, jsdom implements none of `<dialog>` - not `showModal`, `show` or
|
|
267
|
-
`close`, only the `open` attribute reflects. Specs that render an open modal
|
|
268
|
-
fail with `showModal is not a function` until a setup file adds them.
|
|
269
|
-
|
|
270
|
-
The `mw-modal-overlay` div toggled by `mw-modal-open` is still styled, for
|
|
271
|
-
markup that predates this - it cannot trap focus or make the page inert, so it
|
|
272
|
-
is not what to write now.
|
|
273
|
-
|
|
274
|
-
## Accordion
|
|
275
|
-
|
|
276
|
-
`mw-active` on the header **and** the content.
|
|
277
|
-
|
|
278
|
-
```ts
|
|
279
|
-
@Component({
|
|
280
|
-
selector: 'app-faq',
|
|
281
|
-
template: `
|
|
282
|
-
<div class="mw-accordion">
|
|
283
|
-
@for (item of items(); track item.id; let i = $index) {
|
|
284
|
-
<div class="mw-accordion-item">
|
|
285
|
-
<button
|
|
286
|
-
type="button"
|
|
287
|
-
class="mw-accordion-header"
|
|
288
|
-
[class.mw-active]="openIndex() === i"
|
|
289
|
-
[attr.aria-expanded]="openIndex() === i"
|
|
290
|
-
[attr.aria-controls]="item.id"
|
|
291
|
-
(click)="toggle(i)"
|
|
292
|
-
>
|
|
293
|
-
<span>{{ item.question }}</span>
|
|
294
|
-
<i class="fas fa-chevron-down mw-accordion-icon"></i>
|
|
295
|
-
</button>
|
|
296
|
-
<div
|
|
297
|
-
class="mw-accordion-content"
|
|
298
|
-
[id]="item.id"
|
|
299
|
-
[class.mw-active]="openIndex() === i"
|
|
300
|
-
>
|
|
301
|
-
<div class="mw-accordion-content-inner">{{ item.answer }}</div>
|
|
302
|
-
</div>
|
|
303
|
-
</div>
|
|
304
|
-
}
|
|
305
|
-
</div>
|
|
306
|
-
`,
|
|
307
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
308
|
-
})
|
|
309
|
-
export class FaqComponent {
|
|
310
|
-
readonly items =
|
|
311
|
-
input.required<{ id: string; question: string; answer: string }[]>();
|
|
312
|
-
protected readonly openIndex = signal(0);
|
|
313
|
-
|
|
314
|
-
protected toggle(index: number): void {
|
|
315
|
-
this.openIndex.update((current) => (current === index ? -1 : index));
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
## Tabs
|
|
321
|
-
|
|
322
|
-
`data-tab`/`id` pairing is only for the shipped script - bind `active` instead.
|
|
323
|
-
|
|
324
|
-
```html
|
|
325
|
-
<div class="mw-tabs mw-tabs-pills">
|
|
326
|
-
<div class="mw-tabs-nav">
|
|
327
|
-
@for (tab of tabs; track tab.key) {
|
|
328
|
-
<div
|
|
329
|
-
class="mw-tabs-nav-item"
|
|
330
|
-
[class.mw-active]="active() === tab.key"
|
|
331
|
-
(click)="active.set(tab.key)"
|
|
332
|
-
>
|
|
333
|
-
{{ tab.label }}
|
|
334
|
-
</div>
|
|
335
|
-
}
|
|
336
|
-
</div>
|
|
337
|
-
<div class="mw-tabs-content">
|
|
338
|
-
<div class="mw-tabs-panel" [class.mw-active]="active() === 'details'">
|
|
339
|
-
…
|
|
340
|
-
</div>
|
|
341
|
-
<div class="mw-tabs-panel" [class.mw-active]="active() === 'history'">
|
|
342
|
-
…
|
|
343
|
-
</div>
|
|
344
|
-
</div>
|
|
345
|
-
</div>
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
## Header with mobile navigation
|
|
349
|
-
|
|
350
|
-
The panel needs `open` on **both** the burger button and the navbar, plus
|
|
351
|
-
`mw-nav-open` on the `<body>` - the scrim, the scroll lock and the rules that
|
|
352
|
-
hold the bar and the burger in place all key off that one class, and nothing in
|
|
353
|
-
the template can reach the body element. Close it on every navigation.
|
|
354
|
-
|
|
355
|
-
```ts
|
|
356
|
-
@Component({
|
|
357
|
-
selector: 'app-header',
|
|
358
|
-
imports: [RouterLink, RouterLinkActive],
|
|
359
|
-
template: `
|
|
360
|
-
<header class="mw-header">
|
|
361
|
-
<div class="mw-container">
|
|
362
|
-
<div class="mw-logo">
|
|
363
|
-
<button type="button" routerLink="/">
|
|
364
|
-
<img src="/logo.svg" alt="Home" />
|
|
365
|
-
</button>
|
|
366
|
-
</div>
|
|
367
|
-
|
|
368
|
-
<div class="mw-header-actions">
|
|
369
|
-
<nav class="mw-navbar mw-navbar-medium" [class.open]="menuOpen()">
|
|
370
|
-
<ul class="mw-navbar-list">
|
|
371
|
-
@for (item of nav; track item.path) {
|
|
372
|
-
<li class="mw-navbar-item">
|
|
373
|
-
<a
|
|
374
|
-
class="mw-navbar-link"
|
|
375
|
-
[routerLink]="item.path"
|
|
376
|
-
routerLinkActive="mw-active"
|
|
377
|
-
>
|
|
378
|
-
{{ item.label }}
|
|
379
|
-
</a>
|
|
380
|
-
</li>
|
|
381
|
-
}
|
|
382
|
-
</ul>
|
|
383
|
-
</nav>
|
|
384
|
-
|
|
385
|
-
<button class="mw-profile-btn" type="button" (click)="openProfile()">
|
|
386
|
-
<span class="mw-avatar mw-avatar-initials mw-avatar-xs">{{
|
|
387
|
-
initials()
|
|
388
|
-
}}</span>
|
|
389
|
-
<span class="mw-profile-btn-name">{{ user().name }}</span>
|
|
390
|
-
</button>
|
|
391
|
-
|
|
392
|
-
<button
|
|
393
|
-
type="button"
|
|
394
|
-
class="mw-menu-btn"
|
|
395
|
-
aria-label="Menu"
|
|
396
|
-
aria-controls="main-nav"
|
|
397
|
-
[attr.aria-expanded]="menuOpen()"
|
|
398
|
-
[class.open]="menuOpen()"
|
|
399
|
-
(click)="menuOpen.set(!menuOpen())"
|
|
400
|
-
>
|
|
401
|
-
<span class="mw-menu-btn-burger"></span>
|
|
402
|
-
</button>
|
|
403
|
-
</div>
|
|
404
|
-
</div>
|
|
405
|
-
</header>
|
|
406
|
-
`,
|
|
407
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
408
|
-
})
|
|
409
|
-
export class HeaderComponent {
|
|
410
|
-
protected readonly menuOpen = signal(false);
|
|
411
|
-
private readonly body = inject(DOCUMENT).body;
|
|
412
|
-
|
|
413
|
-
constructor(router: Router) {
|
|
414
|
-
effect(() => this.body.classList.toggle('mw-nav-open', this.menuOpen()));
|
|
415
|
-
|
|
416
|
-
router.events
|
|
417
|
-
.pipe(
|
|
418
|
-
filter((e) => e instanceof NavigationEnd),
|
|
419
|
-
takeUntilDestroyed()
|
|
420
|
-
)
|
|
421
|
-
.subscribe(() => this.menuOpen.set(false));
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
`routerLinkActive="mw-active"` replaces the scroll spy - the framework only cares
|
|
427
|
-
about the class name.
|
|
428
|
-
|
|
429
|
-
## Progress bar
|
|
430
|
-
|
|
431
|
-
```html
|
|
432
|
-
<div class="mw-progress-container">
|
|
433
|
-
<div class="mw-progress-barinfo">
|
|
434
|
-
<span class="mw-progress-label">Upload</span>
|
|
435
|
-
<span class="mw-progress-value mw-progress-percent">{{ percent() }}</span>
|
|
436
|
-
</div>
|
|
437
|
-
<div class="mw-progress-bar">
|
|
438
|
-
<div
|
|
439
|
-
class="mw-progress-fill mw-progress-success"
|
|
440
|
-
[style.width.%]="percent()"
|
|
441
|
-
></div>
|
|
442
|
-
</div>
|
|
443
|
-
</div>
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
`mw-progress-percent` only appends the `%` sign - the number comes from you, and
|
|
447
|
-
so does the width.
|
|
448
|
-
|
|
449
|
-
## Scroll reveal
|
|
450
|
-
|
|
451
|
-
`mw-reveal` runs on the browser's scroll timeline and needs nothing from you -
|
|
452
|
-
except in Firefox, which ships none, where the block appears without motion.
|
|
453
|
-
This directive is the Firefox half: it adds to the class, never replaces it.
|
|
454
|
-
|
|
455
|
-
```ts
|
|
456
|
-
import {
|
|
457
|
-
DestroyRef,
|
|
458
|
-
Directive,
|
|
459
|
-
ElementRef,
|
|
460
|
-
afterNextRender,
|
|
461
|
-
inject,
|
|
462
|
-
signal,
|
|
463
|
-
} from '@angular/core';
|
|
464
|
-
|
|
465
|
-
@Directive({
|
|
466
|
-
selector: '[mwReveal]',
|
|
467
|
-
host: {
|
|
468
|
-
'[class.mw-reveal-hidden]': "state() === 'hidden'",
|
|
469
|
-
'[class.mw-reveal-run]': "state() === 'run'",
|
|
470
|
-
'[style.animation-delay.ms]': 'delay()',
|
|
471
|
-
},
|
|
472
|
-
})
|
|
473
|
-
export class MwRevealDirective {
|
|
474
|
-
private readonly host =
|
|
475
|
-
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
476
|
-
|
|
477
|
-
protected readonly state = signal<'off' | 'hidden' | 'run'>('off');
|
|
478
|
-
protected readonly delay = signal(0);
|
|
479
|
-
|
|
480
|
-
constructor() {
|
|
481
|
-
const destroyRef = inject(DestroyRef);
|
|
482
|
-
|
|
483
|
-
afterNextRender(() => {
|
|
484
|
-
if (CSS.supports('animation-timeline', 'view()')) return;
|
|
485
|
-
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
486
|
-
// Already on screen: past its entry range in a timeline browser too
|
|
487
|
-
if (this.host.getBoundingClientRect().top < innerHeight) return;
|
|
488
|
-
|
|
489
|
-
this.state.set('hidden');
|
|
490
|
-
|
|
491
|
-
const observer = new IntersectionObserver(
|
|
492
|
-
([entry]) => {
|
|
493
|
-
if (!entry.isIntersecting) return;
|
|
494
|
-
observer.disconnect();
|
|
495
|
-
this.delay.set(this.columnDelay());
|
|
496
|
-
this.state.set('run');
|
|
497
|
-
},
|
|
498
|
-
{ threshold: 0.15 }
|
|
499
|
-
);
|
|
500
|
-
|
|
501
|
-
observer.observe(this.host);
|
|
502
|
-
destroyRef.onDestroy(() => observer.disconnect());
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// The wave a grid draws in CSS, read back from the columns the browser
|
|
507
|
-
// rendered - one on a phone, so the stagger disappears with them.
|
|
508
|
-
private columnDelay(): number {
|
|
509
|
-
const grid = this.host.parentElement;
|
|
510
|
-
if (!grid?.classList.contains('mw-reveal-stagger')) return 0;
|
|
511
|
-
const columns =
|
|
512
|
-
getComputedStyle(grid).gridTemplateColumns.split(' ').length;
|
|
513
|
-
return ([...grid.children].indexOf(this.host) % columns) * 90;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
A single block carries `mw-reveal`, a grid carries `mw-reveal-stagger` and its
|
|
519
|
-
children carry nothing but the directive:
|
|
520
|
-
|
|
521
|
-
```html
|
|
522
|
-
<article class="mw-card mw-reveal" mwReveal>...</article>
|
|
523
|
-
|
|
524
|
-
<div class="mw-grid-4 mw-reveal-stagger">
|
|
525
|
-
@for (item of items(); track item.id) {
|
|
526
|
-
<article class="mw-card" mwReveal>...</article>
|
|
527
|
-
}
|
|
528
|
-
</div>
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
Unlike the shipped script this also covers what a route renders later - the
|
|
532
|
-
directive runs per element, not once per page.
|
|
533
|
-
|
|
534
|
-
`mw-header-reveal` and `mw-parallax` need the same treatment and the same
|
|
535
|
-
`CSS.supports` guard. The header is a class toggled past a scroll threshold; the
|
|
536
|
-
parallax writes `--mw-parallax-progress` (0 to 1) on each `mw-parallax-media`
|
|
537
|
-
from a `requestAnimationFrame` loop, reading every layer's rect before writing
|
|
538
|
-
to any of them.
|