@helloimjolopez-pathway/pathway-components 0.6.1 → 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.
- package/components/radio/radio-demo.jsx +175 -0
- package/components/radio/radio.jsx +322 -0
- package/dist/pathway-components.js +100 -29
- package/package.json +6 -2
|
@@ -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;
|