maverick-wave 5.38.0 → 6.0.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 (69) hide show
  1. package/README.md +63 -47
  2. package/maverick-wave.min.css +94 -68
  3. package/maverick-wave.min.js +1 -1
  4. package/package.json +7 -7
  5. package/src/js/main.js +382 -57
  6. package/src/scss/abstracts/_mixins.scss +35 -24
  7. package/src/scss/abstracts/_variables.scss +15 -0
  8. package/src/scss/base/_base.scss +107 -64
  9. package/src/scss/base/_index.scss +1 -0
  10. package/src/scss/base/_page-transitions.scss +29 -0
  11. package/src/scss/base/_reset.scss +0 -8
  12. package/src/scss/components/_accordions.scss +23 -18
  13. package/src/scss/components/_action-bar.scss +143 -0
  14. package/src/scss/components/_alerts.scss +13 -1
  15. package/src/scss/components/_avatars.scss +17 -0
  16. package/src/scss/components/_badge.scss +12 -0
  17. package/src/scss/components/_breadcrumbs.scss +10 -2
  18. package/src/scss/components/_chat.scss +250 -0
  19. package/src/scss/components/_code.scss +3 -1
  20. package/src/scss/components/_compare.scss +135 -0
  21. package/src/scss/components/_deck.scss +8 -14
  22. package/src/scss/components/_devices.scss +323 -0
  23. package/src/scss/components/_dropdown.scss +43 -2
  24. package/src/scss/components/_empty-state.scss +43 -0
  25. package/src/scss/components/_flag.scss +16 -0
  26. package/src/scss/components/_index.scss +6 -0
  27. package/src/scss/components/_lightbox.scss +1 -1
  28. package/src/scss/components/_marquee.scss +132 -0
  29. package/src/scss/components/_modals.scss +112 -83
  30. package/src/scss/components/_occasions.scss +62 -11
  31. package/src/scss/components/_pagination.scss +91 -0
  32. package/src/scss/components/_portrait-gallery.scss +1 -1
  33. package/src/scss/components/_progress.scss +122 -2
  34. package/src/scss/components/_segmented.scss +65 -2
  35. package/src/scss/components/_skeleton.scss +8 -0
  36. package/src/scss/components/_spinners.scss +38 -0
  37. package/src/scss/components/_stat.scss +232 -0
  38. package/src/scss/components/_stepper.scss +42 -2
  39. package/src/scss/components/_stories.scss +36 -22
  40. package/src/scss/components/_tables.scss +160 -17
  41. package/src/scss/components/_tabs.scss +42 -4
  42. package/src/scss/components/_tags.scss +39 -4
  43. package/src/scss/components/_tiles.scss +1 -1
  44. package/src/scss/components/_timelines.scss +138 -1
  45. package/src/scss/components/_toasts.scss +5 -1
  46. package/src/scss/components/_tooltip.scss +13 -0
  47. package/src/scss/form-elements/_checkbox.scss +40 -7
  48. package/src/scss/form-elements/_form.scss +2 -4
  49. package/src/scss/form-elements/_input.scss +9 -6
  50. package/src/scss/form-elements/_radio.scss +32 -3
  51. package/src/scss/form-elements/_select.scss +44 -1
  52. package/src/scss/form-elements/_slider.scss +18 -0
  53. package/src/scss/form-elements/_textarea.scss +9 -6
  54. package/src/scss/form-elements/_toggle.scss +37 -4
  55. package/src/scss/layout/_app-shell.scss +661 -0
  56. package/src/scss/layout/_bento.scss +43 -0
  57. package/src/scss/layout/_footer.scss +114 -2
  58. package/src/scss/layout/_grid.scss +7 -2
  59. package/src/scss/layout/_header-reveal.scss +30 -17
  60. package/src/scss/layout/_header.scss +21 -4
  61. package/src/scss/layout/_index.scss +2 -0
  62. package/src/scss/layout/_parallax.scss +13 -11
  63. package/src/scss/utilities/_accessibility.scss +4 -19
  64. package/src/scss/utilities/_index.scss +1 -0
  65. package/src/scss/utilities/_print.scss +7 -3
  66. package/src/scss/utilities/_reveal.scss +25 -17
  67. package/src/scss/utilities/_shape.scss +24 -0
  68. package/src/scss/utilities/_touch-targets.scss +23 -0
  69. package/src/scss/utilities/_variants.scss +25 -6
