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