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