package/src/js/main.js CHANGED
@@ -28,6 +28,27 @@
28
28
  return true;
29
29
  }
30
30
 
31
+ // Blocked storage (all cookies blocked, a sandboxed iframe) throws on access
32
+ const storage = {
33
+ get(key) {
34
+ try {
35
+ return localStorage.getItem(key);
36
+ } catch {
37
+ return null;
38
+ }
39
+ },
40
+ set(key, value) {
41
+ try {
42
+ localStorage.setItem(key, value);
43
+ } catch {}
44
+ },
45
+ remove(key) {
46
+ try {
47
+ localStorage.removeItem(key);
48
+ } catch {}
49
+ },
50
+ };
51
+
31
52
  // Bound to the document, the window, or the one element of its kind a page
32
53
  // has. Running any of this twice would double the listeners, so it happens
33
54
  // once and is not part of init().
@@ -40,6 +61,7 @@
40
61
  initThemeToggle();
41
62
  initColorSwatches();
42
63
  initMobileNav();
64
+ initAppShell();
43
65
  initHeaderReveal();
44
66
  initParallax();
45
67
  initSmoothScrolling();
@@ -65,6 +87,8 @@
65
87
  initMosaics(scope);
66
88
  initStoryReels(scope);
67
89
  initDecks(scope);
90
+ initDevices(scope);
91
+ initAppShells(scope);
68
92
  initAccordions(scope);
69
93
  initProgressBars(scope);
70
94
  initReveals(scope);
@@ -72,6 +96,7 @@
72
96
  initAlerts(scope);
73
97
  initFormSliders(scope);
74
98
  initImageSliders(scope);
99
+ initCompares(scope);
75
100
  initCheckboxLists(scope);
76
101
  initKanbanBoards(scope);
77
102
  initCalendars(scope);
@@ -818,6 +843,41 @@
818
843
  });
819
844
  }
820
845
 
846
+ // ===== Devices =====
847
+ // A device written as a button tours its screenshot on a tap: to the end at
848
+ // the pace the CSS sets, a pause to take the last screen in, then back. The
849
+ // laptop's hover is CSS alone - this is the touch-side equivalent.
850
+ function initDevices(root) {
851
+ root.querySelectorAll('button.mw-device').forEach((device) => {
852
+ if (!fresh(device)) return;
853
+ const screen = device.querySelector('.mw-device-screen');
854
+ const shot = screen && screen.querySelector(':scope > img');
855
+ if (!shot) return;
856
+
857
+ let hold = 0;
858
+ const stop = () => {
859
+ clearTimeout(hold);
860
+ setActive(device, false);
861
+ device.setAttribute('aria-pressed', 'false');
862
+ };
863
+
864
+ device.setAttribute('aria-pressed', 'false');
865
+ device.addEventListener('click', () => {
866
+ if (isActive(device)) return stop();
867
+ // A screenshot no taller than the glass has nowhere to go, and a tour
868
+ // that never ends would leave the button pressed
869
+ if (shot.offsetHeight <= screen.clientHeight) return;
870
+ setActive(device, true);
871
+ device.setAttribute('aria-pressed', 'true');
872
+ });
873
+
874
+ shot.addEventListener('transitionend', (event) => {
875
+ if (event.propertyName !== 'translate' || !isActive(device)) return;
876
+ hold = setTimeout(stop, 1200);
877
+ });
878
+ });
879
+ }
880
+
821
881
  // ===== Decks =====
822
882
  // The swipe is the browser's own scroll snap. This adds what snapping cannot:
823
883
  // buttons, keys and the wheel, counter, progress, fullscreen and the entrance.
@@ -897,9 +957,7 @@
897
957
 
