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

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 (199) hide show
  1. package/.claude/skills/create-mxds-component/SKILL.md +77 -0
  2. package/DESIGN.md +595 -0
  3. package/README.md +1 -2
  4. package/dist/build/accordion.entry.js +2 -1
  5. package/dist/build/autocomplete.entry.js +1 -1
  6. package/dist/build/back-to-top.css +1 -0
  7. package/dist/build/back-to-top.entry.js +1 -0
  8. package/dist/build/button.css +1 -1
  9. package/dist/build/card.css +1 -1
  10. package/dist/build/chunks/Accordion-D2wp8hBG.js +44 -0
  11. package/dist/build/chunks/AccordionGroup-BYtoAFxg.js +47 -0
  12. package/dist/build/chunks/AccordionMobile-D3sTeiM5.js +26 -0
  13. package/dist/build/chunks/Autocomplete-kRo8RNbC.js +135 -0
  14. package/dist/build/chunks/BackToTop-4M-Wl7z3.js +64 -0
  15. package/dist/build/chunks/ClosableAlert-yy84gae6.js +55 -0
  16. package/dist/build/chunks/CookieConsent-wd8gQ2Oe.js +18 -0
  17. package/dist/build/chunks/Dialog-DWoXW-7Z.js +48 -0
  18. package/dist/build/chunks/DropMenu-CSTZq0Zt.js +68 -0
  19. package/dist/build/chunks/FeedbackForm-BochXEDx.js +64 -0
  20. package/dist/build/chunks/Gallery-Rkfv7Mbb.js +165 -0
  21. package/dist/build/chunks/GlobalToggle-CzlkpLpc.js +82 -0
  22. package/dist/build/chunks/InPageNavigation-D1Kk-rCm.js +86 -0
  23. package/dist/build/chunks/Navigation-CDQnN61h.js +147 -0
  24. package/dist/build/chunks/Popover-CiTAftNU.js +30 -0
  25. package/dist/build/chunks/Sticky-DoVndENm.js +51 -0
  26. package/dist/build/chunks/Tabs-BDlxCDNh.js +160 -0
  27. package/dist/build/chunks/Translate-bmM38j6z.js +46 -0
  28. package/dist/build/chunks/cookie-compliance-CG6x-TcV.js +119 -0
  29. package/dist/build/chunks/enums-BrHxPZZ_.js +36 -0
  30. package/dist/build/chunks/polyfills-D-qh1a_2.js +33 -0
  31. package/dist/build/chunks/utilities-Dd-08SW6.js +255 -0
  32. package/dist/build/content-block.css +1 -1
  33. package/dist/build/cookie-consent.entry.js +1 -1
  34. package/dist/build/dialog.entry.js +1 -1
  35. package/dist/build/drop-menu.entry.js +1 -1
  36. package/dist/build/elements.js +147 -1
  37. package/dist/build/feedback-form.css +1 -0
  38. package/dist/build/feedback-form.entry.js +1 -0
  39. package/dist/build/filters.entry.js +145 -1
  40. package/dist/build/gallery.entry.js +1 -1
  41. package/dist/build/global-alert.entry.js +1 -1
  42. package/dist/build/header.css +1 -1
  43. package/dist/build/header.entry.js +1 -1
  44. package/dist/build/icon.css +1 -1
  45. package/dist/build/in-page-navigation.entry.js +1 -1
  46. package/dist/build/navigation.entry.js +1 -1
  47. package/dist/build/pagination.css +1 -1
  48. package/dist/build/popover.entry.js +1 -1
  49. package/dist/build/react.js +2270 -1
  50. package/dist/build/sticky.entry.js +1 -1
  51. package/dist/build/tabs.entry.js +1 -1
  52. package/dist/build/translate.css +1 -0
  53. package/dist/build/translate.entry.js +1 -0
  54. package/dist/build/utility-list.entry.js +53 -1
  55. package/dist/types/Component/BackToTop/BackToTop.d.ts +20 -0
  56. package/dist/types/Component/BackToTop/BackToTop.d.ts.map +1 -0
  57. package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts +27 -0
  58. package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts.map +1 -0
  59. package/dist/types/Component/BackToTop/back-to-top.entry.d.ts +2 -0
  60. package/dist/types/Component/BackToTop/back-to-top.entry.d.ts.map +1 -0
  61. package/dist/types/Component/DropMenu/DropMenu.d.ts +2 -1
  62. package/dist/types/Component/DropMenu/DropMenu.d.ts.map +1 -1
  63. package/dist/types/Component/Tabs/Components/TabDropMenu.d.ts.map +1 -1
  64. package/dist/types/Component/Tabs/Elements/Tabs.d.ts.map +1 -1
  65. package/dist/types/Component/Translate/Elements/Translate.d.ts +23 -0
  66. package/dist/types/Component/Translate/Elements/Translate.d.ts.map +1 -0
  67. package/dist/types/Component/Translate/Translate.d.ts +37 -0
  68. package/dist/types/Component/Translate/Translate.d.ts.map +1 -0
  69. package/dist/types/Component/Translate/languages.d.ts +11 -0
  70. package/dist/types/Component/Translate/languages.d.ts.map +1 -0
  71. package/dist/types/Component/Translate/translate.entry.d.ts +2 -0
  72. package/dist/types/Component/Translate/translate.entry.d.ts.map +1 -0
  73. package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts +37 -0
  74. package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts.map +1 -0
  75. package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts +36 -0
  76. package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts.map +1 -0
  77. package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts +2 -0
  78. package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts.map +1 -0
  79. package/dist/types/Utility/Elements/google-translate-widget.d.ts +91 -0
  80. package/dist/types/Utility/Elements/google-translate-widget.d.ts.map +1 -0
  81. package/dist/types/Utility/utilities.d.ts +2 -0
  82. package/dist/types/Utility/utilities.d.ts.map +1 -1
  83. package/dist/types/elements.d.ts +3 -0
  84. package/dist/types/elements.d.ts.map +1 -1
  85. package/dist/types/enums.d.ts +3 -1
  86. package/dist/types/enums.d.ts.map +1 -1
  87. package/dist/types/react.d.ts +3 -0
  88. package/dist/types/react.d.ts.map +1 -1
  89. package/docs/API.md +1215 -0
  90. package/docs/CSSGuidelines.md +891 -0
  91. package/docs/CodeQuality.md +940 -0
  92. package/docs/ComponentArchitecture.md +714 -0
  93. package/docs/Contributing.md +626 -0
  94. package/docs/MemoryManagement.md +598 -0
  95. package/docs/Migrations.md +6 -0
  96. package/docs/README.md +100 -0
  97. package/docs/Security.md +444 -0
  98. package/docs/Testing.md +739 -0
  99. package/docs/Troubleshooting.md +674 -0
  100. package/package.json +6 -3
  101. package/src/Atom/Button/Button.tsx +1 -1
  102. package/src/Atom/Button/__snapshots__/Button.stories.tsx.snap +2 -2
  103. package/src/Atom/Button/_buttons.css +1 -0
  104. package/src/Atom/Icon/README.md +1 -1
  105. package/src/Atom/Icon/_icon.css +1 -0
  106. package/src/Atom/Icon/images/thumb.svg +1 -0
  107. package/src/Atom/Media/__snapshots__/Media.stories.ts.snap +4 -9
  108. package/src/Atom/Media/media.twig +4 -5
  109. package/src/Component/BackToTop/BackToTop.stories.ts +127 -0
  110. package/src/Component/BackToTop/BackToTop.stories.tsx +53 -0
  111. package/src/Component/BackToTop/BackToTop.tsx +120 -0
  112. package/src/Component/BackToTop/Elements/BackToTop.ts +119 -0
  113. package/src/Component/BackToTop/README.md +164 -0
  114. package/src/Component/BackToTop/__snapshots__/BackToTop.stories.ts.snap +24 -0
  115. package/src/Component/BackToTop/__snapshots__/BackToTop.stories.tsx.snap +3 -0
  116. package/src/Component/BackToTop/back-to-top.css +70 -0
  117. package/src/Component/BackToTop/back-to-top.entry.js +1 -0
  118. package/src/Component/BackToTop/back-to-top.twig +26 -0
  119. package/src/Component/Card/Components/CardContent.tsx +1 -1
  120. package/src/Component/Card/Components/CardMedia.tsx +1 -1
  121. package/src/Component/Card/__snapshots__/Card.stories.tsx.snap +3 -3
  122. package/src/Component/Card/card.css +1 -1
  123. package/src/Component/Carousel/Carousel.stories.ts +66 -36
  124. package/src/Component/Carousel/__snapshots__/Carousel.stories.tsx.snap +5 -5
  125. package/src/Component/ContentBlock/Components/ContentBlockMedia.tsx +1 -1
  126. package/src/Component/ContentBlock/content-block.css +1 -1
  127. package/src/Component/DropMenu/DropMenu.stories.ts +21 -2
  128. package/src/Component/DropMenu/DropMenu.tsx +14 -4
  129. package/src/Component/DropMenu/__snapshots__/DropMenu.stories.ts.snap +3 -3
  130. package/src/Component/DropMenu/__snapshots__/DropMenu.stories.tsx.snap +2 -2
  131. package/src/Component/DropMenu/drop-menu.twig +7 -1
  132. package/src/Component/HeroBanner/__snapshots__/HeroBanner.stories.tsx.snap +1 -1
  133. package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.ts.snap +48 -13
  134. package/src/Component/InPageNavigation/in-page-navigation.twig +4 -6
  135. package/src/Component/ListItem/Components/ListItemMedia.tsx +1 -1
  136. package/src/Component/Pagination/Pagination.stories.ts +57 -27
  137. package/src/Component/Pagination/pagination.css +16 -7
  138. package/src/Component/Popover/__snapshots__/Popover.stories.tsx.snap +3 -3
  139. package/src/Component/Tabs/Components/TabDropMenu.tsx +7 -1
  140. package/src/Component/Tabs/Elements/Tabs.ts +13 -3
  141. package/src/Component/Tabs/Tabs.stories.ts +33 -12
  142. package/src/Component/Tabs/Tabs.stories.tsx +28 -14
  143. package/src/Component/Tabs/__snapshots__/Tabs.stories.ts.snap +3 -3
  144. package/src/Component/Tabs/__snapshots__/Tabs.stories.tsx.snap +2 -2
  145. package/src/Component/Translate/Elements/Translate.ts +76 -0
  146. package/src/Component/Translate/README.md +283 -0
  147. package/src/Component/Translate/Translate.stories.ts +87 -0
  148. package/src/Component/Translate/Translate.stories.tsx +77 -0
  149. package/src/Component/Translate/Translate.tsx +213 -0
  150. package/src/Component/Translate/__snapshots__/Translate.stories.ts.snap +18 -0
  151. package/src/Component/Translate/__snapshots__/Translate.stories.tsx.snap +3 -0
  152. package/src/Component/Translate/languages.ts +39 -0
  153. package/src/Component/Translate/translate.css +62 -0
  154. package/src/Component/Translate/translate.entry.js +1 -0
  155. package/src/Component/Translate/translate.twig +44 -0
  156. package/src/Form/Autocomplete/Autocomplete.stories.tsx +2 -1
  157. package/src/Form/FeedbackForm/Elements/FeedbackForm.ts +122 -0
  158. package/src/Form/FeedbackForm/FeedbackForm.stories.ts +99 -0
  159. package/src/Form/FeedbackForm/FeedbackForm.stories.tsx +73 -0
  160. package/src/Form/FeedbackForm/FeedbackForm.tsx +196 -0
  161. package/src/Form/FeedbackForm/README.md +233 -0
  162. package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.ts.snap +125 -0
  163. package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.tsx.snap +5 -0
  164. package/src/Form/FeedbackForm/feedback-form.css +74 -0
  165. package/src/Form/FeedbackForm/feedback-form.entry.js +1 -0
  166. package/src/Form/FeedbackForm/feedback-form.twig +80 -0
  167. package/src/Layout/Header/Components/GlobalToggle.tsx +1 -1
  168. package/src/Layout/Header/Header.stories.ts +53 -48
  169. package/src/Layout/Header/Header.stories.tsx +35 -22
  170. package/src/Layout/Header/Header.tsx +2 -2
  171. package/src/Layout/Header/__snapshots__/Header.stories.ts.snap +14 -10
  172. package/src/Layout/Header/__snapshots__/Header.stories.tsx.snap +2 -2
  173. package/src/Layout/Header/_toggles.css +7 -7
  174. package/src/Layout/Page/__snapshots__/Page.stories.tsx.snap +1 -1
  175. package/src/Utility/Elements/google-translate-widget.ts +293 -0
  176. package/src/Utility/global.d.ts +4 -0
  177. package/src/Utility/utilities.ts +2 -0
  178. package/src/elements.ts +3 -0
  179. package/src/enums.ts +2 -0
  180. package/src/react.ts +3 -0
  181. package/dist/build/chunks/Accordion-GN0sME4W.js +0 -1
  182. package/dist/build/chunks/AccordionGroup-Bzqp5__q.js +0 -1
  183. package/dist/build/chunks/AccordionMobile-DG5jSe8Q.js +0 -1
  184. package/dist/build/chunks/Autocomplete-B4RtkAyh.js +0 -1
  185. package/dist/build/chunks/ClosableAlert-DEhj8Nxm.js +0 -1
  186. package/dist/build/chunks/CookieConsent-BkCMRVST.js +0 -1
  187. package/dist/build/chunks/Dialog-Haahn-Yn.js +0 -1
  188. package/dist/build/chunks/DropMenu-1baQc8Yc.js +0 -1
  189. package/dist/build/chunks/Gallery-D8NGXy2Q.js +0 -1
  190. package/dist/build/chunks/GlobalToggle-B52fdBQu.js +0 -1
  191. package/dist/build/chunks/InPageNavigation-Dyn2TR-N.js +0 -1
  192. package/dist/build/chunks/Navigation-hOrfLq7h.js +0 -1
  193. package/dist/build/chunks/Popover-Cxg-s5Jn.js +0 -1
  194. package/dist/build/chunks/Sticky-BGCuuEJK.js +0 -1
  195. package/dist/build/chunks/Tabs-DYy1YTdU.js +0 -15
  196. package/dist/build/chunks/cookie-compliance-Bbm-0vpj.js +0 -1
  197. package/dist/build/chunks/enums-DLV_1NAf.js +0 -1
  198. package/dist/build/chunks/polyfills-C7pRt54M.js +0 -1
  199. package/dist/build/chunks/utilities-_Bq3TxAQ.js +0 -1
