@undrr/undrr-mangrove 1.3.3 → 1.4.1

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 (166) hide show
  1. package/README.md +33 -29
  2. package/components/BarChart.js +2 -2
  3. package/components/BookCard.js +6 -0
  4. package/components/BookCard.js.LICENSE.txt +11 -0
  5. package/components/Breadcrumbs.js +6 -0
  6. package/components/Breadcrumbs.js.LICENSE.txt +9 -0
  7. package/components/Checkbox.js +6 -0
  8. package/components/Checkbox.js.LICENSE.txt +9 -0
  9. package/components/Chips.js +6 -0
  10. package/components/Chips.js.LICENSE.txt +9 -0
  11. package/components/CtaButton.js +6 -0
  12. package/components/CtaButton.js.LICENSE.txt +9 -0
  13. package/components/EmbedContainer.js +6 -0
  14. package/components/EmbedContainer.js.LICENSE.txt +9 -0
  15. package/components/Fetcher.js +2 -2
  16. package/components/Footer.js +6 -0
  17. package/components/Footer.js.LICENSE.txt +9 -0
  18. package/components/FormErrorSummary.js +6 -0
  19. package/components/FormErrorSummary.js.LICENSE.txt +9 -0
  20. package/components/FormGroup.js +6 -0
  21. package/components/FormGroup.js.LICENSE.txt +9 -0
  22. package/components/FullWidth.js +6 -0
  23. package/components/FullWidth.js.LICENSE.txt +9 -0
  24. package/components/Gallery.js +1 -1
  25. package/components/Hero.js +6 -0
  26. package/components/Hero.js.LICENSE.txt +9 -0
  27. package/components/HighlightBox.js +6 -0
  28. package/components/HighlightBox.js.LICENSE.txt +9 -0
  29. package/components/HorizontalBookCard.js +6 -0
  30. package/components/HorizontalBookCard.js.LICENSE.txt +11 -0
  31. package/components/HorizontalCard.js +6 -0
  32. package/components/HorizontalCard.js.LICENSE.txt +11 -0
  33. package/components/IconCard.js +2 -2
  34. package/components/Loader.js +6 -0
  35. package/components/Loader.js.LICENSE.txt +9 -0
  36. package/components/MapComponent.js +2 -2
  37. package/components/MegaMenu.js +2 -2
  38. package/components/PageHeader.js +6 -0
  39. package/components/PageHeader.js.LICENSE.txt +9 -0
  40. package/components/Pager.js +2 -2
  41. package/components/QuoteHighlight.js +1 -1
  42. package/components/Radio.js +6 -0
  43. package/components/Radio.js.LICENSE.txt +9 -0
  44. package/components/ScrollContainer.js +1 -1
  45. package/components/Select.js +6 -0
  46. package/components/Select.js.LICENSE.txt +9 -0
  47. package/components/ShareButtons.js +1 -1
  48. package/components/ShowMore.js +6 -0
  49. package/components/ShowMore.js.LICENSE.txt +9 -0
  50. package/components/StatsCard.js +2 -2
  51. package/components/SyndicationSearchWidget.js +2 -2
  52. package/components/Tab.js +6 -0
  53. package/components/Tab.js.LICENSE.txt +9 -0
  54. package/components/TextCta.js +6 -0
  55. package/components/TextCta.js.LICENSE.txt +11 -0
  56. package/components/TextInput.js +6 -0
  57. package/components/TextInput.js.LICENSE.txt +9 -0
  58. package/components/Textarea.js +6 -0
  59. package/components/Textarea.js.LICENSE.txt +9 -0
  60. package/components/VerticalCard.js +6 -0
  61. package/components/VerticalCard.js.LICENSE.txt +11 -0
  62. package/components/hydrate.js +1 -1
  63. package/css/style-delta.css +467 -0
  64. package/css/style-delta.css.map +1 -0
  65. package/css/style-gutenberg.css +21 -18
  66. package/css/style-gutenberg.css.map +1 -1
  67. package/css/style-irp-legacy.css +472 -0
  68. package/css/style-irp-legacy.css.map +1 -0
  69. package/css/style-irp.css +99 -103
  70. package/css/style-irp.css.map +1 -1
  71. package/css/style-legacy.css +465 -0
  72. package/css/style-legacy.css.map +1 -0
  73. package/css/style-mcr-legacy.css +472 -0
  74. package/css/style-mcr-legacy.css.map +1 -0
  75. package/css/style-mcr.css +99 -103
  76. package/css/style-mcr.css.map +1 -1
  77. package/css/style-preventionweb-legacy.css +472 -0
  78. package/css/style-preventionweb-legacy.css.map +1 -0
  79. package/css/style-preventionweb.css +99 -103
  80. package/css/style-preventionweb.css.map +1 -1
  81. package/css/style.css +99 -103
  82. package/css/style.css.map +1 -1
  83. package/error-pages/401.html +10 -11
  84. package/error-pages/403.html +11 -12
  85. package/error-pages/404.html +13 -14
  86. package/error-pages/429.html +12 -13
  87. package/error-pages/500.html +10 -11
  88. package/error-pages/502.html +12 -13
  89. package/error-pages/503.html +12 -13
  90. package/error-pages/504.html +10 -11
  91. package/error-pages/5xx.html +10 -11
  92. package/error-pages/challenge.html +12 -13
  93. package/error-pages/managed-challenge.html +12 -13
  94. package/js/README.md +57 -0
  95. package/js/__tests__/show-more.test.js +76 -0
  96. package/js/__tests__/table-of-contents.test.js +198 -0
  97. package/js/on-this-page-nav.js +531 -0
  98. package/js/show-more.js +24 -3
  99. package/js/table-of-contents.js +168 -0
  100. package/js/tabs.js +445 -83
  101. package/package.json +1 -1
  102. package/scss/Atom/BaseTypography/Blockquote/blockquote.scss +1 -8
  103. package/scss/Atom/BaseTypography/Cite/cite.scss +2 -2
  104. package/scss/Atom/Images/AuthorImage/author-image.scss +4 -4
  105. package/scss/Atom/Images/ImageCaptionCredit/image-caption-credit.scss +24 -28
  106. package/scss/Atom/Images/ImageCredit/image-credit.scss +1 -1
  107. package/scss/Atom/Layout/Container/container.scss +2 -2
  108. package/scss/Atom/Layout/Grid/grid.scss +1 -1
  109. package/scss/Atom/ReachElement/Details/details.scss +6 -6
  110. package/scss/Atom/ReachElement/Figcaption/figcaption.scss +1 -1
  111. package/scss/Atom/Table/table.scss +0 -8
  112. package/scss/Components/Boilerplate/boilerplate.scss +2 -2
  113. package/scss/Components/Breadcrumbs/breadcrumbs.scss +2 -9
  114. package/scss/Components/Buttons/Chips/chips.scss +5 -19
  115. package/scss/Components/Buttons/CtaButton/buttons.scss +3 -124
  116. package/scss/Components/Buttons/CtaButton/cta-button.scss +99 -0
  117. package/scss/Components/Buttons/ShareButtons/share-buttons.scss +2 -2
  118. package/scss/Components/Cards/Card/card.scss +45 -6
  119. package/scss/Components/Cards/StatsCard/stats-card.scss +2 -2
  120. package/scss/Components/ErrorPages/error-pages.scss +12 -12
  121. package/scss/Components/Footer/footer.scss +72 -4
  122. package/scss/Components/Forms/Select/select.scss +2 -2
  123. package/scss/Components/Forms/_form-base.scss +5 -5
  124. package/scss/Components/Forms/_form-legacy.scss +1 -1
  125. package/scss/Components/Gallery/gallery.scss +7 -7
  126. package/scss/Components/Hero/hero.scss +43 -23
  127. package/scss/Components/HighlightBox/highlight-box.scss +5 -5
  128. package/scss/Components/MegaMenu/mega-menu.scss +750 -0
  129. package/scss/Components/MegaMenu/megamenu.scss +3 -733
  130. package/scss/Components/OnThisPageNav/on-this-page-nav.scss +206 -0
  131. package/scss/Components/PageHeader/page-header.scss +5 -5
  132. package/scss/Components/Pagination/pagination.scss +2 -2
  133. package/scss/Components/ScrollContainer/scroll-container.scss +1 -1
  134. package/scss/Components/Snackbar/snackbar.scss +1 -1
  135. package/scss/Components/SyndicationSearchWidget/SyndicationSearchWidget.scss +3 -1480
  136. package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +1515 -0
  137. package/scss/Components/Tab/tab.scss +68 -9
  138. package/scss/Components/TableOfContents/TableOfContents.scss +3 -17
  139. package/scss/Components/TableOfContents/table-of-contents.scss +17 -0
  140. package/scss/Components/TextCta/text-cta.scss +139 -0
  141. package/scss/Components/TextCta/textcta.scss +3 -27
  142. package/scss/Molecules/ImageCaption/image-caption.scss +6 -16
  143. package/scss/Molecules/SectionHeader/section-header.scss +8 -0
  144. package/scss/Molecules/SectionHeader/sectionheader.scss +3 -8
  145. package/scss/Utilities/FullWidth/FullWidth.scss +3 -23
  146. package/scss/Utilities/FullWidth/full-width.scss +23 -0
  147. package/scss/Utilities/Loader/loader.scss +1 -1
  148. package/scss/Utilities/ShowMore/ShowMore.scss +3 -26
  149. package/scss/Utilities/ShowMore/show-more.scss +26 -0
  150. package/scss/assets/scss/_components.scss +15 -9
  151. package/scss/assets/scss/_foundational.scss +13 -7
  152. package/scss/assets/scss/_mixins.scss +9 -314
  153. package/scss/assets/scss/_utility.scss +19 -71
  154. package/scss/assets/scss/_variables-delta.scss +110 -0
  155. package/scss/assets/scss/_variables-irp.scss +1 -1
  156. package/scss/assets/scss/_variables-mcr.scss +1 -1
  157. package/scss/assets/scss/_variables-preventionweb.scss +1 -1
  158. package/scss/assets/scss/_variables.scss +101 -34
  159. package/scss/assets/scss/style-delta.scss +8 -0
  160. package/scss/assets/scss/style-gutenberg.scss +2 -2
  161. package/scss/assets/scss/style-irp-legacy.scss +20 -0
  162. package/scss/assets/scss/style-legacy.scss +20 -0
  163. package/scss/assets/scss/style-mcr-legacy.scss +20 -0
  164. package/scss/assets/scss/style-preventionweb-legacy.scss +20 -0
  165. package/scss/Components/BlockquoteComponent/blockquotecomp.scss +0 -31
  166. package/scss/Components/Buttons/CtaLink/cta-link.scss +0 -61
