@pnx-mixtape/mxds 0.1.0-rc.3 → 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 (230) 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.css +1 -1
  5. package/dist/build/accordion.entry.js +2 -1
  6. package/dist/build/autocomplete.entry.js +1 -1
  7. package/dist/build/back-to-top.css +1 -0
  8. package/dist/build/back-to-top.entry.js +1 -0
  9. package/dist/build/base.css +1 -1
  10. package/dist/build/button.css +1 -1
  11. package/dist/build/card.css +1 -1
  12. package/dist/build/carousel.css +1 -1
  13. package/dist/build/chunks/Accordion-D2wp8hBG.js +44 -0
  14. package/dist/build/chunks/AccordionGroup-BYtoAFxg.js +47 -0
  15. package/dist/build/chunks/AccordionMobile-D3sTeiM5.js +26 -0
  16. package/dist/build/chunks/Autocomplete-kRo8RNbC.js +135 -0
  17. package/dist/build/chunks/BackToTop-4M-Wl7z3.js +64 -0
  18. package/dist/build/chunks/ClosableAlert-yy84gae6.js +55 -0
  19. package/dist/build/chunks/CookieConsent-wd8gQ2Oe.js +18 -0
  20. package/dist/build/chunks/Dialog-DWoXW-7Z.js +48 -0
  21. package/dist/build/chunks/DropMenu-CSTZq0Zt.js +68 -0
  22. package/dist/build/chunks/FeedbackForm-BochXEDx.js +64 -0
  23. package/dist/build/chunks/Gallery-Rkfv7Mbb.js +165 -0
  24. package/dist/build/chunks/GlobalToggle-CzlkpLpc.js +82 -0
  25. package/dist/build/chunks/InPageNavigation-D1Kk-rCm.js +86 -0
  26. package/dist/build/chunks/Navigation-CDQnN61h.js +147 -0
  27. package/dist/build/chunks/Popover-CiTAftNU.js +30 -0
  28. package/dist/build/chunks/Sticky-DoVndENm.js +51 -0
  29. package/dist/build/chunks/Tabs-BDlxCDNh.js +160 -0
  30. package/dist/build/chunks/Translate-bmM38j6z.js +46 -0
  31. package/dist/build/chunks/cookie-compliance-CG6x-TcV.js +119 -0
  32. package/dist/build/chunks/enums-BrHxPZZ_.js +36 -0
  33. package/dist/build/chunks/polyfills-D-qh1a_2.js +33 -0
  34. package/dist/build/chunks/utilities-Dd-08SW6.js +255 -0
  35. package/dist/build/constants.css +1 -1
  36. package/dist/build/content-block.css +1 -1
  37. package/dist/build/cookie-consent.css +1 -1
  38. package/dist/build/cookie-consent.entry.js +1 -1
  39. package/dist/build/dialog.css +1 -1
  40. package/dist/build/dialog.entry.js +1 -1
  41. package/dist/build/drop-menu.entry.js +1 -1
  42. package/dist/build/elements.js +147 -1
  43. package/dist/build/feedback-form.css +1 -0
  44. package/dist/build/feedback-form.entry.js +1 -0
  45. package/dist/build/filters.entry.js +145 -1
  46. package/dist/build/form.css +1 -1
  47. package/dist/build/gallery.entry.js +1 -1
  48. package/dist/build/global-alert.css +1 -1
  49. package/dist/build/global-alert.entry.js +1 -1
  50. package/dist/build/grid.css +1 -1
  51. package/dist/build/header.css +1 -1
  52. package/dist/build/header.entry.js +1 -1
  53. package/dist/build/icon.css +1 -1
  54. package/dist/build/in-page-navigation.entry.js +1 -1
  55. package/dist/build/navigation.css +1 -1
  56. package/dist/build/navigation.entry.js +1 -1
  57. package/dist/build/pagination.css +1 -1
  58. package/dist/build/popover.css +1 -1
  59. package/dist/build/popover.entry.js +1 -1
  60. package/dist/build/react.js +2270 -1
  61. package/dist/build/sticky.entry.js +1 -1
  62. package/dist/build/tabs.css +1 -1
  63. package/dist/build/tabs.entry.js +1 -1
  64. package/dist/build/translate.css +1 -0
  65. package/dist/build/translate.entry.js +1 -0
  66. package/dist/build/utility-list.entry.js +53 -1
  67. package/dist/types/Component/BackToTop/BackToTop.d.ts +20 -0
  68. package/dist/types/Component/BackToTop/BackToTop.d.ts.map +1 -0
  69. package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts +27 -0
  70. package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts.map +1 -0
  71. package/dist/types/Component/BackToTop/back-to-top.entry.d.ts +2 -0
  72. package/dist/types/Component/BackToTop/back-to-top.entry.d.ts.map +1 -0
  73. package/dist/types/Component/DropMenu/DropMenu.d.ts +2 -1
  74. package/dist/types/Component/DropMenu/DropMenu.d.ts.map +1 -1
  75. package/dist/types/Component/Tabs/Components/TabDropMenu.d.ts.map +1 -1
  76. package/dist/types/Component/Tabs/Elements/Tabs.d.ts.map +1 -1
  77. package/dist/types/Component/Translate/Elements/Translate.d.ts +23 -0
  78. package/dist/types/Component/Translate/Elements/Translate.d.ts.map +1 -0
  79. package/dist/types/Component/Translate/Translate.d.ts +37 -0
  80. package/dist/types/Component/Translate/Translate.d.ts.map +1 -0
  81. package/dist/types/Component/Translate/languages.d.ts +11 -0
  82. package/dist/types/Component/Translate/languages.d.ts.map +1 -0
  83. package/dist/types/Component/Translate/translate.entry.d.ts +2 -0
  84. package/dist/types/Component/Translate/translate.entry.d.ts.map +1 -0
  85. package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts +37 -0
  86. package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts.map +1 -0
  87. package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts +36 -0
  88. package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts.map +1 -0
  89. package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts +2 -0
  90. package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts.map +1 -0
  91. package/dist/types/Utility/Elements/google-translate-widget.d.ts +91 -0
  92. package/dist/types/Utility/Elements/google-translate-widget.d.ts.map +1 -0
  93. package/dist/types/Utility/utilities.d.ts +2 -0
  94. package/dist/types/Utility/utilities.d.ts.map +1 -1
  95. package/dist/types/elements.d.ts +3 -0
  96. package/dist/types/elements.d.ts.map +1 -1
  97. package/dist/types/enums.d.ts +3 -1
  98. package/dist/types/enums.d.ts.map +1 -1
  99. package/dist/types/react.d.ts +3 -0
  100. package/dist/types/react.d.ts.map +1 -1
  101. package/docs/API.md +1215 -0
  102. package/docs/CSSGuidelines.md +891 -0
  103. package/docs/CodeQuality.md +940 -0
  104. package/docs/ComponentArchitecture.md +714 -0
  105. package/docs/Contributing.md +626 -0
  106. package/docs/MemoryManagement.md +598 -0
  107. package/docs/Migrations.md +6 -0
  108. package/docs/README.md +100 -0
  109. package/docs/Security.md +444 -0
  110. package/docs/Testing.md +739 -0
  111. package/docs/Troubleshooting.md +674 -0
  112. package/package.json +6 -3
  113. package/src/Atom/Button/Button.tsx +1 -1
  114. package/src/Atom/Button/__snapshots__/Button.stories.tsx.snap +2 -2
  115. package/src/Atom/Button/_buttons.css +1 -0
  116. package/src/Atom/Icon/README.md +1 -1
  117. package/src/Atom/Icon/_icon.css +1 -0
  118. package/src/Atom/Icon/images/thumb.svg +1 -0
  119. package/src/Atom/Media/__snapshots__/Media.stories.ts.snap +4 -9
  120. package/src/Atom/Media/media.twig +4 -5
  121. package/src/Atom/_generic.css +0 -10
  122. package/src/Component/Accordion/accordion.css +3 -1
  123. package/src/Component/BackToTop/BackToTop.stories.ts +127 -0
  124. package/src/Component/BackToTop/BackToTop.stories.tsx +53 -0
  125. package/src/Component/BackToTop/BackToTop.tsx +120 -0
  126. package/src/Component/BackToTop/Elements/BackToTop.ts +119 -0
  127. package/src/Component/BackToTop/README.md +164 -0
  128. package/src/Component/BackToTop/__snapshots__/BackToTop.stories.ts.snap +24 -0
  129. package/src/Component/BackToTop/__snapshots__/BackToTop.stories.tsx.snap +3 -0
  130. package/src/Component/BackToTop/back-to-top.css +70 -0
  131. package/src/Component/BackToTop/back-to-top.entry.js +1 -0
  132. package/src/Component/BackToTop/back-to-top.twig +26 -0
  133. package/src/Component/Card/Components/CardContent.tsx +1 -1
  134. package/src/Component/Card/Components/CardMedia.tsx +1 -1
  135. package/src/Component/Card/__snapshots__/Card.stories.tsx.snap +3 -3
  136. package/src/Component/Card/card.css +1 -1
  137. package/src/Component/Carousel/Carousel.stories.ts +66 -36
  138. package/src/Component/Carousel/__snapshots__/Carousel.stories.tsx.snap +5 -5
  139. package/src/Component/Carousel/carousel.css +1 -1
  140. package/src/Component/ContentBlock/Components/ContentBlockMedia.tsx +1 -1
  141. package/src/Component/ContentBlock/content-block.css +1 -1
  142. package/src/Component/CookieConsent/cookie-consent.css +3 -0
  143. package/src/Component/Dialog/dialog.css +5 -2
  144. package/src/Component/DropMenu/DropMenu.stories.ts +21 -2
  145. package/src/Component/DropMenu/DropMenu.tsx +14 -4
  146. package/src/Component/DropMenu/__snapshots__/DropMenu.stories.ts.snap +3 -3
  147. package/src/Component/DropMenu/__snapshots__/DropMenu.stories.tsx.snap +2 -2
  148. package/src/Component/DropMenu/drop-menu.twig +7 -1
  149. package/src/Component/GlobalAlert/global-alert.css +3 -0
  150. package/src/Component/HeroBanner/__snapshots__/HeroBanner.stories.tsx.snap +1 -1
  151. package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.ts.snap +48 -13
  152. package/src/Component/InPageNavigation/in-page-navigation.twig +4 -6
  153. package/src/Component/ListItem/Components/ListItemMedia.tsx +1 -1
  154. package/src/Component/Navigation/_navigation-collapsible.css +3 -1
  155. package/src/Component/Navigation/_navigation-dropdown.css +3 -1
  156. package/src/Component/Pagination/Pagination.stories.ts +57 -27
  157. package/src/Component/Pagination/pagination.css +16 -7
  158. package/src/Component/Popover/__snapshots__/Popover.stories.tsx.snap +3 -3
  159. package/src/Component/Popover/popover.css +3 -1
  160. package/src/Component/Tabs/Components/TabDropMenu.tsx +7 -1
  161. package/src/Component/Tabs/Elements/Tabs.ts +13 -3
  162. package/src/Component/Tabs/Tabs.stories.ts +33 -12
  163. package/src/Component/Tabs/Tabs.stories.tsx +28 -14
  164. package/src/Component/Tabs/__snapshots__/Tabs.stories.ts.snap +3 -3
  165. package/src/Component/Tabs/__snapshots__/Tabs.stories.tsx.snap +2 -2
  166. package/src/Component/Tabs/tabs.css +4 -5
  167. package/src/Component/Translate/Elements/Translate.ts +76 -0
  168. package/src/Component/Translate/README.md +283 -0
  169. package/src/Component/Translate/Translate.stories.ts +87 -0
  170. package/src/Component/Translate/Translate.stories.tsx +77 -0
  171. package/src/Component/Translate/Translate.tsx +213 -0
  172. package/src/Component/Translate/__snapshots__/Translate.stories.ts.snap +18 -0
  173. package/src/Component/Translate/__snapshots__/Translate.stories.tsx.snap +3 -0
  174. package/src/Component/Translate/languages.ts +39 -0
  175. package/src/Component/Translate/translate.css +62 -0
  176. package/src/Component/Translate/translate.entry.js +1 -0
  177. package/src/Component/Translate/translate.twig +44 -0
  178. package/src/Form/Autocomplete/Autocomplete.stories.tsx +2 -1
  179. package/src/Form/FeedbackForm/Elements/FeedbackForm.ts +122 -0
  180. package/src/Form/FeedbackForm/FeedbackForm.stories.ts +99 -0
  181. package/src/Form/FeedbackForm/FeedbackForm.stories.tsx +73 -0
  182. package/src/Form/FeedbackForm/FeedbackForm.tsx +196 -0
  183. package/src/Form/FeedbackForm/README.md +233 -0
  184. package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.ts.snap +125 -0
  185. package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.tsx.snap +5 -0
  186. package/src/Form/FeedbackForm/feedback-form.css +74 -0
  187. package/src/Form/FeedbackForm/feedback-form.entry.js +1 -0
  188. package/src/Form/FeedbackForm/feedback-form.twig +80 -0
  189. package/src/Form/form.css +5 -3
  190. package/src/Layout/Grid/Grid.stories.ts +1 -0
  191. package/src/Layout/Grid/Grid.stories.tsx +26 -0
  192. package/src/Layout/Grid/__snapshots__/Grid.stories.tsx.snap +2 -0
  193. package/src/Layout/Grid/grid.css +12 -0
  194. package/src/Layout/Header/Components/GlobalToggle.tsx +1 -1
  195. package/src/Layout/Header/Header.stories.ts +53 -48
  196. package/src/Layout/Header/Header.stories.tsx +35 -22
  197. package/src/Layout/Header/Header.tsx +2 -2
  198. package/src/Layout/Header/__snapshots__/Header.stories.ts.snap +14 -10
  199. package/src/Layout/Header/__snapshots__/Header.stories.tsx.snap +2 -2
  200. package/src/Layout/Header/_header.css +4 -2
  201. package/src/Layout/Header/_toggles.css +9 -6
  202. package/src/Layout/Page/__snapshots__/Page.stories.tsx.snap +1 -1
  203. package/src/Utility/Elements/google-translate-widget.ts +293 -0
  204. package/src/Utility/_mixins.css +35 -13
  205. package/src/Utility/global.d.ts +4 -0
  206. package/src/Utility/utilities.ts +2 -0
  207. package/src/_custom-media.css +2 -2
  208. package/src/constants.css +3 -13
  209. package/src/elements.ts +3 -0
  210. package/src/enums.ts +2 -0
  211. package/src/react.ts +3 -0
  212. package/dist/build/chunks/Accordion-GN0sME4W.js +0 -1
  213. package/dist/build/chunks/AccordionGroup-Bzqp5__q.js +0 -1
  214. package/dist/build/chunks/AccordionMobile-DG5jSe8Q.js +0 -1
  215. package/dist/build/chunks/Autocomplete-B4RtkAyh.js +0 -1
  216. package/dist/build/chunks/ClosableAlert-DEhj8Nxm.js +0 -1
  217. package/dist/build/chunks/CookieConsent-BkCMRVST.js +0 -1
  218. package/dist/build/chunks/Dialog-Haahn-Yn.js +0 -1
  219. package/dist/build/chunks/DropMenu-1baQc8Yc.js +0 -1
  220. package/dist/build/chunks/Gallery-D8NGXy2Q.js +0 -1
  221. package/dist/build/chunks/GlobalToggle-B52fdBQu.js +0 -1
  222. package/dist/build/chunks/InPageNavigation-Dyn2TR-N.js +0 -1
  223. package/dist/build/chunks/Navigation-hOrfLq7h.js +0 -1
  224. package/dist/build/chunks/Popover-Cxg-s5Jn.js +0 -1
  225. package/dist/build/chunks/Sticky-BGCuuEJK.js +0 -1
  226. package/dist/build/chunks/Tabs-DYy1YTdU.js +0 -15
  227. package/dist/build/chunks/cookie-compliance-Bbm-0vpj.js +0 -1
  228. package/dist/build/chunks/enums-DLV_1NAf.js +0 -1
  229. package/dist/build/chunks/polyfills-C7pRt54M.js +0 -1
  230. package/dist/build/chunks/utilities-_Bq3TxAQ.js +0 -1