@@ -0,0 +1,213 @@
1
+ import { JSX, KeyboardEvent, MouseEvent, useEffect, useId, useMemo, useRef } from "react"
2
+ import { FocusScope, useFocusManager } from "react-aria"
3
+ import classNames from "classnames"
4
+ import { PopoverPlacementModifier } from "@pnx-mixtape/ids-shape"
5
+ import Icon from "../../Atom/Icon/Icon"
6
+ import { Icons } from "../../enums"
7
+ import { GoogleTranslateWidget } from "../../Utility/utilities"
8
+ import { TranslateLanguage } from "./languages"
9
+
10
+ type TranslateItemsProps = {
11
+ languages: TranslateLanguage[]
12
+ onSelect: (code: string, event: MouseEvent<HTMLAnchorElement>) => void
13
+ }
14
+
15
+ const TranslateItems = ({ languages, onSelect }: TranslateItemsProps): JSX.Element => {
16
+ const focusManager = useFocusManager()
17
+
18
+ const handleKeyDown = (event: KeyboardEvent<HTMLAnchorElement>): void => {
19
+ switch (event.key) {
20
+ case "ArrowDown":
21
+ case "ArrowRight":
22
+ event.preventDefault()
23
+ focusManager?.focusNext({ wrap: true, tabbable: true })
24
+ break
25
+ case "ArrowUp":
26
+ case "ArrowLeft":
27
+ event.preventDefault()
28
+ focusManager?.focusPrevious({ wrap: true, tabbable: true })
29
+ break
30
+ default:
31
+ break
32
+ }
33
+ }
34
+
35
+ return (
36
+ <>
37
+ {languages.map(language => (
38
+ <a
39
+ key={language.code}
40
+ className="mx-translate__item"
41
+ href={`?hl=${language.code}`}
42
+ lang={language.code}
43
+ hrefLang={language.code}
44
+ role="menuitem"
45
+ data-language={language.code}
46
+ onClick={event => onSelect(language.code, event)}
47
+ onKeyDown={handleKeyDown}
48
+ >
49
+ {language.label}
50
+ </a>
51
+ ))}
52
+ </>
53
+ )
54
+ }
55
+
56
+ type TranslateProps = {
57
+ id?: string
58
+ label?: string
59
+ /** The language list — no built-in default, import `DEFAULT_LANGUAGES` from this component to opt into one. */
60
+ languages: TranslateLanguage[]
61
+ defaultLanguage?: string
62
+ /** Paths where translation is disallowed — the blocked notice shows instead. */
63
+ blockedPaths?: string[]
64
+ scriptUrl?: string
65
+ storageKey?: string
66
+ /**
67
+ * Updates the trigger's visible language code + `aria-label` to reflect the active language.
68
+ * On by default — pass `false` to keep the trigger static instead, which some sites prefer.
69
+ */
70
+ updateTriggerLabel?: boolean
71
+ /** Consumer-supplied copy shown once a non-default language is active — entirely opt-in, omit to never show a notice. */
72
+ noticeContent?: string
73
+ /** Consumer-supplied copy shown instead, on a blocked path. */
74
+ noticeBlockedContent?: string
75
+ /** Where to insert the notice. Defaults to the top of `<body>` if omitted — the notice doesn't have to live near this component, e.g. if the trigger is in the header. */
76
+ noticeAnchor?: string
77
+ /** Insertion position relative to `noticeAnchor`. Defaults to `afterend` when set, `afterbegin` otherwise. */
78
+ noticePosition?: InsertPosition
79
+ placement?: PopoverPlacementModifier
80
+ className?: string
81
+ /** Closes the menu after a language is chosen. */
82
+ closeOnSelect?: boolean
83
+ onOpen?: () => void
84
+ onClose?: () => void
85
+ onLanguageChange?: (code: string) => void
86
+ }
87
+
88
+ const Translate = ({
89
+ id,
90
+ label = "Language",
91
+ languages,
92
+ defaultLanguage = "en",
93
+ blockedPaths,
94
+ scriptUrl,
95
+ storageKey,
96
+ updateTriggerLabel = true,
97
+ noticeContent,
98
+ noticeBlockedContent,
99
+ noticeAnchor,
100
+ noticePosition,
101
+ placement = PopoverPlacementModifier.BOTTOM_START,
102
+ className,
103
+ closeOnSelect = true,
104
+ onOpen,
105
+ onClose,
106
+ onLanguageChange,
107
+ }: TranslateProps): JSX.Element => {
108
+ const generatedId = useId()
109
+ const rootId = id ?? generatedId
110
+ const rootRef = useRef<HTMLDivElement>(null)
111
+ const popoverRef = useRef<HTMLDivElement>(null)
112
+ const widgetRef = useRef<GoogleTranslateWidget | null>(null)
113
+ const polyfillRef = useRef<any>(null)
114
+
115
+ const triggerStyle = useMemo(() => ({ anchorName: `--${rootId}` }), [rootId])
116
+ const popoverStyle = useMemo(() => ({ positionAnchor: `--${rootId}` }), [rootId])
117
+
118
+ useEffect(() => {
119
+ if (!rootRef.current) return undefined
120
+ const widget = new GoogleTranslateWidget(rootRef.current, {
121
+ defaultLanguage,
122
+ blockedPaths,
123
+ scriptUrl,
124
+ storageKey,
125
+ updateTriggerLabel,
126
+ notice: noticeContent
127
+ ? {
128
+ content: noticeContent,
129
+ blockedContent: noticeBlockedContent,
130
+ anchorSelector: noticeAnchor,
131
+ position: noticePosition,
132
+ }
133
+ : undefined,
134
+ onLanguageChange,
135
+ })
136
+ widget.init()
137
+ widgetRef.current = widget
138
+ return () => widget.destroy()
139
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- only re-runs on mount/unmount; config changes apply on the next setLanguage call
140
+ }, [])
141
+
142
+ useEffect(() => {
143
+ const popover = popoverRef.current
144
+ if (!popover) return undefined
145
+
146
+ const polyfill = async (): Promise<void> => {
147
+ if (
148
+ !("anchorName" in document.documentElement.style) ||
149
+ !("popover" in HTMLElement.prototype)
150
+ ) {
151
+ const { default: Polyfills } = await import("../Popover/polyfills")
152
+ polyfillRef.current = new Polyfills(popover)
153
+ }
154
+ }
155
+ polyfill().catch(() => {})
156
+
157
+ const controller = new AbortController()
158
+ popover.addEventListener(
159
+ "toggle",
160
+ (event: Event) => {
161
+ const { newState } = event as ToggleEvent
162
+ if (newState === "open") onOpen?.()
163
+ else onClose?.()
164
+ },
165
+ { signal: controller.signal },
166
+ )
167
+
168
+ return () => {
169
+ controller.abort()
170
+ polyfillRef.current?.destroy()
171
+ }
172
+ }, [onOpen, onClose])
173
+
174
+ const handleSelect = (code: string, event: MouseEvent<HTMLAnchorElement>): void => {
175
+ event.preventDefault()
176
+ widgetRef.current?.setLanguage(code)
177
+ if (closeOnSelect) popoverRef.current?.hidePopover()
178
+ }
179
+
180
+ return (
181
+ <div ref={rootRef} className={classNames("mx-translate", className)}>
182
+ <button
183
+ type="button"
184
+ id={`${rootId}-target`}
185
+ className="mx-translate__trigger mx-popover__trigger notranslate"
186
+ popoverTarget={rootId}
187
+ style={triggerStyle}
188
+ data-label={label}
189
+ aria-label={label}
190
+ >
191
+ <Icon icon={Icons.GLOBE} />
192
+ <span className="mx-translate__code">{defaultLanguage.toUpperCase()}</span>
193
+ </button>
194
+ <div
195
+ ref={popoverRef}
196
+ id={rootId}
197
+ className="mx-popover mx-translate__menu notranslate"
198
+ aria-describedby={`${rootId}-target`}
199
+ aria-labelledby={`${rootId}-target`}
200
+ style={popoverStyle}
201
+ data-placement={placement}
202
+ popover=""
203
+ role="menu"
204
+ >
205
+ <FocusScope>
206
+ <TranslateItems languages={languages} onSelect={handleSelect} />
207
+ </FocusScope>
208
+ </div>
209
+ </div>
210
+ )
211
+ }
212
+
213
+ export default Translate
@@ -0,0 +1,18 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Translate 1`] = `
4
+ "
5
+ <mx-translate class="mx-translate" closeonclick="" data-default-language="en" data-update-trigger-label="true" data-notice-content="Our website uses an automatic service to translate content. These translations should be used as a guide only.">
6
+ <button id="unique-0" type="button" class="mx-button mx-button--light mx-button--small mx-translate__trigger mx-popover__trigger notranslate" popovertarget="translate" style="anchor-name: --translate" data-label="Language" aria-label="Language: English">
7
+
8
+ <span class="mx-icon mx-icon--globe"></span>
9
+ <span class="mx-translate__code">EN</span>
10
+ </button>
11
+ <div class="mx-popover mx-translate__menu notranslate" id="translate" aria-describedby="unique-0" aria-labelledby="unique-0" style="position-anchor: --translate" data-placement="bottom-start" popover="" role="menu">
12
+ <a class="mx-translate__item" href="?hl=en" lang="en" hreflang="en" role="menuitem" data-language="en" aria-current="true">English</a>
13
+ <a class="mx-translate__item" href="?hl=fr" lang="fr" hreflang="fr" role="menuitem" data-language="fr">Français</a>
14
+ <a class="mx-translate__item" href="?hl=es" lang="es" hreflang="es" role="menuitem" data-language="es">Español</a>
15
+ </div>
16
+ </mx-translate>
17
+ "
18
+ `;
@@ -0,0 +1,3 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Translate 1`] = `"<div class="mx-translate"><button type="button" id="translate-target" class="mx-translate__trigger mx-popover__trigger notranslate" popovertarget="translate" data-label="Language" aria-label="Language: English" style="anchor-name: --translate;"><span class="mx-icon mx-icon--globe"></span><span class="mx-translate__code">EN</span></button><div id="translate" class="mx-popover mx-translate__menu notranslate" aria-describedby="translate-target" aria-labelledby="translate-target" data-placement="bottom-start" popover="" role="menu" style="position-anchor: --translate;"><span data-focus-scope-start="true" hidden=""></span><a class="mx-translate__item" href="?hl=en" lang="en" hreflang="en" role="menuitem" data-language="en" aria-current="true">English</a><a class="mx-translate__item" href="?hl=fr" lang="fr" hreflang="fr" role="menuitem" data-language="fr">Français</a><a class="mx-translate__item" href="?hl=es" lang="es" hreflang="es" role="menuitem" data-language="es">Español</a><span data-focus-scope-end="true" hidden=""></span></div></div>"`;
@@ -0,0 +1,39 @@
1
+ export type TranslateLanguage = {
2
+ code: string
3
+ label: string
4
+ }
5
+
6
+ /**
7
+ * A representative set of languages Google's Website Translator supports, in each language's
8
+ * own name. Not bundled into `Translate` by default — import and pass this explicitly (or your
9
+ * own list) via the `languages` prop.
10
+ */
11
+ export const DEFAULT_LANGUAGES: TranslateLanguage[] = [
12
+ { code: "en", label: "English" },
13
+ { code: "ar", label: "العربية" },
14
+ { code: "zh-CN", label: "中文 (简体)" },
15
+ { code: "zh-TW", label: "中文 (繁體)" },
16
+ { code: "nl", label: "Nederlands" },
17
+ { code: "fr", label: "Français" },
18
+ { code: "de", label: "Deutsch" },
19
+ { code: "el", label: "Ελληνικά" },
20
+ { code: "hi", label: "हिन्दी" },
21
+ { code: "id", label: "Bahasa Indonesia" },
22
+ { code: "it", label: "Italiano" },
23
+ { code: "ja", label: "日本語" },
24
+ { code: "ko", label: "한국어" },
25
+ { code: "ms", label: "Bahasa Melayu" },
26
+ { code: "fa", label: "فارسی" },
27
+ { code: "pl", label: "Polski" },
28
+ { code: "pt", label: "Português" },
29
+ { code: "ru", label: "Русский" },
30
+ { code: "es", label: "Español" },
31
+ { code: "sw", label: "Kiswahili" },
32
+ { code: "sv", label: "Svenska" },
33
+ { code: "tl", label: "Tagalog" },
34
+ { code: "th", label: "ไทย" },
35
+ { code: "tr", label: "Türkçe" },
36
+ { code: "uk", label: "Українська" },
37
+ { code: "ur", label: "اردو" },
38
+ { code: "vi", label: "Tiếng Việt" },
39
+ ]
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Translate
3
+ *
4
+ * Composes DropMenu/Popover — import their CSS alongside this file.
5
+ */
6
+
7
+ @layer design-system.defaults {
8
+ mx-translate {
9
+ display: inline-block;
10
+ }
11
+ }
12
+
13
+ @layer design-system.components {
14
+ .mx-translate {
15
+ position: relative;
16
+ }
17
+
18
+ .mx-translate__trigger {
19
+ display: inline-flex;
20
+ align-items: center;
21
+ gap: var(--spacing-xxs);
22
+ }
23
+
24
+ .mx-translate__code {
25
+ text-transform: uppercase;
26
+ font-size: var(--font-size-xs);
27
+ }
28
+
29
+ /* @public --translate-menu-max-block-size, --translate-popover-padding */
30
+ .mx-translate__menu {
31
+ --popover-padding: var(--translate-popover-padding, 0px);
32
+
33
+ max-block-size: var(--translate-menu-max-block-size, 60vh);
34
+ overflow-y: auto;
35
+ }
36
+
37
+ .mx-translate__item {
38
+ display: block;
39
+ padding-block: var(--vertical-padding, var(--spacing-xxs));
40
+ padding-inline: var(--horizontal-padding, var(--spacing-m));
41
+ text-decoration: none;
42
+
43
+ &[aria-current="true"] {
44
+ font-weight: var(--font-weight-bold);
45
+ text-decoration: underline;
46
+ }
47
+ }
48
+ }
49
+
50
+ /* Stop Google giving us top padding */
51
+ body {
52
+ inset-block-start: 0 !important;
53
+ min-block-size: 0 !important;
54
+ }
55
+
56
+ /* Hide Google elements */
57
+ /* stylelint-disable selector-id-pattern */
58
+ .skiptranslate,
59
+ #googleTranslateElement,
60
+ #goog-gt-tt {
61
+ display: none !important;
62
+ }
@@ -0,0 +1 @@
1
+ import "./Elements/Translate"
@@ -0,0 +1,44 @@
1
+ {% set attributes = (attributes ?? create_attribute()).addClass('mx-translate') %}
2
+ {% set id = id|default('translate') %}
3
+ {% set label = label|default('Language') %}
4
+ {% set defaultLanguage = defaultLanguage|default('en') %}
5
+ {% set updateTriggerLabel = updateTriggerLabel|default(true) %}
6
+
7
+ <mx-translate{{ attributes }} closeonclick
8
+ data-default-language="{{ defaultLanguage }}"
9
+ {%- if blockedPaths %} data-blocked-paths="{{ blockedPaths|join(',') }}"{% endif -%}
10
+ {%- if scriptUrl %} data-script-url="{{ scriptUrl }}"{% endif -%}
11
+ {%- if storageKey %} data-storage-key="{{ storageKey }}"{% endif -%}
12
+ data-update-trigger-label="{{ updateTriggerLabel ? 'true' : 'false' }}"
13
+ {%- if noticeContent %} data-notice-content="{{ noticeContent }}"{% endif -%}
14
+ {%- if noticeBlockedContent %} data-notice-blocked-content="{{ noticeBlockedContent }}"{% endif -%}
15
+ {%- if noticeAnchor %} data-notice-anchor="{{ noticeAnchor }}"{% endif -%}
16
+ {%- if noticePosition %} data-notice-position="{{ noticePosition }}"{% endif -%}
17
+ >
18
+ <button
19
+ id="{{ id }}-target"
20
+ type="button"
21
+ class="mx-button mx-button--light mx-button--small mx-translate__trigger mx-popover__trigger notranslate"
22
+ popovertarget="{{ id }}"
23
+ style="anchor-name: --{{ id }}"
24
+ data-label="{{ label }}"
25
+ aria-label="{{ label }}"
26
+ >
27
+ {% include '@mixtape/Atom/Icon/icon.twig' with { icon: 'globe' } only %}
28
+ <span class="mx-translate__code">{{ defaultLanguage|upper }}</span>
29
+ </button>
30
+ <div
31
+ class="mx-popover mx-translate__menu notranslate"
32
+ id="{{ id }}"
33
+ aria-describedby="{{ id }}-target"
34
+ aria-labelledby="{{ id }}-target"
35
+ style="position-anchor: --{{ id }}"
36
+ data-placement="{{ placement|default('bottom-start') }}"
37
+ popover
38
+ role="menu"
39
+ >
40
+ {% for language in languages %}
41
+ <a class="mx-translate__item" href="?hl={{ language.code }}" lang="{{ language.code }}" hreflang="{{ language.code }}" role="menuitem" data-language="{{ language.code }}">{{ language.label }}</a>
42
+ {% endfor %}
43
+ </div>
44
+ </mx-translate>
@@ -204,7 +204,7 @@ export const Pending: StoryObj<typeof meta> = {
204
204
  * `docs/Testing.md`'s Automated Screen Reader Testing section.
205
205
  */
206
206
  export const ScreenReaderAnnouncements: StoryObj<typeof meta> = {
207
- play: async ({ canvas, userEvent }) => {
207
+ play: async ({ canvas, canvasElement, userEvent }) => {
208
208
  const input = canvas.getByRole("combobox", { name: "Choose a fruit" })
209
209
 
210
210
  // "lu" matches exactly two fruits: Blueberry, then Plum.
@@ -229,5 +229,6 @@ export const ScreenReaderAnnouncements: StoryObj<typeof meta> = {
229
229
 
230
230
  await userEvent.clear(input)
231
231
  await expect(input).toHaveAttribute("aria-expanded", "false")
232
+ await userEvent.click(canvasElement)
232
233
  },
233
234
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * FeedbackForm
3
+ * @file Reveals a comment field once a rating is chosen, and dispatches a
4
+ * cancelable `mx-feedback-form-submit` event on submit so any backend can be
5
+ * wired up by the consumer — this component never performs a fetch/AJAX call
6
+ * itself. If the consuming app calls `preventDefault()` on that event, the
7
+ * native form submission is also cancelled, handing full control to the
8
+ * consumer; otherwise the browser submits normally via the form's
9
+ * `action`/`method`.
10
+ */
11
+
12
+ import { validators } from "../../../Utility/utilities"
13
+
14
+ export type FeedbackFormSubmitEvent = CustomEvent<{
15
+ rating: string | null
16
+ comment: string
17
+ form: HTMLFormElement
18
+ }>
19
+
20
+ export default class FeedbackForm extends HTMLElement {
21
+ internals_: ElementInternals
22
+ controller: AbortController
23
+ revealed: boolean
24
+
25
+ constructor() {
26
+ super()
27
+ this.internals_ = this.attachInternals()
28
+ this.controller = new AbortController()
29
+ this.revealed = false
30
+ }
31
+
32
+ static get observedAttributes(): string[] {
33
+ return ["pending"]
34
+ }
35
+
36
+ connectedCallback(): void {
37
+ const { signal } = this.controller
38
+
39
+ // Only gate the reveal behind a checked rating once JS is confirmed
40
+ // running — see feedback-form.css.
41
+ this.classList.add("is-enhanced")
42
+
43
+ // Validation is driven by this component's own status message instead of
44
+ // the browser's native validation bubble.
45
+ this.form?.setAttribute("novalidate", "")
46
+
47
+ this.querySelectorAll('input[type="radio"]').forEach(radio => {
48
+ radio.addEventListener("change", this.handleRatingChange, { signal })
49
+ })
50
+
51
+ this.form?.addEventListener("submit", this.handleSubmit, { signal })
52
+ }
53
+
54
+ disconnectedCallback(): void {
55
+ this.controller.abort()
56
+ }
57
+
58
+ attributeChangedCallback(name: string): void {
59
+ if (name !== "pending") return
60
+ const isPending = this.hasAttribute("pending")
61
+ this.internals_.states[isPending ? "add" : "delete"]("pending")
62
+ this.submitButton?.toggleAttribute("disabled", isPending)
63
+ this.submitButton?.setAttribute("aria-busy", String(isPending))
64
+ }
65
+
66
+ handleRatingChange = (): void => {
67
+ if (this.revealed) return
68
+ this.revealed = true
69
+ this.comment?.focus()
70
+ }
71
+
72
+ handleSubmit = (event: SubmitEvent): void => {
73
+ const comment = this.comment
74
+
75
+ if (comment?.required && !validators.notEmpty(comment.value)) {
76
+ event.preventDefault()
77
+ comment.setAttribute("aria-invalid", "true")
78
+ this.status?.removeAttribute("hidden")
79
+ comment.focus()
80
+ return
81
+ }
82
+
83
+ comment?.removeAttribute("aria-invalid")
84
+ this.status?.setAttribute("hidden", "")
85
+
86
+ const rating =
87
+ this.querySelector<HTMLInputElement>('input[type="radio"]:checked')?.value ?? null
88
+ const submitEvent: FeedbackFormSubmitEvent = new CustomEvent("mx-feedback-form-submit", {
89
+ bubbles: true,
90
+ cancelable: true,
91
+ detail: { rating, comment: comment?.value ?? "", form: this.form as HTMLFormElement },
92
+ })
93
+ const shouldProceed = this.dispatchEvent(submitEvent)
94
+ if (!shouldProceed) event.preventDefault()
95
+ }
96
+
97
+ get form(): HTMLFormElement | null {
98
+ return this.querySelector("form")
99
+ }
100
+
101
+ get comment(): HTMLTextAreaElement | null {
102
+ return this.querySelector(".mx-feedback-form__reveal textarea")
103
+ }
104
+
105
+ get status(): HTMLElement | null {
106
+ return this.querySelector('.mx-feedback-form__reveal [role="alert"]')
107
+ }
108
+
109
+ get submitButton(): HTMLButtonElement | null {
110
+ return this.querySelector(".mx-form-actions button")
111
+ }
112
+ }
113
+
114
+ if (!customElements.get("mx-feedback-form")) {
115
+ customElements.define("mx-feedback-form", FeedbackForm)
116
+ }
117
+
118
+ declare global {
119
+ interface HTMLElementTagNameMap {
120
+ "mx-feedback-form": FeedbackForm
121
+ }
122
+ }
@@ -0,0 +1,99 @@
1
+ import { Meta, StoryObj } from "@storybook/html-vite"
2
+ import { expect, waitFor } from "storybook/test"
3
+ import Component from "./feedback-form.twig"
4
+ import "./feedback-form.css"
5
+ import "./Elements/FeedbackForm"
6
+
7
+ type FeedbackFormArgs = {
8
+ id?: string
9
+ name?: string
10
+ question?: string
11
+ yesLabel?: string
12
+ noLabel?: string
13
+ secondaryText?: string
14
+ secondaryLinkText?: string
15
+ secondaryLinkHref?: string
16
+ commentLabel?: string
17
+ commentName?: string
18
+ commentPlaceholder?: string
19
+ commentRequired?: boolean
20
+ requiredErrorMessage?: string
21
+ submitLabel?: string
22
+ action?: string
23
+ method?: string
24
+ errorMessage?: string
25
+ }
26
+
27
+ /**
28
+ * A yes/no "was this page useful?" control that reveals a free-text comment field once a
29
+ * rating is chosen. It requires the `FeedbackForm` custom element javascript.
30
+ *
31
+ * This component never submits anywhere itself — it dispatches a cancelable
32
+ * `mx-feedback-form-submit` event with the chosen rating/comment before the form submits, so any
33
+ * backend can be wired up by the consumer.
34
+ */
35
+ const meta: Meta<FeedbackFormArgs> = {
36
+ tags: ["autodocs"],
37
+ component: Component,
38
+ args: {
39
+ id: "feedback",
40
+ secondaryText: "Your rating will help us improve the website.",
41
+ },
42
+ }
43
+
44
+ export default meta
45
+ type Story = StoryObj<FeedbackFormArgs>
46
+
47
+ export const FeedbackForm: Story = {
48
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
49
+ const wrapper = canvasElement.querySelector("mx-feedback-form") as HTMLElement
50
+ const comment = canvas.getByLabelText("How can we make it better?") as HTMLTextAreaElement
51
+
52
+ await step("Comment field is hidden until a rating is chosen", async () => {
53
+ await expect(comment).not.toBeVisible()
54
+ })
55
+
56
+ await step("Choosing a rating reveals and focuses the comment field", async () => {
57
+ await userEvent.click(canvas.getByRole("radio", { name: "Yes" }))
58
+ await waitFor(() => expect(comment).toBeVisible())
59
+ await expect(comment).toHaveFocus()
60
+ })
61
+
62
+ await step(
63
+ "Submitting dispatches mx-feedback-form-submit with the rating and comment",
64
+ async () => {
65
+ let detail: { rating: string | null; comment: string } | undefined
66
+ wrapper.addEventListener("mx-feedback-form-submit", (event: Event) => {
67
+ event.preventDefault()
68
+ detail = (event as CustomEvent<typeof detail>).detail
69
+ })
70
+ await userEvent.type(comment, "More examples would help.")
71
+ await userEvent.click(canvas.getByRole("button", { name: "Submit" }))
72
+ await waitFor(() =>
73
+ expect(detail).toEqual(
74
+ expect.objectContaining({ rating: "yes", comment: "More examples would help." }),
75
+ ),
76
+ )
77
+ },
78
+ )
79
+ },
80
+ }
81
+
82
+ /** With `commentRequired`, an empty comment blocks submission and shows an inline error. */
83
+ export const RequiredComment: Story = {
84
+ args: {
85
+ id: "feedback-required",
86
+ commentRequired: true,
87
+ },
88
+ play: async ({ canvas, userEvent, step }) => {
89
+ await step("Reveal the comment field", async () => {
90
+ await userEvent.click(canvas.getByRole("radio", { name: "No" }))
91
+ await waitFor(() => expect(canvas.getByLabelText("How can we make it better?")).toBeVisible())
92
+ })
93
+
94
+ await step("Submitting an empty required comment shows the error", async () => {
95
+ await userEvent.click(canvas.getByRole("button", { name: "Submit" }))
96
+ await waitFor(() => expect(canvas.getByRole("alert")).toBeVisible())
97
+ })
98
+ },
99
+ }