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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/.claude/skills/create-mxds-component/SKILL.md +77 -0
  2. package/DESIGN.md +595 -0
  3. package/README.md +1 -2
  4. package/dist/build/accordion.entry.js +2 -1
  5. package/dist/build/autocomplete.entry.js +1 -1
  6. package/dist/build/back-to-top.css +1 -0
  7. package/dist/build/back-to-top.entry.js +1 -0
  8. package/dist/build/button.css +1 -1
  9. package/dist/build/card.css +1 -1
  10. package/dist/build/chunks/Accordion-D2wp8hBG.js +44 -0
  11. package/dist/build/chunks/AccordionGroup-BYtoAFxg.js +47 -0
  12. package/dist/build/chunks/AccordionMobile-D3sTeiM5.js +26 -0
  13. package/dist/build/chunks/Autocomplete-kRo8RNbC.js +135 -0
  14. package/dist/build/chunks/BackToTop-4M-Wl7z3.js +64 -0
  15. package/dist/build/chunks/ClosableAlert-yy84gae6.js +55 -0
  16. package/dist/build/chunks/CookieConsent-wd8gQ2Oe.js +18 -0
  17. package/dist/build/chunks/Dialog-DWoXW-7Z.js +48 -0
  18. package/dist/build/chunks/DropMenu-CSTZq0Zt.js +68 -0
  19. package/dist/build/chunks/FeedbackForm-BochXEDx.js +64 -0
  20. package/dist/build/chunks/Gallery-Rkfv7Mbb.js +165 -0
  21. package/dist/build/chunks/GlobalToggle-CzlkpLpc.js +82 -0
  22. package/dist/build/chunks/InPageNavigation-D1Kk-rCm.js +86 -0
  23. package/dist/build/chunks/Navigation-CDQnN61h.js +147 -0
  24. package/dist/build/chunks/Popover-CiTAftNU.js +30 -0
  25. package/dist/build/chunks/Sticky-DoVndENm.js +51 -0
  26. package/dist/build/chunks/Tabs-BDlxCDNh.js +160 -0
  27. package/dist/build/chunks/Translate-bmM38j6z.js +46 -0
  28. package/dist/build/chunks/cookie-compliance-CG6x-TcV.js +119 -0
  29. package/dist/build/chunks/enums-BrHxPZZ_.js +36 -0
  30. package/dist/build/chunks/polyfills-D-qh1a_2.js +33 -0
  31. package/dist/build/chunks/utilities-Dd-08SW6.js +255 -0
  32. package/dist/build/content-block.css +1 -1
  33. package/dist/build/cookie-consent.entry.js +1 -1
  34. package/dist/build/dialog.entry.js +1 -1
  35. package/dist/build/drop-menu.entry.js +1 -1
  36. package/dist/build/elements.js +147 -1
  37. package/dist/build/feedback-form.css +1 -0
  38. package/dist/build/feedback-form.entry.js +1 -0
  39. package/dist/build/filters.entry.js +145 -1
  40. package/dist/build/gallery.entry.js +1 -1
  41. package/dist/build/global-alert.entry.js +1 -1
  42. package/dist/build/header.css +1 -1
  43. package/dist/build/header.entry.js +1 -1
  44. package/dist/build/icon.css +1 -1
  45. package/dist/build/in-page-navigation.entry.js +1 -1
  46. package/dist/build/navigation.entry.js +1 -1
  47. package/dist/build/pagination.css +1 -1
  48. package/dist/build/popover.entry.js +1 -1
  49. package/dist/build/react.js +2270 -1
  50. package/dist/build/sticky.entry.js +1 -1
  51. package/dist/build/tabs.entry.js +1 -1
  52. package/dist/build/translate.css +1 -0
  53. package/dist/build/translate.entry.js +1 -0
  54. package/dist/build/utility-list.entry.js +53 -1
  55. package/dist/types/Component/BackToTop/BackToTop.d.ts +20 -0
  56. package/dist/types/Component/BackToTop/BackToTop.d.ts.map +1 -0
  57. package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts +27 -0
  58. package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts.map +1 -0
  59. package/dist/types/Component/BackToTop/back-to-top.entry.d.ts +2 -0
  60. package/dist/types/Component/BackToTop/back-to-top.entry.d.ts.map +1 -0
  61. package/dist/types/Component/DropMenu/DropMenu.d.ts +2 -1
  62. package/dist/types/Component/DropMenu/DropMenu.d.ts.map +1 -1
  63. package/dist/types/Component/Tabs/Components/TabDropMenu.d.ts.map +1 -1
  64. package/dist/types/Component/Tabs/Elements/Tabs.d.ts.map +1 -1
  65. package/dist/types/Component/Translate/Elements/Translate.d.ts +23 -0
  66. package/dist/types/Component/Translate/Elements/Translate.d.ts.map +1 -0
  67. package/dist/types/Component/Translate/Translate.d.ts +37 -0
  68. package/dist/types/Component/Translate/Translate.d.ts.map +1 -0
  69. package/dist/types/Component/Translate/languages.d.ts +11 -0
  70. package/dist/types/Component/Translate/languages.d.ts.map +1 -0
  71. package/dist/types/Component/Translate/translate.entry.d.ts +2 -0
  72. package/dist/types/Component/Translate/translate.entry.d.ts.map +1 -0
  73. package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts +37 -0
  74. package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts.map +1 -0
  75. package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts +36 -0
  76. package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts.map +1 -0
  77. package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts +2 -0
  78. package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts.map +1 -0
  79. package/dist/types/Utility/Elements/google-translate-widget.d.ts +91 -0
  80. package/dist/types/Utility/Elements/google-translate-widget.d.ts.map +1 -0
  81. package/dist/types/Utility/utilities.d.ts +2 -0
  82. package/dist/types/Utility/utilities.d.ts.map +1 -1
  83. package/dist/types/elements.d.ts +3 -0
  84. package/dist/types/elements.d.ts.map +1 -1
  85. package/dist/types/enums.d.ts +3 -1
  86. package/dist/types/enums.d.ts.map +1 -1
  87. package/dist/types/react.d.ts +3 -0
  88. package/dist/types/react.d.ts.map +1 -1
  89. package/docs/API.md +1215 -0
  90. package/docs/CSSGuidelines.md +891 -0
  91. package/docs/CodeQuality.md +940 -0
  92. package/docs/ComponentArchitecture.md +714 -0
  93. package/docs/Contributing.md +626 -0
  94. package/docs/MemoryManagement.md +598 -0
  95. package/docs/Migrations.md +6 -0
  96. package/docs/README.md +100 -0
  97. package/docs/Security.md +444 -0
  98. package/docs/Testing.md +739 -0
  99. package/docs/Troubleshooting.md +674 -0
  100. package/package.json +6 -3
  101. package/src/Atom/Button/Button.tsx +1 -1
  102. package/src/Atom/Button/__snapshots__/Button.stories.tsx.snap +2 -2
  103. package/src/Atom/Button/_buttons.css +1 -0
  104. package/src/Atom/Icon/README.md +1 -1
  105. package/src/Atom/Icon/_icon.css +1 -0
  106. package/src/Atom/Icon/images/thumb.svg +1 -0
  107. package/src/Atom/Media/__snapshots__/Media.stories.ts.snap +4 -9
  108. package/src/Atom/Media/media.twig +4 -5
  109. package/src/Component/BackToTop/BackToTop.stories.ts +127 -0
  110. package/src/Component/BackToTop/BackToTop.stories.tsx +53 -0
  111. package/src/Component/BackToTop/BackToTop.tsx +120 -0
  112. package/src/Component/BackToTop/Elements/BackToTop.ts +119 -0
  113. package/src/Component/BackToTop/README.md +164 -0
  114. package/src/Component/BackToTop/__snapshots__/BackToTop.stories.ts.snap +24 -0
  115. package/src/Component/BackToTop/__snapshots__/BackToTop.stories.tsx.snap +3 -0
  116. package/src/Component/BackToTop/back-to-top.css +70 -0
  117. package/src/Component/BackToTop/back-to-top.entry.js +1 -0
  118. package/src/Component/BackToTop/back-to-top.twig +26 -0
  119. package/src/Component/Card/Components/CardContent.tsx +1 -1
  120. package/src/Component/Card/Components/CardMedia.tsx +1 -1
  121. package/src/Component/Card/__snapshots__/Card.stories.tsx.snap +3 -3
  122. package/src/Component/Card/card.css +1 -1
  123. package/src/Component/Carousel/Carousel.stories.ts +66 -36
  124. package/src/Component/Carousel/__snapshots__/Carousel.stories.tsx.snap +5 -5
  125. package/src/Component/ContentBlock/Components/ContentBlockMedia.tsx +1 -1
  126. package/src/Component/ContentBlock/content-block.css +1 -1
  127. package/src/Component/DropMenu/DropMenu.stories.ts +21 -2
  128. package/src/Component/DropMenu/DropMenu.tsx +14 -4
  129. package/src/Component/DropMenu/__snapshots__/DropMenu.stories.ts.snap +3 -3
  130. package/src/Component/DropMenu/__snapshots__/DropMenu.stories.tsx.snap +2 -2
  131. package/src/Component/DropMenu/drop-menu.twig +7 -1
  132. package/src/Component/HeroBanner/__snapshots__/HeroBanner.stories.tsx.snap +1 -1
  133. package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.ts.snap +48 -13
  134. package/src/Component/InPageNavigation/in-page-navigation.twig +4 -6
  135. package/src/Component/ListItem/Components/ListItemMedia.tsx +1 -1
  136. package/src/Component/Pagination/Pagination.stories.ts +57 -27
  137. package/src/Component/Pagination/pagination.css +16 -7
  138. package/src/Component/Popover/__snapshots__/Popover.stories.tsx.snap +3 -3
  139. package/src/Component/Tabs/Components/TabDropMenu.tsx +7 -1
  140. package/src/Component/Tabs/Elements/Tabs.ts +13 -3
  141. package/src/Component/Tabs/Tabs.stories.ts +33 -12
  142. package/src/Component/Tabs/Tabs.stories.tsx +28 -14
  143. package/src/Component/Tabs/__snapshots__/Tabs.stories.ts.snap +3 -3
  144. package/src/Component/Tabs/__snapshots__/Tabs.stories.tsx.snap +2 -2
  145. package/src/Component/Translate/Elements/Translate.ts +76 -0
  146. package/src/Component/Translate/README.md +283 -0
  147. package/src/Component/Translate/Translate.stories.ts +87 -0
  148. package/src/Component/Translate/Translate.stories.tsx +77 -0
  149. package/src/Component/Translate/Translate.tsx +213 -0
  150. package/src/Component/Translate/__snapshots__/Translate.stories.ts.snap +18 -0
  151. package/src/Component/Translate/__snapshots__/Translate.stories.tsx.snap +3 -0
  152. package/src/Component/Translate/languages.ts +39 -0
  153. package/src/Component/Translate/translate.css +62 -0
  154. package/src/Component/Translate/translate.entry.js +1 -0
  155. package/src/Component/Translate/translate.twig +44 -0
  156. package/src/Form/Autocomplete/Autocomplete.stories.tsx +2 -1
  157. package/src/Form/FeedbackForm/Elements/FeedbackForm.ts +122 -0
  158. package/src/Form/FeedbackForm/FeedbackForm.stories.ts +99 -0
  159. package/src/Form/FeedbackForm/FeedbackForm.stories.tsx +73 -0
  160. package/src/Form/FeedbackForm/FeedbackForm.tsx +196 -0
  161. package/src/Form/FeedbackForm/README.md +233 -0
  162. package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.ts.snap +125 -0
  163. package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.tsx.snap +5 -0
  164. package/src/Form/FeedbackForm/feedback-form.css +74 -0
  165. package/src/Form/FeedbackForm/feedback-form.entry.js +1 -0
  166. package/src/Form/FeedbackForm/feedback-form.twig +80 -0
  167. package/src/Layout/Header/Components/GlobalToggle.tsx +1 -1
  168. package/src/Layout/Header/Header.stories.ts +53 -48
  169. package/src/Layout/Header/Header.stories.tsx +35 -22
  170. package/src/Layout/Header/Header.tsx +2 -2
  171. package/src/Layout/Header/__snapshots__/Header.stories.ts.snap +14 -10
  172. package/src/Layout/Header/__snapshots__/Header.stories.tsx.snap +2 -2
  173. package/src/Layout/Header/_toggles.css +7 -7
  174. package/src/Layout/Page/__snapshots__/Page.stories.tsx.snap +1 -1
  175. package/src/Utility/Elements/google-translate-widget.ts +293 -0
  176. package/src/Utility/global.d.ts +4 -0
  177. package/src/Utility/utilities.ts +2 -0
  178. package/src/elements.ts +3 -0
  179. package/src/enums.ts +2 -0
  180. package/src/react.ts +3 -0
  181. package/dist/build/chunks/Accordion-GN0sME4W.js +0 -1
  182. package/dist/build/chunks/AccordionGroup-Bzqp5__q.js +0 -1
  183. package/dist/build/chunks/AccordionMobile-DG5jSe8Q.js +0 -1
  184. package/dist/build/chunks/Autocomplete-B4RtkAyh.js +0 -1
  185. package/dist/build/chunks/ClosableAlert-DEhj8Nxm.js +0 -1
  186. package/dist/build/chunks/CookieConsent-BkCMRVST.js +0 -1
  187. package/dist/build/chunks/Dialog-Haahn-Yn.js +0 -1
  188. package/dist/build/chunks/DropMenu-1baQc8Yc.js +0 -1
  189. package/dist/build/chunks/Gallery-D8NGXy2Q.js +0 -1
  190. package/dist/build/chunks/GlobalToggle-B52fdBQu.js +0 -1
  191. package/dist/build/chunks/InPageNavigation-Dyn2TR-N.js +0 -1
  192. package/dist/build/chunks/Navigation-hOrfLq7h.js +0 -1
  193. package/dist/build/chunks/Popover-Cxg-s5Jn.js +0 -1
  194. package/dist/build/chunks/Sticky-BGCuuEJK.js +0 -1
  195. package/dist/build/chunks/Tabs-DYy1YTdU.js +0 -15
  196. package/dist/build/chunks/cookie-compliance-Bbm-0vpj.js +0 -1
  197. package/dist/build/chunks/enums-DLV_1NAf.js +0 -1
  198. package/dist/build/chunks/polyfills-C7pRt54M.js +0 -1
  199. package/dist/build/chunks/utilities-_Bq3TxAQ.js +0 -1