package/js/tabs.js CHANGED
@@ -1,12 +1,87 @@
1
1
  // mg-tabs
2
+
3
+ // Matches $mg-breakpoint-mobile in _variables.scss
4
+ const BREAKPOINT_MOBILE = 480;
5
+
6
+ /**
7
+ * Determine whether a tab container should behave as stacked (disclosure)
8
+ * rather than horizontal tabs.
9
+ * @param {Element} container - the [data-mg-js-tabs] element
10
+ * @returns {boolean}
11
+ */
12
+ function isStacked(container) {
13
+ return (
14
+ container.dataset.mgJsTabsVariant === 'stacked' ||
15
+ window.innerWidth < BREAKPOINT_MOBILE
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Set the open/close state of a stacked disclosure panel.
21
+ * @param {Element} trigger - the tab link acting as disclosure trigger
22
+ * @param {Element} panel - the section panel
23
+ * @param {boolean} open - true to open, false to close
24
+ */
25
+ export function setDisclosureState(trigger, panel, open) {
26
+ if (open) {
27
+ panel.removeAttribute('hidden');
28
+ trigger.setAttribute('aria-expanded', 'true');
29
+ trigger.classList.add('is-active', 'mg-tabs__stacked--open');
30
+ } else {
31
+ panel.setAttribute('hidden', 'until-found');
32
+ trigger.setAttribute('aria-expanded', 'false');
33
+ trigger.classList.remove('is-active', 'mg-tabs__stacked--open');
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Find the next visible tab index, skipping items hidden by filtering.
39
+ * @param {NodeList} tabs - all tab trigger elements
40
+ * @param {number} fromIndex - current index to start searching from
41
+ * @param {number} step - direction: +1 for forward, -1 for backward
42
+ * @returns {number} index of the next visible tab, or -1 if all hidden
43
+ */
44
+ function findVisibleTab(tabs, fromIndex, step) {
45
+ const len = tabs.length;
46
+ let idx = fromIndex;
47
+ for (let i = 0; i < len; i++) {
48
+ idx = (idx + step + len) % len;
49
+ if (!tabs[idx].closest('.mg-tabs__item--hidden')) return idx;
50
+ }
51
+ return -1;
52
+ }
53
+
54
+ /**
55
+ * Normalize text for filter matching: collapse smart quotes, dashes,
56
+ * and other typographic punctuation to their plain ASCII equivalents.
57
+ * @param {string} text
58
+ * @returns {string}
59
+ */
60
+ function normalizeText(text) {
61
+ return text
62
+ .replace(/[\u2018\u2019\u201A\u2032]/g, "'") // smart single quotes, prime
63
+ .replace(/[\u201C\u201D\u201E\u2033]/g, '"') // smart double quotes, double prime
64
+ .replace(/[\u2013\u2014\u2015]/g, '-') // en dash, em dash, horizontal bar
65
+ .replace(/\u2026/g, '...') // ellipsis
66
+ .replace(/[\u00A0]/g, ' '); // non-breaking space
67
+ }
68
+
2
69
  /**
3
- * Initialize tabs on a page
70
+ * Initialize tabs on a page.
71
+ *
72
+ * @param {NodeList|HTMLElement[]|HTMLElement} [scope] - Elements to init.
73
+ * Accepts a NodeList, array, or a single HTMLElement.
74
+ * Defaults to all [data-mg-js-tabs] in the document.
4
75
  * @param {boolean} [activateDeepLinkOnLoad] - if deep linked tabs should be activated on page load, defaults to true
5
76
  * @example mgTabs();
6
77
  */
7
78
  export function mgTabs(scope, activateDeepLinkOnLoad = true) {
8
- const tabContainers = scope || document.querySelectorAll('[data-mg-js-tabs]');
79
+ const tabContainers = scope
80
+ ? (scope instanceof HTMLElement ? [scope] : scope)
81
+ : document.querySelectorAll('[data-mg-js-tabs]');
9
82
  tabContainers.forEach(container => {
83
+ // Skip auto-init if the element opts out
84
+ if (!scope && container.hasAttribute('data-mg-js-tabs-skip-auto-init')) return;
10
85
  mgTabsRuntime(container, activateDeepLinkOnLoad);
11
86
  });
12
87
  }
@@ -19,7 +94,7 @@ export function mgTabs(scope, activateDeepLinkOnLoad = true) {
19
94
  */
20
95
  export function mgTabsRuntime(scope, activateDeepLinkOnLoad) {
21
96
  var scope = scope || document;
22
- var activateDeepLinkOnLoad = activateDeepLinkOnLoad || true;
97
+ var activateDeepLinkOnLoad = activateDeepLinkOnLoad ?? true;
23
98
 
24
99
  // Get relevant elements and collections
25
100
  if (scope.hasAttribute('data-mg-js-tabs')) {
@@ -29,7 +104,7 @@ export function mgTabsRuntime(scope, activateDeepLinkOnLoad) {
29
104
  scope.querySelectorAll('[data-mg-js-tabs]') || newTab.closest('.mg-tabs'); // compatibility with v1 tabs
30
105
  }
31
106
  const tabs = scope.querySelectorAll('.mg-tabs__link');
32
- var panels = scope.querySelectorAll('[id^="mg-tabs__section"]');
107
+ var panels = scope.querySelectorAll('[id^="mg-tabs__section"]:not(a)');
33
108
  // v1 compatibility
34
109
  // If panels is empty, try finding them in data-mg-js-tabs-content
35
110
  if (!panels.length) {
@@ -37,7 +112,7 @@ export function mgTabsRuntime(scope, activateDeepLinkOnLoad) {
37
112
  .closest('.mg-tabs')
38
113
  .querySelector('[data-mg-js-tabs-content]');
39
114
  if (tabContent) {
40
- panels = tabContent.querySelectorAll('[id^="mg-tabs__section"]');
115
+ panels = tabContent.querySelectorAll('[id^="mg-tabs__section"]:not(a)');
41
116
  }
42
117
  }
43
118
 
@@ -50,24 +125,55 @@ export function mgTabsRuntime(scope, activateDeepLinkOnLoad) {
50
125
  return;
51
126
  }
52
127
 
128
+ // Normalize tabsList to an array so we can iterate uniformly
129
+ // (when scope has [data-mg-js-tabs], tabsList is a single Element)
130
+ const tabsListArray = tabsList.nodeType
131
+ ? [tabsList]
132
+ : Array.from(tabsList);
133
+
53
134
  // Check if tabs have already been initialized
54
- if (tabsList.hasAttribute('data-mg-tabs-initialized')) {
135
+ // Supports both the new dataset property and the legacy attribute for backward compat
136
+ if (tabsList.hasAttribute && tabsList.hasAttribute('data-mg-tabs-initialized')) {
55
137
  return;
56
138
  }
57
- tabsList.setAttribute('data-mg-tabs-initialized', 'true');
139
+ if (tabsList.hasAttribute) {
140
+ tabsList.setAttribute('data-mg-tabs-initialized', 'true');
141
+ } else if (tabsListArray.length > 0) {
142
+ if (tabsListArray[0].hasAttribute('data-mg-tabs-initialized')) {
143
+ return;
144
+ }
145
+ tabsListArray[0].setAttribute('data-mg-tabs-initialized', 'true');
146
+ }
147
+
148
+ // Determine variant from the container element
149
+ const container = tabsListArray[0];
150
+ const stacked = isStacked(container);
58
151
 
59
- // Add semantics are remove user focusability for each tab
152
+ // Add semantics and focusability for each tab
60
153
  Array.prototype.forEach.call(tabs, (tab, i) => {
61
- const tabId = tab.href.split('#')[1]; // calculate an ID based off the tab href (todo: add support for a data-vf-js-tab-id, and if set use that)
62
- tab.setAttribute('role', 'tab');
63
- tab.setAttribute('id', tabId);
64
- tab.setAttribute('data-tabs__item', tabId);
65
- tab.setAttribute('tabindex', '-1');
66
- tab.parentNode.setAttribute('role', 'presentation');
154
+ const panelId = tab.href.split('#')[1];
155
+ tab.setAttribute('data-tabs__item', panelId);
156
+
157
+ // Give trigger a distinct ID so it doesn't collide with the panel's ID
158
+ tab.setAttribute('id', panelId + '--trigger');
159
+
160
+ if (stacked) {
161
+ // Disclosure pattern: each trigger is a button that toggles its panel
162
+ tab.setAttribute('role', 'button');
163
+ tab.setAttribute('aria-expanded', 'false');
164
+ tab.setAttribute('aria-controls', panelId);
165
+ tab.parentNode.removeAttribute('role');
166
+ // Stacked triggers must be in the Tab order (disclosure buttons)
167
+ tab.removeAttribute('tabindex');
168
+ } else {
169
+ // Horizontal tabs: standard tablist pattern (roving tabindex)
170
+ tab.setAttribute('role', 'tab');
171
+ tab.parentNode.setAttribute('role', 'presentation');
172
+ tab.setAttribute('tabindex', '-1');
173
+ }
67
174
 
68
175
  // Reset any active tabs from a previous JS call
69
176
  tab.removeAttribute('aria-selected');
70
- tab.setAttribute('tabindex', '-1');
71
177
  tab.classList.remove('is-active');
72
178
 
73
179
  // Handle clicking of tabs for mouse users
@@ -79,47 +185,86 @@ export function mgTabsRuntime(scope, activateDeepLinkOnLoad) {
79
185
  // Handle keydown events for keyboard users
80
186
  tab.addEventListener('keydown', e => {
81
187
  // Get the index of the current tab in the tabs node list
82
- let index = Array.prototype.indexOf.call(tabs, e.currentTarget);
83
- // Work out which key the user is pressing and
84
- // Calculate the new tab's index where appropriate
85
- let dir =
86
- e.which === 37
87
- ? index - 1
88
- : e.which === 39
89
- ? index + 1
90
- : e.which === 40
91
- ? 'down'
92
- : null;
93
- if (dir !== null) {
188
+ const index = Array.prototype.indexOf.call(tabs, e.currentTarget);
189
+ const parentContainer =
190
+ e.currentTarget.closest('[data-mg-js-tabs]') ||
191
+ e.currentTarget.closest('.mg-tabs');
192
+ const currentlyStacked = isStacked(parentContainer);
193
+
194
+ // Stacked: Space/Enter to toggle, Up/Down to navigate, Home/End for first/last
195
+ // Horizontal: Left/Right to navigate tabs, Down to focus panel
196
+ if (currentlyStacked && (e.key === ' ' || e.key === 'Enter')) {
197
+ e.preventDefault();
198
+ mgTabsSwitch(e.currentTarget, panels);
199
+ return;
200
+ }
201
+
202
+ const prevKey = currentlyStacked ? 'ArrowUp' : 'ArrowLeft';
203
+ const nextKey = currentlyStacked ? 'ArrowDown' : 'ArrowRight';
204
+ let dir = null;
205
+
206
+ if (e.key === prevKey) {
207
+ dir = findVisibleTab(tabs, index, -1);
208
+ } else if (e.key === nextKey) {
209
+ dir = findVisibleTab(tabs, index, 1);
210
+ } else if (!currentlyStacked && e.key === 'ArrowDown') {
211
+ dir = 'down';
212
+ } else if (e.key === 'Home') {
213
+ dir = findVisibleTab(tabs, -1, 1);
214
+ } else if (e.key === 'End') {
215
+ dir = findVisibleTab(tabs, tabs.length, -1);
216
+ }
217
+
218
+ if (dir !== null && dir !== -1) {
94
219
  e.preventDefault();
95
- // If the down key is pressed, move focus to the open panel,
96
- // otherwise switch to the adjacent tab
97
- dir === 'down'
98
- ? panels[i].focus({ preventScroll: true })
99
- : tabs[dir]
100
- ? mgTabsSwitch(tabs[dir], panels)
101
- : void 0;
220
+ if (dir === 'down') {
221
+ panels[i].focus({ preventScroll: true });
222
+ } else if (tabs[dir]) {
223
+ if (currentlyStacked) {
224
+ // In stacked mode, just move focus without switching
225
+ tabs[dir].focus({ preventScroll: true });
226
+ } else {
227
+ mgTabsSwitch(tabs[dir], panels);
228
+ }
229
+ }
102
230
  }
103
231
  });
104
232
  });
105
233
 
106
- // Add tab panel semantics and hide them all
234
+ // Add panel semantics and hide them all
107
235
  Array.prototype.forEach.call(panels, panel => {
108
- panel.setAttribute('role', 'tabpanel');
236
+ const panelId = panel.id;
237
+ // Find the corresponding tab trigger
238
+ const correspondingTab = scope.querySelector(`[data-tabs__item="${panelId}"]`);
239
+ const labelId = correspondingTab ? correspondingTab.id : panelId;
240
+
241
+ if (stacked) {
242
+ // Disclosure pattern: panels are regions labelled by their trigger
243
+ panel.setAttribute('role', 'region');
244
+ panel.setAttribute('aria-labelledby', labelId);
245
+ panel.setAttribute('hidden', 'until-found');
246
+ } else {
247
+ // Horizontal tabs: standard tabpanel
248
+ panel.setAttribute('role', 'tabpanel');
249
+ panel.setAttribute('aria-labelledby', labelId);
250
+ panel.hidden = true;
251
+ }
109
252
  panel.setAttribute('tabindex', '-1');
110
- // let id = panel.getAttribute("id");
111
- panel.setAttribute('aria-labelledby', panel.id);
112
- panel.hidden = true;
113
253
  });
114
254
 
115
- // Add the tabsList role to the first <ul> in the .tabbed container
116
- Array.prototype.forEach.call(tabsList, tabsListset => {
117
- tabsListset.setAttribute('role', 'tablist');
118
- // Show the first tab (if the parent tabset is not stacked and screen is not narrow)
119
- if (
120
- tabsListset.dataset.mgJsTabsVariant != 'stacked' &&
121
- window.innerWidth >= 600
122
- ) {
255
+ // Set up container roles and initial state
256
+ tabsListArray.forEach(tabsListset => {
257
+ if (!stacked) {
258
+ // Apply role="tablist" to the <ul> so role="tab" children are valid
259
+ const tabListEl = tabsListset.querySelector('.mg-tabs__list') || tabsListset;
260
+ tabListEl.setAttribute('role', 'tablist');
261
+
262
+ // All direct <li> children of the tablist need role="presentation"
263
+ // so they don't break the tablist → tab hierarchy
264
+ tabListEl.querySelectorAll(':scope > li').forEach(li => {
265
+ li.setAttribute('role', 'presentation');
266
+ });
267
+
123
268
  // Initially activate the first tab
124
269
  let firstTab = tabsListset.querySelectorAll('.mg-tabs__link')[0];
125
270
  firstTab.removeAttribute('tabindex');
@@ -142,6 +287,11 @@ export function mgTabsRuntime(scope, activateDeepLinkOnLoad) {
142
287
  mgTabsDeepLinkOnLoad(tabs, panels);
143
288
  }
144
289
 
290
+ // Initialize filter if the container has the filterable attribute
291
+ if (stacked && container.dataset.mgJsTabsFilterable != null) {
292
+ mgTabsInitFilter(container, tabs, panels);
293
+ }
294
+
145
295
  // When using anchor links after load, activate the corresponding tab
146
296
  window.addEventListener('hashchange', () => {
147
297
  const hash = window.location.hash
@@ -169,36 +319,56 @@ const mgTabsSwitch = (newTab, panels) => {
169
319
  // get the parent ul of the clicked tab
170
320
  let parentTabContainer =
171
321
  newTab.closest('[data-mg-js-tabs]') || newTab.closest('.mg-tabs'); // compatibility with v1 tabs
322
+ const stacked = isStacked(parentTabContainer);
323
+ const targetPanelId = newTab.getAttribute('data-tabs__item');
172
324
  let oldTab = parentTabContainer.querySelector('[aria-selected]');
173
- const behaveAsHorizontalTabs =
174
- parentTabContainer.dataset.mgJsTabsVariant != 'stacked' &&
175
- window.innerWidth >= 600;
176
-
177
- // if it is marked as stacked or small screen (this is not an inverse of behaveAsHorizontalTabs)
178
- if (
179
- parentTabContainer.dataset.mgJsTabsVariant == 'stacked' ||
180
- window.innerWidth <= 600
181
- ) {
325
+
326
+ // if stacked, toggle the clicked panel independently
327
+ if (stacked) {
328
+ const isSingleOpen = parentTabContainer.dataset.mgJsTabsSingleOpen != null;
329
+
182
330
  for (let item = 0; item < panels.length; item++) {
183
331
  const panel = panels[item];
184
- if (panel.id === newTab.id) {
185
- panel.hidden = !panel.hidden;
332
+ if (panel.id === targetPanelId) {
333
+ const wasHidden = panel.hidden || panel.getAttribute('hidden') === 'until-found';
334
+
335
+ // In single-open mode, close all other panels before opening
336
+ if (isSingleOpen && wasHidden) {
337
+ for (let j = 0; j < panels.length; j++) {
338
+ const otherPanel = panels[j];
339
+ if (otherPanel !== panel && !otherPanel.hidden && otherPanel.getAttribute('hidden') !== 'until-found') {
340
+ const otherTrigger = parentTabContainer.querySelector(
341
+ `[data-tabs__item="${otherPanel.id}"]`
342
+ );
343
+ if (otherTrigger) {
344
+ setDisclosureState(otherTrigger, otherPanel, false);
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ setDisclosureState(newTab, panel, wasHidden);
351
+ break;
186
352
  }
187
353
  }
354
+ // In stacked/disclosure mode, we don't deselect other tabs or use aria-selected
355
+ newTab.focus({ preventScroll: true });
356
+ return;
188
357
  }
189
358
 
359
+ // --- Horizontal tab behavior below ---
360
+
190
361
  if (oldTab) {
191
362
  oldTab.removeAttribute('aria-selected');
192
363
  oldTab.setAttribute('tabindex', '-1');
193
364
  oldTab.classList.remove('is-active');
194
365
 
195
- if (behaveAsHorizontalTabs) {
196
- // normal horizontal tabs
197
- for (let item = 0; item < panels.length; item++) {
198
- const panel = panels[item];
199
- if (panel.id === oldTab.id) {
200
- panel.hidden = true;
201
- }
366
+ const oldPanelId = oldTab.getAttribute('data-tabs__item');
367
+ for (let item = 0; item < panels.length; item++) {
368
+ const panel = panels[item];
369
+ if (panel.id === oldPanelId) {
370
+ panel.hidden = true;
371
+ break;
202
372
  }
203
373
  }
204
374
  }
@@ -209,17 +379,194 @@ const mgTabsSwitch = (newTab, panels) => {
209
379
  // Set the selected state
210
380
  newTab.setAttribute('aria-selected', 'true');
211
381
  newTab.classList.add('is-active');
212
- newTab.classList.toggle('mg-tabs__stacked--open'); // we always track the user's open state, even if not stacked as we may switch to mobile view // Get the indices of the new tab to find the correct tab panel to show
213
- if (behaveAsHorizontalTabs) {
214
- for (let item = 0; item < panels.length; item++) {
215
- const panel = panels[item];
216
- if (panel.id === newTab.id) {
217
- panel.hidden = false;
382
+ newTab.classList.add('mg-tabs__stacked--open'); // track open state for potential mobile view switch
383
+
384
+ for (let item = 0; item < panels.length; item++) {
385
+ const panel = panels[item];
386
+ if (panel.id === targetPanelId) {
387
+ panel.hidden = false;
388
+ break;
389
+ }
390
+ }
391
+ };
392
+
393
+ /**
394
+ * Apply default open/close state for stacked tabs.
395
+ *
396
+ * Priority: per-item `data-mg-js-tabs-default` > container `data-mg-js-tabs-default-open` > open first.
397
+ *
398
+ * @param {Element} container - the [data-mg-js-tabs] element
399
+ * @param {NodeList|Array} tabs - the trigger links
400
+ * @param {NodeList|Array} panels - the section panels
401
+ */
402
+ export function mgTabsApplyStackedDefaults(container, tabs, panels) {
403
+ const defaultOpen = container.dataset.mgJsTabsDefaultOpen;
404
+
405
+ Array.prototype.forEach.call(tabs, (tab, i) => {
406
+ const tabId = tab.getAttribute('data-tabs__item');
407
+ let matchingPanel = null;
408
+ for (let j = 0; j < panels.length; j++) {
409
+ if (panels[j].id === tabId) {
410
+ matchingPanel = panels[j];
218
411
  break;
219
412
  }
220
413
  }
414
+ if (!matchingPanel) return;
415
+
416
+ const perItemDefault = tab.getAttribute('data-mg-js-tabs-default');
417
+ let shouldOpen;
418
+
419
+ if (perItemDefault === 'true') {
420
+ shouldOpen = true;
421
+ } else if (perItemDefault === 'false') {
422
+ shouldOpen = false;
423
+ } else if (defaultOpen === 'true') {
424
+ shouldOpen = true;
425
+ } else if (defaultOpen === 'false') {
426
+ shouldOpen = false;
427
+ } else {
428
+ // No container default — preserve existing behavior: open first tab
429
+ shouldOpen = (i === 0);
430
+ }
431
+
432
+ setDisclosureState(tab, matchingPanel, shouldOpen);
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Initialize filter input for stacked tabs.
438
+ * Injects a search input, sr-only hint, and status region before the tab list.
439
+ * Handles debounced filtering, show/hide via CSS classes, panel expand/collapse,
440
+ * focus rescue, and live region announcements.
441
+ *
442
+ * @param {Element} container - the [data-mg-js-tabs] element
443
+ * @param {NodeList} tabs - the trigger links
444
+ * @param {NodeList} panels - the section panels
445
+ */
446
+ function mgTabsInitFilter(container, tabs, panels) {
447
+ const placeholder = container.dataset.mgJsTabsFilterPlaceholder || 'Filter sections\u2026';
448
+ const ariaLabel = placeholder.replace(/\u2026$/, '').trim();
449
+ const totalCount = tabs.length;
450
+
451
+ // Build filter DOM
452
+ const filterWrapper = document.createElement('div');
453
+ filterWrapper.className = 'mg-tabs__filter';
454
+
455
+ const input = document.createElement('input');
456
+ input.type = 'search';
457
+ input.className = 'mg-form-input mg-tabs__filter-input';
458
+ input.placeholder = placeholder;
459
+ input.setAttribute('aria-label', ariaLabel);
460
+
461
+ const hintId = 'mg-tabs-filter-hint-' + Math.random().toString(36).slice(2, 8);
462
+ input.setAttribute('aria-describedby', hintId);
463
+
464
+ const hint = document.createElement('span');
465
+ hint.id = hintId;
466
+ hint.className = 'mg-u-sr-only';
467
+ hint.textContent = 'Results will filter as you type';
468
+
469
+ filterWrapper.appendChild(input);
470
+ filterWrapper.appendChild(hint);
471
+
472
+ // Status region for screen reader announcements
473
+ const status = document.createElement('p');
474
+ status.className = 'mg-u-sr-only';
475
+ status.setAttribute('role', 'status');
476
+
477
+ // Insert before the tab list
478
+ const tabList = container.querySelector('.mg-tabs__list');
479
+ container.insertBefore(filterWrapper, tabList);
480
+
481
+ // No-results element (hidden by default, shown when matchCount === 0)
482
+ // No role="status" here — the sr-only status element handles announcements
483
+ const noResults = document.createElement('p');
484
+ noResults.className = 'mg-tabs__no-results mg-tabs__no-results--hidden';
485
+ noResults.textContent = 'No matching sections found.';
486
+ if (tabList.nextSibling) {
487
+ container.insertBefore(noResults, tabList.nextSibling);
488
+ } else {
489
+ container.appendChild(noResults);
221
490
  }
222
- };
491
+ container.appendChild(status);
492
+
493
+ let hasFiltered = false;
494
+ let debounceTimer = null;
495
+
496
+ function applyFilter() {
497
+ const query = input.value.toLowerCase().trim();
498
+
499
+ if (!query) {
500
+ if (!hasFiltered) return;
501
+ // Restore: show all items, reset to default state
502
+ const items = container.querySelectorAll('.mg-tabs__item');
503
+ items.forEach(item => {
504
+ item.classList.remove('mg-tabs__item--hidden');
505
+ const contentLi = item.nextElementSibling;
506
+ if (contentLi?.classList.contains('mg-tabs-content')) {
507
+ contentLi.classList.remove('mg-tabs-content--hidden');
508
+ }
509
+ });
510
+ mgTabsApplyStackedDefaults(container, tabs, panels);
511
+ noResults.classList.add('mg-tabs__no-results--hidden');
512
+ status.textContent = '';
513
+ return;
514
+ }
515
+
516
+ hasFiltered = true;
517
+ const items = container.querySelectorAll('.mg-tabs__item');
518
+ const words = normalizeText(query).split(/\s+/).filter(Boolean);
519
+ if (words.length === 0) return;
520
+ let matches = 0;
521
+
522
+ items.forEach(item => {
523
+ const trigger = item.querySelector('.mg-tabs__link');
524
+ const contentLi = item.nextElementSibling;
525
+ const panel = contentLi?.querySelector('.mg-tabs__section');
526
+
527
+ const triggerText = trigger?.textContent?.toLowerCase() || '';
528
+ const panelText = panel?.textContent?.toLowerCase() || '';
529
+ const combinedText = normalizeText(triggerText + ' ' + panelText);
530
+ const isMatch = words.every(word => combinedText.includes(word));
531
+
532
+ item.classList.toggle('mg-tabs__item--hidden', !isMatch);
533
+ if (contentLi?.classList.contains('mg-tabs-content')) {
534
+ contentLi.classList.toggle('mg-tabs-content--hidden', !isMatch);
535
+ }
536
+
537
+ if (panel && trigger) {
538
+ setDisclosureState(trigger, panel, isMatch);
539
+ }
540
+ if (isMatch) matches++;
541
+ });
542
+
543
+ // Focus rescue: if active element is now hidden, move to filter input
544
+ const active = document.activeElement;
545
+ if (active && active.closest && active.closest('.mg-tabs__item--hidden')) {
546
+ input.focus();
547
+ }
548
+
549
+ // Update live regions
550
+ if (matches === 0) {
551
+ noResults.classList.remove('mg-tabs__no-results--hidden');
552
+ status.textContent = 'No matching sections found.';
553
+ } else {
554
+ noResults.classList.add('mg-tabs__no-results--hidden');
555
+ status.textContent = matches + ' of ' + totalCount + ' sections match.';
556
+ }
557
+ }
558
+
559
+ input.addEventListener('input', () => {
560
+ clearTimeout(debounceTimer);
561
+ debounceTimer = setTimeout(applyFilter, 150);
562
+ });
563
+
564
+ // Also handle the native search clear button (fires 'search' event in some browsers)
565
+ input.addEventListener('search', () => {
566
+ clearTimeout(debounceTimer);
567
+ applyFilter();
568
+ });
569
+ }
223
570
 
224
571
  function mgTabsDeepLinkOnLoad(tabs, panels) {
225
572
  var mgTabAnchorFound = false;
@@ -241,18 +588,33 @@ function mgTabsDeepLinkOnLoad(tabs, panels) {
241
588
  }
242
589
 
243
590
  if (!mgTabAnchorFound) {
244
- // No hash found - look for default tab
245
- let defaultTabFound = false;
246
- Array.from(tabs).forEach(tab => {
247
- if (tab.getAttribute('data-mg-js-tabs-default') === 'true') {
248
- mgTabsSwitch(tab, panels);
249
- defaultTabFound = true;
250
- }
251
- });
591
+ // Determine the container to check for stacked mode
592
+ const container = tabs.length > 0
593
+ ? (tabs[0].closest('[data-mg-js-tabs]') || tabs[0].closest('.mg-tabs'))
594
+ : null;
595
+
596
+ if (container && isStacked(container)) {
597
+ mgTabsApplyStackedDefaults(container, tabs, panels);
598
+ } else {
599
+ // Horizontal tabs: find default or activate first
600
+ let defaultTabFound = false;
601
+ Array.from(tabs).forEach(tab => {
602
+ if (tab.getAttribute('data-mg-js-tabs-default') === 'true') {
603
+ mgTabsSwitch(tab, panels);
604
+ defaultTabFound = true;
605
+ }
606
+ });
252
607
 
253
- // If no default tab is found, activate the first tab
254
- if (!defaultTabFound && tabs.length > 0) {
255
- mgTabsSwitch(tabs[0], panels);
608
+ if (!defaultTabFound && tabs.length > 0) {
609
+ mgTabsSwitch(tabs[0], panels);
610
+ }
256
611
  }
257
612
  }
258
613
  }
614
+
615
+ // Auto-wrap so the browser Event object is not passed as scope
616
+ if (document.readyState === 'loading') {
617
+ document.addEventListener('DOMContentLoaded', () => mgTabs(), false);
618
+ } else {
619
+ mgTabs();
620
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@undrr/undrr-mangrove",
3
- "version": "1.3.3",
3
+ "version": "1.4.1",
4
4
  "description": "Mangrove design System for UNDRR",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -1,4 +1,4 @@
1
- blockquote {
1
+ .mg-body blockquote {
2
2
  font-size: $mg-font-size-600;
3
3
  font-weight: 600;
4
4
  line-height: 1.16;
@@ -9,10 +9,3 @@ blockquote {
9
9
  line-height: 1.15;
10
10
  }
11
11
  }
12
-
13
- // burmese lang
14
- :lang(my) {
15
- blockquote {
16
- line-height: 1.7;
17
- }
18
- }
@@ -1,6 +1,6 @@
1
- cite {
1
+ .mg-body cite {
2
2
  display: block;
3
3
  font-style: inherit;
4
4
  font-weight: 400;
5
- margin-top: 1.125rem;
5
+ margin-top: mg-rem(11.25);
6
6
  }