@pnx-mixtape/mxds 0.1.0-rc.2 → 0.1.0-rc.4

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 (211) hide show
  1. package/.storybook/{preview.ts → preview.tsx} +4 -0
  2. package/.storybook/src/docs.tsx +29 -0
  3. package/dist/build/accordion.css +1 -1
  4. package/dist/build/accordion.entry.js +1 -1
  5. package/dist/build/autocomplete.css +1 -1
  6. package/dist/build/autocomplete.entry.js +1 -1
  7. package/dist/build/base.css +1 -1
  8. package/dist/build/breadcrumb.css +1 -1
  9. package/dist/build/button.css +1 -1
  10. package/dist/build/card.css +1 -1
  11. package/dist/build/carousel.css +1 -1
  12. package/dist/build/chunks/{Accordion-z-dH6nFH.js → Accordion-GN0sME4W.js} +1 -1
  13. package/dist/build/chunks/{AccordionMobile-BYPPNyf5.js → AccordionMobile-DG5jSe8Q.js} +1 -1
  14. package/dist/build/chunks/{Autocomplete-2L6tLLuh.js → Autocomplete-B4RtkAyh.js} +1 -1
  15. package/dist/build/chunks/{ClosableAlert-DdXbF1J2.js → ClosableAlert-DEhj8Nxm.js} +1 -1
  16. package/dist/build/chunks/{Dialog-Ds8gzhri.js → Dialog-Haahn-Yn.js} +1 -1
  17. package/dist/build/chunks/Gallery-D8NGXy2Q.js +1 -0
  18. package/dist/build/chunks/GlobalToggle-B52fdBQu.js +1 -0
  19. package/dist/build/chunks/{InPageNavigation-CVieEl9u.js → InPageNavigation-Dyn2TR-N.js} +1 -1
  20. package/dist/build/chunks/Navigation-hOrfLq7h.js +1 -0
  21. package/dist/build/chunks/{Sticky-cWUVSOXN.js → Sticky-BGCuuEJK.js} +1 -1
  22. package/dist/build/chunks/{Tabs-DA_Ez8pv.js → Tabs-DYy1YTdU.js} +1 -1
  23. package/dist/build/chunks/enums-DLV_1NAf.js +1 -0
  24. package/dist/build/chunks/utilities-_Bq3TxAQ.js +1 -0
  25. package/dist/build/constants.css +1 -1
  26. package/dist/build/cookie-consent.css +1 -1
  27. package/dist/build/dialog.css +1 -1
  28. package/dist/build/dialog.entry.js +1 -1
  29. package/dist/build/elements.js +1 -1
  30. package/dist/build/filters.css +1 -1
  31. package/dist/build/filters.entry.js +1 -1
  32. package/dist/build/form.css +1 -1
  33. package/dist/build/gallery.entry.js +1 -1
  34. package/dist/build/global-alert.css +1 -1
  35. package/dist/build/global-alert.entry.js +1 -1
  36. package/dist/build/grid.css +1 -1
  37. package/dist/build/header.css +1 -1
  38. package/dist/build/header.entry.js +1 -1
  39. package/dist/build/icon.css +1 -1
  40. package/dist/build/in-page-navigation.entry.js +1 -1
  41. package/dist/build/list-item.css +1 -1
  42. package/dist/build/navigation.css +1 -1
  43. package/dist/build/navigation.entry.js +1 -1
  44. package/dist/build/one-time-code.css +1 -0
  45. package/dist/build/popover.css +1 -1
  46. package/dist/build/print.css +1 -1
  47. package/dist/build/react.js +1 -1
  48. package/dist/build/steps.css +1 -1
  49. package/dist/build/sticky.entry.js +1 -1
  50. package/dist/build/tabs.css +1 -1
  51. package/dist/build/tabs.entry.js +1 -1
  52. package/dist/build/utilities.css +1 -1
  53. package/dist/types/Atom/Link/Link.d.ts.map +1 -1
  54. package/dist/types/Component/Accordion/Accordion.d.ts.map +1 -1
  55. package/dist/types/Component/Dialog/Dialog.d.ts.map +1 -1
  56. package/dist/types/Component/Filters/Elements/Filters.d.ts +20 -1
  57. package/dist/types/Component/Filters/Elements/Filters.d.ts.map +1 -1
  58. package/dist/types/Component/Filters/Filters.d.ts +7 -2
  59. package/dist/types/Component/Filters/Filters.d.ts.map +1 -1
  60. package/dist/types/Component/Gallery/Gallery.d.ts.map +1 -1
  61. package/dist/types/Component/Pagination/Components/PaginationContext.d.ts.map +1 -1
  62. package/dist/types/Component/Tabs/Tabs.d.ts.map +1 -1
  63. package/dist/types/Form/Autocomplete/Autocomplete.d.ts +2 -2
  64. package/dist/types/Form/Autocomplete/Autocomplete.d.ts.map +1 -1
  65. package/dist/types/Form/Autocomplete/Elements/Autocomplete.d.ts +10 -0
  66. package/dist/types/Form/Autocomplete/Elements/Autocomplete.d.ts.map +1 -1
  67. package/dist/types/Form/OneTimeCode/OneTimeCode.d.ts +12 -0
  68. package/dist/types/Form/OneTimeCode/OneTimeCode.d.ts.map +1 -0
  69. package/dist/types/Utility/Hooks/useIsomorphicLayoutEffect.d.ts +4 -0
  70. package/dist/types/Utility/Hooks/useIsomorphicLayoutEffect.d.ts.map +1 -0
  71. package/dist/types/enums.d.ts +4 -0
  72. package/dist/types/enums.d.ts.map +1 -1
  73. package/dist/types/react.d.ts +1 -0
  74. package/dist/types/react.d.ts.map +1 -1
  75. package/package.json +3 -3
  76. package/src/Atom/Button/__snapshots__/Button.stories.ts.snap +2 -3
  77. package/src/Atom/Button/_buttons.css +16 -0
  78. package/src/Atom/Button/button.twig +4 -1
  79. package/src/Atom/DefinitionList/DefinitionList.stories.ts +3 -0
  80. package/src/Atom/Icon/_icon.css +3 -0
  81. package/src/Atom/Link/Link.tsx +16 -3
  82. package/src/Atom/Link/__snapshots__/Link.stories.ts.snap +7 -12
  83. package/src/Atom/Link/__snapshots__/Link.stories.tsx.snap +1 -1
  84. package/src/Atom/Link/_links.css +9 -0
  85. package/src/Atom/Link/link.twig +8 -2
  86. package/src/Atom/LinkedImage/linked-image.twig +1 -2
  87. package/src/Atom/_generic.css +0 -10
  88. package/src/Component/Accordion/Accordion.stories.ts +31 -0
  89. package/src/Component/Accordion/Accordion.tsx +3 -2
  90. package/src/Component/Accordion/AccordionItem.stories.ts +27 -0
  91. package/src/Component/Accordion/__snapshots__/Accordion.stories.ts.snap +36 -0
  92. package/src/Component/Accordion/__snapshots__/AccordionItem.stories.ts.snap +16 -0
  93. package/src/Component/Accordion/accordion.css +3 -1
  94. package/src/Component/Breadcrumb/__snapshots__/Breadcrumb.stories.ts.snap +3 -6
  95. package/src/Component/Breadcrumb/breadcrumb.css +4 -0
  96. package/src/Component/Card/__snapshots__/Card.stories.ts.snap +11 -22
  97. package/src/Component/Card/card.css +2 -2
  98. package/src/Component/Carousel/Carousel.stories.ts +55 -0
  99. package/src/Component/Carousel/Carousel.stories.tsx +74 -0
  100. package/src/Component/Carousel/__snapshots__/Carousel.stories.ts.snap +273 -88
  101. package/src/Component/Carousel/__snapshots__/Carousel.stories.tsx.snap +5 -3
  102. package/src/Component/Carousel/carousel.css +1 -1
  103. package/src/Component/ContentBlock/__snapshots__/ContentBlock.stories.ts.snap +12 -24
  104. package/src/Component/CookieConsent/cookie-consent.css +6 -1
  105. package/src/Component/Dialog/Dialog.stories.ts +33 -0
  106. package/src/Component/Dialog/Dialog.stories.tsx +25 -0
  107. package/src/Component/Dialog/Dialog.tsx +2 -2
  108. package/src/Component/Dialog/__snapshots__/Dialog.stories.ts.snap +33 -2
  109. package/src/Component/Dialog/__snapshots__/Dialog.stories.tsx.snap +2 -0
  110. package/src/Component/Dialog/dialog.css +5 -2
  111. package/src/Component/DropMenu/DropMenu.stories.ts +39 -1
  112. package/src/Component/DropMenu/DropMenu.stories.tsx +29 -0
  113. package/src/Component/DropMenu/DropMenu.tsx +1 -1
  114. package/src/Component/DropMenu/__snapshots__/DropMenu.stories.ts.snap +14 -0
  115. package/src/Component/DropMenu/__snapshots__/DropMenu.stories.tsx.snap +3 -1
  116. package/src/Component/Filters/Elements/Filters.ts +71 -1
  117. package/src/Component/Filters/FilterItem.stories.ts +2 -1
  118. package/src/Component/Filters/FilterItem.tsx +1 -1
  119. package/src/Component/Filters/Filters.figma.ts +1 -1
  120. package/src/Component/Filters/Filters.stories.ts +95 -3
  121. package/src/Component/Filters/Filters.stories.tsx +246 -17
  122. package/src/Component/Filters/Filters.tsx +145 -29
  123. package/src/Component/Filters/README.md +102 -37
  124. package/src/Component/Filters/__snapshots__/FilterItem.stories.tsx.snap +3 -3
  125. package/src/Component/Filters/__snapshots__/Filters.stories.ts.snap +82 -4
  126. package/src/Component/Filters/__snapshots__/Filters.stories.tsx.snap +9 -3
  127. package/src/Component/Filters/filters.css +11 -0
  128. package/src/Component/Gallery/Gallery.tsx +2 -2
  129. package/src/Component/GlobalAlert/global-alert.css +3 -0
  130. package/src/Component/HeroBanner/HeroBanner.stories.ts +1 -1
  131. package/src/Component/HeroBanner/__snapshots__/HeroBanner.stories.ts.snap +4 -8
  132. package/src/Component/HeroSearch/__snapshots__/HeroSearch.stories.ts.snap +5 -10
  133. package/src/Component/InPageAlert/__snapshots__/InPageAlert.stories.ts.snap +6 -12
  134. package/src/Component/InPageNavigation/InPageNavigation.stories.ts +50 -0
  135. package/src/Component/InPageNavigation/InPageNavigation.stories.tsx +78 -0
  136. package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.ts.snap +35 -1
  137. package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.tsx.snap +2 -0
  138. package/src/Component/LinkList/__snapshots__/LinkList.stories.ts.snap +6 -12
  139. package/src/Component/ListItem/__snapshots__/ListItem.stories.ts.snap +7 -14
  140. package/src/Component/ListItem/list-item.css +1 -1
  141. package/src/Component/Navigation/Dropdown.stories.tsx +46 -0
  142. package/src/Component/Navigation/Navigation.stories.ts +56 -0
  143. package/src/Component/Navigation/__snapshots__/Dropdown.stories.tsx.snap +2 -0
  144. package/src/Component/Navigation/__snapshots__/Navigation.stories.ts.snap +57 -0
  145. package/src/Component/Navigation/_navigation-collapsible.css +3 -1
  146. package/src/Component/Navigation/_navigation-dropdown.css +3 -1
  147. package/src/Component/Pagination/Components/PaginationContext.tsx +2 -1
  148. package/src/Component/Pagination/Components/PaginationItem.tsx +1 -1
  149. package/src/Component/Pagination/Pagination.stories.ts +36 -0
  150. package/src/Component/Pagination/Pagination.stories.tsx +31 -0
  151. package/src/Component/Pagination/__snapshots__/Pagination.stories.ts.snap +69 -12
  152. package/src/Component/Pagination/__snapshots__/Pagination.stories.tsx.snap +3 -1
  153. package/src/Component/Pagination/pagination-item.twig +1 -1
  154. package/src/Component/Popover/__snapshots__/Popover.stories.ts.snap +13 -26
  155. package/src/Component/Popover/popover.css +4 -2
  156. package/src/Component/SideNavigation/__snapshots__/SideNavigation.stories.ts.snap +1 -2
  157. package/src/Component/SocialLinks/__snapshots__/SocialLinks.stories.ts.snap +6 -9
  158. package/src/Component/Steps/Steps.stories.ts +1 -1
  159. package/src/Component/Steps/steps.css +13 -0
  160. package/src/Component/Tabs/Tabs.stories.ts +22 -0
  161. package/src/Component/Tabs/Tabs.stories.tsx +24 -0
  162. package/src/Component/Tabs/Tabs.tsx +2 -2
  163. package/src/Component/Tabs/__snapshots__/Tabs.stories.ts.snap +18 -0
  164. package/src/Component/Tabs/__snapshots__/Tabs.stories.tsx.snap +3 -1
  165. package/src/Component/Tabs/tabs.css +6 -5
  166. package/src/Form/Autocomplete/Autocomplete.stories.ts +81 -4
  167. package/src/Form/Autocomplete/Autocomplete.stories.tsx +85 -1
  168. package/src/Form/Autocomplete/Autocomplete.tsx +19 -6
  169. package/src/Form/Autocomplete/Elements/Autocomplete.ts +24 -1
  170. package/src/Form/Autocomplete/__snapshots__/Autocomplete.stories.ts.snap +38 -4
  171. package/src/Form/Autocomplete/__snapshots__/Autocomplete.stories.tsx.snap +8 -4
  172. package/src/Form/Autocomplete/autocomplete.css +12 -1
  173. package/src/Form/Autocomplete/autocomplete.twig +1 -1
  174. package/src/Form/OneTimeCode/OneTimeCode.stories.ts +20 -0
  175. package/src/Form/OneTimeCode/OneTimeCode.stories.tsx +18 -0
  176. package/src/Form/OneTimeCode/OneTimeCode.tsx +56 -0
  177. package/src/Form/OneTimeCode/README.md +83 -0
  178. package/src/Form/OneTimeCode/__snapshots__/OneTimeCode.stories.ts.snap +7 -0
  179. package/src/Form/OneTimeCode/__snapshots__/OneTimeCode.stories.tsx.snap +3 -0
  180. package/src/Form/OneTimeCode/one-time-code.css +13 -0
  181. package/src/Form/OneTimeCode/one-time-code.twig +31 -0
  182. package/src/Form/__snapshots__/Validation.stories.ts.snap +1 -1
  183. package/src/Form/form.css +95 -35
  184. package/src/Layout/Footer/__snapshots__/Footer.stories.ts.snap +18 -33
  185. package/src/Layout/Grid/Grid.stories.ts +1 -0
  186. package/src/Layout/Grid/Grid.stories.tsx +26 -0
  187. package/src/Layout/Grid/__snapshots__/Grid.stories.tsx.snap +2 -0
  188. package/src/Layout/Grid/grid.css +12 -0
  189. package/src/Layout/Header/Header.stories.ts +33 -0
  190. package/src/Layout/Header/Header.stories.tsx +31 -0
  191. package/src/Layout/Header/__snapshots__/Header.stories.ts.snap +50 -0
  192. package/src/Layout/Header/__snapshots__/Header.stories.tsx.snap +2 -0
  193. package/src/Layout/Header/_header.css +4 -2
  194. package/src/Layout/Header/_toggles.css +4 -1
  195. package/src/Layout/Masthead/__snapshots__/Masthead.stories.ts.snap +8 -16
  196. package/src/Layout/Section/__snapshots__/Background.stories.ts.snap +2 -4
  197. package/src/Layout/Section/__snapshots__/Section.stories.ts.snap +4 -8
  198. package/src/Utility/Hooks/useIsomorphicLayoutEffect.ts +8 -0
  199. package/src/Utility/_mixins.css +46 -0
  200. package/src/Utility/print.css +1 -1
  201. package/src/Utility/utilities.css +20 -0
  202. package/src/_custom-media.css +3 -2
  203. package/src/constants.css +3 -13
  204. package/src/enums.ts +5 -0
  205. package/src/react.ts +1 -0
  206. package/tools/test-utils/screen-reader.ts +33 -0
  207. package/dist/build/chunks/Gallery-BT3cxS5m.js +0 -1
  208. package/dist/build/chunks/GlobalToggle-DQHho97V.js +0 -1
  209. package/dist/build/chunks/Navigation-B9Wug-ok.js +0 -1
  210. package/dist/build/chunks/disclosure-widget-BETzOHLO.js +0 -1
  211. package/dist/build/chunks/utilities-C5dAO2YA.js +0 -1
