@scalewing/react 1.14.0 → 1.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c0a1c95: `RadioGroup` options take a `description` (`docs/requests/teisoro-radio-group.md`, 2026-09-29 follow-up, Teisoro F-007-S05 task 1365). `RadioGroupOption.description?: ReactNode` is secondary text for that option: a muted caption at the row's inline end while it fits beside the label, and a second line under the label text when it does not or below `md` (new generated classes `sw-radio-group-body` and `sw-radio-group-option-description`). It is the radio's accessible description (`aria-describedby`), the radio's name stays its `label` text (`aria-labelledby` on the label text), and a press on it chooses the option. It may hold phrasing content such as a `Badge`, nothing interactive; a taller description grows the row downward and the label stays on the radio's line. `undefined`, `null`, booleans and `''` are no description; `0` and a component that renders nothing are one. An option with a description fills the group's width. Without `description` nothing changes. No new dependencies.
8
+
3
9
  ## 1.14.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -152,6 +152,30 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
152
152
  />
153
153
  ```
154
154
 
155
+ An option's optional `description` (a `ReactNode`) is secondary text for that option alone, such as its history. It is a muted caption after the label, at the row's inline end, while the two fit on one line; when they do not, and always below the `md` breakpoint, it wraps to a second line that starts under the label text, not under the radio. The label keeps the first line. An option with a description fills the group's width. The description is the radio's accessible description (`aria-describedby` on that option's input), while its name stays the `label` text, and a press on it chooses the option as the label does. A disabled option fades its description with the rest of the option. It may hold phrasing content such as a small `Badge`, but nothing interactive, because it sits inside the option's `<label>`. A description taller than the label, such as a default-size `Badge`, grows the row downward; the label stays on the radio's line. `undefined`, `null`, `false`, `true` and `''` are no description. Any other node is one, including `0` and a component that renders nothing, so pass `undefined` when there is nothing to say. The group's own `description` still describes the whole group.
156
+
157
+ ```tsx
158
+ <RadioGroup
159
+ legend="Sighting source"
160
+ onChange={setSource}
161
+ options={[
162
+ {
163
+ value: 'observer',
164
+ label: 'Field observer',
165
+ icon: <User />,
166
+ description: <Badge size="sm">Most recent</Badge>,
167
+ },
168
+ {
169
+ value: 'camera',
170
+ label: 'Camera trap',
171
+ icon: <Camera />,
172
+ description: '2 sightings · last Sep 13, 2026',
173
+ },
174
+ ]}
175
+ value={source}
176
+ />
177
+ ```
178
+
155
179
  `SegmentedControl` is one exclusive choice shown as a track of segments (`role="radiogroup"`), named by `aria-label` or `aria-labelledby`, with a controlled `value` and `onChange`; `variant="filled"` gives every segment the same width and fills the selection with the accent, and `disabled` keeps the choice visible but inert. `error` puts a message under the track (`sw-field-error`), links it to the group by `aria-describedby` and sets `aria-invalid` and a danger outline on the group (`Mark` in forced colors); as `Field`'s it is announced politely from a live region that is always there, never as an alert, and an empty string is no error. `required` sets `aria-required` on the group; since the control has no label of its own, the element that labels it shows the mark. The track sits in a `.sw-segmented-field` wrapper that holds the message and takes the track's place in the layout, so a `ref` still reaches the `radiogroup`. `error` is for a control in a `Stack` or block layout, where the track keeps its width and the message wraps under it. While it shows a message the field is at least 24ch wide, capped at its container, so a short compact track does not squeeze the message into a column. In an `Inline` the field therefore grows and the rest of the row moves, while the track keeps its labels' width.
156
180
 
157
181
  ```tsx
