From 04f6307c696059b93be3672b8bf8467517e91399 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 30 Mar 2026 13:35:24 +0200 Subject: [PATCH] :bug: Fix radio-buttons component in the DS (#8820) --- .../main/ui/ds/controls/radio_buttons.cljs | 42 ++++++---- .../app/main/ui/ds/controls/radio_buttons.mdx | 77 +++++++++++++------ .../main/ui/ds/controls/radio_buttons.scss | 5 ++ .../ui/ds/controls/radio_buttons.stories.jsx | 29 ++++++- 4 files changed, 113 insertions(+), 40 deletions(-) diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs b/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs index ea9dd6fff3..044fe87bc4 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs @@ -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?}]]))])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx b/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx index 226319286a..5346d8751c 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx @@ -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. @@ -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. - ```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 \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.scss b/frontend/src/app/main/ui/ds/controls/radio_buttons.scss index 05957025dc..56e53fae7e 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.scss +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.scss @@ -20,6 +20,11 @@ width: 100%; display: flex; } + + &.disabled { + outline: $b-1 solid var(--color-background-quaternary); + background-color: transparent; + } } .label { diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx b/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx index 7133a1b961..157f83e465 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx @@ -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 }) => , + render: (args) => { + const [selected, setSelected] = React.useState(args.selected); + + return ( + { + setSelected(value); + }} + /> + ); + }, }; export const Default = {}; @@ -80,3 +101,9 @@ export const WithIcons = { options: optionsIcon, }, }; + +export const WithOptionDisabled = { + args: { + options: optionsDisabled, + }, +};