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

View File

@ -11,11 +11,17 @@ import * as RadioButtons from "./radio_buttons.stories";
# Radio Buttons # 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 ## 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} /> <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" {:id "align-right"
:label "Right" :label "Right"
:value "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} /> <Canvas of={RadioButtons.WithIcons} />
```clj ```clj
(ns app.main.ui.foo (ns app.main.ui.foo
(:require (:require
@ -63,35 +71,58 @@ Radio buttons with icons only. In this case, the label will act as the tooltip o
:label "Right align" :label "Right align"
:value "right"}]}] :value "right"}]}]
``` ```
### Anatomy
## Anatomy Each option is composed of:
Under the hood, each option is represented by A visible control (button or icon button)
- a button, which is the visible and clickable element. It may be either an icon button or a text button. A hidden native input (radio or checkbox) that stores the state
- a radio input, which is not visible but retains the current state of the option.
A radio group is defined by giving each of radio buttons in the group the same name. Once a radio group is established, All options share the same name, forming a radio group. Selecting one option automatically deselects the previously selected one.
selecting any radio button in that group automatically deselects any currently-selected radio button in the same group.
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, ### Selection
and therefore the selected option can be deselected. However, it will still only be possible to select one option. 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 ### Allow empty
among all elements.
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 ## 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. ### When not to use
- In preference panels and configuration screens.
### 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). ### Notes
- For actions that require confirmation (use buttons instead).
- For temporary states that need explicit "Apply" action. 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%; width: 100%;
display: flex; display: flex;
} }
&.disabled {
outline: $b-1 solid var(--color-background-quaternary);
background-color: transparent;
}
} }
.label { .label {

View File

@ -15,6 +15,12 @@ const options = [
{ id: "right", label: "Right", value: "right" }, { 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 = [ const optionsIcon = [
{ id: "left", label: "Left align", value: "left", icon: "text-align-left" }, { id: "left", label: "Left align", value: "left", icon: "text-align-left" },
{ {
@ -68,9 +74,24 @@ export default {
parameters: { parameters: {
controls: { controls: {
exclude: ["options", "on-change"], 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 = {}; export const Default = {};
@ -80,3 +101,9 @@ export const WithIcons = {
options: optionsIcon, options: optionsIcon,
}, },
}; };
export const WithOptionDisabled = {
args: {
options: optionsDisabled,
},
};