@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
@@ -66,7 +66,7 @@ import { IconColourModifier, IconSizeModifier, IconRotateModifier } from "@pnx-m
66
66
 
67
67
  ## Available icons (`Icons` enum)
68
68
 
69
- `arrow-down`, `arrow-left`, `arrow-up`, `arrow-right`, `chevron-down`, `chevron-left`, `chevron-right`, `chevron-up`, `close`, `collapse`, `download`, `drupal`, `error`, `expand`, `external-link`, `facebook`, `google`, `info`, `instagram`, `linkedin`, `mastodon`, `bluesky`, `menu`, `search`, `success`, `twitter`, `warning`, `youtube`, `email`, `file`, `heart-selected`, `heart-unselected`, `link`, `lock`, `login`, `more`, `pencil`, `phone`, `pin`, `print`, `question`, `restart`, `share`, `star-selected`, `star-unselected`, `tick`, `trash`, `user`
69
+ `arrow-down`, `arrow-left`, `arrow-up`, `arrow-right`, `chevron-down`, `chevron-left`, `chevron-right`, `chevron-up`, `close`, `collapse`, `download`, `drupal`, `error`, `expand`, `external-link`, `facebook`, `globe`, `google`, `info`, `instagram`, `linkedin`, `mastodon`, `bluesky`, `menu`, `search`, `success`, `twitter`, `warning`, `youtube`, `email`, `file`, `heart-selected`, `heart-unselected`, `link`, `lock`, `login`, `more`, `pencil`, `phone`, `pin`, `print`, `question`, `restart`, `share`, `star-selected`, `star-unselected`, `thumb`, `tick`, `trash`, `user`
70
70
 
71
71
  ## Colour modifiers
72
72
 
@@ -77,6 +77,7 @@
77
77
  @add-mixin icon print;
78
78
  @add-mixin icon question;
79
79
  @add-mixin icon share;
80
+ @add-mixin icon thumb;
80
81
 