@@ -0,0 +1,73 @@
1
+ import { Meta, StoryObj } from "@storybook/react-vite"
2
+ import { expect, fn, waitFor } from "storybook/test"
3
+ import Component from "./FeedbackForm"
4
+ import "./feedback-form.css"
5
+ import { Page } from "../../../.storybook-react/decorators"
6
+
7
+ const meta: Meta<typeof Component> = {
8
+ tags: ["autodocs"],
9
+ component: Component,
10
+ decorators: [Page],
11
+ args: {
12
+ id: "feedback",
13
+ secondaryText: "If you need a response, ",
14
+ secondaryLinkText: "contact us",
15
+ secondaryLinkHref: "#contact",
16
+ onSubmit: fn(),
17
+ },
18
+ }
19
+
20
+ export default meta
21
+
22
+ export const FeedbackForm: StoryObj<typeof meta> = {
23
+ play: async ({ canvasElement, canvas, userEvent, step, args }) => {
24
+ const wrapper = canvasElement.querySelector(".mx-feedback-form") as HTMLElement
25
+ const comment = canvas.getByLabelText("How can we make it better?") as HTMLTextAreaElement
26
+
27
+ await step("Comment field is hidden until a rating is chosen", async () => {
28
+ await expect(comment).not.toBeVisible()
29
+ })
30
+
31
+ await step("Choosing a rating reveals and focuses the comment field", async () => {
32
+ await userEvent.click(canvas.getByRole("radio", { name: "Yes" }))
33
+ await waitFor(() => expect(comment).toBeVisible())
34
+ await expect(comment).toHaveFocus()
35
+ })
36
+
37
+ await step("Submitting calls onSubmit with the chosen rating and comment", async () => {
38
+ await userEvent.type(comment, "More examples would help.")
39
+ await userEvent.click(canvas.getByRole("button", { name: "Submit" }))
40
+ await waitFor(() =>
41
+ expect(args.onSubmit).toHaveBeenCalledWith(
42
+ expect.objectContaining({ rating: "yes", comment: "More examples would help." }),
43
+ ),
44
+ )
45
+ })
46
+
47
+ await expect(wrapper).toBeInTheDocument()
48
+ },
49
+ }
50
+
51
+ /** With `commentRequired`, an empty comment blocks submission and shows an inline error. */
52
+ export const RequiredComment: StoryObj<typeof meta> = {
53
+ args: {
54
+ id: "feedback-required",
55
+ commentRequired: true,
56
+ onSubmit: fn(),
57
+ },
58
+ play: async ({ canvas, userEvent, step, args }) => {
59
+ await step("Reveal the comment field", async () => {
60
+ await userEvent.click(canvas.getByRole("radio", { name: "No" }))
61
+ await waitFor(() => expect(canvas.getByLabelText("How can we make it better?")).toBeVisible())
62
+ })
63
+
64
+ await step(
65
+ "Submitting an empty required comment shows the error and blocks onSubmit",
66
+ async () => {
67
+ await userEvent.click(canvas.getByRole("button", { name: "Submit" }))
68
+ await waitFor(() => expect(canvas.getByRole("alert")).toBeVisible())
69
+ await expect(args.onSubmit).not.toHaveBeenCalled()
70
+ },
71
+ )
72
+ },
73
+ }
@@ -0,0 +1,196 @@
1
+ import { JSX, FormEventHandler, ChangeEventHandler, useId, useRef, useState } from "react"
2
+ import classNames from "classnames"
3
+ import { ButtonModifiers, IconRotateModifier } from "@pnx-mixtape/ids-shape"
4
+ import Button from "../../Atom/Button/Button"
5
+ import Icon from "../../Atom/Icon/Icon"
6
+ import Link from "../../Atom/Link/Link"
7
+ import FormItem from "../FormItem/FormItem"
8
+ import FormLabel from "../Label/FormLabel"
9
+ import { Icons } from "../../enums"
10
+ import { validators } from "../../Utility/utilities"
11
+
12
+ const SUBMIT_MODIFIERS = [ButtonModifiers.DARK]
13
+
14
+ export type FeedbackFormSubmitDetail = {
15
+ rating: string | null
16
+ comment: string
17
+ form: HTMLFormElement
18
+ }
19
+
20
+ type FeedbackFormProps = {
21
+ id?: string
22
+ name?: string
23
+ question?: string
24
+ yesLabel?: string
25
+ noLabel?: string
26
+ /** Optional secondary copy, e.g. pointing readers to an alternative contact channel. */
27
+ secondaryText?: string
28
+ secondaryLinkText?: string
29
+ secondaryLinkHref?: string
30
+ commentLabel?: string
31
+ commentName?: string
32
+ commentPlaceholder?: string
33
+ commentRequired?: boolean
34
+ requiredErrorMessage?: string
35
+ submitLabel?: string
36
+ /** Progressive-enhancement form target — the consumer's `onSubmit` can still take over. */
37
+ action?: string
38
+ method?: string
39
+ /** Consumer-supplied top-level notice, e.g. after a failed async submit. */
40
+ errorMessage?: string
41
+ /** Reflected as `aria-busy`/`disabled` on the submit button while the consumer's own submit handling is in flight. */
42
+ pending?: boolean
43
+ className?: string
44
+ /** Called with the form's values on a valid submit — this component never fetches/submits over the network itself. */
45
+ onSubmit?: (detail: FeedbackFormSubmitDetail) => void
46
+ }
47
+
48
+ const FeedbackForm = ({
49
+ id,
50
+ name = "rating",
51
+ question = "Was the information on this page useful?",
52
+ yesLabel = "Yes",
53
+ noLabel = "No",
54
+ secondaryText,
55
+ secondaryLinkText,
56
+ secondaryLinkHref,
57
+ commentLabel = "How can we make it better?",
58
+ commentName = "comment",
59
+ commentPlaceholder,
60
+ commentRequired = false,
61
+ requiredErrorMessage = "This field is required.",
62
+ submitLabel = "Submit",
63
+ action,
64
+ method = "post",
65
+ errorMessage,
66
+ pending = false,
67
+ className,
68
+ onSubmit,
69
+ }: FeedbackFormProps): JSX.Element => {
70
+ const generatedId = useId()
71
+ const rootId = id ?? generatedId
72
+ const commentId = `${rootId}-comment`
73
+ const commentRef = useRef<HTMLTextAreaElement>(null)
74
+ const revealedRef = useRef(false)
75
+ const [showError, setShowError] = useState(false)
76
+
77
+ const handleRatingChange: ChangeEventHandler<HTMLInputElement> = () => {
78
+ if (revealedRef.current) return
79
+ revealedRef.current = true
80
+ commentRef.current?.focus()
81
+ }
82
+
83
+ const handleSubmit: FormEventHandler<HTMLFormElement> = event => {
84
+ const comment = commentRef.current
85
+
86
+ if (commentRequired && comment && !validators.notEmpty(comment.value)) {
87
+ event.preventDefault()
88
+ setShowError(true)
89
+ comment.focus()
90
+ return
91
+ }
92
+
93
+ setShowError(false)
94
+ if (!onSubmit) return
95
+ // Hand full control to the consumer — this component never submits over
96
+ // the network itself, so the native POST must not also fire.
97
+ event.preventDefault()
98
+ const rating = new FormData(event.currentTarget).get(name) as string | null
99
+ onSubmit({ rating, comment: comment?.value ?? "", form: event.currentTarget })
100
+ }
101
+
102
+ return (
103
+ <div className={classNames("mx-feedback-form", "is-enhanced", className)}>
104
+ <form action={action} method={method} noValidate onSubmit={handleSubmit}>
105
+ {errorMessage && (
106
+ <div className="mx-feedback-form__error" role="alert">
107
+ {errorMessage}
108
+ </div>
109
+ )}
110
+
111
+ <fieldset className="mx-fieldset mx-fieldset--plain mx-feedback-form__rating">
112
+ <legend className="mx-legend">{question}</legend>
113
+ <div className="mx-feedback-form__option">
114
+ <input
115
+ type="radio"
116
+ className="sr-only"
117
+ id={`${rootId}-yes`}
118
+ name={name}
119
+ value="yes"
120
+ onChange={handleRatingChange}
121
+ />
122
+ <label
123
+ className="mx-button mx-button--dark mx-button--outline"
124
+ htmlFor={`${rootId}-yes`}
125
+ >
126
+ <Icon icon={Icons.THUMB} />
127
+ <span>{yesLabel}</span>
128
+ </label>
129
+ </div>
130
+ <div className="mx-feedback-form__option">
131
+ <input
132
+ type="radio"
133
+ className="sr-only"
134
+ id={`${rootId}-no`}
135
+ name={name}
136
+ value="no"
137
+ onChange={handleRatingChange}
138
+ />
139
+ <label
140
+ className="mx-button mx-button--dark mx-button--outline"
141
+ htmlFor={`${rootId}-no`}
142
+ >
143
+ <Icon icon={Icons.THUMB} rotate={IconRotateModifier.ONEEIGHTY} />
144
+ <span>{noLabel}</span>
145
+ </label>
146
+ </div>
147
+ </fieldset>
148
+
149
+ {(secondaryText || secondaryLinkText) && (
150
+ <p className="mx-feedback-form__secondary">
151
+ {secondaryText}
152
+ {secondaryLinkText && secondaryLinkHref && (
153
+ <Link to={secondaryLinkHref}>{secondaryLinkText}</Link>
154
+ )}
155
+ </p>
156
+ )}
157
+
158
+ <div className="mx-feedback-form__reveal">
159
+ <FormItem>
160
+ <FormLabel id={commentId} text={commentLabel} />
161
+ <textarea
162
+ ref={commentRef}
163
+ className="mx-input__textarea"
164
+ id={commentId}
165
+ name={commentName}
166
+ placeholder={commentPlaceholder}
167
+ required={commentRequired}
168
+ aria-describedby={`status-${commentId}`}
169
+ />
170
+ </FormItem>
171
+ <div
172
+ className="mx-error-message"
173
+ id={`status-${commentId}`}
174
+ role="alert"
175
+ hidden={!showError}
176
+ >
177
+ {requiredErrorMessage}
178
+ </div>
179
+ <div className="mx-form-actions">
180
+ <Button
181
+ type="submit"
182
+ modifiers={SUBMIT_MODIFIERS}
183
+ disabled={pending}
184
+ data-pending={pending}
185
+ aria-busy={pending}
186
+ >
187
+ {submitLabel}
188
+ </Button>
189
+ </div>
190
+ </div>
191
+ </form>
192
+ </div>
193
+ )
194
+ }
195
+
196
+ export default FeedbackForm
@@ -0,0 +1,233 @@
1
+ # FeedbackForm
2
+
3
+ A yes/no "was this page useful?" control that reveals a free-text comment field once a rating
4
+ is chosen.
5
+
6
+ **This component never submits anywhere itself.** Progressive enhancement, native `<form
7
+ action>` submission still works — but the Web Component dispatches a cancelable
8
+ `mx-feedback-form-submit` event (and React calls an `onSubmit` prop) before the form submits, so
9
+ any backend can be wired up by the consumer: a Drupal webform AJAX endpoint, a REST call,
10
+ whatever. This keeps the component usable whether you're posting to a classic HTML form action
11
+ or handling submission entirely in JS.
12
+
13
+ ---
14
+
15
+ ## CSS Import
16
+
17
+ ```css
18
+ @import "@pnx-mixtape/mxds/src/Form/FeedbackForm/feedback-form.css";
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Twig
24
+
25
+ ```twig
26
+ {% include '@mixtape/Form/FeedbackForm/feedback-form.twig' with {
27
+ id: 'feedback',
28
+ secondaryText: 'If you need a response, ',
29
+ secondaryLinkText: 'contact us',
30
+ secondaryLinkHref: '/contact',
31
+ } %}
32
+ ```
33
+
34
+ **feedback-form.twig props:**
35
+
36
+ | Prop | Type | Default | Description |
37
+ | ---------------------- | ----------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
38
+ | `id` | `string` | `feedback-form` | Root id, used to derive the rating/comment field ids |
39
+ | `name` | `string` | `rating` | `name` on the two rating radios |
40
+ | `question` | `string` | `Was the information on this page useful?` | Rating fieldset `<legend>` text |
41
+ | `yesLabel` / `noLabel` | `string` | `Yes` / `No` | Rating radio labels |
42
+ | `secondaryText` | `string` | — | Optional copy shown below the rating (e.g. pointing to another contact channel) |
43
+ | `secondaryLinkText` | `string` | — | Optional link text — only rendered together with `secondaryLinkHref` |
44
+ | `secondaryLinkHref` | `string` | — | Optional link destination |
45
+ | `commentLabel` | `string` | `How can we make it better?` | Label for the revealed comment field |
46
+ | `commentName` | `string` | `comment` | `name` on the comment `<textarea>` |
47
+ | `commentPlaceholder` | `string` | — | Comment `<textarea>` placeholder |
48
+ | `commentRequired` | `boolean` | `false` | Adds `required` to the comment field, and enables the required-field error message |
49
+ | `requiredErrorMessage` | `string` | `This field is required.` | Message shown when `commentRequired` is set and the comment is submitted empty |
50
+ | `submitLabel` | `string` | `Submit` | Submit button text |
51
+ | `action` | `string` | — | `<form action>` — progressive-enhancement fallback target |
52
+ | `method` | `string` | `post` | `<form method>` |
53
+ | `errorMessage` | `string` | — | Consumer-supplied top-level notice (e.g. after a failed async submit), rendered as a `role="alert"` block |
54
+ | `attributes` | `Attribute` | — | Optional Drupal-style attributes object — `mx-feedback-form` is always merged on top |
55
+
56
+ Registers as `<mx-feedback-form>`. Import the element to activate its behaviour:
57
+
58
+ ```js
59
+ import "@pnx-mixtape/mxds/src/Form/FeedbackForm/Elements/FeedbackForm"
60
+ ```
61
+
62
+ ---
63
+
64
+ ## HTML + Web Component
65
+
66
+ ```html
67
+ <mx-feedback-form class="mx-feedback-form">
68
+ <form method="post">
69
+ <fieldset class="mx-fieldset mx-fieldset--plain mx-feedback-form__rating">
70
+ <legend class="mx-legend">Was the information on this page useful?</legend>
71
+ <div class="mx-feedback-form__option">
72
+ <input type="radio" class="sr-only" id="feedback-yes" name="rating" value="yes" />
73
+ <label class="mx-button mx-button--dark mx-button--outline" for="feedback-yes">
74
+ <span class="mx-icon mx-icon--thumb"></span>
75
+ <span>Yes</span>
76
+ </label>
77
+ </div>
78
+ <div class="mx-feedback-form__option">
79
+ <input type="radio" class="sr-only" id="feedback-no" name="rating" value="no" />
80
+ <label class="mx-button mx-button--dark mx-button--outline" for="feedback-no">
81
+ <span class="mx-icon mx-icon--thumb mx-icon--rotate-180"></span>
82
+ <span>No</span>
83
+ </label>
84
+ </div>
85
+ </fieldset>
86
+ <div class="mx-feedback-form__reveal">
87
+ <!-- FormItem-wrapped comment textarea, error status, submit button -->
88
+ </div>
89
+ </form>
90
+ </mx-feedback-form>
91
+ ```
92
+
93
+ **Reveal behaviour:** the comment field and submit button are always visible and submittable in
94
+ the static/no-JS markup. Once the `<mx-feedback-form>` element connects, it adds an
95
+ `is-enhanced` class to itself — only then does CSS hide the reveal block until a rating radio is
96
+ checked (`:has()`-driven). This means a blocked or failed script load can never leave the form's
97
+ only submit path permanently hidden — see [Accessibility](#accessibility).
98
+
99
+ **Validation:** when `commentRequired` is set and the comment is empty on submit, the Web
100
+ Component prevents the native submit, marks the textarea `aria-invalid="true"`, reveals the
101
+ pre-rendered `role="alert"` status message, and moves focus to the comment field — rather than
102
+ relying on the browser's native validation bubble (`novalidate` is set on the form).
103
+
104
+ **Submit contract:** on a valid submit, the Web Component dispatches a cancelable
105
+ `mx-feedback-form-submit` `CustomEvent` with `detail: { rating, comment, form }` _before_ the
106
+ form actually submits. If a listener calls `preventDefault()` on that event, the native
107
+ submission is also cancelled — full consumer ownership of the request. If nothing cancels it,
108
+ the browser proceeds with the form's `action`/`method` as normal.
109
+
110
+ ```js
111
+ const root = document.querySelector("mx-feedback-form")
112
+
113
+ root.addEventListener("mx-feedback-form-submit", async event => {
114
+ event.preventDefault() // take over submission ourselves
115
+ const { rating, comment } = event.detail
116
+ await fetch("/api/feedback", {
117
+ method: "POST",
118
+ body: JSON.stringify({ rating, comment }),
119
+ })
120
+ })
121
+ ```
122
+
123
+ **Pending state:** while your own async submit is in flight, reflect it with the `pending`
124
+ attribute:
125
+
126
+ ```js
127
+ root.setAttribute("pending", "")
128
+ // ... await your request ...
129
+ root.removeAttribute("pending")
130
+ ```
131
+
132
+ This adds mxds's standard `pending` custom state (`internals_.states`, so `:state(pending)` is
133
+ selectable in CSS) and disables/`aria-busy`s the submit button.
134
+
135
+ ---
136
+
137
+ ## React
138
+
139
+ ```tsx
140
+ import { FeedbackForm } from "@pnx-mixtape/mxds/react"
141
+
142
+ const Example = () => {
143
+ const [pending, setPending] = useState(false)
144
+
145
+ const handleSubmit = async ({ rating, comment }) => {
146
+ setPending(true)
147
+ await fetch("/api/feedback", { method: "POST", body: JSON.stringify({ rating, comment }) })
148
+ setPending(false)
149
+ }
150
+
151
+ return (
152
+ <FeedbackForm
153
+ id="feedback"
154
+ secondaryText="If you need a response, "
155
+ secondaryLinkText="contact us"
156
+ secondaryLinkHref="/contact"
157
+ pending={pending}
158
+ onSubmit={handleSubmit}
159
+ />
160
+ )
161
+ }
162
+ ```
163
+
164
+ **`FeedbackForm` props:** same set as the Twig props above (camelCase, no `attributes`), plus:
165
+
166
+ | Prop | Type | Default | Description |
167
+ | ----------- | --------------------------------------------- | ------- | ------------------------------------------------------------------------------- |
168
+ | `pending` | `boolean` | `false` | Reflected as `aria-busy`/`disabled` on the submit button |
169
+ | `onSubmit` | `(detail: { rating, comment, form }) => void` | — | Called on a valid submit — see [React ⇄ Twig parity](#react--twig-parity) below |
170
+ | `className` | `string` | — | Extra class on the root |
171
+
172
+ ---
173
+
174
+ ### React ⇄ Twig parity
175
+
176
+ - **Submit ownership differs by design.** In Twig/Web Component, `mx-feedback-form-submit` is a
177
+ _cancelable_ event — the native form submission proceeds unless a listener calls
178
+ `preventDefault()`. In React, supplying `onSubmit` always prevents the native submission (a
179
+ React app wiring up `onSubmit` expects full control, not an opt-in interception) — there's no
180
+ React equivalent of "let it submit natively AND notify me."
181
+ - `pending` has no Twig prop — it's a runtime-only concept. The Twig/Web-Component equivalent is
182
+ the `pending` HTML _attribute_, toggled by your own JS after render (see above), not a
183
+ template-render-time prop.
184
+ - `yesIcon`, `noIcon`, `commentItem`, and `submitButton` in `feedback-form.twig` are internal
185
+ `{% set x %}...{% endset %}` template locals used to pre-render nested `Icon`/`FormItem`/
186
+ `Button` markup for composition — not props, and not present in React (which composes the
187
+ equivalent components directly in JSX). `pnpm audit-parity`'s static extractor doesn't
188
+ recognize the `{% set x %}...{% endset %}` capture-block form (only the inline `{% set x = … %}`
189
+ form), so it misreports these as Twig-only props — a known tool limitation, not a real
190
+ divergence.
191
+ - `commentPlaceholder` and `commentRequired` genuinely are used in Twig — passed through to
192
+ `Form/Textarea/input-textarea.twig` inside a nested `{% include ... with {...} %}` call. The
193
+ audit tool's extractor doesn't scan inside `with {}` object literals, so it misreports these as
194
+ unused — also a known tool limitation.
195
+
196
+ ---
197
+
198
+ ## Modifiers
199
+
200
+ No BEM modifier classes. `mx-feedback-form--l`-style breakpoint modifiers don't apply here —
201
+ this component isn't visibility/breakpoint-gated the way `BackToTop`/`Sticky` are.
202
+
203
+ ---
204
+
205
+ ## Accessibility
206
+
207
+ - The rating group uses a real `<fieldset>`/`<legend>` — no `role="radiogroup"` needed, this is
208
+ the native equivalent.
209
+ - Each rating option is a real, focusable `<input type="radio">` paired with a `<label>` styled
210
+ as a button (reusing the `Button` atom's own classes/custom properties — `mx-button
211
+ mx-button--dark mx-button--outline`, with `:has(input:checked)` undoing the outline to show
212
+ the selected state). The input itself is visually hidden with `.sr-only` (clip, not
213
+ `display:none`) so it stays in the tab order and the accessibility tree — only its default
214
+ circular appearance is hidden, keyboard operation (Tab to focus, arrow keys to move within the
215
+ native radio group, Space/click to select) is unaffected. The visible label still carries icon
216
+ - text, not icon-only/visually-hidden text.
217
+ - **The comment field and submit button are reachable without JavaScript.** The reveal-on-rating
218
+ behaviour is only switched on once JS is confirmed running (`is-enhanced`, added by the Web
219
+ Component's `connectedCallback`; always present in React) — a blocked or failed script load
220
+ never leaves the form's only path to submit permanently hidden.
221
+ - The required-field error is a `role="alert"` region, programmatically associated with the
222
+ comment field via `aria-describedby`, and focus moves to the field when validation fails.
223
+ - The top-level `errorMessage` notice is also `role="alert"`.
224
+
225
+ ---
226
+
227
+ ## Customisation via CSS custom properties
228
+
229
+ ```css
230
+ .mx-feedback-form {
231
+ --feedback-form-icon-size: var(--spacing-m); /* size of the thumb icons */
232
+ }
233
+ ```
@@ -0,0 +1,125 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Feedback Form 1`] = `
4
+ "
5
+
6
+ <mx-feedback-form class="mx-feedback-form is-enhanced">
7
+ <form action="" method="post" novalidate="">
8
+
9
+ <fieldset class="mx-fieldset mx-fieldset--plain mx-feedback-form__rating">
10
+ <legend class="mx-legend">Was the information on this page useful?</legend>
11
+ <div class="mx-feedback-form__option">
12
+ <input type="radio" class="sr-only" id="feedback-yes" name="rating" value="yes">
13
+ <label class="mx-button mx-button--dark mx-button--outline" for="feedback-yes">
14
+
15
+ <span class="mx-icon mx-icon--thumb"></span>
16
+ <span>Yes</span>
17
+ </label>
18
+ </div>
19
+ <div class="mx-feedback-form__option">
20
+ <input type="radio" class="sr-only" id="feedback-no" name="rating" value="no">
21
+ <label class="mx-button mx-button--dark mx-button--outline" for="feedback-no">
22
+
23
+ <span class="mx-icon mx-icon--thumb mx-icon--rotate-180"></span>
24
+ <span>No</span>
25
+ </label>
26
+ </div>
27
+ </fieldset>
28
+
29
+ <p class="mx-feedback-form__secondary">
30
+ Your rating will help us improve the website.
31
+ </p>
32
+
33
+ <div class="mx-feedback-form__reveal">
34
+
35
+ <div class="mx-form-item">
36
+
37
+ <label class="mx-label" for="feedback-comment">
38
+ How can we make it better?
39
+ </label>
40
+
41
+
42
+
43
+ <textarea aria-describedby="unique-0" class="mx-input__textarea" id="feedback-comment" name="unique-0"></textarea>
44
+
45
+
46
+
47
+ </div>
48
+
49
+ <div class="mx-error-message" id="unique-0" role="alert" hidden="">This field is required.</div>
50
+ <div class="mx-form-actions">
51
+
52
+ <button class="mx-button mx-button--dark" type="submit">
53
+
54
+ <span>Submit</span>
55
+
56
+ </button>
57
+
58
+ </div>
59
+ </div>
60
+ </form>
61
+ </mx-feedback-form>
62
+ "
63
+ `;
64
+
65
+ exports[`Required Comment 1`] = `
66
+ "
67
+
68
+ <mx-feedback-form class="mx-feedback-form is-enhanced">
69
+ <form action="" method="post" novalidate="">
70
+
71
+ <fieldset class="mx-fieldset mx-fieldset--plain mx-feedback-form__rating">
72
+ <legend class="mx-legend">Was the information on this page useful?</legend>
73
+ <div class="mx-feedback-form__option">
74
+ <input type="radio" class="sr-only" id="feedback-required-yes" name="rating" value="yes">
75
+ <label class="mx-button mx-button--dark mx-button--outline" for="feedback-required-yes">
76
+
77
+ <span class="mx-icon mx-icon--thumb"></span>
78
+ <span>Yes</span>
79
+ </label>
80
+ </div>
81
+ <div class="mx-feedback-form__option">
82
+ <input type="radio" class="sr-only" id="feedback-required-no" name="rating" value="no">
83
+ <label class="mx-button mx-button--dark mx-button--outline" for="feedback-required-no">
84
+
85
+ <span class="mx-icon mx-icon--thumb mx-icon--rotate-180"></span>
86
+ <span>No</span>
87
+ </label>
88
+ </div>
89
+ </fieldset>
90
+
91
+ <p class="mx-feedback-form__secondary">
92
+ Your rating will help us improve the website.
93
+ </p>
94
+
95
+ <div class="mx-feedback-form__reveal">
96
+
97
+ <div class="mx-form-item">
98
+
99
+ <label class="mx-label" for="feedback-required-comment">
100
+ How can we make it better?
101
+ </label>
102
+
103
+
104
+
105
+ <textarea aria-describedby="unique-0" class="mx-input__textarea" id="feedback-required-comment" name="unique-0" required="" aria-invalid="true"></textarea>
106
+
107
+
108
+
109
+ </div>
110
+
111
+ <div class="mx-error-message" id="unique-0" role="alert">This field is required.</div>
112
+ <div class="mx-form-actions">
113
+
114
+ <button class="mx-button mx-button--dark" type="submit">
115
+
116
+ <span>Submit</span>
117
+
118
+ </button>
119
+
120
+ </div>
121
+ </div>
122
+ </form>
123
+ </mx-feedback-form>
124
+ "
125
+ `;
@@ -0,0 +1,5 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Feedback Form 1`] = `"<div class="mx-feedback-form is-enhanced"><form method="post" novalidate=""><fieldset class="mx-fieldset mx-fieldset--plain mx-feedback-form__rating"><legend class="mx-legend">Was the information on this page useful?</legend><div class="mx-feedback-form__option"><input class="sr-only" id="feedback-yes" type="radio" value="yes" name="rating"><label class="mx-button mx-button--dark mx-button--outline" for="feedback-yes"><span class="mx-icon mx-icon--thumb"></span><span>Yes</span></label></div><div class="mx-feedback-form__option"><input class="sr-only" id="feedback-no" type="radio" value="no" name="rating"><label class="mx-button mx-button--dark mx-button--outline" for="feedback-no"><span class="mx-icon mx-icon--thumb mx-icon--rotate-180"></span><span>No</span></label></div></fieldset><p class="mx-feedback-form__secondary">If you need a response, <a href="#contact" class="mx-link">contact us</a></p><div class="mx-feedback-form__reveal"><div class="mx-form-item"><label class="mx-label" for="feedback-comment">How can we make it better?</label><textarea class="mx-input__textarea" id="feedback-comment" name="comment" aria-describedby="status-feedback-comment"></textarea></div><div class="mx-error-message" id="status-feedback-comment" role="alert" hidden="">This field is required.</div><div class="mx-form-actions"><button class="mx-button mx-button--dark" type="submit" data-pending="false" aria-busy="false"><span>Submit</span></button></div></div></form></div>"`;
4
+
5
+ exports[`Required Comment 1`] = `"<div class="mx-feedback-form is-enhanced"><form method="post" novalidate=""><fieldset class="mx-fieldset mx-fieldset--plain mx-feedback-form__rating"><legend class="mx-legend">Was the information on this page useful?</legend><div class="mx-feedback-form__option"><input class="sr-only" id="feedback-required-yes" type="radio" value="yes" name="rating"><label class="mx-button mx-button--dark mx-button--outline" for="feedback-required-yes"><span class="mx-icon mx-icon--thumb"></span><span>Yes</span></label></div><div class="mx-feedback-form__option"><input class="sr-only" id="feedback-required-no" type="radio" value="no" name="rating"><label class="mx-button mx-button--dark mx-button--outline" for="feedback-required-no"><span class="mx-icon mx-icon--thumb mx-icon--rotate-180"></span><span>No</span></label></div></fieldset><p class="mx-feedback-form__secondary">If you need a response, <a href="#contact" class="mx-link">contact us</a></p><div class="mx-feedback-form__reveal"><div class="mx-form-item"><label class="mx-label" for="feedback-required-comment">How can we make it better?</label><textarea class="mx-input__textarea" id="feedback-required-comment" name="comment" required="" aria-describedby="status-feedback-required-comment"></textarea></div><div class="mx-error-message" id="status-feedback-required-comment" role="alert">This field is required.</div><div class="mx-form-actions"><button class="mx-button mx-button--dark" type="submit" data-pending="false" aria-busy="false"><span>Submit</span></button></div></div></form></div>"`;