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.
Files changed (144) hide show
  1. package/README.md +2 -2
  2. package/maverick-wave.min.css +2 -2
  3. package/package.json +8 -2
  4. package/src/scss/components/_avatars.scss +35 -3
  5. package/src/scss/components/_dropdown.scss +24 -1
  6. package/src/scss/components/_lang-switch.scss +4 -8
  7. package/src/scss/components/_modals.scss +33 -5
  8. package/.claude/settings.local.json +0 -43
  9. package/.claude/skills/mw-maverick-wave/SKILL.md +0 -532
  10. package/.claude/skills/mw-maverick-wave/examples/angular-form.md +0 -279
  11. package/.claude/skills/mw-maverick-wave/examples/angular-list-page.md +0 -309
  12. package/.claude/skills/mw-maverick-wave/examples/angular-services.md +0 -538
  13. package/.claude/skills/mw-maverick-wave/examples/static-landing-page.md +0 -530
  14. package/.claude/skills/mw-maverick-wave/references/components.md +0 -2169
  15. package/.claude/skills/mw-maverick-wave/references/forms.md +0 -358
  16. package/.claude/skills/mw-maverick-wave/references/javascript.md +0 -164
  17. package/.claude/skills/mw-maverick-wave/references/layout.md +0 -764
  18. package/.claude/skills/mw-maverick-wave/references/theming.md +0 -528
  19. package/.github/workflows/ci.yml +0 -41
  20. package/.idea/codeStyles/Project.xml +0 -59
  21. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  22. package/.idea/maverick-wave.iml +0 -12
  23. package/.idea/modules.xml +0 -8
  24. package/.idea/prettier.xml +0 -6
  25. package/.idea/vcs.xml +0 -6
  26. package/.prettierignore +0 -8
  27. package/.prettierrc.json +0 -7
  28. package/CHANGELOG.md +0 -950
  29. package/CLAUDE.md +0 -60
  30. package/gulpfile.js +0 -167
  31. package/index.html +0 -1730
  32. package/release.sh +0 -31
  33. package/scripts/verify.js +0 -252
  34. package/src/assets/favicon/apple-touch-icon.png +0 -0
  35. package/src/assets/favicon/favicon-96x96.png +0 -0
  36. package/src/assets/favicon/favicon.svg +0 -1
  37. package/src/assets/favicon/site.webmanifest +0 -37
  38. package/src/assets/favicon/web-app-manifest-192x192.png +0 -0
  39. package/src/assets/favicon/web-app-manifest-512x512.png +0 -0
  40. package/src/assets/favicon.ico +0 -0
  41. package/src/assets/header-logo.svg +0 -24
  42. package/src/assets/images/gallery-city.svg +0 -49
  43. package/src/assets/images/gallery-desert.svg +0 -25
  44. package/src/assets/images/gallery-forest.svg +0 -46
  45. package/src/assets/images/gallery-lake.svg +0 -26
  46. package/src/assets/images/gallery-mountain.svg +0 -19
  47. package/src/assets/images/gallery-ocean.svg +0 -26
  48. package/src/assets/images/gallery-oldtown.svg +0 -41
  49. package/src/assets/images/gallery-snow.svg +0 -39
  50. package/src/assets/images/photo-balloon.svg +0 -35
  51. package/src/assets/images/photo-forest-path.svg +0 -31
  52. package/src/assets/images/photo-lighthouse.svg +0 -42
  53. package/src/assets/images/photo-palm.svg +0 -27
  54. package/src/assets/images/photo-peak.svg +0 -27
  55. package/src/assets/images/photo-tower.svg +0 -34
  56. package/src/assets/images/photo-waterfall.svg +0 -32
  57. package/src/assets/images/slider-field.svg +0 -124
  58. package/src/assets/images/slider-forest.svg +0 -33
  59. package/src/assets/images/slider-lake.svg +0 -35
  60. package/src/assets/images/slider-mountains.svg +0 -29
  61. package/src/assets/images/slider-trees.svg +0 -41
  62. package/src/assets/images/story-aurora.svg +0 -36
  63. package/src/assets/images/story-beach.svg +0 -34
  64. package/src/assets/images/story-stars.svg +0 -35
  65. package/src/assets/images/tile-analytics.svg +0 -22
  66. package/src/assets/images/tile-cloud.svg +0 -19
  67. package/src/assets/images/tile-ecommerce.svg +0 -14
  68. package/src/assets/images/tile-enterprise.svg +0 -22
  69. package/src/assets/images/tile-marketing.svg +0 -16
  70. package/src/assets/media/demo-chime.wav +0 -0
  71. package/src/assets/media/story-sunrise.jpg +0 -0
  72. package/src/assets/media/story-sunrise.mp4 +0 -0
  73. package/src/partials/accordions-container.html +0 -179
  74. package/src/partials/alerts-container.html +0 -151
  75. package/src/partials/announcement-container.html +0 -67
  76. package/src/partials/avatars-container.html +0 -229
  77. package/src/partials/badges-container.html +0 -114
  78. package/src/partials/blog-posts-container.html +0 -158
  79. package/src/partials/breadcrumbs-container.html +0 -126
  80. package/src/partials/button-bar-container.html +0 -175
  81. package/src/partials/buttons-container.html +0 -272
  82. package/src/partials/calendar-container.html +0 -203
  83. package/src/partials/cards-container.html +0 -727
  84. package/src/partials/code-container.html +0 -194
  85. package/src/partials/colors-container.html +0 -263
  86. package/src/partials/coming-soon-container.html +0 -10
  87. package/src/partials/contact-container.html +0 -85
  88. package/src/partials/content-slider-container.html +0 -36
  89. package/src/partials/divider-container.html +0 -70
  90. package/src/partials/documentation-container.html +0 -204
  91. package/src/partials/dropdown-container.html +0 -119
  92. package/src/partials/empty-state-container.html +0 -79
  93. package/src/partials/feed-container.html +0 -506
  94. package/src/partials/footer-container.html +0 -113
  95. package/src/partials/form-container.html +0 -262
  96. package/src/partials/form-elements-container.html +0 -616
  97. package/src/partials/form-field-container.html +0 -166
  98. package/src/partials/gallery-container.html +0 -62
  99. package/src/partials/get-started-container.html +0 -423
  100. package/src/partials/grid-container.html +0 -191
  101. package/src/partials/header-container.html +0 -69
  102. package/src/partials/header-utilities-container.html +0 -331
  103. package/src/partials/highlights-container.html +0 -71
  104. package/src/partials/history-container.html +0 -67
  105. package/src/partials/home-container.html +0 -31
  106. package/src/partials/html-lists-container.html +0 -63
  107. package/src/partials/info-container.html +0 -104
  108. package/src/partials/input-group-container.html +0 -299
  109. package/src/partials/item-lists-container.html +0 -368
  110. package/src/partials/kanban-container.html +0 -459
  111. package/src/partials/kbd-container.html +0 -63
  112. package/src/partials/leader-row-container.html +0 -139
  113. package/src/partials/login-container.html +0 -65
  114. package/src/partials/lunch-menu-container.html +0 -341
  115. package/src/partials/media-container.html +0 -27
  116. package/src/partials/meta-info-container.html +0 -21
  117. package/src/partials/modals-container.html +0 -199
  118. package/src/partials/mosaic-container.html +0 -173
  119. package/src/partials/page-header-container.html +0 -78
  120. package/src/partials/pagination-container.html +0 -64
  121. package/src/partials/palette-container.html +0 -480
  122. package/src/partials/panels-container.html +0 -126
  123. package/src/partials/parallax-container.html +0 -147
  124. package/src/partials/portrait-gallery-container.html +0 -175
  125. package/src/partials/preview-container.html +0 -136
  126. package/src/partials/pricing-container.html +0 -523
  127. package/src/partials/progress-container.html +0 -230
  128. package/src/partials/prose-container.html +0 -43
  129. package/src/partials/ratings-container.html +0 -104
  130. package/src/partials/section-head-container.html +0 -77
  131. package/src/partials/segmented-container.html +0 -66
  132. package/src/partials/skeleton-container.html +0 -87
  133. package/src/partials/spinners-container.html +0 -96
  134. package/src/partials/stepper-container.html +0 -111
  135. package/src/partials/stories-container.html +0 -182
  136. package/src/partials/tables-container.html +0 -309
  137. package/src/partials/tabs-container.html +0 -290
  138. package/src/partials/tags-container.html +0 -242
  139. package/src/partials/techstack-bucket-container.html +0 -187
  140. package/src/partials/testimonials-container.html +0 -88
  141. package/src/partials/tiles-container.html +0 -247
  142. package/src/partials/timelines-container.html +0 -196
  143. package/src/partials/typography-container.html +0 -74
  144. 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
- &#120299;
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.