@@ -0,0 +1,283 @@
1
+ # Translate
2
+
3
+ A language switcher that drives Google's Website Translator widget from custom, accessible UI —
4
+ built on `DropMenu`/`Popover` for the accessible trigger-button-and-menu behaviour, so this
5
+ component only has to handle actually driving translation.
6
+
7
+ **Unlike every other mxds component, Translate loads a third-party runtime script**
8
+ (`translate.google.com/translate_a/element.js`) on first language change. This is Google's
9
+ official, unversioned "Website Translator" widget, not an mxds-controlled dependency — mxds
10
+ cannot guarantee its availability, behaviour, or continued existence. Consumers embedding
11
+ Translate should be aware this introduces an external network dependency and a third-party
12
+ script executing on their page. See "Google Translate quirks" below.
13
+
14
+ ---
15
+
16
+ ## CSS Import
17
+
18
+ ```css
19
+ @import "@pnx-mixtape/mxds/src/Component/Translate/translate.css";
20
+ @import "@pnx-mixtape/mxds/src/Component/Popover/popover.css";
21
+ @import "@pnx-mixtape/mxds/src/Component/GlobalAlert/global-alert.css";
22
+ ```
23
+
24
+ The notice (if you use one — see [Notice placement](#notice-placement)) reuses `GlobalAlert`'s
25
+ classes directly, so its CSS is still needed even though the notice itself is built by
26
+ `GoogleTranslateWidget`, not by including `GlobalAlert`.
27
+
28
+ ---
29
+
30
+ ## Twig
31
+
32
+ ```twig
33
+ {% include '@mixtape/Component/Translate/translate.twig' with {
34
+ id: 'translate',
35
+ languages: [
36
+ { code: 'en', label: 'English' },
37
+ { code: 'fr', label: 'Français' },
38
+ { code: 'es', label: 'Español' },
39
+ ],
40
+ defaultLanguage: 'en',
41
+ noticeContent: 'Our website uses an automatic service to translate content. These translations should be used as a guide only.',
42
+ noticeBlockedContent: 'This page is only available in English.',
43
+ } %}
44
+ ```
45
+
46
+ **translate.twig props:**
47
+
48
+ | Prop | Type | Default | Description |
49
+ | ---------------------- | ----------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
50
+ | `id` | `string` | `translate` | Root id, used to wire the trigger/menu together |
51
+ | `label` | `string` | `Language` | Trigger's accessible name |
52
+ | `languages` | `{ code: string, label: string }[]` | — | The language list — **no built-in default**, pass your own |
53
+ | `defaultLanguage` | `string` | `en` | The page's source language code |
54
+ | `blockedPaths` | `string[]` | — | Paths where translation is disallowed — `noticeBlockedContent` shows instead |
55
+ | `updateTriggerLabel` | `boolean` | `true` | Keep the trigger's visible code + `aria-label` in sync with the active language. Pass `false` to keep the trigger static instead, which some sites prefer |
56
+ | `noticeContent` | `string` | — | Copy shown once a non-default language is active. **Entirely opt-in** — omit to never create a notice at all |
57
+ | `noticeBlockedContent` | `string` | — | Copy shown instead, on a blocked path |
58
+ | `noticeAnchor` | `string` | — | CSS selector for where to insert the notice. Defaults to the top of `<body>` if omitted — see [Notice placement](#notice-placement) below |
59
+ | `noticePosition` | `string` | — | Insertion position relative to `noticeAnchor` (`beforebegin`/`afterbegin`/`beforeend`/`afterend`). Defaults to `afterend` when `noticeAnchor` is set, `afterbegin` otherwise |
60
+ | `placement` | `PopoverPlacementModifier` | `bottom-start` | Menu position relative to the trigger |
61
+ | `scriptUrl` | `string` | — | Advanced override for Google's script URL (e.g. a self-hosted proxy) |
62
+ | `storageKey` | `string` | `googtrans` | Advanced override — risky to change, see "Google Translate quirks" |
63
+ | `attributes` | `Attribute` | — | Optional Drupal-style attributes object — `mx-translate` is always merged on top |
64
+
65
+ Registers as `<mx-translate>`. Import the element to activate its behaviour:
66
+
67
+ ```js
68
+ import "@pnx-mixtape/mxds/src/Component/Translate/Elements/Translate"
69
+ ```
70
+
71
+ ---
72
+
73
+ ## HTML + Web Component
74
+
75
+ ```html
76
+ <mx-translate class="mx-translate" closeonclick data-default-language="en">
77
+ <button
78
+ id="translate-target"
79
+ type="button"
80
+ class="mx-translate__trigger mx-popover__trigger notranslate"
81
+ popovertarget="translate"
82
+ style="anchor-name: --translate"
83
+ data-label="Language"
84
+ aria-label="Language"
85
+ >
86
+ <span class="mx-icon mx-icon--globe"></span>
87
+ <span class="mx-translate__code">EN</span>
88
+ </button>
89
+ <div
90
+ class="mx-popover mx-translate__menu notranslate"
91
+ id="translate"
92
+ style="position-anchor: --translate"
93
+ data-placement="bottom-start"
94
+ popover
95
+ role="menu"
96
+ >
97
+ <a
98
+ class="mx-translate__item"
99
+ href="?hl=fr"
100
+ lang="fr"
101
+ hreflang="fr"
102
+ role="menuitem"
103
+ data-language="fr"
104
+ >Français</a
105
+ >
106
+ <!-- ...one per language -->
107
+ </div>
108
+ </mx-translate>
109
+ ```
110
+
111
+ **`<mx-translate>` attributes:**
112
+
113
+ | Attribute | Description |
114
+ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `data-default-language` | The page's source language code |
116
+ | `data-blocked-paths` | Comma-separated list of blocked path prefixes |
117
+ | `data-update-trigger-label` | Keep the trigger's visible code + `aria-label` in sync with the active language. Defaults to `"true"` — set to `"false"` to keep the trigger static instead |
118
+ | `data-notice-content` | Copy shown once a non-default language is active. **Entirely opt-in** — omit to never create a notice |
119
+ | `data-notice-blocked-content` | Copy shown instead, on a blocked path |
120
+ | `data-notice-anchor` | CSS selector for where to insert the notice — see [Notice placement](#notice-placement) |
121
+ | `data-notice-position` | Insertion position relative to `data-notice-anchor` |
122
+ | `data-script-url` | Advanced override for Google's script URL |
123
+ | `data-storage-key` | Advanced override for the `localStorage` key |
124
+
125
+ ### Notice placement
126
+
127
+ The notice isn't pre-rendered — it's created via JS, the first time a language is actually
128
+ chosen, and inserted wherever `noticeAnchor`/`noticePosition` (or their React equivalents) point.
129
+ This matters because **the notice doesn't have to live next to the trigger.** If `<mx-translate>`
130
+ is placed in a page header, you usually don't want the notice crammed in there too — real
131
+ deployments of this pattern vary: one site renders it directly below the header, another at the
132
+ very top of the page, above everything else. Point `noticeAnchor` at whatever landmark makes
133
+ sense for your layout (omit it entirely to default to the top of `<body>`, matching the simplest
134
+ case of "always at the top of the page").
135
+
136
+ If no `noticeContent` is given, no notice is ever created — it's an entirely optional feature,
137
+ not something every consumer has to account for.
138
+
139
+ **Styled as a `GlobalAlert`** (`mx-global-alert mx-global-alert--light`/`--critical`, plus
140
+ `mx-page` for GlobalAlert's own full-bleed grid treatment) — a site-wide banner is the right fit
141
+ for "this page is machine-translated," not a page-scoped `InPageAlert`. It's a **non-dismissible**
142
+ variant: there's no close button and no `<mx-closable-alert>` wrapper, so it isn't backed by
143
+ dismissal `localStorage` the way a real `GlobalAlert` usually is — it simply shows and hides
144
+ itself as the active language/blocked-path state changes, same as everything else this component
145
+ drives.
146
+
147
+ **Keyboard nav, click-to-select, and light-dismiss are all inherited from `DropMenu`/`Popover`**
148
+ — `<mx-translate>` extends `DropMenu`'s Web Component, so roving `ArrowUp`/`Down`/`Left`/`Right`
149
+ focus (with wraparound), `Enter`/click to select, `Escape`/outside-click to close, and
150
+ `aria-expanded`/`aria-controls` bookkeeping via the native Popover API all come from there for
151
+ free. Translate's own `Elements/Translate.ts` only adds: driving `GoogleTranslateWidget` on
152
+ selection, and setting/clearing `aria-current` on the chosen language link (something DropMenu's
153
+ own selection bookkeeping only does for button-mode items, not `<a>` links like these).
154
+
155
+ **Events:**
156
+
157
+ ```js
158
+ document.querySelector("mx-translate").addEventListener("translate-language-change", e => {
159
+ console.log(e.detail.code) // the chosen language code
160
+ })
161
+ ```
162
+
163
+ `translate-open` / `translate-close` fire on menu open/close (React analogues of `onOpen`/
164
+ `onClose`).
165
+
166
+ ---
167
+
168
+ ## React
169
+
170
+ ```tsx
171
+ import { Translate } from "@pnx-mixtape/mxds/react"
172
+ import { DEFAULT_LANGUAGES } from "@pnx-mixtape/mxds/src/Component/Translate/languages"
173
+
174
+ ;<Translate
175
+ id="translate"
176
+ languages={DEFAULT_LANGUAGES}
177
+ defaultLanguage="en"
178
+ noticeContent="Our website uses an automatic service to translate content. These translations should be used as a guide only."
179
+ noticeBlockedContent="This page is only available in English."
180
+ noticeAnchor="#site-header"
181
+ onLanguageChange={code => console.log(code)}
182
+ />
183
+ ```
184
+
185
+ `DEFAULT_LANGUAGES` is an **opt-in** exported constant — `Translate` itself never defaults to it
186
+ implicitly. Import it, or pass your own `languages` list.
187
+
188
+ **`Translate` props:** same set as the Twig props above (camelCase), plus:
189
+
190
+ | Prop | Type | Default | Description |
191
+ | -------------------- | ------------------------ | ------- | ----------------------------------------------------------------------------------------------- |
192
+ | `closeOnSelect` | `boolean` | `true` | Closes the menu after a language is chosen |
193
+ | `onOpen` / `onClose` | `() => void` | — | Called when the menu opens/closes |
194
+ | `onLanguageChange` | `(code: string) => void` | — | Called when a language is chosen — analytics/telemetry hook, replaces a hardcoded tracking call |
195
+ | `className` | `string` | — | Extra class on the root |
196
+
197
+ Note that `noticeContent`/`noticeBlockedContent` are plain `string`s here, not `ReactNode` — see
198
+ [parity](#react--twig-parity) below for why.
199
+
200
+ ---
201
+
202
+ ### React ⇄ Twig parity
203
+
204
+ - `closeOnSelect` has no Twig prop — the Web Component always closes on click (the `closeonclick`
205
+ attribute is hardcoded in `translate.twig`, matching `DropMenu`'s own attribute-driven
206
+ convention rather than being exposed as a template prop).
207
+ - `onOpen`/`onClose`/`onLanguageChange` have no Twig props — Twig/HTML has no concept of a
208
+ function prop. The Web Component dispatches `translate-open`/`translate-close`/
209
+ `translate-language-change` `CustomEvent`s instead.
210
+ - `noticeContent`/`noticeBlockedContent` are plain strings in **both** stacks, not `ReactNode` in
211
+ React — the shared `GoogleTranslateWidget` class builds the notice's DOM from an HTML template
212
+ string so it can insert the exact same markup at the configured anchor point regardless of
213
+ framework. This is the one place Translate trades away React's usual "pass rich JSX content"
214
+ flexibility for truly identical cross-stack behaviour (the notice isn't part of either
215
+ framework's own render tree — see [Notice placement](#notice-placement) above).
216
+ - The React item list is built directly in JSX rather than composing `DropMenu`'s own
217
+ `DropMenuItem` — `DropMenuItem` only renders `<button>`, and a language switcher needs real
218
+ `<a href>` links (for progressive enhancement/crawlability) with `lang`/`hreflang` attributes
219
+ DropMenuItem doesn't support. Roving focus still comes from `react-aria`'s `useFocusManager`,
220
+ the same primitive DropMenuItem itself uses.
221
+
222
+ ---
223
+
224
+ ## Modifiers
225
+
226
+ No BEM modifier classes.
227
+
228
+ ---
229
+
230
+ ## Accessibility
231
+
232
+ - By default, the trigger's accessible name (`aria-label`) updates to reflect the active language
233
+ (e.g. `"Language: Français"`) rather than staying static — pass `updateTriggerLabel={false}` to
234
+ keep it static instead, which some sites prefer.
235
+ - Each language link gets `lang`/`hreflang` set to its own code, so screen readers pronounce each
236
+ language name using that language's own phonetics.
237
+ - The active language gets `aria-current="true"`, cleared from the previously active one.
238
+ - `notranslate` is applied to the trigger and menu so Google's own translator doesn't attempt to
239
+ translate the switcher's own UI.
240
+ - `aria-expanded`, `aria-controls`, keyboard support (roving arrows, Enter, Escape), and focus
241
+ return to the trigger on close are all inherited from the native Popover API via `DropMenu`
242
+ — see its own README for the full contract.
243
+ - The menu scrolls (`overflow-y: auto`, capped at `--translate-menu-max-block-size`) rather than
244
+ growing unbounded off-screen for a long language list.
245
+
246
+ ---
247
+
248
+ ## Customisation via CSS custom properties
249
+
250
+ ```css
251
+ .mx-translate__menu {
252
+ --translate-menu-max-block-size: 60vh; /* menu scroll cap */
253
+ }
254
+ ```
255
+
256
+ Also inherits `Popover`'s own custom properties (`--popover-padding`, `--popover-background`,
257
+ etc.) via the shared `.mx-popover` class.
258
+
259
+ ---
260
+
261
+ ## Google Translate quirks
262
+
263
+ - **bfcache restore:** Google's translator instance doesn't survive the browser's back/forward
264
+ cache. `GoogleTranslateWidget` listens for `pageshow` and re-injects the script/re-applies the
265
+ active language when a page is restored from bfcache.
266
+ - **`googtrans` storage key:** the active language is persisted to `localStorage` under the
267
+ literal key `googtrans`, in the exact format (`/<source>/<target>`) Google's own script reads —
268
+ this is intentional, so a page using Google's default banner elsewhere would interoperate.
269
+ Overriding `storageKey` breaks that interop; only do so if you specifically need to namespace
270
+ it away from Google's own script.
271
+ - **One Google widget per page:** the underlying script and `TranslateElement` instance are
272
+ loaded/instantiated once globally (shared across multiple `Translate` instances on the same
273
+ page, e.g. a header and footer switcher) — Google's widget is inherently page-global, there's
274
+ no way to run two independent translation states on one page.
275
+ - **The hidden `<select class="goog-te-combo">` populates its `<option>` list asynchronously**,
276
+ on Google's own timeline, after `TranslateElement` is constructed — the `<select>` element
277
+ itself can exist before the option for your target language does. Setting `.value` to a code
278
+ with no matching `<option>` yet is a silent DOM no-op (the browser just ignores it), so
279
+ `GoogleTranslateWidget` waits for the specific `<option>` to exist, not just the `<select>`,
280
+ before driving it. It also tracks the most recently requested language and drops the result of
281
+ an earlier, slower-resolving selection if a newer one has since superseded it — without this,
282
+ switching languages quickly (or switching again before the first selection's options finish
283
+ loading) could apply a stale value or fire an out-of-order `change` event.
@@ -0,0 +1,87 @@
1
+ import { Meta, StoryObj } from "@storybook/html-vite"
2
+ import { expect, waitFor } from "storybook/test"
3
+ import Component from "./translate.twig"
4
+ import "./translate.css"
5
+ import "./Elements/Translate"
6
+
7
+ // Deps.
8
+ import "../Popover/popover.css"
9
+ import "../GlobalAlert/global-alert.css"
10
+
11
+ const LANGUAGES = [
12
+ { code: "en", label: "English" },
13
+ { code: "fr", label: "Français" },
14
+ { code: "es", label: "Español" },
15
+ ]
16
+
17
+ /**
18
+ * A language switcher that drives Google's Website Translator widget. It requires the
19
+ * `Translate` custom element javascript, and is built on `DropMenu`/`Popover` — their CSS must
20
+ * also be imported.
21
+ *
22
+ * Loading Google's own translation script (on first language change) is an unavoidable
23
+ * third-party network dependency — see the README's "Google Translate quirks" section.
24
+ */
25
+ const meta: Meta<typeof Component> = {
26
+ tags: ["autodocs"],
27
+ component: Component,
28
+ args: {
29
+ id: "translate",
30
+ languages: LANGUAGES,
31
+ defaultLanguage: "en",
32
+ noticeContent:
33
+ "Our website uses an automatic service to translate content. These translations should be used as a guide only.",
34
+ },
35
+ }
36
+
37
+ export default meta
38
+ type Story = StoryObj<typeof meta>
39
+
40
+ export const Translate: Story = {
41
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
42
+ // This story's own selection below persists to real localStorage (by design — see the
43
+ // README's "Google Translate quirks" section) — clear any stale value up front so this test
44
+ // (and a fresh view of the story) always starts from the real default state.
45
+ window.localStorage.removeItem("googtrans")
46
+
47
+ const trigger = canvas.getByRole("button", { name: "Language" })
48
+ const menu = canvasElement.querySelector("#translate") as HTMLElement
49
+ const french = await canvas.findByRole("menuitem", { name: "Français", hidden: true })
50
+ // The notice is created via JS on first use (not pre-rendered) and inserted at the
51
+ // configured anchor point — the top of <body> by default here — not necessarily inside
52
+ // canvasElement, since the whole point is that it doesn't have to live next to the trigger
53
+ // (e.g. trigger in the header, notice below it). Query the owner document, and only once
54
+ // it's actually been created.
55
+ const findNotice = (): HTMLElement | null =>
56
+ canvasElement.ownerDocument.querySelector("[data-translate-notice]")
57
+
58
+ await step("Menu starts closed; notice doesn't exist until a language is chosen", async () => {
59
+ await expect(menu.matches(":popover-open")).toBe(false)
60
+ await expect(findNotice()).toBeNull()
61
+ })
62
+
63
+ await step("Opens on trigger click, listing every language as a link", async () => {
64
+ await userEvent.click(trigger)
65
+ await waitFor(() => expect(menu.matches(":popover-open")).toBe(true))
66
+ await expect(french).toHaveAttribute("href", "?hl=fr")
67
+ })
68
+
69
+ await step(
70
+ "Choosing a language marks it current, updates the trigger, and shows the notice",
71
+ async () => {
72
+ await userEvent.click(french)
73
+ await waitFor(() => expect(french).toHaveAttribute("aria-current", "true"))
74
+ await expect(trigger).toHaveAttribute("aria-label", "Language: Français")
75
+ await waitFor(() => expect(findNotice()).not.toHaveAttribute("hidden"))
76
+ },
77
+ )
78
+
79
+ await step("Restores original state", async () => {
80
+ await userEvent.click(trigger)
81
+ await waitFor(() => expect(menu.matches(":popover-open")).toBe(true))
82
+ await userEvent.click(canvas.getByRole("menuitem", { name: "English" }))
83
+ window.localStorage.removeItem("googtrans")
84
+ await userEvent.click(canvasElement)
85
+ })
86
+ },
87
+ }
@@ -0,0 +1,77 @@
1
+ import { Meta, StoryObj } from "@storybook/react-vite"
2
+ import { expect, waitFor } from "storybook/test"
3
+ import Component from "./Translate"
4
+ import "./translate.css"
5
+ import "../Popover/popover.css"
6
+ import "../GlobalAlert/global-alert.css"
7
+ import { Page } from "../../../.storybook-react/decorators"
8
+ import { TranslateLanguage } from "./languages"
9
+
10
+ const LANGUAGES: TranslateLanguage[] = [
11
+ { code: "en", label: "English" },
12
+ { code: "fr", label: "Français" },
13
+ { code: "es", label: "Español" },
14
+ ]
15
+
16
+ const meta: Meta<typeof Component> = {
17
+ tags: ["autodocs"],
18
+ component: Component,
19
+ decorators: [Page],
20
+ args: {
21
+ id: "translate",
22
+ languages: LANGUAGES,
23
+ defaultLanguage: "en",
24
+ noticeContent:
25
+ "Our website uses an automatic service to translate content. These translations should be used as a guide only.",
26
+ },
27
+ }
28
+
29
+ export default meta
30
+
31
+ export const Translate: StoryObj<typeof meta> = {
32
+ play: async ({ canvasElement, canvas, userEvent, step }) => {
33
+ // This story's own selection below persists to real localStorage (by design — see the
34
+ // README's "Google Translate quirks" section) — clear any stale value up front so this test
35
+ // (and a fresh view of the story) always starts from the real default state.
36
+ window.localStorage.removeItem("googtrans")
37
+
38
+ const trigger = canvas.getByRole("button", { name: "Language" })
39
+ const menu = canvasElement.querySelector("#translate") as HTMLElement
40
+ const french = await canvas.findByRole("menuitem", { name: "Français", hidden: true })
41
+ // The notice is created via JS on first use (not pre-rendered) and inserted at the
42
+ // configured anchor point — the top of <body> by default here — not necessarily inside
43
+ // canvasElement, since the whole point is that it doesn't have to live next to the trigger
44
+ // (e.g. trigger in the header, notice below it). Query the owner document, and only once
45
+ // it's actually been created.
46
+ const findNotice = (): HTMLElement | null =>
47
+ canvasElement.ownerDocument.querySelector("[data-translate-notice]")
48
+
49
+ await step("Menu starts closed; notice doesn't exist until a language is chosen", async () => {
50
+ await expect(menu.matches(":popover-open")).toBe(false)
51
+ await expect(findNotice()).toBeNull()
52
+ })
53
+
54
+ await step("Opens on trigger click, listing every language as a link", async () => {
55
+ await userEvent.click(trigger)
56
+ await waitFor(() => expect(menu.matches(":popover-open")).toBe(true))
57
+ await expect(french).toHaveAttribute("href", "?hl=fr")
58
+ })
59
+
60
+ await step(
61
+ "Choosing a language marks it current, updates the trigger, and shows the notice",
62
+ async () => {
63
+ await userEvent.click(french)
64
+ await waitFor(() => expect(french).toHaveAttribute("aria-current", "true"))
65
+ await expect(trigger).toHaveAttribute("aria-label", "Language: Français")
66
+ await waitFor(() => expect(findNotice()).not.toHaveAttribute("hidden"))
67
+ },
68
+ )
69
+
70
+ await step("Restores original state", async () => {
71
+ await userEvent.click(trigger)
72
+ await waitFor(() => expect(menu.matches(":popover-open")).toBe(true))
73
+ await userEvent.click(canvas.getByRole("menuitem", { name: "English" }))
74
+ window.localStorage.removeItem("googtrans")
75
+ })
76
+ },
77
+ }
@@ -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