@helloimjolopez-pathway/pathway-components 0.6.2 → 0.7.0

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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Radio's standalone demo. Imports the real module, so there is nothing here
3
+ * that can fall out of step with the shipped component: see
4
+ * components/_demo/harness.jsx for why the newer demos are generated.
5
+ */
6
+ import React, { useState } from "react";
7
+ import { DemoChrome, mount } from "../_demo/harness.jsx";
8
+ import { RadioGroup, Radio, RadioMark, SIZES, ORIENTATIONS } from "./radio.jsx";
9
+ import { Button } from "../button/button.jsx";
10
+
11
+ const U = (n) => `var(--semantic-layout-units-${n})`;
12
+ const C = (n) => `var(--semantic-color-${n})`;
13
+ const Y = (n) => `var(--semantic-type-${n})`;
14
+
15
+ const label = { fontSize: Y("font-size-xs"), lineHeight: Y("line-height-xs-single"), color: C("foreground-static-neutral-subtle") };
16
+ const card = {
17
+ background: C("fill-surface-elevated"), border: `1px solid ${C("stroke-static-neutral-base")}`,
18
+ borderRadius: U("cornerradius-large"), padding: U("padding-relaxed"),
19
+ display: "flex", flexDirection: "column", gap: U("gap-base"), minWidth: 0,
20
+ };
21
+ const h2 = { margin: 0, fontSize: Y("font-size-m"), lineHeight: Y("line-height-m-single"), fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") };
22
+
23
+ const TYPES = ["Unselected", "Selected", "Error Unselected", "Error Selected"];
24
+ const STATES = ["Active", "Hover", "Focused", "Pressed", "Disabled"];
25
+
26
+ function Pick({ list, value, onPick }) {
27
+ return (
28
+ <div style={{ display: "flex", gap: U("gap-xtight"), flexWrap: "wrap" }}>
29
+ {list.map((x) => <Button key={x} size="S" buttonStyle={x === value ? "Fill" : "Outlined"} type="Secondary" text={x} onClick={() => onPick(x)} />)}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ function InContext() {
35
+ const [contact, setContact] = useState(null);
36
+ const [tried, setTried] = useState(false);
37
+ const error = tried && !contact;
38
+ return (
39
+ <section style={card}>
40
+ <h2 style={h2}>Set up a recurring gift</h2>
41
+ <span style={label}>Press Save gift without picking how to reach Ana. Tab into a group and use the arrow keys.</span>
42
+ <RadioGroup label="Frequency" helpText="The first gift is taken on the start date." defaultValue="monthly" name="frequency">
43
+ <Radio value="once" text="One time" />
44
+ <Radio value="weekly" text="Weekly" />
45
+ <Radio value="biweekly" text="Every two weeks" />
46
+ <Radio value="monthly" text="Monthly" />
47
+ </RadioGroup>
48
+ <RadioGroup label="Statement delivery" orientation="Horizontal" defaultValue="email" name="statement">
49
+ <Radio value="email" text="Email" />
50
+ <Radio value="mail" text="Mail" />
51
+ <Radio value="sms" text="Text message" disabled />
52
+ </RadioGroup>
53
+ <RadioGroup label="Best way to reach Ana" required name="contact" value={contact} onChange={setContact}
54
+ error={error} errorMessage="Choose how to reach Ana">
55
+ <Radio value="phone" text="Phone" />
56
+ <Radio value="email" text="Email" />
57
+ <Radio value="none" text="Don’t contact" />
58
+ </RadioGroup>
59
+ <div><Button text="Save gift" type="Primary" buttonStyle="Fill" onClick={() => setTried(true)} /></div>
60
+ </section>
61
+ );
62
+ }
63
+
64
+ function Playground() {
65
+ const [size, setSize] = useState("Base");
66
+ const [orientation, setOrientation] = useState("Vertical");
67
+ const [state, setState] = useState("Default");
68
+ const [required, setRequired] = useState("off");
69
+ return (
70
+ <section style={card}>
71
+ <h2 style={h2}>Playground</h2>
72
+ <div style={{ display: "flex", gap: U("gap-relaxed"), flexWrap: "wrap" }}>
73
+ <div><div style={label}>Size</div><Pick list={SIZES} value={size} onPick={setSize} /></div>
74
+ <div><div style={label}>Orientation</div><Pick list={ORIENTATIONS} value={orientation} onPick={setOrientation} /></div>
75
+ <div><div style={label}>State</div><Pick list={["Default", "Error", "Disabled"]} value={state} onPick={setState} /></div>
76
+ <div><div style={label}>Required</div><Pick list={["off", "on"]} value={required} onPick={setRequired} /></div>
77
+ </div>
78
+ <RadioGroup label="Gift type" size={size} orientation={orientation} required={required === "on"}
79
+ error={state === "Error"} errorMessage="Choose a gift type" disabled={state === "Disabled"}
80
+ helpText="You can change this later.">
81
+ <Radio value="one" text="One-time gift" />
82
+ <Radio value="recurring" text="Recurring gift" />
83
+ <Radio value="pledge" text="Pledge payment" />
84
+ </RadioGroup>
85
+ </section>
86
+ );
87
+ }
88
+
89
+ function Matrix() {
90
+ return (
91
+ <section style={card}>
92
+ <h2 style={h2}>Every Type, State and Size</h2>
93
+ <div style={{ overflowX: "auto" }}>
94
+ <table style={{ borderCollapse: "collapse", fontSize: Y("font-size-xs"), color: C("foreground-static-neutral-subtle") }}>
95
+ <thead><tr><th />{STATES.map((s) => <th key={s} style={{ padding: U("padding-xtight"), textAlign: "left", fontWeight: Y("weight-medium") }}>{s}</th>)}</tr></thead>
96
+ <tbody>
97
+ {SIZES.flatMap((size) => TYPES.map((t) => (
98
+ <tr key={size + t}>
99
+ <th style={{ padding: U("padding-xtight"), textAlign: "left", fontWeight: Y("weight-medium") }}>{t}, {size}</th>
100
+ {STATES.map((s) => (
101
+ <td key={s} style={{ padding: U("padding-xtight") }}>
102
+ <RadioGroup label={`${t}, ${s}, ${size}`} showLabel={false} size={size} error={t.startsWith("Error")}
103
+ value={t.endsWith("Selected") && !t.endsWith("Unselected") ? "x" : null} onChange={() => {}}>
104
+ <Radio value="x" text="Label" disabled={s === "Disabled"} forceState={["Hover", "Focused", "Pressed"].includes(s) ? s : undefined} />
105
+ </RadioGroup>
106
+ </td>
107
+ ))}
108
+ </tr>
109
+ )))}
110
+ </tbody>
111
+ </table>
112
+ </div>
113
+ </section>
114
+ );
115
+ }
116
+
117
+ function Content() {
118
+ return (
119
+ <section style={card}>
120
+ <h2 style={h2}>Long and translated labels</h2>
121
+ <div style={{ display: "flex", gap: U("gap-relaxed"), flexWrap: "wrap", alignItems: "flex-start" }}>
122
+ <RadioGroup label="Gift acknowledgement" defaultValue="letter">
123
+ <Radio value="email" text="Send a thank-you email" />
124
+ <Radio value="letter" text="Send a printed thank-you letter with the year-end giving statement, and copy the primary contact" />
125
+ <Radio value="none" text="Don’t acknowledge" />
126
+ </RadioGroup>
127
+ <RadioGroup label="Frecuencia" helpText="La primera donación se cobra en la fecha de inicio." defaultValue="m">
128
+ <Radio value="o" text="Una vez" />
129
+ <Radio value="w" text="Cada semana" />
130
+ <Radio value="b" text="Cada dos semanas" />
131
+ <Radio value="m" text="Cada mes" />
132
+ </RadioGroup>
133
+ </div>
134
+ </section>
135
+ );
136
+ }
137
+
138
+ function InMenu() {
139
+ const options = ["Date, newest first", "Amount, largest first", "Donor name, A to Z"];
140
+ const [picked, setPicked] = useState(options[0]);
141
+ return (
142
+ <section style={card}>
143
+ <h2 style={h2}>RadioMark inside a menu</h2>
144
+ <span style={label}>The Menu owns the menuitemradio role and draws RadioMark (Base) as its leading visual.</span>
145
+ <div role="menu" aria-label="Sort by" style={{ display: "flex", flexDirection: "column", width: 280,
146
+ borderRadius: U("cornerradius-base"), border: `1px solid ${C("stroke-static-neutral-base")}`, background: C("fill-surface-elevated") }}>
147
+ {options.map((o) => (
148
+ <div key={o} role="menuitemradio" aria-checked={picked === o} tabIndex={0}
149
+ onClick={() => setPicked(o)} onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); setPicked(o); } }}
150
+ style={{ display: "flex", alignItems: "center", cursor: "pointer", fontSize: Y("font-size-s"), color: C("foreground-static-neutral-base") }}>
151
+ <RadioMark selected={picked === o} />{o}
152
+ </div>
153
+ ))}
154
+ </div>
155
+ </section>
156
+ );
157
+ }
158
+
159
+ function App() {
160
+ return (
161
+ <DemoChrome title="Radio"
162
+ blurb={"Pick exactly one option from a short list people can see all at once. Base on desktop, Large on touch. " +
163
+ "Tab lands on the picked option, arrows move and pick, Space picks. The focus ring shows for the keyboard only."}>
164
+ <div style={{ display: "flex", flexDirection: "column", gap: U("gap-relaxed") }}>
165
+ <InContext />
166
+ <Playground />
167
+ <Matrix />
168
+ <Content />
169
+ <InMenu />
170
+ </div>
171
+ </DemoChrome>
172
+ );
173
+ }
174
+
175
+ mount(<App />);
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Radio and RadioGroup, Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the Radio
5
+ * implementation; the standalone demo (radio.html), the Storybook stories and
6
+ * the <pathway-radio-group> web component all consume this.
7
+ *
8
+ * Pick exactly one option from a short list people can see all at once. Each
9
+ * option is a Radio (ring, dot when picked, label). A RadioGroup holds the
10
+ * options with one label, the Required asterisk, and help text or an error.
11
+ * Not for several choices (Checkbox), on or off (Switch), long lists (Input,
12
+ * Type Select) or filtering a view (Select (filter)).
13
+ *
14
+ * Built on React Aria Components RadioGroup and Radio, the same primitive as
15
+ * the Menu and the Input (WAI-ARIA radio group: Tab lands on the picked
16
+ * option, arrows move and pick, Space picks).
17
+ *
18
+ * Figma properties to props:
19
+ * Radio Type (Unselected, Selected, Error *) selection from the group value; Error from group `error`
20
+ * Radio State (Active, Hover, Focused, Pressed, Disabled) runtime; Disabled is `disabled`
21
+ * Radio Size (Base, Large) `size` (on the group, or per Radio)
22
+ * Radio Show Text, Text `showText`, `text` (or children)
23
+ * RadioGroup Orientation (Vertical, Horizontal) `orientation`
24
+ * RadioGroup State (Default, Error, Disabled) `error` + `errorMessage`, `disabled`
25
+ * RadioGroup Label, Required, Show Label, Show Help Text `label`, `required`, `showLabel`, `helpText`
26
+ * RadioGroup Options slot children (Radio elements)
27
+ *
28
+ * Spec: components/radio/radio-spec.md
29
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40002974-83004 (Radio)
30
+ * https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40019964-292 (RadioGroup)
31
+ */
32
+
33
+ import React, { createContext, useContext, useEffect } from "react";
34
+ import { RadioGroup as RACRadioGroup, Radio as RACRadio, Label, Text } from "react-aria-components";
35
+
36
+ // ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
37
+ // SC = semantic-color (themes/*.css), SL = semantic-layout-units (layout.css),
38
+ // CL = contextual-layout-units (layout-contextual.css), ST = semantic-type
39
+ // (type.css), M = motion (motion.css). Modeless names only.
40
+ const SC = (p) => `var(--semantic-color-${p})`;
41
+ const SL = (p) => `var(--semantic-layout-units-${p})`;
42
+ const CL = (p) => `var(--contextual-layout-units-${p})`;
43
+ const ST = (p) => `var(--semantic-type-${p})`;
44
+ const M = (p) => `var(--motion-${p})`;
45
+
46
+ export const SIZES = ["Base", "Large"];
47
+ export const ORIENTATIONS = ["Vertical", "Horizontal"];
48
+
49
+ // ─── COLOUR TOKENS ────────────────────────────────────────────────────────────
50
+ // Per state, exactly as Figma binds them (spec §3).
51
+ export const T = {
52
+ ring: SC("stroke-action-secondary-rest"),
53
+ ringHover: SC("stroke-action-secondary-hover"),
54
+ ringPressed: SC("stroke-action-secondary-pressed"),
55
+ ringOn: SC("stroke-action-primary-strong-rest"),
56
+ ringOnHover: SC("stroke-action-primary-strong-hover"),
57
+ ringOnPressed: SC("stroke-action-primary-strong-pressed"),
58
+ ringError: SC("stroke-action-status-negative-rest"),
59
+ ringErrorHover: SC("stroke-action-status-negative-hover"),
60
+ ringErrorPressed: SC("stroke-action-status-negative-pressed"),
61
+ ringDisabled: SC("stroke-action-disabled"),
62
+ dot: SC("fill-action-primary-strong-rest"),
63
+ dotHover: SC("fill-action-primary-strong-hover"),
64
+ dotPressed: SC("fill-action-primary-strong-pressed"),
65
+ dotError: SC("fill-action-status-negative-strong-rest"),
66
+ dotErrorHover: SC("fill-action-status-negative-strong-hover"),
67
+ dotErrorPressed: SC("fill-action-status-negative-strong-pressed"),
68
+ dotDisabled: SC("fill-action-disabled"),
69
+ layerHover: SC("fill-action-selection-hover"),
70
+ layerPressed: SC("fill-action-selection-selected"),
71
+ focusFill: SC("fill-surface-elevated"),
72
+ focusRing: SC("stroke-focusring-base"),
73
+ label: SC("foreground-static-neutral-base"),
74
+ labelDisabled: SC("foreground-action-disabled"),
75
+ groupLabel: SC("foreground-action-secondary-rest"),
76
+ groupLabelError: SC("foreground-action-status-negative-on-subtle-hover"),
77
+ required: SC("foreground-static-negative-on-subtle"),
78
+ help: SC("foreground-action-secondary-rest"),
79
+ helpError: SC("foreground-static-negative-on-subtle"),
80
+ helpErrorIcon: SC("fill-action-status-negative-strong-rest"),
81
+ };
82
+
83
+ // ─── LAYOUT ───────────────────────────────────────────────────────────────────
84
+ // Ring, dot and the label max width are measurements, not tokens (CLAUDE.md §5).
85
+ export const L = {
86
+ target: SL("accessibility-touch-target-aa-width"), // 44, the press area and the control column
87
+ targetH: SL("accessibility-touch-target-aa-height"),
88
+ ringWidth: CL("selection-controls-borderwidth"), // 0.75 (G3)
89
+ radius: SL("cornerradius-full"),
90
+ controlGap: SL("gap-xxtight"), // control column to label; label row to options; options to help text
91
+ requiredGap: SL("gap-xxxtight"), // label to asterisk
92
+ rowGap: SL("gap-base"), // between options, Horizontal
93
+ labelEnd: SL("padding-base"), // after the option label
94
+ labelBlock: { Base: SL("padding-tight"), Large: SL("padding-xtight") }, // Large: G2
95
+ helpGap: SL("gap-tight"), // help icon to text
96
+ helpIconTop: SL("borderwidth-base"),
97
+ ring: { Base: 20, Large: 24 },
98
+ dot: { Base: 10, Large: 12 },
99
+ labelMax: 480,
100
+ helpMax: 360,
101
+ helpIcon: 16,
102
+ };
103
+
104
+ // ─── TYPE ─────────────────────────────────────────────────────────────────────
105
+ // Figma text styles bind the primitive tracking. Figma Letter Spacing/Wide is
106
+ // 0.3 (semantic `spacious`), Standard is 0.1 (semantic `wide`), ExtraWide is
107
+ // 0.6 (semantic `extraspacious`). Spec §3.7.
108
+ export const TYPE = {
109
+ Base: { fontSize: ST("font-size-s"), lineHeight: ST("line-height-s-single"), letterSpacing: ST("letter-spacing-spacious") },
110
+ Large: { fontSize: ST("font-size-r"), lineHeight: ST("line-height-r-single"), letterSpacing: ST("letter-spacing-wide") },
111
+ group: { fontSize: ST("font-size-s"), lineHeight: ST("line-height-s-single"), letterSpacing: ST("letter-spacing-spacious"), fontWeight: ST("weight-medium") },
112
+ help: { fontSize: ST("font-size-xs"), lineHeight: ST("line-height-xs-single"), letterSpacing: ST("letter-spacing-extraspacious") },
113
+ };
114
+
115
+ const CSS = `
116
+ .pw-radio-group{display:flex;flex-direction:column;align-items:flex-start;gap:${L.controlGap};min-width:0;
117
+ font-family:${ST("family-brand")},sans-serif}
118
+ .pw-radio-group__label{display:inline-flex;gap:${L.requiredGap};color:${T.groupLabel};
119
+ font-size:${TYPE.group.fontSize};line-height:${TYPE.group.lineHeight};letter-spacing:${TYPE.group.letterSpacing};font-weight:${TYPE.group.fontWeight}}
120
+ .pw-radio-group__required{color:${T.required}}
121
+ .pw-radio-group[data-invalid] .pw-radio-group__label{color:${T.groupLabelError}}
122
+ .pw-radio-group[data-disabled] .pw-radio-group__label,.pw-radio-group[data-disabled] .pw-radio-group__required{color:${T.labelDisabled}}
123
+ .pw-radio-group__options{display:flex;flex-direction:column;max-width:100%}
124
+ .pw-radio-group[data-orientation=horizontal] .pw-radio-group__options{flex-direction:row;flex-wrap:wrap;column-gap:${L.rowGap}}
125
+ .pw-radio-group__help{display:flex;align-items:center;gap:${L.helpGap};max-width:${L.helpMax}px;color:${T.help};
126
+ font-size:${TYPE.help.fontSize};line-height:${TYPE.help.lineHeight};letter-spacing:${TYPE.help.letterSpacing};font-weight:${ST("weight-regular")}}
127
+ .pw-radio-group__help[data-error]{color:${T.helpError};font-weight:${ST("weight-semibold")}}
128
+ .pw-radio-group__help-icon{flex:none;display:inline-flex;padding-top:${L.helpIconTop};color:${T.helpErrorIcon}}
129
+ .pw-radio-group__help-icon .material-symbols-rounded{font-family:"Material Symbols Rounded";font-size:${L.helpIcon}px;line-height:1;
130
+ font-variation-settings:'FILL' 1,'wght' 400,'GRAD' 0,'opsz' 24}
131
+
132
+ .pw-radio{display:flex;align-items:flex-start;gap:${L.controlGap};box-sizing:border-box;width:fit-content;max-width:100%;
133
+ min-width:${L.target};min-height:${L.targetH};cursor:pointer;outline:none;-webkit-tap-highlight-color:transparent;
134
+ font-family:${ST("family-brand")},sans-serif}
135
+ .pw-radio[data-disabled]{cursor:not-allowed}
136
+ .pw-radio__control{flex:none;display:inline-grid;place-items:center;width:${L.target};height:${L.targetH};border-radius:${L.radius};
137
+ transition:background-color ${M("duration-2")} ${M("easing-standard")}}
138
+ .pw-radio__mark{box-sizing:border-box;display:inline-grid;place-items:center;width:${L.ring.Base}px;height:${L.ring.Base}px;
139
+ border-radius:${L.radius};border:${L.ringWidth} solid ${T.ring};background:transparent;
140
+ transition:border-color ${M("duration-2")} ${M("easing-standard")},background-color ${M("duration-2")} ${M("easing-standard")}}
141
+ .pw-radio__dot{width:${L.dot.Base}px;height:${L.dot.Base}px;border-radius:${L.radius};background:${T.dot};transform:scale(0);
142
+ transition:transform ${M("duration-3")} ${M("easing-burst")},background-color ${M("duration-2")} ${M("easing-standard")}}
143
+ .pw-radio[data-size=Large] .pw-radio__mark{width:${L.ring.Large}px;height:${L.ring.Large}px}
144
+ .pw-radio[data-size=Large] .pw-radio__dot{width:${L.dot.Large}px;height:${L.dot.Large}px}
145
+ .pw-radio__label{box-sizing:border-box;min-width:0;max-width:${L.labelMax}px;overflow-wrap:anywhere;color:${T.label};
146
+ padding:${L.labelBlock.Base} ${L.labelEnd} ${L.labelBlock.Base} 0;
147
+ font-size:${TYPE.Base.fontSize};line-height:${TYPE.Base.lineHeight};letter-spacing:${TYPE.Base.letterSpacing};font-weight:${ST("weight-regular")}}
148
+ .pw-radio[data-size=Large] .pw-radio__label{padding-block:${L.labelBlock.Large};
149
+ font-size:${TYPE.Large.fontSize};line-height:${TYPE.Large.lineHeight};letter-spacing:${TYPE.Large.letterSpacing}}
150
+
151
+ .pw-radio[data-selected] .pw-radio__mark{border-color:${T.ringOn}}
152
+ .pw-radio[data-selected] .pw-radio__dot{transform:scale(1)}
153
+
154
+ .pw-radio:not([data-disabled]):is([data-hovered],[data-force=Hover]) .pw-radio__control{background:${T.layerHover}}
155
+ .pw-radio:not([data-disabled]):is([data-pressed],[data-force=Pressed]) .pw-radio__control{background:${T.layerPressed}}
156
+ .pw-radio:not([data-disabled]):is([data-hovered],[data-force=Hover]) .pw-radio__mark{border-color:${T.ringHover}}
157
+ .pw-radio:not([data-disabled]):is([data-pressed],[data-force=Pressed]) .pw-radio__mark{border-color:${T.ringPressed}}
158
+ .pw-radio[data-selected]:not([data-disabled]):is([data-hovered],[data-force=Hover]) .pw-radio__mark{border-color:${T.ringOnHover}}
159
+ .pw-radio[data-selected]:not([data-disabled]):is([data-hovered],[data-force=Hover]) .pw-radio__dot{background:${T.dotHover}}
160
+ .pw-radio[data-selected]:not([data-disabled]):is([data-pressed],[data-force=Pressed]) .pw-radio__mark{border-color:${T.ringOnPressed}}
161
+ .pw-radio[data-selected]:not([data-disabled]):is([data-pressed],[data-force=Pressed]) .pw-radio__dot{background:${T.dotPressed}}
162
+
163
+ .pw-radio[data-invalid] .pw-radio__mark{border-color:${T.ringError}}
164
+ .pw-radio[data-invalid] .pw-radio__dot{background:${T.dotError}}
165
+ .pw-radio[data-invalid]:not([data-disabled]):is([data-hovered],[data-force=Hover]) .pw-radio__mark{border-color:${T.ringErrorHover}}
166
+ .pw-radio[data-invalid]:not([data-disabled]):is([data-hovered],[data-force=Hover]) .pw-radio__dot{background:${T.dotErrorHover}}
167
+ .pw-radio[data-invalid]:not([data-disabled]):is([data-pressed],[data-force=Pressed]) .pw-radio__mark{border-color:${T.ringErrorPressed}}
168
+ .pw-radio[data-invalid]:not([data-disabled]):is([data-pressed],[data-force=Pressed]) .pw-radio__dot{background:${T.dotErrorPressed}}
169
+
170
+ .pw-radio[data-disabled] .pw-radio__mark{border-color:${T.ringDisabled}}
171
+ .pw-radio[data-disabled] .pw-radio__dot{background:${T.dotDisabled}}
172
+ .pw-radio[data-disabled] .pw-radio__label{color:${T.labelDisabled}}
173
+
174
+ .pw-radio:is([data-focus-visible],[data-force=Focused]) .pw-radio__mark{background:${T.focusFill};
175
+ outline:${CL("focusring-width")} solid ${T.focusRing};outline-offset:${CL("focusring-offset")}}
176
+
177
+ .pw-radio-mark{display:inline-grid;place-items:center;width:${L.target};height:${L.targetH};flex:none}
178
+
179
+ @media (prefers-reduced-motion: reduce){.pw-radio__control,.pw-radio__mark,.pw-radio__dot{transition:none}}
180
+ `;
181
+
182
+ let injected = false;
183
+ function useStyles() {
184
+ useEffect(() => {
185
+ if (injected || typeof document === "undefined" || document.getElementById("pw-radio-css")) { injected = true; return; }
186
+ const el = document.createElement("style");
187
+ el.id = "pw-radio-css";
188
+ el.textContent = CSS;
189
+ document.head.appendChild(el);
190
+ injected = true;
191
+ }, []);
192
+ }
193
+
194
+ const RadioGroupContext = createContext({ size: "Base" });
195
+
196
+ // ─── RADIOGROUP ───────────────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * RadioGroup
200
+ *
201
+ * @param {string} label - Group label. Always required: it names the radiogroup
202
+ * @param {boolean} showLabel - Figma Show Label. false keeps the label as the accessible name only
203
+ * @param {boolean} required - Figma Required. Adds the asterisk and aria-required
204
+ * @param {string} helpText - Figma Show Help Text. Help text under the options
205
+ * @param {boolean} error - Figma State=Error. Every option takes its Error type
206
+ * @param {string} errorMessage - Replaces the help text while `error` is set. Says what to do
207
+ * @param {boolean} disabled - Figma State=Disabled
208
+ * @param {"Vertical"|"Horizontal"} orientation - Figma Orientation
209
+ * @param {"Base"|"Large"} size - Radio Size for every option. Large on touch layouts
210
+ * @param {string} value - Controlled value
211
+ * @param {string} defaultValue - Uncontrolled starting value
212
+ * @param {function} onChange - (value: string) => void
213
+ * @param {string} name - Form field name
214
+ */
215
+ export function RadioGroup({
216
+ label,
217
+ showLabel = true,
218
+ required = false,
219
+ helpText,
220
+ error = false,
221
+ errorMessage,
222
+ disabled = false,
223
+ orientation = "Vertical",
224
+ size = "Base",
225
+ value,
226
+ defaultValue,
227
+ onChange,
228
+ name,
229
+ className = "",
230
+ children,
231
+ ...rest
232
+ }) {
233
+ useStyles();
234
+ const showError = error && errorMessage;
235
+ return (
236
+ <RACRadioGroup
237
+ {...rest}
238
+ name={name}
239
+ value={value}
240
+ defaultValue={defaultValue}
241
+ onChange={onChange}
242
+ isInvalid={error}
243
+ isRequired={required}
244
+ isDisabled={disabled}
245
+ orientation={orientation === "Horizontal" ? "horizontal" : "vertical"}
246
+ aria-label={showLabel ? undefined : label}
247
+ data-size={size}
248
+ className={`pw-radio-group${className ? ` ${className}` : ""}`}
249
+ >
250
+ {showLabel && label && (
251
+ <Label className="pw-radio-group__label">
252
+ {label}
253
+ {required && <span className="pw-radio-group__required" aria-hidden="true">*</span>}
254
+ </Label>
255
+ )}
256
+ <RadioGroupContext.Provider value={{ size }}>
257
+ <div className="pw-radio-group__options">{children}</div>
258
+ </RadioGroupContext.Provider>
259
+ {showError ? (
260
+ <Text slot="errorMessage" className="pw-radio-group__help" data-error="">
261
+ <span className="pw-radio-group__help-icon" aria-hidden="true"><span className="material-symbols-rounded">error</span></span>
262
+ <span>{errorMessage}</span>
263
+ </Text>
264
+ ) : helpText ? (
265
+ <Text slot="description" className="pw-radio-group__help">{helpText}</Text>
266
+ ) : null}
267
+ </RACRadioGroup>
268
+ );
269
+ }
270
+
271
+ // ─── RADIOMARK ────────────────────────────────────────────────────────────────
272
+
273
+ /**
274
+ * RadioMark: the drawn control alone (44 column, ring, dot), for a host that
275
+ * owns the radio role itself, such as a Menu item (PopoverMenu.Item.Leading
276
+ * Type=Radio). Never interactive on its own.
277
+ */
278
+ export function RadioMark({ selected = false, error = false, disabled = false, size = "Base" }) {
279
+ useStyles();
280
+ return (
281
+ <span className="pw-radio pw-radio-mark" aria-hidden="true" data-size={size}
282
+ data-selected={selected || undefined} data-invalid={error || undefined} data-disabled={disabled || undefined}>
283
+ <span className="pw-radio__control"><span className="pw-radio__mark"><span className="pw-radio__dot" /></span></span>
284
+ </span>
285
+ );
286
+ }
287
+
288
+ // ─── RADIO ────────────────────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Radio: one option. Must sit inside a RadioGroup.
292
+ *
293
+ * @param {string} value - The value the group takes when this is picked
294
+ * @param {string} text - Figma Text. The label (children work too)
295
+ * @param {boolean} showText - Figma Show Text. false needs `aria-label`
296
+ * @param {"Base"|"Large"} size - Overrides the group's size
297
+ * @param {boolean} disabled - Figma State=Disabled for this option only
298
+ * @param {"Hover"|"Focused"|"Pressed"} forceState - Documentation only: hold a state for state matrices
299
+ */
300
+ export function Radio({ value, text, children, showText = true, size, disabled = false, forceState, className = "", ...rest }) {
301
+ useStyles();
302
+ const group = useContext(RadioGroupContext);
303
+ const s = size || group.size || "Base";
304
+ const content = children ?? text;
305
+ return (
306
+ <RACRadio
307
+ {...rest}
308
+ value={value}
309
+ isDisabled={disabled}
310
+ data-size={s}
311
+ data-force={forceState}
312
+ className={`pw-radio${className ? ` ${className}` : ""}`}
313
+ >
314
+ <span className="pw-radio__control" aria-hidden="true">
315
+ <span className="pw-radio__mark"><span className="pw-radio__dot" /></span>
316
+ </span>
317
+ {showText && content != null && content !== "" && <span className="pw-radio__label">{content}</span>}
318
+ </RACRadio>
319
+ );
320
+ }
321
+
322
+ export default RadioGroup;