@@ -1,12 +1,27 @@
1
1
  /**
2
2
  * Filters
3
- * @file Support filter count elements and closing the "right" variant on mobile..
3
+ * @file Support filter count elements and closing the "right" variant on mobile.
4
+ *
5
+ * The `pending` property is fully consumer-controlled — there's no promise to
6
+ * await here, only a native `submit` event — so intercept the form's submit,
7
+ * set `el.pending = true` before your fetch/navigation, and `= false` once it
8
+ * settles. It reflects `aria-busy` on the form, the same contract the React
9
+ * implementation exposes: one attribute drives both the assistive-tech signal
10
+ * and the spinner (see the generic `[aria-busy="true"]` utility in
11
+ * Utility/utilities.css).
12
+ *
13
+ * `mx-filters--auto` (the `instant` prop) auto-submits the form as filters
14
+ * change — immediately for checkbox/radio/select fields, debounced for text
15
+ * fields — and on reset. The consumer still needs to intercept the form's
16
+ * `submit` event and call `preventDefault()` to avoid a real page navigation,
17
+ * same as manual submission, but now it can fire on every keystroke.
4
18
  */
5
19
 
6
20
  import { createFocusTrap, FocusTrap } from "focus-trap"
7
21
  import "../../Accordion/Elements/Accordion"