@@ -1,15 +1,5 @@
1
- import { type ReactNode } from 'react';
2
- export type RadioGroupOption = {
3
- value: string;
4
- label: string;
5
- disabled?: boolean;
6
- /**
7
- * A decorative glyph between the radio and the label, such as a person or
8
- * a building for the kind of check. It is `aria-hidden` and drawn in the
9
- * text color: the option's accessible name stays its `label` text.
10
- */
11
- icon?: ReactNode;
12
- };
1
+ import { type RadioGroupOption } from './radio-group/RadioGroupOptionLabel.js';
2
+ export type { RadioGroupOption };
13
3
  export type RadioGroupProps = {
14
4
  legend: string;
15
5
  value: string;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
4
  import { FieldErrorRegion } from './FieldErrorRegion.js';
5
+ import { RadioGroupOptionLabel, } from './radio-group/RadioGroupOptionLabel.js';
5
6
  export function RadioGroup({ legend, value, onChange, options, required = false, disabled = false, description, error, }) {
6
7
  const name = useId();
7
8
  const descriptionId = useId();
@@ -19,10 +20,8 @@ export function RadioGroup({ legend, value, onChange, options, required = false,
19
20
  throw new RangeError('options must have unique values');
20
21
  if (value !== '' && !values.includes(value))
21
22
  throw new RangeError('value must match an option or be empty');
22
- return (_jsxs("fieldset", { "aria-describedby": describedBy || undefined, "aria-invalid": Boolean(error), className: "sw-radio-group", disabled: disabled, children: [_jsx("legend", { className: "sw-radio-group-legend", children: legend }), description ? (_jsx("span", { className: "sw-radio-group-description", id: descriptionId, children: description })) : null, _jsx("span", { className: "sw-radio-group-options", children: options.map((option) => (_jsxs("label", { className: "sw-radio-group-option", children: [_jsxs("span", { className: "sw-radio-group-control", children: [_jsx("input", { checked: value === option.value, className: "sw-radio-group-input", disabled: option.disabled, name: name, onChange: (event) => {
23
- if (!disabled &&
24
- !option.disabled &&
25
- event.currentTarget.checked)
26
- onChange(option.value);
27
- }, required: required, type: "radio", value: option.value }), _jsx("span", { "aria-hidden": "true", className: "sw-radio-group-mark" })] }), option.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-radio-group-icon", children: option.icon })) : null, _jsx("span", { className: "sw-radio-group-text", children: option.label })] }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-radio-group-error", id: errorId, message: error })] }));
23
+ return (_jsxs("fieldset", { "aria-describedby": describedBy || undefined, "aria-invalid": Boolean(error), className: "sw-radio-group", disabled: disabled, children: [_jsx("legend", { className: "sw-radio-group-legend", children: legend }), description ? (_jsx("span", { className: "sw-radio-group-description", id: descriptionId, children: description })) : null, _jsx("span", { className: "sw-radio-group-options", children: options.map((option) => (_jsx(RadioGroupOptionLabel, { checked: value === option.value, name: name, onSelect: () => {
24
+ if (!disabled && !option.disabled)
25
+ onChange(option.value);
26
+ }, option: option, required: required }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-radio-group-error", id: errorId, message: error })] }));
28
27
  }
@@ -0,0 +1,39 @@
1
+ import { type ReactNode } from 'react';
2
+ export type RadioGroupOption = {
3
+ value: string;
4
+ label: string;
5
+ disabled?: boolean;
6
+ /**
7
+ * A decorative glyph between the radio and the label, such as a person or
8
+ * a building for the kind of check. It is `aria-hidden` and drawn in the
9
+ * text color: the option's accessible name stays its `label` text.
10
+ */
11
+ icon?: ReactNode;
12
+ /**
13
+ * Secondary text for this option, such as its history: a muted caption at
14
+ * the row's inline end, or on a second line under the label when the row
15
+ * is narrower than md or the two do not fit. It is the radio's accessible
16
+ * description (`aria-describedby`), never part of its name, and a press on
17
+ * it chooses the option. It may hold phrasing content such as a `Badge`,
18
+ * but nothing interactive: it sits inside the option's `<label>`.
19
+ * `undefined`, `null`, `false`, `true` and `''` are no description; any
20
+ * other node, `0` or a component that renders nothing included, is one,
21
+ * so pass `undefined` when there is nothing to say.
22
+ */
23
+ description?: ReactNode;
24
+ };
25
+ type RadioGroupOptionLabelProps = {
26
+ option: RadioGroupOption;
27
+ name: string;
28
+ checked: boolean;
29
+ required: boolean;
30
+ onSelect: () => void;
31
+ };
32
+ /**
33
+ * One option of a RadioGroup: the native radio and its mark, the optional
34
+ * glyph, and the label. With a description, the label and the description
35
+ * share a wrapping body, and the radio is named by the label text alone and
36
+ * described by the description.
37
+ */
38
+ export declare function RadioGroupOptionLabel({ option, name, checked, required, onSelect, }: RadioGroupOptionLabelProps): import("react").JSX.Element;
39
+ export {};
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ /** Whether React renders something for a description: not nothing, a boolean or ''. */
4
+ function hasDescription(description) {
5
+ return (description !== undefined &&
6
+ description !== null &&
7
+ typeof description !== 'boolean' &&
8
+ description !== '');
9
+ }
10
+ /**
11
+ * One option of a RadioGroup: the native radio and its mark, the optional
12
+ * glyph, and the label. With a description, the label and the description
13
+ * share a wrapping body, and the radio is named by the label text alone and
14
+ * described by the description.
15
+ */
16
+ export function RadioGroupOptionLabel({ option, name, checked, required, onSelect, }) {
17
+ const id = useId();
18
+ const textId = `${id}-text`;
19
+ const descriptionId = `${id}-description`;
20
+ const described = hasDescription(option.description);
21
+ const text = (_jsx("span", { className: "sw-radio-group-text", id: described ? textId : undefined, children: option.label }));
22
+ return (_jsxs("label", { className: "sw-radio-group-option", children: [_jsxs("span", { className: "sw-radio-group-control", children: [_jsx("input", { "aria-describedby": described ? descriptionId : undefined, "aria-labelledby": described ? textId : undefined, checked: checked, className: "sw-radio-group-input", disabled: option.disabled, name: name, onChange: (event) => {
23
+ if (event.currentTarget.checked)
24
+ onSelect();
25
+ }, required: required, type: "radio", value: option.value }), _jsx("span", { "aria-hidden": "true", className: "sw-radio-group-mark" })] }), option.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-radio-group-icon", children: option.icon })) : null, described ? (_jsxs("span", { className: "sw-radio-group-body", children: [text, _jsx("span", { className: "sw-radio-group-option-description", id: descriptionId, children: option.description })] })) : (text)] }));
26
+ }
@@ -1,4 +1,5 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
+ import { breakpointQuery } from './breakpoints.js';
2
3
  const label = typographyVariants.label;
3
4
  const caption = typographyVariants.caption;
4
5
  export function cssRadioGroupClasses() {
@@ -118,6 +119,8 @@ export function cssRadioGroupClasses() {
118
119
  min-width: 0;
119
120
  }
120
121
 
122
+ ${optionDescriptionRules()}
123
+
121
124
  .sw-radio-group-description,
122
125
  .sw-radio-group-error {
123
126
  font-family: var(--sw-font-sans);
@@ -141,6 +144,57 @@ export function cssRadioGroupClasses() {
141
144
  .sw-radio-group[aria-invalid='true'] .sw-radio-group-mark { border-color: CanvasText; }
142
145
  }`;
143
146
  }
147
+ /**
148
+ * An option with a description fills the group's width. Its label and
149
+ * description share a wrapping body: the label grows, so the description sits
150
+ * at the row's inline end while both fit, and wraps to its own line, starting
151
+ * under the label text, when they do not. The label keeps the row first.
152
+ * Below md the description always takes its own line. The radio and glyph
153
+ * sit on the label's line, which starts where a one-line option centers it.
154
+ * Items align to the line's start, not a shared baseline, so a description
155
+ * taller than the label (a default-size Badge) grows the row downward and
156
+ * never pushes the label off the radio's line; a caption line is centred
157
+ * on the label's line box.
158
+ */
159
+ function optionDescriptionRules() {
160
+ return `.sw-radio-group-option:has(> .sw-radio-group-body) {
161
+ align-items: flex-start;
162
+ box-sizing: border-box;
163
+ padding-block-start: calc((var(--sw-control-md-min-height) - var(--sw-space-5)) / 2);
164
+ width: 100%;
165
+ }
166
+
167
+ .sw-radio-group-option:has(> .sw-radio-group-body) > .sw-radio-group-icon {
168
+ min-height: var(--sw-space-5);
169
+ }
170
+
171
+ .sw-radio-group-body {
172
+ align-items: flex-start;
173
+ column-gap: var(--sw-space-3);
174
+ display: flex;
175
+ flex: 1 1 0;
176
+ flex-wrap: wrap;
177
+ min-width: 0;
178
+ padding-block-start: calc((var(--sw-space-5) - ${label.lineHeight}px) / 2);
179
+ }
180
+
181
+ .sw-radio-group-body > .sw-radio-group-text { flex: 1 1 auto; }
182
+
183
+ .sw-radio-group-option-description {
184
+ color: var(--sw-color-muted);
185
+ flex: 0 1 auto;
186
+ font-family: var(--sw-font-sans);
187
+ font-size: ${caption.fontSize}px;
188
+ font-weight: ${caption.fontWeight};
189
+ line-height: ${caption.lineHeight}px;
190
+ margin-block-start: calc((${label.lineHeight}px - ${caption.lineHeight}px) / 2);
191
+ min-width: 0;
192
+ }
193
+
194
+ @media ${breakpointQuery('below', 'md')} {
195
+ .sw-radio-group-option-description { flex-basis: 100%; }
196
+ }`;
197
+ }
144
198
  export function radioGroupClassCatalog() {
145
199
  return [
146
200
  'sw-radio-group',
@@ -152,6 +206,8 @@ export function radioGroupClassCatalog() {
152
206
  'sw-radio-group-mark',
153
207
  'sw-radio-group-icon',
154
208
  'sw-radio-group-text',
209
+ 'sw-radio-group-body',
210
+ 'sw-radio-group-option-description',
155
211
  'sw-radio-group-description',
156
212
  'sw-radio-group-error',
157
213
  ];
package/dist/styles.css CHANGED
@@ -1125,6 +1125,44 @@ body {
1125
1125
  min-width: 0;
1126
1126
  }
1127
1127
 
1128
+ .sw-radio-group-option:has(> .sw-radio-group-body) {
1129
+ align-items: flex-start;
1130
+ box-sizing: border-box;
1131
+ padding-block-start: calc((var(--sw-control-md-min-height) - var(--sw-space-5)) / 2);
1132
+ width: 100%;
1133
+ }
1134
+
1135
+ .sw-radio-group-option:has(> .sw-radio-group-body) > .sw-radio-group-icon {
1136
+ min-height: var(--sw-space-5);
1137
+ }
1138
+
1139
+ .sw-radio-group-body {
1140
+ align-items: flex-start;
1141
+ column-gap: var(--sw-space-3);
1142
+ display: flex;
1143
+ flex: 1 1 0;
1144
+ flex-wrap: wrap;
1145
+ min-width: 0;
1146
+ padding-block-start: calc((var(--sw-space-5) - 20px) / 2);
1147
+ }
1148
+
1149
+ .sw-radio-group-body > .sw-radio-group-text { flex: 1 1 auto; }
1150
+
1151
+ .sw-radio-group-option-description {
1152
+ color: var(--sw-color-muted);
1153
+ flex: 0 1 auto;
1154
+ font-family: var(--sw-font-sans);
1155
+ font-size: 13px;
1156
+ font-weight: 400;
1157
+ line-height: 18px;
1158
+ margin-block-start: calc((20px - 18px) / 2);
1159
+ min-width: 0;
1160
+ }
1161
+
1162
+ @media not all and (min-width: 48rem) {
1163
+ .sw-radio-group-option-description { flex-basis: 100%; }
1164
+ }
1165
+
1128
1166
  .sw-radio-group-description,
1129
1167
  .sw-radio-group-error {
1130
1168
  font-family: var(--sw-font-sans);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",