🐛 Fix radio-buttons component in the DS (#8820)

This commit is contained in:
Eva Marco 2026-03-30 13:35:24 +02:00 committed by GitHub
parent 87bb1b8e74
commit 04f6307c69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 113 additions and 40 deletions

View File

@ -36,45 +36,55 @@
[:selected {:optional true}
[:maybe [:or :keyword :string]]]
[:allow-empty {:optional true} :boolean]
[:disabled {:optional true} :boolean]
[:options [:vector {:min 1} schema:radio-button]]
[:on-change {:optional true} fn?]])
(mf/defc radio-buttons*
{::mf/schema schema:radio-buttons}
[{:keys [class variant extended name selected allow-empty options on-change] :rest props}]
[{:keys [class variant extended name selected allow-empty options on-change disabled] :rest props}]
(let [options (if (array? options)
(mfu/bean options)
options)
type (if allow-empty "checkbox" "radio")
variant (d/nilv variant "secondary")
type (if allow-empty "checkbox" "radio")
variant (d/nilv variant "secondary")
wrapper-disabled (d/nilv disabled false)
handle-click
(mf/use-fn
(fn [event]
(let [target (dom/get-target event)
label (dom/get-parent-with-data target "label")]
(dom/prevent-default event)
(dom/stop-propagation event)
(dom/click label))))
label (dom/get-parent-with-data target "label")
input (dom/query label "input")
disabled? (dom/get-attribute target "disabled")]
(when-not disabled?
(dom/click input)))))
handle-change
(mf/use-fn
(mf/deps selected on-change)
(mf/deps selected on-change allow-empty)
(fn [event]
(let [input (dom/get-target event)
value (dom/get-target-val event)]
(let [input (dom/get-target event)
value (dom/get-target-val event)
selected-str (when selected (d/name selected))
new-value (if (and allow-empty (= value selected-str))
nil
value)]
(when (fn? on-change)
(on-change value event))
(on-change new-value event))
(dom/blur! input))))
props
(mf/spread-props props {:key (dm/str name "-" selected)
:class [class (stl/css-case :wrapper true
:disabled disabled
:extended extended)]})]
[:> :div props
(for [[idx {:keys [id class value label icon disabled]}] (d/enumerate options)]
(let [checked? (= selected value)]
(let [value-str (d/name value)
selected-str (when selected (d/name selected))
checked? (= selected-str value-str)]
[:label {:key idx
:html-for id
:data-label true
@ -88,13 +98,13 @@
:aria-pressed checked?
:aria-label label
:icon icon
:disabled disabled}]
:disabled (or disabled wrapper-disabled)}]
[:> button* {:variant variant
:on-click handle-click
:aria-pressed checked?
:class (stl/css-case :button true
:extended extended)
:disabled disabled}
:disabled (or disabled wrapper-disabled)}
label])
[:input {:id id
@ -102,6 +112,6 @@
:on-change handle-change
:type type
:name name
:disabled disabled
:disabled (or disabled wrapper-disabled)
:value value
:default-checked checked?}]]))]))
:checked checked?}]]))]))

View File

@ -11,11 +11,17 @@ import * as RadioButtons from "./radio_buttons.stories";
# Radio Buttons
The `radio-buttons*` component allows users to switch between two or more options that are mutually exclusive.
The `radio-buttons*` component lets users select a single option from a set of mutually exclusive choices.
It is designed for immediate selection changes, without requiring a confirmation step.
---
## Variants
Radio buttons with text only. The label will be the text of the button.
### Text only
Radio buttons using text labels. The label is displayed directly on each option.
<Canvas of={RadioButtons.Default} />
@ -34,12 +40,14 @@ Radio buttons with text only. The label will be the text of the button.
{:id "align-right"
:label "Right"
:value "right"}]}]
Icon only
```
Radio buttons with icons only. In this case, the label will act as the tooltip of each button.
### Icons only
Radio buttons using icons instead of text labels. The label is used as tooltip and accessibility text.
<Canvas of={RadioButtons.WithIcons} />
```clj
(ns app.main.ui.foo
(:require
@ -63,35 +71,58 @@ Radio buttons with icons only. In this case, the label will act as the tooltip o
:label "Right align"
:value "right"}]}]
```
### Anatomy
## Anatomy
Each option is composed of:
Under the hood, each option is represented by
- a button, which is the visible and clickable element. It may be either an icon button or a text button.
- a radio input, which is not visible but retains the current state of the option.
A visible control (button or icon button)
A hidden native input (radio or checkbox) that stores the state
A radio group is defined by giving each of radio buttons in the group the same name. Once a radio group is established,
selecting any radio button in that group automatically deselects any currently-selected radio button in the same group.
All options share the same name, forming a radio group. Selecting one option automatically deselects the previously selected one.
The `selected` parameter should be set to the value of the option that is to be active. Otherwise, no option will be selected.
## Behavior
If the parameter `allow-empty` is enabled, then the component will work with checkboxes instead of radio buttons,
and therefore the selected option can be deselected. However, it will still only be possible to select one option.
### Selection
The selected prop controls the active option
It must match the value of one of the provided options
If selected is nil, no option is selected
The `extended` parameter allows the component to use all the available space from the parent and distribute it equally
among all elements.
### Allow empty
Any option can be individually disabled using the `disabled` parameter.
When allow-empty is enabled:
The selected option can be deselected
Only one option can still be active at a time
This introduces toggle-like behavior over a single selection group
### Extended
When extended is enabled:
The component expands to fill the width of its container
Options are evenly distributed across available space
### Disabled state
The entire group can be disabled using the `:disabled` prop
Individual options can also be disabled using `:disabled` inside each option
Disabled options cannot be interacted with.
## Usage Guidelines
### When to Use
### When to use
For settings where users must choose exactly one option
For preference or configuration panels
When changes should take effect immediately
- For multiple choice settings that take effect immediately.
- In preference panels and configuration screens.
### When not to use
### When Not to Use
For boolean toggles → use a switch or checkbox
For multiple selection → use checkboxes
For actions requiring confirmation → use buttons or dialogs
For workflows that require an explicit “Apply” step
- For boolean settings (use switch or checkbox instead).
- For actions that require confirmation (use buttons instead).
- For temporary states that need explicit "Apply" action.
### Notes
This component is controlled: state must be managed externally via selected
It does not manage internal state
The on-change handler is called with the new value whenever selection changes

View File

@ -20,6 +20,11 @@
width: 100%;
display: flex;
}
&.disabled {
outline: $b-1 solid var(--color-background-quaternary);
background-color: transparent;
}
}
.label {

View File

@ -15,6 +15,12 @@ const options = [
{ id: "right", label: "Right", value: "right" },
];
const optionsDisabled = [
{ id: "left", label: "Left", value: "left" },
{ id: "center", label: "Center", value: "center", disabled: true },
{ id: "right", label: "Right", value: "right" },
];
const optionsIcon = [
{ id: "left", label: "Left align", value: "left", icon: "text-align-left" },
{
@ -68,9 +74,24 @@ export default {
parameters: {
controls: {
exclude: ["options", "on-change"],
disabled: {
control: { type: "boolean" },
},
},
},
render: ({ ...args }) => <RadioButtons {...args} />,
render: (args) => {
const [selected, setSelected] = React.useState(args.selected);
return (
<RadioButtons
{...args}
selected={selected}
onChange={(value, event) => {
setSelected(value);
}}
/>
);
},
};
export const Default = {};
@ -80,3 +101,9 @@ export const WithIcons = {
options: optionsIcon,
},
};
export const WithOptionDisabled = {
args: {
options: optionsDisabled,
},
};