8
22
  import "../../Accordion/Elements/AccordionMobile"
9
23
  import { validators } from "../../../Utility/utilities"
24
+ import { DEBOUNCE_MS } from "../../../enums"
10
25
 
11
26
  type EntryType = {
12
27
  item: HTMLElement
@@ -25,6 +40,8 @@ export default class Filters extends HTMLElement {
25
40
  focusTrapTimeout?: ReturnType<typeof setTimeout> | null
26
41
  scrollLockClass: string = "body--scroll-lock"
27
42
  isRight?: boolean
43
+ isInstant?: boolean
44
+ instantDebounce?: ReturnType<typeof setTimeout> | null
28
45
 
29
46
  static observedAttributes = ["count"]
30
47
 
@@ -34,6 +51,7 @@ export default class Filters extends HTMLElement {
34
51
  this.controller = new AbortController()
35
52
  this.mq = this.getAttribute("mq") || "(max-width: 720px)"
36
53
  this.isRight = this.classList.contains(`mx-filters--right`)
54
+ this.isInstant = this.classList.contains("mx-filters--auto")
37
55
  }
38
56
 
39
57
  connectedCallback(): void {
@@ -55,6 +73,11 @@ export default class Filters extends HTMLElement {
55
73
  this.form?.addEventListener("submit", this.handleCounters, { signal })
56
74
  this.form?.addEventListener("reset", this.handleReset, { signal })
57
75
 
76
+ if (this.isInstant) {
77
+ this.addEventListener("change", this.handleInstantSubmit, { signal })
78
+ this.addEventListener("input", this.handleInstantSubmit, { signal })
79
+ }
80
+
58
81
  if (this.isRight) {
59
82
  this.handleBreakpoint(this.breakpoint)
60
83
  this.breakpoint.addEventListener("change", this.handleBreakpoint, {
@@ -107,6 +130,10 @@ export default class Filters extends HTMLElement {
107
130
  clearTimeout(this.timeout)
108
131
  this.timeout = null
109
132
  }
133
+ if (this.instantDebounce) {
134
+ clearTimeout(this.instantDebounce)
135
+ this.instantDebounce = null
136
+ }
110
137
 
111
138
  // Clean up focus trap timers
112
139
  if (this.rafId) {
@@ -156,6 +183,41 @@ export default class Filters extends HTMLElement {
156
183
  if (entry.counter) entry.counter.textContent = ""
157
184
  })
158
185
  this.setAttribute("count", "0")
186
+
187
+ if (this.instantDebounce) {
188
+ clearTimeout(this.instantDebounce)
189
+ this.instantDebounce = null
190
+ }
191
+ // The "reset" event fires *before* the browser actually clears field
192
+ // values (per the HTML reset algorithm), so requestSubmit() here would
193
+ // submit the stale, pre-reset state. Defer to the next task (reusing the
194
+ // debounce field, since any pending debounce was just cleared above), by
195
+ // which point the native reset has already applied.
196
+ if (this.isInstant) {
197
+ this.instantDebounce = setTimeout(() => this.form?.requestSubmit(), 0)
198
+ }
199
+ }
200
+
201
+ handleInstantSubmit = (event: Event): void => {
202
+ const target = event.target as HTMLInputElement | HTMLSelectElement
203
+ const isImmediate =
204
+ target instanceof HTMLSelectElement ||
205
+ (target instanceof HTMLInputElement && ["checkbox", "radio"].includes(target.type))
206
+
207
+ // Avoid a double-fire: only act on "change" for immediate-type fields,
208
+ // and only on "input" for text-type fields (native "change" on a text
209
+ // input only fires on blur, not per keystroke).
210
+ if (isImmediate !== (event.type === "change")) return
211
+
212
+ if (this.instantDebounce) {
213
+ clearTimeout(this.instantDebounce)
214
+ this.instantDebounce = null
215
+ }
216
+ if (isImmediate) {
217
+ this.form?.requestSubmit()
218
+ } else {
219
+ this.instantDebounce = setTimeout(() => this.form?.requestSubmit(), DEBOUNCE_MS)
220
+ }
159
221
  }
160
222
 
161
223
  handleCounters = (): void => {
@@ -246,6 +308,14 @@ export default class Filters extends HTMLElement {
246
308
  get breakpoint(): MediaQueryList {
247
309
  return window.matchMedia(this.mq)
248
310
  }
311
+
312
+ get pending(): boolean {
313
+ return this.form?.getAttribute("aria-busy") === "true"
314
+ }
315
+
316
+ set pending(value: boolean) {
317
+ this.form?.setAttribute("aria-busy", String(value))
318
+ }
249
319
  }
250
320
 
251
321
  if (!customElements.get("mx-filters")) customElements.define("mx-filters", Filters)
@@ -88,7 +88,8 @@ const meta: Meta<MxFilterItemType> = {
88
88
  },
89
89
  as: {
90
90
  description: "Optionally create a fieldset wrapper, for checkboxes and radio fields.",
91
- options: ["div, fieldset"],
91
+ options: ["div", "fieldset"],
92
+ control: "radio",
92
93
  table: {
93
94
  type: { summary: "string" },
94
95
  defaultValue: { summary: "div" },
@@ -59,7 +59,7 @@ const FilterItem = ({
59
59
  )
60
60
 
61
61
  const fieldContent = (
62
- <Tag className={itemClass} {...(id ? { id } : {})}>
62
+ <Tag className={itemClass} id={id ? `${id}-item` : undefined}>
63
63
  {label}
64
64
  {descriptionStart}
65
65
  {fields.length > 0 ? (
@@ -24,7 +24,7 @@ const actions = figma.code`[
24
24
  Apply filters
25
25
  </button>,
26
26
  <button key="clear" type="reset" className="mx-button mx-button--light">
27
- Clear all filters
27
+ Clear all
28
28
  </button>,
29
29
  ]`
30
30
 
@@ -1,4 +1,5 @@
1
1
  import { Meta, StoryObj } from "@storybook/html-vite"
2
+ import { expect, waitFor } from "storybook/test"
2
3
  import Component from "./filters.twig"
3
4
  import FilterItem from "./filter-item.twig"
4
5
  import "./Elements/Filters"
@@ -85,7 +86,7 @@ const meta: Meta<FiltersType> = {
85
86
  }),
86
87
  Button({
87
88
  as: "reset",
88
- title: "Clear all filters",
89
+ title: "Clear all",
89
90
  modifiers: [ButtonModifiers.LIGHT],
90
91
  }),
91
92
  ].join(""),
@@ -296,7 +297,14 @@ export const CollapsibleRight: Story = {
296
297
  }
297
298
 
298
299
  /**
299
- * Instant is currently just an example without a submit button, where some custom javascript handles dynamically submitting (eg. ajax).
300
+ * `instant` auto-submits the form as filters change: immediately for
301
+ * checkbox/radio/select fields, debounced (~400ms) for text fields so a fast
302
+ * typist doesn't trigger a submit per keystroke. Reset also auto-submits.
303
+ * There's no submit button here - some other UI (e.g. a live result count)
304
+ * signals that results updated, since there's nothing to click. As with
305
+ * manual submission, the consumer still needs to intercept the form's
306
+ * `submit` event and call `preventDefault()` to avoid a real page
307
+ * navigation - this matters more here since it can fire on every keystroke.
300
308
  */
301
309
  export const Instant: Story = {
302
310
  args: {
@@ -304,10 +312,94 @@ export const Instant: Story = {
304
312
  actions: [
305
313
  Button({
306
314
  as: "reset",
307
- title: "Clear all filters",
315
+ title: "Clear all",
308
316
  modifiers: [ButtonModifiers.LIGHT],
309
317
  disabled: true,
310
318
  }),
311
319
  ].join(""),
312
320
  },
321
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
322
+ const root = canvasElement.querySelector("mx-filters")
323
+ const form = root?.querySelector("form")
324
+ if (!root || !form) throw new Error("mx-filters > form not found")
325
+
326
+ let submitCount = 0
327
+ form.addEventListener("submit", event => {
328
+ event.preventDefault()
329
+ submitCount += 1
330
+ })
331
+
332
+ await step("Checking a checkbox submits immediately", async () => {
333
+ await userEvent.click(canvas.getByRole("checkbox", { name: "News" }))
334
+ await waitFor(() => expect(submitCount).toBe(1))
335
+ })
336
+
337
+ await step("Typing debounces before submitting", async () => {
338
+ const input = canvas.getByRole("textbox", { name: "Keywords" })
339
+ await userEvent.type(input, "abc")
340
+ expect(submitCount).toBe(1)
341
+ await waitFor(() => expect(submitCount).toBe(2), { timeout: 1000 })
342
+ })
343
+
344
+ await step("An immediate change cancels a pending debounce", async () => {
345
+ const input = canvas.getByRole("textbox", { name: "Keywords" })
346
+ await userEvent.type(input, "d")
347
+ await userEvent.click(canvas.getByRole("checkbox", { name: "Media Releases" }))
348
+ await waitFor(() => expect(submitCount).toBe(3))
349
+ // Give the (cancelled) debounce window time to prove it doesn't also fire.
350
+ await new Promise(resolve => setTimeout(resolve, 600))
351
+ expect(submitCount).toBe(3)
352
+ })
353
+
354
+ await step("Reset auto-submits", async () => {
355
+ await userEvent.click(canvas.getByRole("button", { name: "Clear all" }))
356
+ await waitFor(() => expect(submitCount).toBe(4))
357
+ })
358
+ },
359
+ }
360
+
361
+ // Demonstrates the built-in pending state: intercepting the form's `submit`
362
+ // event and toggling the consumer-controlled `pending` property (see
363
+ // Elements/Filters.ts) around a simulated fetch delay. The Web Component
364
+ // reflects that as `aria-busy` on the form - the same attribute React's
365
+ // implementation sets. A single generic CSS rule (`[aria-busy="true"]` in
366
+ // Utility/utilities.css) drives the spinner for both, and for any other
367
+ // component that reflects `aria-busy`.
368
+ export const Pending: Story = {
369
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
370
+ const root = canvasElement.querySelector("mx-filters")
371
+ const form = root?.querySelector("form")
372
+ if (!root || !form) throw new Error("mx-filters > form not found")
373
+
374
+ form.addEventListener("submit", event => {
375
+ event.preventDefault()
376
+ root.pending = true
377
+ setTimeout(() => {
378
+ root.pending = false
379
+ }, 150)
380
+ })
381
+
382
+ const submitButton = canvas.getByRole("button", { name: "Apply filters" })
383
+
384
+ // The submit button starts disabled until a filter is active (see
385
+ // Elements/Filters.ts's `count` handling) - check one first.
386
+ await step("Checking a filter enables Apply", async () => {
387
+ await userEvent.click(canvas.getByRole("checkbox", { name: "News" }))
388
+ await waitFor(() => expect(submitButton).toBeEnabled())
389
+ })
390
+
391
+ await step("Shows a pending state while the simulated submit resolves", async () => {
392
+ await userEvent.click(submitButton)
393
+ await expect(form).toHaveAttribute("aria-busy", "true")
394
+ })
395
+
396
+ await step("Clears pending once the submit settles", async () => {
397
+ await waitFor(() => expect(form).toHaveAttribute("aria-busy", "false"), { timeout: 2000 })
398
+ })
399
+
400
+ await step("Reset back to no active filters", async () => {
401
+ await userEvent.click(canvas.getByRole("button", { name: "Clear all" }))
402
+ await waitFor(() => expect(submitButton).toBeDisabled())
403
+ })
404
+ },
313
405
  }
@@ -1,5 +1,7 @@
1
+ import { JSX } from "react"
1
2
  import { Meta, StoryObj } from "@storybook/react-vite"
2
- import Component from "./Filters"
3
+ import { expect, waitFor } from "storybook/test"
4
+ import Component, { useFiltersContext } from "./Filters"
3
5
  import FilterItem from "./FilterItem"
4
6
  import FormLabel from "../../Form/Label/FormLabel"
5
7
  import { SingleCheckbox } from "../../Form/Checkbox/FormCheckbox"
@@ -10,6 +12,7 @@ import "../Accordion/accordion.css"
10
12
  import "../Tag/tag.css"
11
13
  import "../../Form/form.css"
12
14
  import { Page } from "../../../.storybook-react/decorators"
15
+ import { modes } from "../../../.storybook/modes"
13
16
 
14
17
  const keywordsItem = (
15
18
  <FilterItem
@@ -70,14 +73,25 @@ const collapsibleCategoriesItem = (
70
73
  />
71
74
  )
72
75
 
73
- const actions = [
74
- <button key="apply" type="submit" className="mx-button mx-button--dark">
75
- Apply filters
76
- </button>,
77
- <button key="clear" type="reset" className="mx-button mx-button--light">
78
- Clear all filters
79
- </button>,
80
- ]
76
+ // Reads pending/empty state from FiltersContext to disable Apply/Clear the
77
+ // same way the Web Component does via its `count` attribute: both buttons
78
+ // are disabled while no filter is active, and while a submit is pending.
79
+ const FilterActions = ({ hideSubmit = false }: { hideSubmit?: boolean }): JSX.Element => {
80
+ const { pending, totalCount } = useFiltersContext()
81
+ const disabled = pending || totalCount === 0
82
+ return (
83
+ <>
84
+ {!hideSubmit && (
85
+ <button type="submit" className="mx-button mx-button--dark" disabled={disabled}>
86
+ Apply filters
87
+ </button>
88
+ )}
89
+ <button type="reset" className="mx-button mx-button--light" disabled={disabled}>
90
+ Clear all
91
+ </button>
92
+ </>
93
+ )
94
+ }
81
95
 
82
96
  /**
83
97
  * A search-refinement form built from FilterItem components. Tracks active-filter
@@ -94,25 +108,56 @@ const meta: Meta<typeof Component> = {
94
108
  collapsible: false,
95
109
  counter: false,
96
110
  items: [keywordsItem, categoriesItem],
97
- actions,
111
+ actions: [<FilterActions key="apply" />],
98
112
  },
99
113
  argTypes: {
100
114
  direction: {
101
115
  control: "select",
102
- options: ["down"],
103
- description:
104
- 'Layout variant. "right" (drawer) is not yet supported in React — it requires AccordionMobile React parity.',
116
+ options: ["down", "right"],
117
+ description: "Layout variant for mobile.",
105
118
  },
106
119
  collapsible: { control: "boolean" },
107
120
  counter: { control: "boolean" },
108
- instant: { control: "boolean" },
121
+ instant: {
122
+ control: "boolean",
123
+ description:
124
+ "Auto-submits the form as filters change (immediately for checkbox/radio/select, debounced for text) and on reset.",
125
+ },
109
126
  sticky: { control: "boolean" },
110
127
  },
128
+ parameters: {
129
+ chromatic: {
130
+ modes: {
131
+ mobile: modes["xs"],
132
+ desktop: modes["xl"],
133
+ },
134
+ },
135
+ },
111
136
  }
112
137
 
113
138
  export default meta
114
139
 
115
- export const Default: StoryObj<typeof meta> = {}
140
+ export const Default: StoryObj<typeof meta> = {
141
+ play: async ({ canvas, userEvent, step }) => {
142
+ const input = canvas.getByRole("checkbox", { name: "News" })
143
+ const submit = canvas.getByRole("button", { name: "Apply filters" })
144
+ const clear = canvas.getByRole("button", { name: "Clear all" })
145
+ await step("Starts with disabled buttons", async () => {
146
+ await waitFor(() => expect(submit).toBeDisabled())
147
+ await waitFor(() => expect(clear).toBeDisabled())
148
+ })
149
+ await step("Buttons activate after interaction", async () => {
150
+ await userEvent.click(input)
151
+ await waitFor(() => expect(submit).not.toBeDisabled())
152
+ await waitFor(() => expect(clear).not.toBeDisabled())
153
+ })
154
+ await step("Restore original state", async () => {
155
+ await userEvent.click(input)
156
+ await waitFor(() => expect(submit).toBeDisabled())
157
+ await waitFor(() => expect(clear).toBeDisabled())
158
+ })
159
+ },
160
+ }
116
161
 
117
162
  /**
118
163
  * The filter panel collapses into a `<details>` accordion on mobile (below 720px).
@@ -124,6 +169,78 @@ export const Collapsible: StoryObj<typeof meta> = {
124
169
  direction: "down",
125
170
  items: [collapsibleKeywordsItem, collapsibleCategoriesItem],
126
171
  },
172
+ play: async ({ canvasElement, userEvent, step }) => {
173
+ const summary = canvasElement.querySelector("summary")
174
+ const details = canvasElement.querySelector("details")
175
+ if (!summary || !details) throw new Error("details/summary not found")
176
+ const isMobile = matchMedia("(max-width: 767px)").matches
177
+ if (!isMobile) {
178
+ await step("Is open on desktop", async () => {
179
+ await waitFor(() => expect(details).toHaveAttribute("open"))
180
+ })
181
+ } else {
182
+ await step("Starts closed on mobile", async () => {
183
+ await waitFor(() => expect(details).not.toHaveAttribute("open"))
184
+ })
185
+ await step("Opens and closes", async () => {
186
+ await userEvent.click(summary)
187
+ await waitFor(() => expect(details).toHaveAttribute("open"))
188
+ await userEvent.click(summary)
189
+ await waitFor(() => expect(details).not.toHaveAttribute("open"))
190
+ })
191
+ }
192
+ },
193
+ }
194
+
195
+ /**
196
+ * The filter panel collapses into a `<details>` accordion on mobile (below 720px).
197
+ * But opens as an off-canvas drawer instead of normal accordion.
198
+ */
199
+ export const CollapsibleRight: StoryObj<typeof meta> = {
200
+ args: {
201
+ collapsible: true,
202
+ direction: "right",
203
+ items: [collapsibleKeywordsItem, collapsibleCategoriesItem],
204
+ },
205
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
206
+ const summary = canvasElement.querySelector("summary")
207
+ const details = canvasElement.querySelector("details")
208
+ if (!summary || !details) throw new Error("details/summary not found")
209
+ const isMobile = matchMedia("(max-width: 767px)").matches
210
+ if (!isMobile) {
211
+ await step("Is open on desktop", async () => {
212
+ await waitFor(() => expect(details).toHaveAttribute("open"))
213
+ })
214
+ } else {
215
+ await step("Starts closed on mobile", async () => {
216
+ await waitFor(() => expect(details).not.toHaveAttribute("open"))
217
+ })
218
+
219
+ await step("Typed input survives closing the drawer", async () => {
220
+ await userEvent.click(summary)
221
+ await waitFor(() => expect(details).toHaveAttribute("open"))
222
+
223
+ const input = canvas.getByRole("textbox", { name: "Keywords" })
224
+ await userEvent.type(input, "hello")
225
+ await waitFor(() => expect(input).toHaveValue("hello"))
226
+
227
+ await userEvent.click(canvas.getByRole("button", { name: "Close filters" }))
228
+ await waitFor(() => expect(details).not.toHaveAttribute("open"))
229
+
230
+ await userEvent.click(summary)
231
+ await waitFor(() => expect(details).toHaveAttribute("open"))
232
+ await waitFor(() =>
233
+ expect(canvas.getByRole("textbox", { name: "Keywords" })).toHaveValue("hello"),
234
+ )
235
+ })
236
+
237
+ await step("Restore original state", async () => {
238
+ await userEvent.clear(canvas.getByRole("textbox", { name: "Keywords" }))
239
+ await userEvent.click(canvas.getByRole("button", { name: "Close filters" }))
240
+ await waitFor(() => expect(details).not.toHaveAttribute("open"))
241
+ })
242
+ }
243
+ },
127
244
  }
128
245
 
129
246
  /**
@@ -133,9 +250,121 @@ export const Collapsible: StoryObj<typeof meta> = {
133
250
  */
134
251
  export const WithCounter: StoryObj<typeof meta> = {
135
252
  args: {
136
- collapsible: true,
137
253
  counter: true,
138
- direction: "down",
139
254
  items: [collapsibleKeywordsItem, collapsibleCategoriesItem],
140
255
  },
256
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
257
+ const input = canvas.getByRole("checkbox", { name: "News" })
258
+ await step("Starts with no count", async () => {
259
+ const count = canvasElement.querySelectorAll(".mx-tag")
260
+ await waitFor(() => expect(count).toHaveLength(0))
261
+ })
262
+ await step("Count is added after interaction", async () => {
263
+ await userEvent.click(input)
264
+ const count = canvasElement.querySelectorAll(".mx-tag")
265
+ await waitFor(() => expect(count).toHaveLength(2))
266
+ })
267
+ await step("Restore original state", async () => {
268
+ await userEvent.click(input)
269
+ const count = canvasElement.querySelectorAll(".mx-tag")
270
+ await waitFor(() => expect(count).toHaveLength(0))
271
+ })
272
+ },
273
+ }
274
+
275
+ // `instant` auto-submits the form as filters change: immediately for
276
+ // checkbox/radio/select fields, debounced (~400ms) for text fields so a fast
277
+ // typist doesn't trigger a submit per keystroke. Reset also auto-submits. As
278
+ // with manual submission, `onSubmit` must call `event.preventDefault()` to
279
+ // avoid a real page navigation - this matters more here since it can fire on
280
+ // every keystroke.
281
+ export const Instant: StoryObj<typeof meta> = {
282
+ args: {
283
+ instant: true,
284
+ actions: [<FilterActions hideSubmit={true} key="clear" />],
285
+ onSubmit: event => {
286
+ event.preventDefault()
287
+ },
288
+ },
289
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
290
+ const form = canvasElement.querySelector(".mx-filters__form")
291
+ if (!form) throw new Error("mx-filters__form not found")
292
+ let submitCount = 0
293
+ form.addEventListener("submit", () => {
294
+ submitCount += 1
295
+ })
296
+
297
+ await step("Checking a checkbox submits immediately", async () => {
298
+ await userEvent.click(canvas.getByRole("checkbox", { name: "News" }))
299
+ await waitFor(() => expect(submitCount).toBe(1))
300
+ })
301
+
302
+ await step("Typing debounces before submitting", async () => {
303
+ const input = canvas.getByRole("textbox", { name: "Keywords" })
304
+ await userEvent.type(input, "abc")
305
+ expect(submitCount).toBe(1)
306
+ await waitFor(() => expect(submitCount).toBe(2), { timeout: 1000 })
307
+ })
308
+
309
+ await step("An immediate change cancels a pending debounce", async () => {
310
+ const input = canvas.getByRole("textbox", { name: "Keywords" })
311
+ await userEvent.type(input, "d")
312
+ await userEvent.click(canvas.getByRole("checkbox", { name: "Media Releases" }))
313
+ await waitFor(() => expect(submitCount).toBe(3))
314
+ // Give the (cancelled) debounce window time to prove it doesn't also fire.
315
+ await new Promise(resolve => setTimeout(resolve, 600))
316
+ expect(submitCount).toBe(3)
317
+ })
318
+
319
+ await step("Reset auto-submits", async () => {
320
+ await userEvent.click(canvas.getByRole("button", { name: "Clear all" }))
321
+ await waitFor(() => expect(submitCount).toBe(4))
322
+ // The Keywords field is a controlled input with its own local state
323
+ // (FormText) - a native form reset doesn't fire its onChange, so
324
+ // React's next render re-applies the stale typed value over the
325
+ // reset DOM. Clear it for real so the story ends in a stable state.
326
+ const input = canvas.getByRole("textbox", { name: "Keywords" })
327
+ await userEvent.clear(input)
328
+ await waitFor(() => expect(submitCount).toBe(5), { timeout: 1000 })
329
+ })
330
+ },
331
+ }
332
+
333
+ // Demonstrates the built-in pending state: `onSubmit` returns a promise
334
+ // (standing in for a real fetch/router navigation), so the component sets
335
+ // `aria-busy` on the form until it resolves. A generic CSS rule
336
+ // (`[aria-busy="true"]` in Utility/utilities.css) shows the spinner - no
337
+ // component-specific markup or class needed.
338
+ export const Pending: StoryObj<typeof meta> = {
339
+ args: {
340
+ onSubmit: event => {
341
+ event.preventDefault()
342
+ return new Promise(resolve => setTimeout(resolve, 800))
343
+ },
344
+ },
345
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
346
+ const form = canvasElement.querySelector(".mx-filters__form")
347
+ if (!form) throw new Error("mx-filters__form not found")
348
+ const submitButton = canvas.getByRole("button", { name: "Apply filters" })
349
+
350
+ // Apply/Clear start disabled until a filter is active (see FilterActions).
351
+ await step("Checking a filter enables Apply", async () => {
352
+ await userEvent.click(canvas.getByRole("checkbox", { name: "News" }))
353
+ await waitFor(() => expect(submitButton).toBeEnabled())
354
+ })
355
+
356
+ await step("Shows a pending spinner while the submit resolves", async () => {
357
+ await userEvent.click(submitButton)
358
+ await expect(form).toHaveAttribute("aria-busy", "true")
359
+ })
360
+
361
+ await step("Clears pending once the submit settles", async () => {
362
+ await waitFor(
363
+ () => {
364
+ expect(form).toHaveAttribute("aria-busy", "false")
365
+ },
366
+ { timeout: 2000 },
367
+ )
368
+ })
369
+ },
141
370
  }