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