From f7d671e4d2b6734fe11105fb65476e24c86547d3 Mon Sep 17 00:00:00 2001 From: Ahmad Hakim Date: Fri, 7 Aug 2026 16:01:35 +0200 Subject: [PATCH] new(component): radio-group --- assets/docs/components/field.md | 129 +++--- assets/docs/components/menu.md | 9 +- assets/docs/components/radio-group.md | 371 ++++++++++++++++++ assets/docs/components/switch.md | 6 +- assets/docs/components/table.md | 20 - assets/docs/getting-started/cli.md | 9 +- assets/globals.css | 6 +- assets/llms.txt | 1 + assets/social/accordion.webp | Bin 11270 -> 7212 bytes assets/social/area-chart.webp | Bin 15248 -> 9794 bytes assets/social/attachment.webp | Bin 11854 -> 7746 bytes assets/social/avatar.webp | Bin 8494 -> 5846 bytes assets/social/badge.webp | Bin 8464 -> 5778 bytes assets/social/bar-chart.webp | Bin 17340 -> 10744 bytes assets/social/blocks.webp | Bin 12164 -> 7726 bytes assets/social/bubble.webp | Bin 13986 -> 8938 bytes assets/social/button-group.webp | Bin 10660 -> 7190 bytes assets/social/button.webp | Bin 8096 -> 5574 bytes assets/social/card.webp | Bin 7220 -> 5012 bytes assets/social/changelog.webp | Bin 7474 -> 5214 bytes assets/social/chart.webp | Bin 9120 -> 6190 bytes assets/social/charts.webp | Bin 14492 -> 9174 bytes assets/social/checkbox.webp | Bin 10264 -> 6784 bytes assets/social/cli.webp | Bin 9916 -> 6496 bytes assets/social/client-state-var.webp | Bin 7742 -> 5476 bytes assets/social/collapsible.webp | Bin 9480 -> 6498 bytes assets/social/components.webp | Bin 17326 -> 10858 bytes assets/social/create.webp | Bin 13432 -> 8782 bytes assets/social/dashboards.webp | Bin 13964 -> 9006 bytes assets/social/dev.webp | Bin 11060 -> 7218 bytes assets/social/dialog.webp | Bin 12240 -> 7916 bytes assets/social/docs.webp | Bin 14544 -> 9428 bytes assets/social/events.webp | Bin 9418 -> 6526 bytes assets/social/field.webp | Bin 10558 -> 6786 bytes assets/social/frame.webp | Bin 6908 -> 4882 bytes assets/social/index.webp | Bin 12944 -> 8460 bytes assets/social/input-group.webp | Bin 8096 -> 5646 bytes assets/social/input.webp | Bin 10964 -> 7184 bytes assets/social/installation.webp | Bin 8760 -> 6002 bytes assets/social/introduction.webp | Bin 17262 -> 10864 bytes assets/social/javascript.webp | Bin 7590 -> 5376 bytes assets/social/line-chart.webp | Bin 18064 -> 11356 bytes assets/social/markdown.webp | Bin 12216 -> 7856 bytes assets/social/marker.webp | Bin 11284 -> 7236 bytes assets/social/menu.webp | Bin 10266 -> 6814 bytes assets/social/message.webp | Bin 11820 -> 7662 bytes assets/social/radio-group.webp | Bin 0 -> 8616 bytes assets/social/scroll-fade.webp | Bin 9736 -> 6546 bytes assets/social/select.webp | Bin 10206 -> 6812 bytes assets/social/separator.webp | Bin 7980 -> 5562 bytes assets/social/shimmer.webp | Bin 8162 -> 5622 bytes assets/social/skills.webp | Bin 11000 -> 7236 bytes assets/social/spinner.webp | Bin 7850 -> 5536 bytes assets/social/state.webp | Bin 10342 -> 6956 bytes assets/social/switch.webp | Bin 9680 -> 6388 bytes assets/social/table.webp | Bin 7182 -> 5060 bytes assets/social/tabs.webp | Bin 10698 -> 7034 bytes assets/social/textarea.webp | Bin 9426 -> 6160 bytes assets/social/theming.webp | Bin 8652 -> 5854 bytes assets/social/typeset.webp | Bin 12594 -> 8188 bytes assets/social/typography.webp | Bin 9722 -> 6562 bytes components/ui/field.py | 2 +- components/ui/menu.py | 9 +- components/ui/radio_group.py | 94 +++++ components/ui/switch.py | 6 +- components/ui/table.py | 20 - docs/components/field.md | 127 +++--- docs/components/radio_group.md | 110 ++++++ docs/getting_started/cli.md | 9 +- .../lib/components/radio_group_choice_card.py | 35 ++ .../lib/components/radio_group_description.py | 32 ++ native/lib/components/radio_group_disabled.py | 31 ++ native/lib/components/radio_group_fieldset.py | 35 ++ native/lib/components/radio_group_invalid.py | 35 ++ native/registry/anatomy.py | 4 + native/registry/anatomy/radio_group.py | 6 + native/registry/components.py | 4 + native/templates/_source.py | 2 +- scripts/generate_preview_cards.py | 2 +- tests/test_clean_code.py | 46 +++ 80 files changed, 951 insertions(+), 209 deletions(-) create mode 100644 assets/docs/components/radio-group.md create mode 100644 assets/social/radio-group.webp create mode 100644 components/ui/radio_group.py create mode 100644 docs/components/radio_group.md create mode 100644 native/lib/components/radio_group_choice_card.py create mode 100644 native/lib/components/radio_group_description.py create mode 100644 native/lib/components/radio_group_disabled.py create mode 100644 native/lib/components/radio_group_fieldset.py create mode 100644 native/lib/components/radio_group_invalid.py create mode 100644 native/registry/anatomy/radio_group.py create mode 100644 tests/test_clean_code.py diff --git a/assets/docs/components/field.md b/assets/docs/components/field.md index ce01dde..4fade29 100644 --- a/assets/docs/components/field.md +++ b/assets/docs/components/field.md @@ -134,7 +134,7 @@ class ClassNames: FIELD_CONTENT = "group/field-content flex flex-1 flex-col gap-0.5 leading-snug" - FIELD_LABEL = "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border border-input *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col" + FIELD_LABEL = "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[:checked]:border-primary/30 has-[:checked]:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border border-input *:data-[slot=field]:p-2.5 dark:has-[:checked]:border-primary/20 dark:has-[:checked]:bg-primary/10 has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col" FIELD_TITLE = "flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50" @@ -296,51 +296,50 @@ field = Field() A single control with label, helper text, and validation. -```python +```text field.root -├── field.label -├── Input / Textarea / switch.root / select.root -├── field.description -└── field.error + |- field.label + |- Input / Textarea / switch.root / select.root + |- field.description + \- field.error ``` ## field.group Related fields in one group. Use `field.separator` between sections when needed. -```python +```text field.group -├── field.root -│ ├── field.label -│ ├── Input / Textarea / switch.root / select.root -│ ├── field.description -│ └── field.error -├── field.separator -└── field.root - ├── field.label - └── Input / Textarea / switch.root / select.root + |- field.root + | |- field.label + | |- Input / Textarea / switch.root / select.root + | |- field.description + | \- field.error + |- field.separator + \- field.root + |- field.label + \- Input / Textarea / switch.root / select.root ``` ## field.set Semantic grouping with a legend and description, usually containing a `field.group`. -```python +```text field.set -├── field.legend -├── field.description -└── field.group - ├── field.root - │ ├── field.label - │ ├── Input / Textarea / switch.root / select.root - │ ├── field.description - │ └── field.error - └── field.root - ├── field.label - └── Input / Textarea / switch.root / select.root + |- field.legend + |- field.description + \- field.group + |- field.root + | |- field.label + | |- Input / Textarea / switch.root / select.root + | |- field.description + | \- field.error + \- field.root + |- field.label + \- Input / Textarea / switch.root / select.root ``` - - `field.root` is the core wrapper for a single field. - `field.content` is a flex column that groups label and description. Not required if you have no description. @@ -501,9 +500,9 @@ def field_demo() -> rx.Component: # Responsive Layout -- **Vertical fields**: Default orientation stacks label, control, and helper text—ideal for mobile-first layouts. +- **Vertical fields**: Default orientation stacks label, control, and helper text—ideal for mobile-first layouts. -- **Horizontal fields**: Set `orientation="horizontal"` on `field.root` to align the label and control side-by-side. Pair with `field.content` to keep descriptions aligned. +- **Horizontal fields**: Set `orientation="horizontal"` on `field.root` to align the label and control side-by-side. Pair with `field.content` to keep descriptions aligned. - **Responsive fields**: Set `orientation="responsive"` for automatic column layouts inside container-aware parents. @@ -530,9 +529,9 @@ field.root( Container that renders a semantic `fieldset` with spacing presets. -| Prop | Type | Default | -| --- | --- | --- | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ----- | ------- | +| `class_name` | `str` | | ```python field.set( @@ -545,10 +544,10 @@ field.set( Legend element for a `field.set`. Switch to the `"label"` variant to align with standard label sizing. -| Prop | Type | Default | -| --- | --- | --- | -| `variant` | `Literal["legend", "label"]` | `"legend"` | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ---------------------------- | ---------- | +| `variant` | `Literal["legend", "label"]` | `"legend"` | +| `class_name` | `str` | | ```python field.legend("Notification Preferences", variant="label") @@ -560,9 +559,9 @@ The `field.legend` has two variants: `legend` and `label`. The `label` variant a Layout wrapper that stacks `field.root` components and enables container queries for responsive orientations. -| Prop | Type | Default | -| --- | --- | --- | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ----- | ------- | +| `class_name` | `str` | | ```python field.group( @@ -576,11 +575,11 @@ field.group( The core wrapper for a single field. Provides orientation control, invalid state styling, and spacing configurations. -| Prop | Type | Default | -| --- | --- | --- | -| `orientation` | `"vertical" | "horizontal" | "responsive"` | `"vertical"` | -| `class_name` | `str` | | -| `data_invalid` | `str` | | +| Prop | Type | Default | +| -------------- | ----------- | ------------ | ------------- | ------------ | +| `orientation` | `"vertical" | "horizontal" | "responsive"` | `"vertical"` | +| `class_name` | `str` | | +| `data_invalid` | `str` | | ```python field.root( @@ -594,9 +593,9 @@ field.root( Flex column that groups control and descriptions when the label sits beside the control. Not required if you have no layout description block. -| Prop | Type | Default | -| --- | --- | --- | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ----- | ------- | +| `class_name` | `str` | | ```python field.root( @@ -612,10 +611,10 @@ field.root( Label styled for both direct inputs and nested `field` child items. -| Prop | Type | Default | -| --- | --- | --- | -| `html_for` | `str` | | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ----- | ------- | +| `html_for` | `str` | | +| `class_name` | `str` | | ```python field.label("Email", html_for="email") @@ -625,9 +624,9 @@ field.label("Email", html_for="email") Renders a standalone title with matching label typography properties inside a `field.content` node block. -| Prop | Type | Default | -| --- | --- | --- | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ----- | ------- | +| `class_name` | `str` | | ```python field.content( @@ -640,9 +639,9 @@ field.content( Helper text slot that automatically line-balances lengthy strings cleanly when utilized inside horizontal configurations. -| Prop | Type | Default | -| --- | --- | --- | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ----- | ------- | +| `class_name` | `str` | | ```python field.description("We never share your email with anyone.") @@ -652,9 +651,9 @@ field.description("We never share your email with anyone.") Visual divider rule used to separate sections or categories inside a wrapping `field.group` component. Accepts optional inline children contents. -| Prop | Type | Default | -| --- | --- | --- | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ----- | ------- | +| `class_name` | `str` | | ```python field.separator("Or continue with") @@ -664,9 +663,9 @@ field.separator("Or continue with") Accessible error notification typography container block configured automatically with standard application state layout variables (`role="alert"`). -| Prop | Type | Default | -| --- | --- | --- | -| `class_name` | `str` | | +| Prop | Type | Default | +| ------------ | ----- | ------- | +| `class_name` | `str` | | ```python field.error("Invalid passcode combination provided.") diff --git a/assets/docs/components/menu.md b/assets/docs/components/menu.md index 5b2e647..1a7f3db 100644 --- a/assets/docs/components/menu.md +++ b/assets/docs/components/menu.md @@ -193,27 +193,21 @@ class MenuItem(CoreComponent): props["data-slot"] = "menu-item" cls.set_class_name(ClassNames.ITEM, props) - # 0. Pop out the variant prop so we can apply the data-variant attribute variant = props.pop("variant", "default") - props["data-variant"] = ( - variant # This maps to your Tailwind: data-[variant=destructive] - ) + props["data-variant"] = variant item_id = props.get("id") or f"menu-item-{next(_menu_item_counter)}" props["id"] = item_id - # 1. Pop out any existing user on_click triggers user_on_click = props.pop("on_click", None) click_events = [] - # 2. Append the user's custom event(s) if they provided any if user_on_click is not None: if isinstance(user_on_click, list): click_events.extend(user_on_click) else: click_events.append(user_on_click) - # 3. If close_on_click is active, append the parent-closing script if close_on_click: close_script = rx.call_script( f""" @@ -224,7 +218,6 @@ class MenuItem(CoreComponent): ) click_events.append(close_script) - # 4. Bind the combined chain back to props if there's anything to execute if click_events: props["on_click"] = click_events diff --git a/assets/docs/components/radio-group.md b/assets/docs/components/radio-group.md new file mode 100644 index 0000000..02f14ad --- /dev/null +++ b/assets/docs/components/radio-group.md @@ -0,0 +1,371 @@ +--- +title: "Radio Group" +description: "A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time." +order: 0 +--- + + +## Radio Group, A Set Of Checkable Buttons—Known As Radio Buttons—Where No More Than One Of The Buttons Can Be Checked At A Time. + + +```python +from components.ui.radio_group import radio_group +``` + +```python +from typing import Any + +from reflex.components.component import Component +from reflex.utils.imports import ImportVar +from reflex.vars import FunctionVar, Var +from reflex.vars.base import VarData + +PACKAGE_CN = "clsx-for-tailwind@1.0.0" +CN = Var( + "cn", + _var_data=VarData( + imports={ + PACKAGE_CN: ImportVar(tag="cn"), + }, + ), +).to(FunctionVar) + + +class CoreComponent(Component): + unstyled: Var[bool] + + @classmethod + def set_class_name( + cls, default_class_name: str | Var[str], props: dict[str, Any] + ) -> None: + + if "render_" in props: + return + + props_class_name = props.get("class_name", "") + + if props.pop("unstyled", False): + props["class_name"] = props_class_name + return + + props["class_name"] = cn(default_class_name, props_class_name) + + def _exclude_props(self) -> list[str]: + return [ + *super()._exclude_props(), + "unstyled", + ] + + +def cn(*classes: Var | str | tuple | list | None) -> Var: + return CN.call(*classes).to(str) +``` + +```python +import reflex as rx +from reflex.components.component import ComponentNamespace + +from ..core.core import CoreComponent, cn + + +class ClassNames: + ROOT = "w-full flex flex-col gap-3 disabled:opacity-50 disabled:cursor-not-allowed text-sm" + + ITEM_ROOT = ( + "group relative inline-flex size-4 shrink-0 cursor-pointer items-center " + "justify-center rounded-full border border-input " + "has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 " + "has-[:focus-visible]:border-ring has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50 " + "has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-3 " + "has-[[aria-invalid=true]]:ring-destructive/20 " + "dark:bg-input/30 " + "has-[:checked]:border-primary " + "peer-has-[[data-slot=field-content]]:mt-0.5 [&:has(~[data-slot=field-content])]:mt-0.5" + ) + + ITEM_INPUT = "peer sr-only" + + ITEM_INDICATOR = "hidden peer-checked:flex items-center justify-center" + + ITEM_INDICATOR_DOT = "size-2 rounded-full bg-primary" + + _KNOWN_INPUT_PROPS = ( + "checked", + "default_checked", + "disabled", + "required", + "id", + ) + + +class RadioGroupRoot(CoreComponent): + @classmethod + def create(cls, *children, disabled: bool = False, **props) -> rx.Component: + custom_classes = props.pop("class_name", "") + props["data-slot"] = "radio-group" + props["role"] = "radiogroup" + if disabled: + props["disabled"] = True + cls.set_class_name(cn(ClassNames.ROOT, custom_classes), props) + return rx.el.fieldset(*children, **props) + + +class RadioGroupItem(CoreComponent): + @classmethod + def create(cls, *children, name: str, value: str, **props) -> rx.Component: + custom_classes = props.pop("class_name", "") + + input_props = {"name": name, "value": value} + for key in list(props.keys()): + if ( + key in ClassNames._KNOWN_INPUT_PROPS + or key.startswith("on_") + or key.startswith("data-") + or key.startswith("aria-") + ): + input_props[key] = props.pop(key) + + input_props["type"] = "radio" + input_props["data-slot"] = "radio-group-item-input" + input_props["class_name"] = ClassNames.ITEM_INPUT + + props["data-slot"] = "radio-group-item" + + if not children: + children = (RadioGroupIndicator.create(),) + + cls.set_class_name(cn(ClassNames.ITEM_ROOT, custom_classes), props) + return rx.el.label(rx.el.input(**input_props), *children, **props) + + +class RadioGroupIndicator(CoreComponent): + @classmethod + def create(cls, *children, **props) -> rx.Component: + if len(children) == 0: + children = (rx.el.span(class_name=ClassNames.ITEM_INDICATOR_DOT),) + props["data-slot"] = "radio-group-item-indicator" + cls.set_class_name(ClassNames.ITEM_INDICATOR, props) + return rx.el.span(*children, **props) + + +class RadioGroup(ComponentNamespace): + root = staticmethod(RadioGroupRoot.create) + item = staticmethod(RadioGroupItem.create) + indicator = staticmethod(RadioGroupIndicator.create) + class_names = ClassNames + + +radio_group = RadioGroup() +``` + +# Examples + +## Description + +Radio group items with a description using the `Field` component. + +```python +def radio_group_description() -> rx.Component: + return radio_group.root( + *[ + field.root( + radio_group.item( + name="spacing", + value=value, + id=f"desc-{value}", + default_checked=(value == "comfortable"), + ), + field.content( + field.label(title, html_for=f"desc-{value}"), + field.description(description), + ), + orientation="horizontal", + ) + for value, title, description in OPTIONS + ], + class_name="w-fit", + ) +``` + +## Choice Card + +Use `field.label` to wrap the entire `Field` for a clickable card-style selection. + +```python +def radio_group_choice_card() -> rx.Component: + return radio_group.root( + *[ + field.label( + field.root( + field.content( + field.title(title), + field.description(description), + ), + radio_group.item( + name="plan", + value=value, + id=f"{value}-plan", + default_checked=(value == "plus"), + ), + orientation="horizontal", + ), + html_for=f"{value}-plan", + ) + for value, title, description in PLANS + ], + class_name="max-w-sm", + ) +``` + +## Fieldset + +Use `field.set` and `field.legend` to group radio items with a label and description. + +```python +def radio_group_fieldset() -> rx.Component: + return field.set( + field.legend("Subscription Plan", variant="label"), + field.description("Yearly and lifetime plans offer significant savings."), + radio_group.root( + *[ + field.root( + radio_group.item( + name="plan", + value=value, + id=f"plan-{value}", + default_checked=(value == "monthly"), + ), + field.label( + label, html_for=f"plan-{value}", class_name="font-normal" + ), + orientation="horizontal", + ) + for value, label in PLANS + ], + ), + class_name="w-full max-w-xs", + ) +``` + +## Disabled + +Use the `disabled` prop on `radio.root` to disable all items. + +```python +def radio_group_disabled() -> rx.Component: + return radio_group.root( + *[ + field.root( + radio_group.item( + name="disabled-demo", + value=value, + id=id_, + disabled=is_disabled, + default_checked=(value == "option2"), + ), + field.label(label, html_for=id_, class_name="font-normal"), + orientation="horizontal", + **({"data-disabled": "true"} if is_disabled else {}), + ) + for value, id_, label, is_disabled in OPTIONS + ], + class_name="w-fit", + ) +``` + +## Invalid + +Use `aria-invalid` on `radio.item` and `data-invalid` on `field.root` to show validation errors. + +```python +def radio_group_invalid() -> rx.Component: + return field.set( + field.legend("Notification Preferences", variant="label"), + field.description("Choose how you want to receive notifications."), + radio_group.root( + *[ + field.root( + radio_group.item( + name="notification-preferences", + value=value, + id_=id_, + default_checked=(value == "email"), + aria_invalid=True, + ), + field.label(label, html_for=id_, class_name="font-normal"), + orientation="horizontal", + data_invalid=True, + ) + for value, id_, label in OPTIONS + ], + ), + class_name="w-full max-w-xs", + ) +``` + +# API Reference + +## radio_group.root + +The container element for a set of radio options. Renders a native `
`. Setting `disabled=True` on the root automatically disables all descendant radio controls without needing to set it on each item individually. + +```python +radio_group.root( + radio_group.item(name="plan", value="free", id_="plan-free"), + radio_group.item(name="plan", value="pro", id_="plan-pro"), + disabled=False, +) +``` + +| Prop | Type | Default | +| ------------ | ------ | --------------- | +| `disabled` | `bool` | `False` | +| `class_name` | `str` | `""` | +| `data_slot` | `str` | `"radio-group"` | +| `role` | `str` | `"radiogroup"` | + +Any additional HTML props passed to `radio_group.root` are forwarded directly to the underlying `
`. + +## radio_group.item + +Represents an individual radio control wrapped in a native `