81
82
  .mx-icon--status::before {
82
83
  mask-image: url("./images/info.svg");
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-thumbs-up preview-icon"><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/><path d="M7 10v12"/></svg>
@@ -6,9 +6,7 @@ exports[`Attribution 1`] = `
6
6
  <figure class="mx-media mx-media--image"> <picture>
7
7
  <img src="https://picsum.photos/id/56/558/418?grayscale" alt="Some blurry bubble image">
8
8
  </picture>
9
-
10
- <figcaption><b>Figure 1.</b> A picsum image
11
- - Jane Doe </figcaption>
9
+ <figcaption><b>Figure 1.</b> A picsum image - Jane Doe</figcaption>
12
10
  </figure>"
13
11
  `;
14
12
 
@@ -18,8 +16,7 @@ exports[`Media 1`] = `
18
16
  <figure class="mx-media mx-media--image"> <picture>
19
17
  <img src="https://picsum.photos/id/56/558/418?grayscale" alt="Some blurry bubble image">
20
18
  </picture>
21
-
22
- <figcaption><b>Figure 1.</b> A picsum image </figcaption>
19
+ <figcaption><b>Figure 1.</b> A picsum image</figcaption>
23
20
  </figure>"
24
21
  `;
25
22
 
@@ -29,8 +26,7 @@ exports[`No Figure 1`] = `
29
26
  <picture>
30
27
  <img src="https://picsum.photos/id/56/558/418?grayscale" alt="Some blurry bubble image">
31
28
  </picture>
32
-
33
- "
29
+ "
34
30
  `;
35
31
 
36
32
  exports[`Remote Video 1`] = `
@@ -39,7 +35,6 @@ exports[`Remote Video 1`] = `
39
35
  <figure class="mx-media mx-media--video"> <figure class="mx-video" data-src="https://www.youtube.com/embed/NpEaa2P7qZI" data-title="Something from youtube">
40
36
  <iframe src="https://www.youtube.com/embed/NpEaa2P7qZI" title="Something from youtube" allow="fullscreen; picture-in-picture; encrypted-media; clipboard-write" allowfullscreen=""></iframe>
41
37
  </figure>
42
-
43
- <figcaption>A remote video </figcaption>
38
+ <figcaption>A remote video</figcaption>
44
39
  </figure>"
45
40
  `;
@@ -11,11 +11,10 @@
11
11
  {% if wrap %}<figure{{ attributes }}>{% endif %}
12
12
  {{ item }}
13
13
  {% if caption is not empty or attribution is not empty %}
14
- <figcaption>{% apply spaceless %}
15
- {{ caption }}
16
- {% if caption is not empty and attribution is not empty %} - {% endif %}
17
- {{ attribution }}
18
- {% endapply %}
14
+ <figcaption>
15
+ {{- caption -}}
16
+ {%- if caption is not empty and attribution is not empty %} - {% endif -%}
17
+ {{- attribution -}}
19
18
  </figcaption>
20
19
  {% endif %}
21
20
  {% if wrap %}</figure>{% endif %}
@@ -0,0 +1,127 @@
1
+ import { Meta, StoryObj } from "@storybook/html-vite"
2
+ import { expect, waitFor } from "storybook/test"
3
+ import Component from "./back-to-top.twig"
4
+ import "./back-to-top.css"
5
+ import "./Elements/BackToTop"
6
+
7
+ /**
8
+ * A trigger that appears once the reader has scrolled past a threshold and jumps back to a
9
+ * target element on the page, with focus management and reduced-motion support.
10
+ * It requires the `BackToTop` custom element javascript.
11
+ *
12
+ * By default it watches how far the reader has scrolled (`visibleAfter`, `100vh`) — pass
13
+ * `target` to watch a specific element (e.g. a page's `<h1>`) leaving the viewport instead.
14
+ */
15
+ const meta: Meta<typeof Component> = {
16
+ tags: ["autodocs"],
17
+ component: Component,
18
+ args: {
19
+ title: "Back to top",
20
+ href: "#main",
21
+ visibleAfter: "1vh",
22
+ },
23
+ argTypes: {
24
+ target: {
25
+ description:
26
+ "CSS selector of an element to observe — visible once it scrolls above the viewport top. Omit to use `visibleAfter` instead.",
27
+ control: "text",
28
+ type: "string",
29
+ },
30
+ visibleAfter: {
31
+ description:
32
+ "Distance down the page before the trigger appears. Only used when `target` is unset.",
33
+ control: "text",
34
+ type: "string",
35
+ table: {
36
+ defaultValue: { summary: "100vh" },
37
+ },
38
+ },
39
+ href: {
40
+ description: "Scroll destination — a same-page hash.",
41
+ control: "text",
42
+ type: {
43
+ name: "string",
44
+ required: true,
45
+ },
46
+ table: {
47
+ defaultValue: { summary: "#main" },
48
+ },
49
+ },
50
+ offset: {
51
+ description:
52
+ "Pixel offset to avoid overlapping another fixed element (e.g. a sticky CTA bar).",
53
+ control: "number",
54
+ type: "number",
55
+ },
56
+ title: {
57
+ description: "Trigger label.",
58
+ control: "text",
59
+ type: {
60
+ name: "string",
61
+ required: true,
62
+ },
63
+ table: {
64
+ defaultValue: { summary: "Back to top" },
65
+ },
66
+ },
67
+ breakpoint: {
68
+ description: "Restrict display to a breakpoint and up. Omit to show at all viewport sizes.",
69
+ control: "select",
70
+ options: ["", "l"],
71
+ type: "string",
72
+ },
73
+ },
74
+ render: args => `
75
+ <div style="block-size: 100px; padding-block-start: 2rem;">
76
+ <p>Scroll down to reveal the "Back to top" trigger.</p>
77
+ </div>
78
+ <div id="main">
79
+ <h2>Main content</h2>
80
+ <p>This is the scroll destination.</p>
81
+ </div>
82
+ ${Component(args)}
83
+ `,
84
+ }
85
+
86
+ export default meta
87
+ type Story = StoryObj<typeof meta>
88
+
89
+ /**
90
+ * Visibility is driven by real window scroll position (see the component's `setVisible`), which
91
+ * isn't reliably simulated via `window.scrollTo` in this test canvas — so this play function
92
+ * exercises the same toggle the scroll listener would call, directly, along with the click
93
+ * behaviour, rather than simulating a real scroll.
94
+ */
95
+ export const BackToTop: Story = {
96
+ play: async ({ canvasElement, userEvent, step }) => {
97
+ const wrapper = canvasElement.querySelector("mx-back-to-top") as InstanceType<
98
+ typeof HTMLElement
99
+ > & { setVisible: (isVisible: boolean) => void }
100
+ const trigger = canvasElement.querySelector("a") as HTMLAnchorElement
101
+ const main = canvasElement.querySelector("#main") as HTMLElement
102
+
103
+ await step("Starts hidden", async () => {
104
+ await expect(wrapper.classList.contains("is-visible")).toBe(false)
105
+ })
106
+
107
+ await step("Becomes visible once toggled", async () => {
108
+ let detail: { isVisible: boolean } | undefined
109
+ wrapper.addEventListener("back-to-top-toggle", (event: Event) => {
110
+ detail = (event as CustomEvent<{ isVisible: boolean }>).detail
111
+ })
112
+ wrapper.setVisible(true)
113
+ await expect(wrapper.classList.contains("is-visible")).toBe(true)
114
+ await expect(detail?.isVisible).toBe(true)
115
+ })
116
+
117
+ await step("Activating it moves focus to the destination", async () => {
118
+ await userEvent.click(trigger)
119
+ await waitFor(() => expect(document.activeElement).toBe(main))
120
+ })
121
+
122
+ await step("Restores toggled state", async () => {
123
+ wrapper.setVisible(false)
124
+ await expect(wrapper.classList.contains("is-visible")).toBe(false)
125
+ })
126
+ },
127
+ }
@@ -0,0 +1,53 @@
1
+ import { Meta, StoryObj } from "@storybook/react-vite"
2
+ import { expect, waitFor } from "storybook/test"
3
+ import Component from "./BackToTop"
4
+ import "./back-to-top.css"
5
+ import { Page } from "../../../.storybook-react/decorators"
6
+
7
+ const fillerStyle = { blockSize: "50vh", paddingBlockStart: "2rem" }
8
+
9
+ const meta: Meta<typeof Component> = {
10
+ tags: ["autodocs"],
11
+ component: Component,
12
+ decorators: [Page],
13
+ args: {
14
+ title: "Back to top",
15
+ to: "#main",
16
+ },
17
+ render: args => (
18
+ <>
19
+ <div style={fillerStyle}>
20
+ <p>Scroll down to reveal the &quot;Back to top&quot; trigger.</p>
21
+ </div>
22
+ <div id="main">
23
+ <h2>Main content</h2>
24
+ <p>This is the scroll destination.</p>
25
+ </div>
26
+ <Component {...args} />
27
+ </>
28
+ ),
29
+ }
30
+
31
+ export default meta
32
+
33
+ /**
34
+ * Visibility is driven by real window scroll position, which isn't reliably simulated via
35
+ * `window.scrollTo` in this test canvas — see the Twig/Web Component story, which exercises the
36
+ * toggle directly instead. This story covers the click behaviour, which doesn't depend on it.
37
+ */
38
+ export const BackToTop: StoryObj<typeof meta> = {
39
+ play: async ({ canvasElement, userEvent, step }) => {
40
+ const wrapper = canvasElement.querySelector(".mx-back-to-top") as HTMLElement
41
+ const trigger = canvasElement.querySelector("a") as HTMLAnchorElement
42
+ const main = canvasElement.querySelector("#main") as HTMLElement
43
+
44
+ await step("Starts hidden", async () => {
45
+ await expect(wrapper.classList.contains("is-visible")).toBe(false)
46
+ })
47
+
48
+ await step("Activating it moves focus to the destination", async () => {
49
+ await userEvent.click(trigger)
50
+ await waitFor(() => expect(document.activeElement).toBe(main))
51
+ })
52
+ },
53
+ }
@@ -0,0 +1,120 @@
1
+ import { JSX, MouseEventHandler, useEffect, useMemo, useState } from "react"
2
+ import classNames from "classnames"
3
+ import { ButtonModifiers } from "@pnx-mixtape/ids-shape"
4
+ import { ButtonLink } from "../../Atom/Button/Button"
5
+ import { Icons } from "../../enums"
6
+
7
+ const TRIGGER_MODIFIERS = [ButtonModifiers.LIGHT]
8
+
9
+ /** Resolves a CSS length (`px` or `vh`) to a pixel value. */
10
+ const resolveThreshold = (value: string): number => {
11
+ const amount = Number.parseFloat(value)
12
+ if (Number.isNaN(amount)) return window.innerHeight
13
+ if (value.endsWith("vh")) return (amount / 100) * window.innerHeight
14
+ return amount
15
+ }
16
+
17
+ type BackToTopProps = {
18
+ /** CSS selector of an element to observe — visible once it scrolls above the viewport top. */
19
+ target?: string
20
+ /** When `target` is unset, distance down the page before the trigger appears. */
21
+ visibleAfter?: string
22
+ /** Scroll destination. Must be a same-page hash (`#id`). */
23
+ to?: string
24
+ /** Pixel offset to avoid overlapping another fixed element (e.g. a sticky CTA bar). */
25
+ offset?: number
26
+ title?: string
27
+ /** Restricts display to a breakpoint and up. Omit to show at all viewport sizes. */
28
+ breakpoint?: "l"
29
+ className?: string
30
+ /** Called whenever the visible state changes. React analogue of the Web Component's `back-to-top-toggle` event. */
31
+ onVisibleChange?: (isVisible: boolean) => void
32
+ }
33
+
34
+ const BackToTop = ({
35
+ target,
36
+ visibleAfter = "100vh",
37
+ to = "#main",
38
+ offset,
39
+ title = "Back to top",
40
+ breakpoint,
41
+ className,
42
+ onVisibleChange,
43
+ }: BackToTopProps): JSX.Element => {
44
+ const [isVisible, setIsVisible] = useState(false)
45
+
46
+ useEffect(() => {
47
+ const setNext = (next: boolean): void => {
48
+ setIsVisible(prev => {
49
+ if (prev !== next) onVisibleChange?.(next)
50
+ return next
51
+ })
52
+ }
53
+
54
+ const observed = target ? document.querySelector(target) : null
55
+
56
+ if (observed) {
57
+ const io = new IntersectionObserver(([{ boundingClientRect }]) => {
58
+ setNext(boundingClientRect.y < 0)
59
+ })
60
+ io.observe(observed)
61
+ return () => io.disconnect()
62
+ }
63
+
64
+ const threshold = resolveThreshold(visibleAfter)
65
+ const handleScroll = (): void => setNext(window.scrollY > threshold)
66
+ handleScroll()
67
+ window.addEventListener("scroll", handleScroll, { passive: true })
68
+ return () => window.removeEventListener("scroll", handleScroll)
69
+ }, [target, visibleAfter, onVisibleChange])
70
+
71
+ const handleClick: MouseEventHandler<HTMLAnchorElement> = event => {
72
+ if (!to.startsWith("#")) return
73
+ const dest = document.querySelector<HTMLElement>(to)
74
+ if (!dest) return
75
+
76
+ event.preventDefault()
77
+
78
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
79
+ dest.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" })
80
+
81
+ const hadTabindex = dest.hasAttribute("tabindex")
82
+ if (!hadTabindex) dest.setAttribute("tabindex", "-1")
83
+ dest.focus({ preventScroll: true })
84
+ if (!hadTabindex) {
85
+ dest.addEventListener("blur", () => dest.removeAttribute("tabindex"), { once: true })
86
+ }
87
+
88
+ window.history?.pushState?.(null, "", to)
89
+ }
90
+
91
+ const style = useMemo<Record<string, string | number>>(() => {
92
+ const next: Record<string, string | number> = {}
93
+ if (offset) next["--back-to-top-offset"] = `${offset}px`
94
+ return next
95
+ }, [offset])
96
+
97
+ return (
98
+ <div
99
+ className={classNames(
100
+ breakpoint ? `mx-back-to-top--${breakpoint}` : "mx-back-to-top",
101
+ className,
102
+ // Always "enhanced" — React has already confirmed JS is running.
103
+ { "is-enhanced": true, "is-visible": isVisible },
104
+ )}
105
+ style={style}
106
+ >
107
+ <ButtonLink
108
+ to={to}
109
+ onClick={handleClick}
110
+ modifiers={TRIGGER_MODIFIERS}
111
+ className="mx-back-to-top__trigger mx-button--small"
112
+ iconStart={Icons.ARROW_UP}
113
+ >
114
+ {title}
115
+ </ButtonLink>
116
+ </div>
117
+ )
118
+ }
119
+
120
+ export default BackToTop
@@ -0,0 +1,119 @@
1
+ /**
2
+ * BackToTop — shows/hides a "jump to top" trigger based on scroll position,
3
+ * and manages focus/scroll when it's activated.
4
+ */
5
+
6
+ type ToggleDetail = {
7
+ isVisible: boolean
8
+ target: BackToTop
9
+ }
10
+
11
+ /** Resolves a CSS length (`px` or `vh`) to a pixel value. */
12
+ const resolveThreshold = (value: string): number => {
13
+ const amount = Number.parseFloat(value)
14
+ if (Number.isNaN(amount)) return window.innerHeight
15
+ if (value.endsWith("vh")) return (amount / 100) * window.innerHeight
16
+ return amount
17
+ }
18
+
19
+ export default class BackToTop extends HTMLElement {
20
+ internals_: ElementInternals
21
+ controller: AbortController
22
+ io?: IntersectionObserver
23
+
24
+ constructor() {
25
+ super()
26
+ this.internals_ = this.attachInternals()
27
+ this.controller = new AbortController()
28
+ }
29
+
30
+ connectedCallback(): void {
31
+ const { signal } = this.controller
32
+
33
+ // Only hide-until-scrolled once JS is confirmed running — see back-to-top.css.
34
+ this.classList.add("is-enhanced")
35
+
36
+ this.trigger?.addEventListener("click", this.handleClick, { signal })
37
+
38
+ const selector = this.getAttribute("target")
39
+ const target = selector ? document.querySelector(selector) : null
40
+
41
+ if (target) {
42
+ this.io = new IntersectionObserver(([{ boundingClientRect }]) => {
43
+ this.setVisible(boundingClientRect.y < 0)
44
+ })
45
+ this.io.observe(target)
46
+ return
47
+ }
48
+
49
+ const threshold = resolveThreshold(this.getAttribute("visibleafter") || "100vh")
50
+ this.setVisible(window.scrollY > threshold)
51
+ window.addEventListener("scroll", () => this.setVisible(window.scrollY > threshold), {
52
+ signal,
53
+ passive: true,
54
+ })
55
+ }
56
+
57
+ disconnectedCallback(): void {
58
+ this.controller.abort()
59
+ this.io?.disconnect()
60
+ }
61
+
62
+ get trigger(): HTMLAnchorElement | null {
63
+ return this.querySelector("a")
64
+ }
65
+
66
+ setVisible(isVisible: boolean): void {
67
+ if (this.classList.contains("is-visible") === isVisible) return
68
+ this.classList.toggle("is-visible", isVisible)
69
+ this.dispatchEvent(
70
+ new CustomEvent<ToggleDetail>("back-to-top-toggle", {
71
+ bubbles: true,
72
+ cancelable: true,
73
+ detail: { isVisible, target: this },
74
+ }),
75
+ )
76
+ }
77
+
78
+ /**
79
+ * Prevents the native anchor jump so we can scroll smoothly (honouring
80
+ * reduced-motion), then moves focus to the destination — a plain anchor
81
+ * jump scrolls the page but does not reliably move accessible focus
82
+ * unless the destination is already focusable.
83
+ */
84
+ handleClick = (event: MouseEvent): void => {
85
+ const link = event.currentTarget as HTMLAnchorElement
86
+ const hash = link.getAttribute("href")
87
+ if (!hash?.startsWith("#")) return
88
+
89
+ const dest = document.querySelector<HTMLElement>(hash)
90
+ if (!dest) return
91
+
92
+ event.preventDefault()
93
+
94
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
95
+ dest.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" })
96
+
97
+ const hadTabindex = dest.hasAttribute("tabindex")
98
+ if (!hadTabindex) dest.setAttribute("tabindex", "-1")
99
+ dest.focus({ preventScroll: true })
100
+ if (!hadTabindex) {
101
+ dest.addEventListener("blur", () => dest.removeAttribute("tabindex"), {
102
+ once: true,
103
+ signal: this.controller.signal,
104
+ })
105
+ }
106
+
107
+ window.history?.pushState?.(null, "", hash)
108
+ }
109
+ }
110
+
111
+ if (!customElements.get("mx-back-to-top")) {
112
+ customElements.define("mx-back-to-top", BackToTop)
113
+ }
114
+
115
+ declare global {
116
+ interface HTMLElementTagNameMap {
117
+ "mx-back-to-top": BackToTop
118
+ }
119
+ }
@@ -0,0 +1,164 @@
1
+ # BackToTop
2
+
3
+ A trigger that appears once the reader has scrolled past a threshold and jumps back to a target
4
+ element on the page. Shows/hides itself via `IntersectionObserver`, and manages focus and
5
+ reduced-motion-aware smooth scrolling when activated.
6
+
7
+ ---
8
+
9
+ ## CSS Import
10
+
11
+ ```css
12
+ @import "@pnx-mixtape/mxds/src/Component/BackToTop/back-to-top.css";
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Twig
18
+
19
+ ```twig
20
+ {% include '@mixtape/Component/BackToTop/back-to-top.twig' with {
21
+ href: '#main',
22
+ title: 'Back to top',
23
+ } %}
24
+
25
+ {# Watch a specific element (e.g. the page's <h1>) instead of a scroll distance #}
26
+ {% include '@mixtape/Component/BackToTop/back-to-top.twig' with {
27
+ target: '#page-title',
28
+ href: '#main',
29
+ } %}
30
+ ```
31
+
32
+ **back-to-top.twig props:**
33
+
34
+ | Prop | Type | Default | Description |
35
+ | -------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------ |
36
+ | `href` | `string` | `#main` | Scroll destination — a same-page hash |
37
+ | `title` | `string` | `Back to top` | Trigger label |
38
+ | `target` | `string` | — | CSS selector of an element to observe — visible once it scrolls above the viewport top |
39
+ | `visibleAfter` | `string` | `100vh` | Distance down the page before the trigger appears. Only used when `target` is unset |
40
+ | `offset` | `number` | — | Pixel offset to avoid overlapping another fixed element (e.g. a sticky CTA bar). Sets `--back-to-top-offset` |
41
+ | `breakpoint` | `string` | — | `l` restricts display to the large breakpoint and up. Omit to show at all viewport sizes |
42
+ | `attributes` | `Attribute` | — | Optional Drupal-style attributes object — modifier classes are merged on top |
43
+
44
+ ---
45
+
46
+ ## HTML + Web Component
47
+
48
+ ```html
49
+ <mx-back-to-top class="mx-back-to-top" target="#page-title">
50
+ <a class="mx-button mx-button--light mx-button--small mx-back-to-top__trigger" href="#main">
51
+ <span class="mx-icon mx-icon--arrow-up"></span>
52
+ <span>Back to top</span>
53
+ </a>
54
+ </mx-back-to-top>
55
+ ```
56
+
57
+ Registers as `<mx-back-to-top>`. Import the element to activate its behaviour:
58
+
59
+ ```js
60
+ import "@pnx-mixtape/mxds/src/Component/BackToTop/Elements/BackToTop"
61
+ ```
62
+
63
+ **`<mx-back-to-top>` attributes:**
64
+
65
+ | Attribute | Description |
66
+ | -------------- | ----------------------------------------------------------------------------------------------------- |
67
+ | `target` | CSS selector of an element to observe — visible once it scrolls above the viewport top |
68
+ | `visibleafter` | Distance down the page before the trigger appears (default `100vh`). Used only when `target` is unset |
69
+
70
+ If no `target` is set, visibility is driven by a plain scroll-position check instead (`window.scrollY` compared against `visibleafter`, resolved from a `px` or `vh` value) rather than an `IntersectionObserver` — this avoids depending on the host page's containing-block/positioning context, which a synthetic absolutely-positioned sentinel would be sensitive to.
71
+
72
+ **Behaviour on click:** the Web Component intercepts the click, scrolls the destination into view
73
+ (`behavior: smooth`, or `auto` when `prefers-reduced-motion: reduce`), temporarily makes the
74
+ destination focusable if it isn't already (`tabindex="-1"`, removed again on blur), moves focus
75
+ to it, and updates the URL hash via `history.pushState` — all without a full native anchor jump,
76
+ so the smooth scroll and reduced-motion behaviour are never a race against the browser's own
77
+ default jump.
78
+
79
+ **Event:**
80
+
81
+ ```js
82
+ document.querySelector("mx-back-to-top").addEventListener("back-to-top-toggle", e => {
83
+ console.log(e.detail.isVisible) // true when the trigger becomes visible, false when it hides
84
+ })
85
+ ```
86
+
87
+ ---
88
+
89
+ ## React
90
+
91
+ ```tsx
92
+ import { BackToTop } from "@pnx-mixtape/mxds/react"
93
+
94
+ <BackToTop />
95
+
96
+ // Watch a specific element instead of a scroll distance
97
+ <BackToTop target="#page-title" href="#main" />
98
+
99
+ // Desktop-only, offset to avoid overlapping a sticky CTA bar
100
+ <BackToTop breakpoint="l" offset={56} />
101
+ ```
102
+
103
+ **`BackToTop` props:**
104
+
105
+ | Prop | Type | Default | Description |
106
+ | ----------------- | ------------------------------ | ------------- | -------------------------------------------------------------------------------------- |
107
+ | `to` | `string` | `#main` | Scroll destination — a same-page hash |
108
+ | `title` | `string` | `Back to top` | Trigger label |
109
+ | `target` | `string` | — | CSS selector of an element to observe — visible once it scrolls above the viewport top |
110
+ | `visibleAfter` | `string` | `100vh` | Distance down the page before the trigger appears. Only used when `target` is unset |
111
+ | `offset` | `number` | — | Pixel offset to avoid overlapping another fixed element. Sets `--back-to-top-offset` |
112
+ | `breakpoint` | `"l"` | — | Restrict display to the large breakpoint and up. Omit to show at all viewport sizes |
113
+ | `className` | `string` | — | Extra class on the root |
114
+ | `onVisibleChange` | `(isVisible: boolean) => void` | — | Called whenever the visible state changes. React analogue of `back-to-top-toggle` |
115
+
116
+ ---
117
+
118
+ ## Modifiers
119
+
120
+ | CSS class | React `breakpoint` | Behaviour |
121
+ | ------------------- | ------------------ | ----------------------------------------------------------- |
122
+ | `mx-back-to-top` | `undefined` | Visible (once scrolled) at all viewport sizes |
123
+ | `mx-back-to-top--l` | `"l"` | Visible (once scrolled) only at the large breakpoint and up |
124
+
125
+ ---
126
+
127
+ ### React ⇄ Twig parity
128
+
129
+ `onVisibleChange` has no Twig equivalent — Twig/HTML has no concept of a function prop. The
130
+ `<mx-back-to-top>` Web Component dispatches a `back-to-top-toggle` `CustomEvent` instead; listen
131
+ for it if you need the same notification outside React.
132
+
133
+ ---
134
+
135
+ ## Accessibility
136
+
137
+ - The trigger is a real `<a href>` (via the `Button` atom, `as: 'link'`) and is always visible,
138
+ in the tab order, and keyboard-activatable **without JavaScript**. The scroll-gated show/hide
139
+ behaviour is only switched on once JS has confirmed it can drive it — the Web Component adds
140
+ an `is-enhanced` class in `connectedCallback`, and React (which always has JS) adds it
141
+ unconditionally — so a blocked or failed script load can never leave the trigger permanently
142
+ hidden or unreachable.
143
+ - On activation, focus is moved to the scroll destination. If the destination isn't natively
144
+ focusable, a temporary `tabindex="-1"` is added and removed again once it loses focus — this
145
+ fixes a common gap where a plain anchor jump scrolls the page but leaves screen reader focus
146
+ behind.
147
+ - Smooth scrolling respects `prefers-reduced-motion: reduce` (falls back to an instant jump).
148
+ - Hidden from print via `@media print`.
149
+
150
+ ---
151
+
152
+ ## Customisation via CSS custom properties
153
+
154
+ ```css
155
+ .mx-back-to-top {
156
+ --back-to-top-gap: 64px; /* distance from the bottom of the viewport */
157
+ --back-to-top-offset: 0; /* additional offset, e.g. to clear a sticky CTA bar */
158
+ --back-to-top-inline-end: var(--spacing-m); /* distance from the side of the viewport */
159
+ --back-to-top-z-index: 10;
160
+ }
161
+ ```
162
+
163
+ The React component writes `--back-to-top-offset` as an inline style from the `offset` prop. In
164
+ HTML/Twig, pass `offset` (which sets it inline) or override any of these in your own CSS.