898
958
  function show(index, smooth = true) {
899
959
  const slide = slides[Math.max(0, Math.min(slides.length - 1, index))];
900
- const reduced =
901
- window.matchMedia &&
902
- window.matchMedia('(prefers-reduced-motion: reduce)').matches;
960
+ const reduced = reducedMotion();
903
961
  scroller.scrollTo({
904
962
  left: across ? slide.offsetLeft : 0,
905
963
  top: across ? 0 : slide.offsetTop,
@@ -1091,7 +1149,7 @@
1091
1149
  themeToggle.setAttribute('aria-disabled', 'true');
1092
1150
 
1093
1151
  // Clean up potentially conflicting localStorage
1094
- localStorage.removeItem('mw-theme');
1152
+ storage.remove('mw-theme');
1095
1153
 
1096
1154
  // Ensure the theme classes are gone for fixed mode
1097
1155
  root.classList.remove('mw-theme-light', 'mw-theme-dark');
@@ -1101,15 +1159,9 @@
1101
1159
 
1102
1160
  // mode switchable - only runs if themeMode === 'switchable'
1103
1161
  const systemLight = window.matchMedia('(prefers-color-scheme: light)');
1162
+ const preferred = () => systemLight.matches;
1104
1163
 
1105
- // Only trust the OS where the stylesheet can act on it. Without light-dark()
1106
- // the theme tokens sit on :root as the dark set and the light one arrives by
1107
- // class alone, so a light machine would get a sun icon over a dark page.
1108
- const followsSystem =
1109
- window.CSS && CSS.supports('color', 'light-dark(#000, #fff)');
1110
- const preferred = () => followsSystem && systemLight.matches;
1111
-
1112
- const stored = localStorage.getItem('mw-theme');
1164
+ const stored = storage.get('mw-theme');
1113
1165
  let isLight = stored ? stored === 'light' : preferred();
1114
1166
 
1115
1167
  // Function to apply theme styles and icon
@@ -1137,7 +1189,7 @@
1137
1189
 
1138
1190
  // Until the reader picks a side, the page keeps tracking the OS switch
1139
1191
  systemLight.addEventListener('change', () => {
1140
- if (localStorage.getItem('mw-theme')) return;
1192
+ if (storage.get('mw-theme')) return;
1141
1193
  isLight = preferred();
1142
1194
  applyTheme(isLight);
1143
1195
  updateColorSwatchHexValues();
@@ -1147,7 +1199,7 @@
1147
1199
  themeToggle.addEventListener('click', () => {
1148
1200
  isLight = !isLight;
1149
1201
  applyTheme(isLight);
1150
- localStorage.setItem('mw-theme', isLight ? 'light' : 'dark'); // Save
1202
+ storage.set('mw-theme', isLight ? 'light' : 'dark');
1151
1203
 
1152
1204
  // No transition left to wait out, so the swatches can read the new values now
1153
1205
  updateColorSwatchHexValues();
@@ -1280,6 +1332,197 @@
1280
1332
  });
1281
1333
  }
1282
1334
 
1335
+ // ===== App Shell =====
1336
+ // Only classes change, so an application can drive the same states itself
1337
+ // Where _app-shell.scss docks the sidebar (lg) and the aside (xl)
1338
+ function appBreakpoints() {
1339
+ return {
1340
+ sidebar: window.matchMedia('(width >= 992px)'),
1341
+ aside: window.matchMedia('(width >= 1200px)'),
1342
+ };
1343
+ }
1344
+
1345
+ // The panel lying over the content, if one does
1346
+ function appOverlay(app, docked) {
1347
+ if (
1348
+ !docked.sidebar.matches &&
1349
+ app.classList.contains('mw-app-sidebar-open')
1350
+ )
1351
+ return app.querySelector('.mw-app-sidebar');
1352
+ if (!docked.aside.matches && app.classList.contains('mw-app-aside-open'))
1353
+ return app.querySelector('.mw-app-aside');
1354
+ return null;
1355
+ }
1356
+
1357
+ // Whatever an open panel covers goes inert, and every toggle states its panel
1358
+ function syncAppShell(app, docked) {
1359
+ const panel = appOverlay(app, docked);
1360
+ [...app.children].forEach((child) => {
1361
+ child.inert = Boolean(panel) && child !== panel;
1362
+ });
1363
+
1364
+ const sidebarOpen = docked.sidebar.matches
1365
+ ? !app.classList.contains('mw-app-collapsed')
1366
+ : app.classList.contains('mw-app-sidebar-open');
1367
+ const states = [
1368
+ ['[data-mw-app-toggle]', '.mw-app-sidebar', sidebarOpen],
1369
+ [
1370
+ '[data-mw-app-aside-toggle]',
1371
+ '.mw-app-aside',
1372
+ app.classList.contains('mw-app-aside-open'),
1373
+ ],
1374
+ ];
1375
+ states.forEach(([toggles, selector, open]) => {
1376
+ const target = app.querySelector(selector);
1377
+ app.querySelectorAll(toggles).forEach((toggle) => {
1378
+ toggle.setAttribute('aria-expanded', String(open));
1379
+ if (target && target.id && !toggle.hasAttribute('aria-controls')) {
1380
+ toggle.setAttribute('aria-controls', target.id);
1381
+ }
1382
+ });
1383
+ });
1384
+ }
1385
+
1386
+ // `data-mw-app-persist="name"` keeps the rail and the docked aside per app
1387
+ function appMemory(app, key, value) {
1388
+ const name = app.dataset.mwAppPersist;
1389
+ if (!name) return null;
1390
+ if (value === undefined) return storage.get(`mw-app:${name}:${key}`);
1391
+ storage.set(`mw-app:${name}:${key}`, value ? '1' : '0');
1392
+ return null;
1393
+ }
1394
+
1395
+ function initAppShell() {
1396
+ const docked = appBreakpoints();
1397
+
1398
+ function focusInto(panel) {
1399
+ const target =
1400
+ panel.querySelector('[aria-current="page"], .mw-active') ||
1401
+ panel.querySelector(
1402
+ 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
1403
+ );
1404
+ if (target) target.focus({ preventScroll: true });
1405
+ }
1406
+
1407
+ // Back to the toggle outside the panel, or focus is left on a hidden one
1408
+ function focusToggle(app, selector, panel) {
1409
+ const toggle = [...app.querySelectorAll(selector)].find(
1410
+ (el) => !panel || !panel.contains(el)
1411
+ );
1412
+ if (toggle) toggle.focus({ preventScroll: true });
1413
+ }
1414
+
1415
+ function closeOverlay(app, returnFocus) {
1416
+ const panel = appOverlay(app, docked);
1417
+ if (!panel) return;
1418
+ const aside = panel.classList.contains('mw-app-aside');
1419
+ app.classList.remove(aside ? 'mw-app-aside-open' : 'mw-app-sidebar-open');
1420
+ syncAppShell(app, docked);
1421
+ if (returnFocus) {
1422
+ focusToggle(
1423
+ app,
1424
+ aside ? '[data-mw-app-aside-toggle]' : '[data-mw-app-toggle]',
1425
+ panel
1426
+ );
1427
+ }
1428
+ }
1429
+
1430
+ document.addEventListener('click', (e) => {
1431
+ const app = e.target.closest('.mw-app');
1432
+ if (!app) return;
1433
+
1434
+ const toggle = e.target.closest('[data-mw-app-toggle]');
1435
+ if (toggle) {
1436
+ if (docked.sidebar.matches) {
1437
+ appMemory(app, 'collapsed', app.classList.toggle('mw-app-collapsed'));
1438
+ syncAppShell(app, docked);
1439
+ return;
1440
+ }
1441
+ const sidebar = app.querySelector('.mw-app-sidebar');
1442
+ app.classList.remove('mw-app-aside-open');
1443
+ const opened = app.classList.toggle('mw-app-sidebar-open');
1444
+ syncAppShell(app, docked);
1445
+ if (opened) focusInto(sidebar);
1446
+ else if (sidebar && sidebar.contains(toggle)) {
1447
+ focusToggle(app, '[data-mw-app-toggle]', sidebar);
1448
+ }
1449
+ return;
1450
+ }
1451
+
1452
+ const asideToggle = e.target.closest('[data-mw-app-aside-toggle]');
1453
+ if (asideToggle) {
1454
+ const aside = app.querySelector('.mw-app-aside');
1455
+ if (!docked.sidebar.matches)
1456
+ app.classList.remove('mw-app-sidebar-open');
1457
+ const opened = app.classList.toggle('mw-app-aside-open');
1458
+ if (docked.aside.matches) appMemory(app, 'aside', opened);
1459
+ syncAppShell(app, docked);
1460
+ if (opened && aside && !docked.aside.matches) focusInto(aside);
1461
+ if (!opened && aside && aside.contains(asideToggle)) {
1462
+ focusToggle(app, '[data-mw-app-aside-toggle]', aside);
1463
+ }
1464
+ return;
1465
+ }
1466
+
1467
+ const panel = appOverlay(app, docked);
1468
+ if (!panel) return;
1469
+ // Scrim taps land on the app; an SPA link in the nav keeps the page
1470
+ if (!panel.contains(e.target)) closeOverlay(app, true);
1471
+ else if (
1472
+ panel.classList.contains('mw-app-sidebar') &&
1473
+ e.target.closest('a[href]')
1474
+ ) {
1475
+ closeOverlay(app, false);
1476
+ }
1477
+ });
1478
+
1479
+ document.addEventListener('keydown', (e) => {
1480
+ if (e.key !== 'Escape') return;
1481
+ // An open dialog or menu owns this Escape; the next one closes the panel
1482
+ if (document.querySelector('dialog:modal, .mw-dropdown[open]')) return;
1483
+ const app = [...document.querySelectorAll('.mw-app')].find((el) =>
1484
+ appOverlay(el, docked)
1485
+ );
1486
+ if (app) closeOverlay(app, true);
1487
+ });
1488
+
1489
+ // An undocking aside closes instead of landing on top of the content
1490
+ function onResize() {
1491
+ document.querySelectorAll('.mw-app').forEach((app) => {
1492
+ if (docked.sidebar.matches) app.classList.remove('mw-app-sidebar-open');
1493
+ if (!docked.aside.matches) app.classList.remove('mw-app-aside-open');
1494
+ syncAppShell(app, docked);
1495
+ });
1496
+ }
1497
+
1498
+ docked.sidebar.addEventListener('change', onResize);
1499
+ docked.aside.addEventListener('change', onResize);
1500
+ }
1501
+
1502
+ // Per shell, so MaverickWave.init() after a render restores that one too
1503
+ function initAppShells(root) {
1504
+ const docked = appBreakpoints();
1505
+ root.querySelectorAll('.mw-app').forEach((app) => {
1506
+ if (!fresh(app)) return;
1507
+ const collapsed = appMemory(app, 'collapsed');
1508
+ const aside = appMemory(app, 'aside');
1509
+ if (collapsed !== null || aside !== null) {
1510
+ // Straight into the remembered state, without animating there
1511
+ app.classList.add('mw-app-restoring');
1512
+ if (collapsed !== null) {
1513
+ app.classList.toggle('mw-app-collapsed', collapsed === '1');
1514
+ }
1515
+ if (aside !== null && docked.aside.matches) {
1516
+ app.classList.toggle('mw-app-aside-open', aside === '1');
1517
+ }
1518
+ requestAnimationFrame(() =>
1519
+ requestAnimationFrame(() => app.classList.remove('mw-app-restoring'))
1520
+ );
1521
+ }
1522
+ syncAppShell(app, docked);
1523
+ });
1524
+ }
1525
+
1283
1526
  // ===== Progress Bars =====
1284
1527
 
1285
1528
  // The fill is driven from CSS where the browser can do it (_progress.scss), so
@@ -1332,7 +1575,7 @@
1332
1575
  // it arrives.
1333
1576
  function initReveals(root) {
1334
1577
  if (CSS.supports('animation-timeline', 'view()')) return;
1335
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1578
+ if (reducedMotion()) return;
1336
1579
 
1337
1580
  const targets = Array.from(
1338
1581
  root.querySelectorAll('.mw-reveal, .mw-reveal-stagger > *')
@@ -1379,7 +1622,7 @@
1379
1622
  const header = document.querySelector('.mw-header-reveal');
1380
1623
  if (!header) return;
1381
1624
  if (CSS.supports('animation-timeline', 'scroll()')) return;
1382
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1625
+ if (reducedMotion()) return;
1383
1626
 
1384
1627
  const ribbon = document.querySelector(
1385
1628
  '.mw-announcement:not(.mw-announcement-static)'
@@ -1417,7 +1660,7 @@
1417
1660
  // CSS where the depth variants can still reach it.
1418
1661
  function initParallax() {
1419
1662
  if (CSS.supports('animation-timeline', 'view()')) return;
1420
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1663
+ if (reducedMotion()) return;
1421
1664
 
1422
1665
  const media = document.querySelectorAll('.mw-parallax-media');
1423
1666
  if (!media.length) return;
@@ -1582,7 +1825,7 @@
1582
1825
 
1583
1826
  // Not `behavior: 'smooth'`: the browser's own curve takes no duration,
1584
1827
  // and the one it picks cannot be slowed down.
1585
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
1828
+ if (reducedMotion()) {
1586
1829
  window.scrollTo({ top: target, behavior: 'instant' });
1587
1830
  } else {
1588
1831
  glideTo(target);
@@ -1599,7 +1842,14 @@
1599
1842
  // ===== Scroll Spy =====
1600
1843
  function initScrollSpy() {
1601
1844
  const sections = document.querySelectorAll('section[id]');
1602
- const navLinks = document.querySelectorAll('.mw-navbar-link');
1845
+ // Links into this page only, or a scroll clears the current page's mark
1846
+ const page = (path) => path.replace(/index\.html$/, '');
1847
+ const navLinks = [...document.querySelectorAll('.mw-navbar-link')].filter(
1848
+ (link) => {
1849
+ const url = new URL(link.href, location.href);
1850
+ return url.hash && page(url.pathname) === page(location.pathname);
1851
+ }
1852
+ );
1603
1853
 
1604
1854
  if (sections.length === 0 || navLinks.length === 0) return;
1605
1855
 
@@ -1761,6 +2011,14 @@
1761
2011
  }
1762
2012
 
1763
2013
  // ===== Utility Functions =====
2014
+ // The OS setting or mw-motion-off on <html> - the stylesheet honours both
2015
+ function reducedMotion() {
2016
+ return (
2017
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
2018
+ document.documentElement.classList.contains('mw-motion-off')
2019
+ );
2020
+ }
2021
+
1764
2022
  function debounce(func, wait) {
1765
2023
  let timeout;
1766
2024
  return function () {
@@ -1840,20 +2098,14 @@
1840
2098
 
1841
2099
  // ===== Modals =====
1842
2100
 
1843
- // Two shapes: the older `mw-modal-overlay` div toggled by class, and `<dialog>`,
1844
- // where focus trap, Escape and the inert background are the browser's job.
1845
- // Delegated, so a modal added later works too.
2101
+ // A `<dialog>`: focus trap, Escape and the inert background are the
2102
+ // browser's job. Delegated, so a modal added later works too.
1846
2103
  function initModals() {
1847
2104
  document.addEventListener('click', (e) => {
1848
2105
  const closer = e.target.closest('.mw-modal-close');
1849
2106
  if (closer) {
1850
2107
  const dialog = closer.closest('dialog.mw-modal');
1851
- if (dialog) {
1852
- dialog.close();
1853
- return;
1854
- }
1855
- const overlay = closer.closest('.mw-modal-overlay');
1856
- if (overlay) overlay.classList.remove('mw-modal-open');
2108
+ if (dialog) dialog.close();
1857
2109
  return;
1858
2110
  }
1859
2111
 
@@ -1863,42 +2115,27 @@
1863
2115
  return;
1864
2116
  }
1865
2117
 
1866
- // Light dismiss for a dialog the browser does not dismiss itself. A click
2118
+ // `closedby="any"` where the browser does not understand it yet. A click
1867
2119
  // that lands on the dialog element is a click on the backdrop - the
1868
2120
  // content sits in .mw-modal-header/body/footer inside it.
1869
2121
  if (
1870
- e.target.matches('dialog.mw-modal[open]') &&
1871
- !dismissesItself(e.target)
2122
+ e.target.matches('dialog.mw-modal[open][closedby="any"]') &&
2123
+ !('closedBy' in e.target)
1872
2124
  ) {
1873
2125
  e.target.close();
1874
2126
  }
1875
2127
  });
1876
2128
  }
1877
2129
 
1878
- // True when `closedby` is on the element *and* understood, which is the only
1879
- // case where the browser closes the dialog on a backdrop click by itself.
1880
- function dismissesItself(dialog) {
1881
- return 'closedBy' in dialog && dialog.getAttribute('closedby') !== null;
1882
- }
1883
-
1884
- // Opens either shape. Exposed, because which modal opens when is the
1885
- // application's decision and not something markup can express on its own.
2130
+ // Exposed, because which modal opens when is the application's decision and
2131
+ // not something markup can express on its own
2132
+ // Not over itself: showModal() throws on a dialog already open as a drawer
1886
2133
  function openModal(modal) {
1887
- if (!modal) return;
1888
- if (modal.tagName === 'DIALOG') {
1889
- modal.showModal();
1890
- } else {
1891
- modal.classList.add('mw-modal-open');
1892
- }
2134
+ if (modal instanceof HTMLDialogElement && !modal.open) modal.showModal();
1893
2135
  }
1894
2136
 
1895
2137
  function closeModal(modal) {
1896
- if (!modal) return;
1897
- if (modal.tagName === 'DIALOG') {
1898
- modal.close();
1899
- } else {
1900
- modal.classList.remove('mw-modal-open');
1901
- }
2138
+ if (modal instanceof HTMLDialogElement) modal.close();
1902
2139
  }
1903
2140
 
1904
2141
  window.mwOpenModal = (id) => openModal(document.getElementById(id));
@@ -1948,6 +2185,26 @@
1948
2185
  });
1949
2186
  }
1950
2187
 
2188
+ // ===== Before/After Compare =====
2189
+ // The range is the control; this only hands its position to the CSS
2190
+ function initCompares(root) {
2191
+ root.querySelectorAll('.mw-compare-range').forEach((range) => {
2192
+ if (!fresh(range)) return;
2193
+ const frame = range.closest('.mw-compare');
2194
+ if (!frame) return;
2195
+
2196
+ const update = () => {
2197
+ const min = Number(range.min) || 0;
2198
+ const max = Number(range.max) || 100;
2199
+ const share = ((range.valueAsNumber - min) / (max - min)) * 100;
2200
+ frame.style.setProperty('--mw-compare-position', share + '%');
2201
+ };
2202
+
2203
+ range.addEventListener('input', update);
2204
+ update();
2205
+ });
2206
+ }
2207
+
1951
2208
  // ===== Kanban Boards =====
1952
2209
  function initKanbanBoards(root) {
1953
2210
  root.querySelectorAll('.mw-kanban').forEach((board) => {
@@ -2517,8 +2774,9 @@
2517
2774
  const effect = effects.find((name) =>
2518
2775
  html.classList.contains('mw-occasion-' + name)
2519
2776
  );
2777
+ if (reducedMotion()) return;
2778
+ initOccasionArrivals(effect);
2520
2779
  if (!effect) return;
2521
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
2522
2780
 
2523
2781
  if (html.classList.contains('mw-occasion-scroll')) {
2524
2782
  startOccasionDrift(effect);
@@ -2877,20 +3135,58 @@
2877
3135
  });
2878
3136
  }
2879
3137
 
3138
+ // No view timeline for the card pieces (Firefox): an observer plays them
3139
+ function initOccasionArrivals(effect) {
3140
+ if (CSS.supports('animation-timeline', 'view()')) return;
3141
+
3142
+ const siteWide =
3143
+ effect &&
3144
+ document.documentElement.classList.contains('mw-occasion-scroll');
3145
+ const hosts = [
3146
+ ...document.querySelectorAll(
3147
+ siteWide
3148
+ ? '.mw-card, .mw-panel, .mw-testimonial, .mw-occasion-spot'
3149
+ : '.mw-occasion-scroll:not(:root)'
3150
+ ),
3151
+ ].filter((el) => el.getBoundingClientRect().top >= window.innerHeight);
3152
+ if (!hosts.length) return;
3153
+
3154
+ const obs = new IntersectionObserver(
3155
+ (entries, observer) => {
3156
+ entries.forEach((entry) => {
3157
+ if (!entry.isIntersecting) return;
3158
+ observer.unobserve(entry.target);
3159
+ entry.target.classList.replace(
3160
+ 'mw-occasion-waiting',
3161
+ 'mw-occasion-arriving'
3162
+ );
3163
+ });
3164
+ },
3165
+ { threshold: 0.15 }
3166
+ );
3167
+
3168
+ hosts.forEach((el) => {
3169
+ el.classList.add('mw-occasion-waiting');
3170
+ obs.observe(el);
3171
+ });
3172
+ }
3173
+
2880
3174
  // Scrubbed by the scroll on the compositor, so it stands still whenever the
2881
3175
  // page does. A lap is a little more than a screen of scrolling, which makes
2882
3176
  // the count a matter of page length.
2883
3177
  function startOccasionDrift(effect) {
2884
- // Not CSS.supports: Firefox behind its flag parses the syntax, but plays
2885
- // the whole page as one lap - pieces that crawl read as stuck
2886
- if (!window.ScrollTimeline) return;
2887
-
2888
3178
  const layer = document.createElement('div');
2889
3179
  layer.className = 'mw-occasion-drift';
2890
3180
  layer.setAttribute('aria-hidden', 'true');
3181
+ // Not CSS.supports: Firefox behind its flag parses the syntax, but plays
3182
+ // the whole page as one lap - pieces that crawl read as stuck
3183
+ const driven = !window.ScrollTimeline;
3184
+ // Before the append, or the particles start on the clock and park mid-lap
3185
+ if (driven) layer.classList.add('mw-occasion-driven');
2891
3186
  if (effect === 'summer') occasionBeams(layer);
2892
3187
  else occasionDriftParticles(layer, effect);
2893
3188
  document.body.appendChild(layer);
3189
+ if (driven) driveOccasionDrift(layer);
2894
3190
 
2895
3191
  function laps() {
2896
3192
  const scrollable = document.documentElement.scrollHeight - innerHeight;
@@ -2904,6 +3200,32 @@
2904
3200
  window.addEventListener('resize', debounce(laps, 200), { passive: true });
2905
3201
  }
2906
3202
 
3203
+ // Hands the CSS the progress scroll(root) would; hidden where that is idle
3204
+ function driveOccasionDrift(layer) {
3205
+ const root = document.documentElement;
3206
+ let queued = false;
3207
+
3208
+ const update = () => {
3209
+ queued = false;
3210
+ const scrollable = root.scrollHeight - root.clientHeight;
3211
+ const progress =
3212
+ scrollable > 0 ? Math.min(1, Math.max(0, scrollY / scrollable)) : 0;
3213
+ layer.style.visibility = scrollable > 0 ? '' : 'hidden';
3214
+ layer.style.setProperty('--mw-occasion-progress', progress.toFixed(4));
3215
+ };
3216
+
3217
+ const request = () => {
3218
+ if (queued) return;
3219
+ queued = true;
3220
+ requestAnimationFrame(update);
3221
+ };
3222
+
3223
+ update();
3224
+ window.addEventListener('load', request);
3225
+ window.addEventListener('scroll', request, { passive: true });
3226
+ window.addEventListener('resize', request, { passive: true });
3227
+ }
3228
+
2907
3229
  // Sizes at a 1280px window, scaled with it within limits. Smaller than this a
2908
3230
  // leaf on the page reads as a speck, and a speck as a rendering fault.
2909
3231
  function occasionDriftParticles(layer, effect) {
@@ -2938,12 +3260,15 @@
2938
3260
  for (let i = 0; i < count; i++) {
2939
3261
  const shape = shapes[i % shapes.length];
2940
3262
  const soft = ['maple', 'oak', 'birch', 'petal'].includes(shape);
3263
+ const start = ((i * 37) % 100) * 0.3;
2941
3264
  occasionParticle(layer, shape, 'falling', {
2942
3265
  '--mw-occasion-x':
2943
3266
  ((i + between(0.1, 0.9)) * (100 / count)).toFixed(1) + 'vw',
2944
3267
  '--mw-occasion-size':
2945
3268
  (between(sizes[shape][0], sizes[shape][1]) * scale).toFixed(1) + 'px',
2946
- '--mw-occasion-start': (((i * 37) % 100) * 0.3).toFixed(1) + '%',
3269
+ '--mw-occasion-start': start.toFixed(1) + '%',
3270
+ // The start again as a number - a calc() cannot turn a % into one
3271
+ '--mw-occasion-from': (start / 100).toFixed(3),
2947
3272
  '--mw-occasion-lap': String(i % 3),
2948
3273
  '--mw-occasion-drift': between(-6, 6).toFixed(1) + 'vw',
2949
3274
  '--mw-occasion-sway': between(8, 24).toFixed(0) + 'px',