@pnx-mixtape/mxds 0.1.0-rc.2 → 0.1.0-rc.3
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.
- package/.storybook/{preview.ts → preview.tsx} +4 -0
- package/.storybook/src/docs.tsx +29 -0
- package/dist/build/accordion.entry.js +1 -1
- package/dist/build/autocomplete.css +1 -1
- package/dist/build/autocomplete.entry.js +1 -1
- package/dist/build/base.css +1 -1
- package/dist/build/breadcrumb.css +1 -1
- package/dist/build/button.css +1 -1
- package/dist/build/card.css +1 -1
- package/dist/build/chunks/{Accordion-z-dH6nFH.js → Accordion-GN0sME4W.js} +1 -1
- package/dist/build/chunks/{AccordionMobile-BYPPNyf5.js → AccordionMobile-DG5jSe8Q.js} +1 -1
- package/dist/build/chunks/{Autocomplete-2L6tLLuh.js → Autocomplete-B4RtkAyh.js} +1 -1
- package/dist/build/chunks/{ClosableAlert-DdXbF1J2.js → ClosableAlert-DEhj8Nxm.js} +1 -1
- package/dist/build/chunks/{Dialog-Ds8gzhri.js → Dialog-Haahn-Yn.js} +1 -1
- package/dist/build/chunks/Gallery-D8NGXy2Q.js +1 -0
- package/dist/build/chunks/GlobalToggle-B52fdBQu.js +1 -0
- package/dist/build/chunks/{InPageNavigation-CVieEl9u.js → InPageNavigation-Dyn2TR-N.js} +1 -1
- package/dist/build/chunks/Navigation-hOrfLq7h.js +1 -0
- package/dist/build/chunks/{Sticky-cWUVSOXN.js → Sticky-BGCuuEJK.js} +1 -1
- package/dist/build/chunks/{Tabs-DA_Ez8pv.js → Tabs-DYy1YTdU.js} +1 -1
- package/dist/build/chunks/enums-DLV_1NAf.js +1 -0
- package/dist/build/chunks/utilities-_Bq3TxAQ.js +1 -0
- package/dist/build/cookie-consent.css +1 -1
- package/dist/build/dialog.entry.js +1 -1
- package/dist/build/elements.js +1 -1
- package/dist/build/filters.css +1 -1
- package/dist/build/filters.entry.js +1 -1
- package/dist/build/form.css +1 -1
- package/dist/build/gallery.entry.js +1 -1
- package/dist/build/global-alert.entry.js +1 -1
- package/dist/build/header.entry.js +1 -1
- package/dist/build/icon.css +1 -1
- package/dist/build/in-page-navigation.entry.js +1 -1
- package/dist/build/list-item.css +1 -1
- package/dist/build/navigation.entry.js +1 -1
- package/dist/build/one-time-code.css +1 -0
- package/dist/build/popover.css +1 -1
- package/dist/build/print.css +1 -1
- package/dist/build/react.js +1 -1
- package/dist/build/steps.css +1 -1
- package/dist/build/sticky.entry.js +1 -1
- package/dist/build/tabs.css +1 -1
- package/dist/build/tabs.entry.js +1 -1
- package/dist/build/utilities.css +1 -1
- package/dist/types/Atom/Link/Link.d.ts.map +1 -1
- package/dist/types/Component/Accordion/Accordion.d.ts.map +1 -1
- package/dist/types/Component/Dialog/Dialog.d.ts.map +1 -1
- package/dist/types/Component/Filters/Elements/Filters.d.ts +20 -1
- package/dist/types/Component/Filters/Elements/Filters.d.ts.map +1 -1
- package/dist/types/Component/Filters/Filters.d.ts +7 -2
- package/dist/types/Component/Filters/Filters.d.ts.map +1 -1
- package/dist/types/Component/Gallery/Gallery.d.ts.map +1 -1
- package/dist/types/Component/Pagination/Components/PaginationContext.d.ts.map +1 -1
- package/dist/types/Component/Tabs/Tabs.d.ts.map +1 -1
- package/dist/types/Form/Autocomplete/Autocomplete.d.ts +2 -2
- package/dist/types/Form/Autocomplete/Autocomplete.d.ts.map +1 -1
- package/dist/types/Form/Autocomplete/Elements/Autocomplete.d.ts +10 -0
- package/dist/types/Form/Autocomplete/Elements/Autocomplete.d.ts.map +1 -1
- package/dist/types/Form/OneTimeCode/OneTimeCode.d.ts +12 -0
- package/dist/types/Form/OneTimeCode/OneTimeCode.d.ts.map +1 -0
- package/dist/types/Utility/Hooks/useIsomorphicLayoutEffect.d.ts +4 -0
- package/dist/types/Utility/Hooks/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/types/enums.d.ts +4 -0
- package/dist/types/enums.d.ts.map +1 -1
- package/dist/types/react.d.ts +1 -0
- package/dist/types/react.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/Atom/Button/__snapshots__/Button.stories.ts.snap +2 -3
- package/src/Atom/Button/_buttons.css +16 -0
- package/src/Atom/Button/button.twig +4 -1
- package/src/Atom/DefinitionList/DefinitionList.stories.ts +3 -0
- package/src/Atom/Icon/_icon.css +3 -0
- package/src/Atom/Link/Link.tsx +16 -3
- package/src/Atom/Link/__snapshots__/Link.stories.ts.snap +7 -12
- package/src/Atom/Link/__snapshots__/Link.stories.tsx.snap +1 -1
- package/src/Atom/Link/_links.css +9 -0
- package/src/Atom/Link/link.twig +8 -2
- package/src/Atom/LinkedImage/linked-image.twig +1 -2
- package/src/Component/Accordion/Accordion.stories.ts +31 -0
- package/src/Component/Accordion/Accordion.tsx +3 -2
- package/src/Component/Accordion/AccordionItem.stories.ts +27 -0
- package/src/Component/Accordion/__snapshots__/Accordion.stories.ts.snap +36 -0
- package/src/Component/Accordion/__snapshots__/AccordionItem.stories.ts.snap +16 -0
- package/src/Component/Breadcrumb/__snapshots__/Breadcrumb.stories.ts.snap +3 -6
- package/src/Component/Breadcrumb/breadcrumb.css +4 -0
- package/src/Component/Card/__snapshots__/Card.stories.ts.snap +11 -22
- package/src/Component/Card/card.css +2 -2
- package/src/Component/Carousel/Carousel.stories.ts +55 -0
- package/src/Component/Carousel/Carousel.stories.tsx +74 -0
- package/src/Component/Carousel/__snapshots__/Carousel.stories.ts.snap +273 -88
- package/src/Component/Carousel/__snapshots__/Carousel.stories.tsx.snap +5 -3
- package/src/Component/ContentBlock/__snapshots__/ContentBlock.stories.ts.snap +12 -24
- package/src/Component/CookieConsent/cookie-consent.css +3 -1
- package/src/Component/Dialog/Dialog.stories.ts +33 -0
- package/src/Component/Dialog/Dialog.stories.tsx +25 -0
- package/src/Component/Dialog/Dialog.tsx +2 -2
- package/src/Component/Dialog/__snapshots__/Dialog.stories.ts.snap +33 -2
- package/src/Component/Dialog/__snapshots__/Dialog.stories.tsx.snap +2 -0
- package/src/Component/DropMenu/DropMenu.stories.ts +39 -1
- package/src/Component/DropMenu/DropMenu.stories.tsx +29 -0
- package/src/Component/DropMenu/DropMenu.tsx +1 -1
- package/src/Component/DropMenu/__snapshots__/DropMenu.stories.ts.snap +14 -0
- package/src/Component/DropMenu/__snapshots__/DropMenu.stories.tsx.snap +3 -1
- package/src/Component/Filters/Elements/Filters.ts +71 -1
- package/src/Component/Filters/FilterItem.stories.ts +2 -1
- package/src/Component/Filters/FilterItem.tsx +1 -1
- package/src/Component/Filters/Filters.figma.ts +1 -1
- package/src/Component/Filters/Filters.stories.ts +95 -3
- package/src/Component/Filters/Filters.stories.tsx +246 -17
- package/src/Component/Filters/Filters.tsx +145 -29
- package/src/Component/Filters/README.md +102 -37
- package/src/Component/Filters/__snapshots__/FilterItem.stories.tsx.snap +3 -3
- package/src/Component/Filters/__snapshots__/Filters.stories.ts.snap +82 -4
- package/src/Component/Filters/__snapshots__/Filters.stories.tsx.snap +9 -3
- package/src/Component/Filters/filters.css +11 -0
- package/src/Component/Gallery/Gallery.tsx +2 -2
- package/src/Component/HeroBanner/HeroBanner.stories.ts +1 -1
- package/src/Component/HeroBanner/__snapshots__/HeroBanner.stories.ts.snap +4 -8
- package/src/Component/HeroSearch/__snapshots__/HeroSearch.stories.ts.snap +5 -10
- package/src/Component/InPageAlert/__snapshots__/InPageAlert.stories.ts.snap +6 -12
- package/src/Component/InPageNavigation/InPageNavigation.stories.ts +50 -0
- package/src/Component/InPageNavigation/InPageNavigation.stories.tsx +78 -0
- package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.ts.snap +35 -1
- package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.tsx.snap +2 -0
- package/src/Component/LinkList/__snapshots__/LinkList.stories.ts.snap +6 -12
- package/src/Component/ListItem/__snapshots__/ListItem.stories.ts.snap +7 -14
- package/src/Component/ListItem/list-item.css +1 -1
- package/src/Component/Navigation/Dropdown.stories.tsx +46 -0
- package/src/Component/Navigation/Navigation.stories.ts +56 -0
- package/src/Component/Navigation/__snapshots__/Dropdown.stories.tsx.snap +2 -0
- package/src/Component/Navigation/__snapshots__/Navigation.stories.ts.snap +57 -0
- package/src/Component/Pagination/Components/PaginationContext.tsx +2 -1
- package/src/Component/Pagination/Components/PaginationItem.tsx +1 -1
- package/src/Component/Pagination/Pagination.stories.ts +36 -0
- package/src/Component/Pagination/Pagination.stories.tsx +31 -0
- package/src/Component/Pagination/__snapshots__/Pagination.stories.ts.snap +69 -12
- package/src/Component/Pagination/__snapshots__/Pagination.stories.tsx.snap +3 -1
- package/src/Component/Pagination/pagination-item.twig +1 -1
- package/src/Component/Popover/__snapshots__/Popover.stories.ts.snap +13 -26
- package/src/Component/Popover/popover.css +1 -1
- package/src/Component/SideNavigation/__snapshots__/SideNavigation.stories.ts.snap +1 -2
- package/src/Component/SocialLinks/__snapshots__/SocialLinks.stories.ts.snap +6 -9
- package/src/Component/Steps/Steps.stories.ts +1 -1
- package/src/Component/Steps/steps.css +13 -0
- package/src/Component/Tabs/Tabs.stories.ts +22 -0
- package/src/Component/Tabs/Tabs.stories.tsx +24 -0
- package/src/Component/Tabs/Tabs.tsx +2 -2
- package/src/Component/Tabs/__snapshots__/Tabs.stories.ts.snap +18 -0
- package/src/Component/Tabs/__snapshots__/Tabs.stories.tsx.snap +3 -1
- package/src/Component/Tabs/tabs.css +2 -0
- package/src/Form/Autocomplete/Autocomplete.stories.ts +81 -4
- package/src/Form/Autocomplete/Autocomplete.stories.tsx +85 -1
- package/src/Form/Autocomplete/Autocomplete.tsx +19 -6
- package/src/Form/Autocomplete/Elements/Autocomplete.ts +24 -1
- package/src/Form/Autocomplete/__snapshots__/Autocomplete.stories.ts.snap +38 -4
- package/src/Form/Autocomplete/__snapshots__/Autocomplete.stories.tsx.snap +8 -4
- package/src/Form/Autocomplete/autocomplete.css +12 -1
- package/src/Form/Autocomplete/autocomplete.twig +1 -1
- package/src/Form/OneTimeCode/OneTimeCode.stories.ts +20 -0
- package/src/Form/OneTimeCode/OneTimeCode.stories.tsx +18 -0
- package/src/Form/OneTimeCode/OneTimeCode.tsx +56 -0
- package/src/Form/OneTimeCode/README.md +83 -0
- package/src/Form/OneTimeCode/__snapshots__/OneTimeCode.stories.ts.snap +7 -0
- package/src/Form/OneTimeCode/__snapshots__/OneTimeCode.stories.tsx.snap +3 -0
- package/src/Form/OneTimeCode/one-time-code.css +13 -0
- package/src/Form/OneTimeCode/one-time-code.twig +31 -0
- package/src/Form/__snapshots__/Validation.stories.ts.snap +1 -1
- package/src/Form/form.css +90 -32
- package/src/Layout/Footer/__snapshots__/Footer.stories.ts.snap +18 -33
- package/src/Layout/Header/Header.stories.ts +33 -0
- package/src/Layout/Header/Header.stories.tsx +31 -0
- package/src/Layout/Header/__snapshots__/Header.stories.ts.snap +50 -0
- package/src/Layout/Header/__snapshots__/Header.stories.tsx.snap +2 -0
- package/src/Layout/Masthead/__snapshots__/Masthead.stories.ts.snap +8 -16
- package/src/Layout/Section/__snapshots__/Background.stories.ts.snap +2 -4
- package/src/Layout/Section/__snapshots__/Section.stories.ts.snap +4 -8
- package/src/Utility/Hooks/useIsomorphicLayoutEffect.ts +8 -0
- package/src/Utility/_mixins.css +24 -0
- package/src/Utility/print.css +1 -1
- package/src/Utility/utilities.css +20 -0
- package/src/_custom-media.css +1 -0
- package/src/enums.ts +5 -0
- package/src/react.ts +1 -0
- package/tools/test-utils/screen-reader.ts +33 -0
- package/dist/build/chunks/Gallery-BT3cxS5m.js +0 -1
- package/dist/build/chunks/GlobalToggle-DQHho97V.js +0 -1
- package/dist/build/chunks/Navigation-B9Wug-ok.js +0 -1
- package/dist/build/chunks/disclosure-widget-BETzOHLO.js +0 -1
- 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" },
|
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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: {
|
|
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
|
}
|