@pnx-mixtape/mxds 0.1.0-rc.4 → 0.1.0-rc.6
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/.claude/skills/create-mxds-component/SKILL.md +77 -0
- package/DESIGN.md +595 -0
- package/README.md +2 -3
- package/dist/build/accordion.entry.js +2 -1
- package/dist/build/autocomplete.entry.js +1 -1
- package/dist/build/back-to-top.css +1 -0
- package/dist/build/back-to-top.entry.js +1 -0
- package/dist/build/base.css +1 -1
- package/dist/build/button.css +1 -1
- package/dist/build/card.css +1 -1
- package/dist/build/chunks/Accordion-Bqe8vTNR.js +44 -0
- package/dist/build/chunks/AccordionGroup-BYtoAFxg.js +47 -0
- package/dist/build/chunks/AccordionMobile-D517oXYZ.js +26 -0
- package/dist/build/chunks/Autocomplete-kRo8RNbC.js +135 -0
- package/dist/build/chunks/BackToTop-4M-Wl7z3.js +64 -0
- package/dist/build/chunks/ClosableAlert-CZ114JsL.js +55 -0
- package/dist/build/chunks/CookieConsent-wd8gQ2Oe.js +18 -0
- package/dist/build/chunks/Dialog-DoYcGqhW.js +48 -0
- package/dist/build/chunks/DropMenu-CSTZq0Zt.js +68 -0
- package/dist/build/chunks/FeedbackForm-BsL7fv8s.js +64 -0
- package/dist/build/chunks/Gallery-DqJPujX5.js +165 -0
- package/dist/build/chunks/GlobalToggle-7H3F1Wvc.js +82 -0
- package/dist/build/chunks/InPageNavigation-D7m3IN89.js +86 -0
- package/dist/build/chunks/Navigation-CRModT40.js +142 -0
- package/dist/build/chunks/Popover-CiTAftNU.js +30 -0
- package/dist/build/chunks/Sticky-C1H8MNqK.js +51 -0
- package/dist/build/chunks/Tabs-BJD3kABL.js +160 -0
- package/dist/build/chunks/Translate-yAgySwet.js +46 -0
- package/dist/build/chunks/cookie-compliance-CG6x-TcV.js +119 -0
- package/dist/build/chunks/enums-BrHxPZZ_.js +36 -0
- package/dist/build/chunks/polyfills-D-qh1a_2.js +33 -0
- package/dist/build/chunks/utilities-B9mNRs7V.js +255 -0
- package/dist/build/content-block.css +1 -1
- package/dist/build/cookie-consent.entry.js +1 -1
- package/dist/build/dialog.entry.js +1 -1
- package/dist/build/drop-menu.entry.js +1 -1
- package/dist/build/elements.js +147 -1
- package/dist/build/feedback-form.css +1 -0
- package/dist/build/feedback-form.entry.js +1 -0
- package/dist/build/filters.entry.js +145 -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.css +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/navigation.css +1 -1
- package/dist/build/navigation.entry.js +1 -1
- package/dist/build/pagination.css +1 -1
- package/dist/build/popover.entry.js +1 -1
- package/dist/build/react.js +2291 -1
- package/dist/build/side-navigation.css +1 -1
- package/dist/build/sidebar.css +1 -1
- package/dist/build/sticky.entry.js +1 -1
- package/dist/build/tabs.entry.js +1 -1
- package/dist/build/translate.css +1 -0
- package/dist/build/translate.entry.js +1 -0
- package/dist/build/utility-list.entry.js +53 -1
- package/dist/types/Component/BackToTop/BackToTop.d.ts +20 -0
- package/dist/types/Component/BackToTop/BackToTop.d.ts.map +1 -0
- package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts +27 -0
- package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts.map +1 -0
- package/dist/types/Component/BackToTop/back-to-top.entry.d.ts +2 -0
- package/dist/types/Component/BackToTop/back-to-top.entry.d.ts.map +1 -0
- package/dist/types/Component/DropMenu/DropMenu.d.ts +2 -1
- package/dist/types/Component/DropMenu/DropMenu.d.ts.map +1 -1
- package/dist/types/Component/Navigation/Elements/Navigation.d.ts +2 -3
- package/dist/types/Component/Navigation/Elements/Navigation.d.ts.map +1 -1
- package/dist/types/Component/SideNavigation/Components/SideNavigationTree.d.ts +3 -3
- package/dist/types/Component/SideNavigation/Components/SideNavigationTree.d.ts.map +1 -1
- package/dist/types/Component/SideNavigation/SideNavigation.d.ts +3 -1
- package/dist/types/Component/SideNavigation/SideNavigation.d.ts.map +1 -1
- package/dist/types/Component/Tabs/Components/TabDropMenu.d.ts.map +1 -1
- package/dist/types/Component/Tabs/Elements/Tabs.d.ts.map +1 -1
- package/dist/types/Component/Translate/Elements/Translate.d.ts +23 -0
- package/dist/types/Component/Translate/Elements/Translate.d.ts.map +1 -0
- package/dist/types/Component/Translate/Translate.d.ts +37 -0
- package/dist/types/Component/Translate/Translate.d.ts.map +1 -0
- package/dist/types/Component/Translate/languages.d.ts +11 -0
- package/dist/types/Component/Translate/languages.d.ts.map +1 -0
- package/dist/types/Component/Translate/translate.entry.d.ts +2 -0
- package/dist/types/Component/Translate/translate.entry.d.ts.map +1 -0
- package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts +37 -0
- package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts.map +1 -0
- package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts +36 -0
- package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts.map +1 -0
- package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts +2 -0
- package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts.map +1 -0
- package/dist/types/Utility/Elements/google-translate-widget.d.ts +91 -0
- package/dist/types/Utility/Elements/google-translate-widget.d.ts.map +1 -0
- package/dist/types/Utility/utilities.d.ts +2 -0
- package/dist/types/Utility/utilities.d.ts.map +1 -1
- package/dist/types/elements.d.ts +3 -0
- package/dist/types/elements.d.ts.map +1 -1
- package/dist/types/enums.d.ts +3 -1
- package/dist/types/enums.d.ts.map +1 -1
- package/dist/types/react.d.ts +3 -0
- package/dist/types/react.d.ts.map +1 -1
- package/dist/types/tokens.d.ts +1 -0
- package/dist/types/tokens.d.ts.map +1 -1
- package/docs/API.md +1215 -0
- package/docs/CSSGuidelines.md +893 -0
- package/docs/CodeQuality.md +940 -0
- package/docs/ComponentArchitecture.md +714 -0
- package/docs/Contributing.md +626 -0
- package/docs/MemoryManagement.md +598 -0
- package/docs/Migrations.md +6 -0
- package/docs/README.md +100 -0
- package/docs/Security.md +444 -0
- package/docs/Testing.md +739 -0
- package/docs/Troubleshooting.md +674 -0
- package/package.json +9 -6
- package/src/Atom/Button/Button.tsx +1 -1
- package/src/Atom/Button/__snapshots__/Button.stories.tsx.snap +2 -2
- package/src/Atom/Button/_buttons.css +1 -0
- package/src/Atom/Icon/README.md +1 -1
- package/src/Atom/Icon/_icon.css +1 -0
- package/src/Atom/Icon/images/thumb.svg +1 -0
- package/src/Atom/Link/_links.css +1 -0
- package/src/Atom/Media/__snapshots__/Media.stories.ts.snap +4 -9
- package/src/Atom/Media/media.twig +4 -5
- package/src/Component/BackToTop/BackToTop.stories.ts +127 -0
- package/src/Component/BackToTop/BackToTop.stories.tsx +53 -0
- package/src/Component/BackToTop/BackToTop.tsx +120 -0
- package/src/Component/BackToTop/Elements/BackToTop.ts +119 -0
- package/src/Component/BackToTop/README.md +164 -0
- package/src/Component/BackToTop/__snapshots__/BackToTop.stories.ts.snap +24 -0
- package/src/Component/BackToTop/__snapshots__/BackToTop.stories.tsx.snap +3 -0
- package/src/Component/BackToTop/back-to-top.css +70 -0
- package/src/Component/BackToTop/back-to-top.entry.js +1 -0
- package/src/Component/BackToTop/back-to-top.twig +26 -0
- package/src/Component/Card/Components/CardContent.tsx +1 -1
- package/src/Component/Card/Components/CardMedia.tsx +1 -1
- package/src/Component/Card/__snapshots__/Card.stories.tsx.snap +3 -3
- package/src/Component/Card/card.css +1 -1
- package/src/Component/Carousel/Carousel.stories.ts +66 -36
- package/src/Component/Carousel/__snapshots__/Carousel.stories.tsx.snap +5 -5
- package/src/Component/ContentBlock/Components/ContentBlockMedia.tsx +1 -1
- package/src/Component/ContentBlock/content-block.css +1 -1
- package/src/Component/DropMenu/DropMenu.stories.ts +21 -2
- package/src/Component/DropMenu/DropMenu.tsx +14 -4
- package/src/Component/DropMenu/__snapshots__/DropMenu.stories.ts.snap +3 -3
- package/src/Component/DropMenu/__snapshots__/DropMenu.stories.tsx.snap +2 -2
- package/src/Component/DropMenu/drop-menu.twig +7 -1
- package/src/Component/HeroBanner/__snapshots__/HeroBanner.stories.tsx.snap +1 -1
- package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.ts.snap +48 -13
- package/src/Component/InPageNavigation/in-page-navigation.twig +4 -6
- package/src/Component/ListItem/Components/ListItemMedia.tsx +1 -1
- package/src/Component/Navigation/Elements/Navigation.ts +25 -29
- package/src/Component/Navigation/README.md +11 -10
- package/src/Component/Navigation/__snapshots__/Navigation.stories.ts.snap +8 -8
- package/src/Component/Navigation/_navigation-dropdown.css +9 -11
- package/src/Component/Navigation/_navigation-mega.css +5 -11
- package/src/Component/Navigation/_navigation.css +5 -3
- package/src/Component/Navigation/menu.twig +1 -1
- package/src/Component/Navigation/navigation.twig +0 -1
- package/src/Component/Pagination/Pagination.stories.ts +57 -27
- package/src/Component/Pagination/pagination.css +16 -7
- package/src/Component/Popover/__snapshots__/Popover.stories.tsx.snap +3 -3
- package/src/Component/SideNavigation/Components/SideNavigationTree.tsx +68 -24
- package/src/Component/SideNavigation/README.md +78 -32
- package/src/Component/SideNavigation/SideNavigation.stories.ts +41 -2
- package/src/Component/SideNavigation/SideNavigation.stories.tsx +31 -0
- package/src/Component/SideNavigation/SideNavigation.tsx +13 -3
- package/src/Component/SideNavigation/__snapshots__/SideNavigation.stories.ts.snap +87 -3
- package/src/Component/SideNavigation/__snapshots__/SideNavigation.stories.tsx.snap +2 -0
- package/src/Component/SideNavigation/side-navigation.css +3 -3
- package/src/Component/SideNavigation/side-navigation.twig +10 -3
- package/src/Component/Tabs/Components/TabDropMenu.tsx +7 -1
- package/src/Component/Tabs/Elements/Tabs.ts +13 -3
- package/src/Component/Tabs/Tabs.stories.ts +33 -12
- package/src/Component/Tabs/Tabs.stories.tsx +28 -14
- package/src/Component/Tabs/__snapshots__/Tabs.stories.ts.snap +3 -3
- package/src/Component/Tabs/__snapshots__/Tabs.stories.tsx.snap +2 -2
- package/src/Component/Translate/Elements/Translate.ts +76 -0
- package/src/Component/Translate/README.md +285 -0
- package/src/Component/Translate/Translate.stories.ts +87 -0
- package/src/Component/Translate/Translate.stories.tsx +77 -0
- package/src/Component/Translate/Translate.tsx +213 -0
- package/src/Component/Translate/__snapshots__/Translate.stories.ts.snap +18 -0
- package/src/Component/Translate/__snapshots__/Translate.stories.tsx.snap +3 -0
- package/src/Component/Translate/languages.ts +39 -0
- package/src/Component/Translate/translate.css +62 -0
- package/src/Component/Translate/translate.entry.js +1 -0
- package/src/Component/Translate/translate.twig +44 -0
- package/src/Form/Autocomplete/Autocomplete.stories.tsx +2 -1
- package/src/Form/FeedbackForm/Elements/FeedbackForm.ts +122 -0
- package/src/Form/FeedbackForm/FeedbackForm.stories.ts +99 -0
- package/src/Form/FeedbackForm/FeedbackForm.stories.tsx +73 -0
- package/src/Form/FeedbackForm/FeedbackForm.tsx +196 -0
- package/src/Form/FeedbackForm/README.md +233 -0
- package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.ts.snap +125 -0
- package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.tsx.snap +5 -0
- package/src/Form/FeedbackForm/feedback-form.css +74 -0
- package/src/Form/FeedbackForm/feedback-form.entry.js +1 -0
- package/src/Form/FeedbackForm/feedback-form.twig +80 -0
- package/src/Form/form.css +17 -19
- package/src/Introduction.mdx +1 -1
- package/src/Layout/Footer/__snapshots__/Footer.stories.ts.snap +3 -3
- package/src/Layout/Header/Components/GlobalToggle.tsx +1 -1
- package/src/Layout/Header/Header.stories.ts +53 -48
- package/src/Layout/Header/Header.stories.tsx +35 -22
- package/src/Layout/Header/Header.tsx +2 -2
- package/src/Layout/Header/__snapshots__/Header.stories.ts.snap +18 -14
- package/src/Layout/Header/__snapshots__/Header.stories.tsx.snap +2 -2
- package/src/Layout/Header/_toggles.css +7 -7
- package/src/Layout/Page/__snapshots__/Page.stories.tsx.snap +1 -1
- package/src/Layout/Sidebar/sidebar.css +10 -3
- package/src/Utility/Elements/google-translate-widget.ts +293 -0
- package/src/Utility/global.d.ts +4 -0
- package/src/Utility/utilities.ts +2 -0
- package/src/_custom-media.css +2 -0
- package/src/elements.ts +3 -0
- package/src/enums.ts +2 -0
- package/src/react.ts +3 -0
- package/src/tokens.js +1 -0
- package/dist/build/chunks/Accordion-GN0sME4W.js +0 -1
- package/dist/build/chunks/AccordionGroup-Bzqp5__q.js +0 -1
- package/dist/build/chunks/AccordionMobile-DG5jSe8Q.js +0 -1
- package/dist/build/chunks/Autocomplete-B4RtkAyh.js +0 -1
- package/dist/build/chunks/ClosableAlert-DEhj8Nxm.js +0 -1
- package/dist/build/chunks/CookieConsent-BkCMRVST.js +0 -1
- package/dist/build/chunks/Dialog-Haahn-Yn.js +0 -1
- package/dist/build/chunks/DropMenu-1baQc8Yc.js +0 -1
- package/dist/build/chunks/Gallery-D8NGXy2Q.js +0 -1
- package/dist/build/chunks/GlobalToggle-B52fdBQu.js +0 -1
- package/dist/build/chunks/InPageNavigation-Dyn2TR-N.js +0 -1
- package/dist/build/chunks/Navigation-hOrfLq7h.js +0 -1
- package/dist/build/chunks/Popover-Cxg-s5Jn.js +0 -1
- package/dist/build/chunks/Sticky-BGCuuEJK.js +0 -1
- package/dist/build/chunks/Tabs-DYy1YTdU.js +0 -15
- package/dist/build/chunks/cookie-compliance-Bbm-0vpj.js +0 -1
- package/dist/build/chunks/enums-DLV_1NAf.js +0 -1
- package/dist/build/chunks/polyfills-C7pRt54M.js +0 -1
- package/dist/build/chunks/utilities-_Bq3TxAQ.js +0 -1
|
@@ -0,0 +1,285 @@
|
|
|
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, EN"
|
|
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, FR"`) rather than staying static — pass `updateTriggerLabel={false}` to
|
|
234
|
+
keep it static instead, which some sites prefer.
|
|
235
|
+
- The accessible name always includes the visible language code (e.g. `EN`) so it satisfies WCAG
|
|
236
|
+
2.5.3 Label in Name — speech-input users can activate the trigger by saying what they see.
|
|
237
|
+
- Each language link gets `lang`/`hreflang` set to its own code, so screen readers pronounce each
|
|
238
|
+
language name using that language's own phonetics.
|
|
239
|
+
- The active language gets `aria-current="true"`, cleared from the previously active one.
|
|
240
|
+
- `notranslate` is applied to the trigger and menu so Google's own translator doesn't attempt to
|
|
241
|
+
translate the switcher's own UI.
|
|
242
|
+
- `aria-expanded`, `aria-controls`, keyboard support (roving arrows, Enter, Escape), and focus
|
|
243
|
+
return to the trigger on close are all inherited from the native Popover API via `DropMenu`
|
|
244
|
+
— see its own README for the full contract.
|
|
245
|
+
- The menu scrolls (`overflow-y: auto`, capped at `--translate-menu-max-block-size`) rather than
|
|
246
|
+
growing unbounded off-screen for a long language list.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Customisation via CSS custom properties
|
|
251
|
+
|
|
252
|
+
```css
|
|
253
|
+
.mx-translate__menu {
|
|
254
|
+
--translate-menu-max-block-size: 60vh; /* menu scroll cap */
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Also inherits `Popover`'s own custom properties (`--popover-padding`, `--popover-background`,
|
|
259
|
+
etc.) via the shared `.mx-popover` class.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Google Translate quirks
|
|
264
|
+
|
|
265
|
+
- **bfcache restore:** Google's translator instance doesn't survive the browser's back/forward
|
|
266
|
+
cache. `GoogleTranslateWidget` listens for `pageshow` and re-injects the script/re-applies the
|
|
267
|
+
active language when a page is restored from bfcache.
|
|
268
|
+
- **`googtrans` storage key:** the active language is persisted to `localStorage` under the
|
|
269
|
+
literal key `googtrans`, in the exact format (`/<source>/<target>`) Google's own script reads —
|
|
270
|
+
this is intentional, so a page using Google's default banner elsewhere would interoperate.
|
|
271
|
+
Overriding `storageKey` breaks that interop; only do so if you specifically need to namespace
|
|
272
|
+
it away from Google's own script.
|
|
273
|
+
- **One Google widget per page:** the underlying script and `TranslateElement` instance are
|
|
274
|
+
loaded/instantiated once globally (shared across multiple `Translate` instances on the same
|
|
275
|
+
page, e.g. a header and footer switcher) — Google's widget is inherently page-global, there's
|
|
276
|
+
no way to run two independent translation states on one page.
|
|
277
|
+
- **The hidden `<select class="goog-te-combo">` populates its `<option>` list asynchronously**,
|
|
278
|
+
on Google's own timeline, after `TranslateElement` is constructed — the `<select>` element
|
|
279
|
+
itself can exist before the option for your target language does. Setting `.value` to a code
|
|
280
|
+
with no matching `<option>` yet is a silent DOM no-op (the browser just ignores it), so
|
|
281
|
+
`GoogleTranslateWidget` waits for the specific `<option>` to exist, not just the `<select>`,
|
|
282
|
+
before driving it. It also tracks the most recently requested language and drops the result of
|
|
283
|
+
an earlier, slower-resolving selection if a newer one has since superseded it — without this,
|
|
284
|
+
switching languages quickly (or switching again before the first selection's options finish
|
|
285
|
+
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\b/ })
|
|
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, FR")
|
|
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\b/ })
|
|
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, FR")
|
|
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}, ${defaultLanguage.toUpperCase()}`}
|
|
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
|