@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
@@ -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 %}
@@ -9,16 +9,6 @@
9
9
  box-sizing: border-box;
10
10
  background-repeat: no-repeat;
11
11
  margin: 0;
12
- opacity: 1;
13
- transition-property:
14
- opacity, color, background-color, border-color, transform, rotate, display, overlay;
15
- transition-duration: 0.5s;
16
- transition-timing-function: var(--ease);
17
- transition-behavior: allow-discrete;
18
-
19
- @starting-style {
20
- opacity: 0;
21
- }
22
12
  }
23
13
 
24
14
  :focus {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Accordion
3
3
  */
4
+ @import "../../Utility/_mixins.css";
4
5
 
5
6
  @layer design-system.defaults {
6
7
  details {
@@ -59,8 +60,9 @@
59
60
  }
60
61
 
61
62
  & > .mx-accordion__content {
63
+ @add-mixin transition;
64
+
62
65
  display: none;
63
- opacity: 0;
64
66
  overflow: hidden;
65
67
  }
66
68
 
@@ -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
+ }