From 232d880c05c992ad2833e97da3311412fba7e46f Mon Sep 17 00:00:00 2001 From: elvis kahoro Date: Sat, 24 Oct 2026 13:10:41 +0000 Subject: [PATCH] fix(components): honor unstyled prop for components bypassing set_class_name Route class_name for avatar, badge, button, frame, input, select and tabs through CoreComponent.set_class_name so unstyled=True strips defaults. Adds regression tests and regenerated component docs. Co-Authored-By: Claude Sonnet 5.5 --- assets/docs/components/attachment.md | 14 +- assets/docs/components/avatar.md | 30 +- assets/docs/components/badge.md | 9 +- assets/docs/components/button.md | 14 +- assets/docs/components/frame.md | 51 +--- assets/docs/components/input-group.md | 22 +- assets/docs/components/input.md | 8 +- assets/docs/components/select.md | 15 +- assets/docs/components/table.md | 8 +- assets/docs/components/tabs.md | 23 +- components/ui/avatar.py | 30 +- components/ui/badge.py | 9 +- components/ui/button.py | 14 +- components/ui/frame.py | 51 +--- components/ui/input.py | 8 +- components/ui/select.py | 15 +- components/ui/tabs.py | 23 +- tests/test_unstyled.py | 381 ++++++++++++++++++++++++++ 18 files changed, 523 insertions(+), 202 deletions(-) create mode 100644 tests/test_unstyled.py diff --git a/assets/docs/components/attachment.md b/assets/docs/components/attachment.md index fb1911e6..276ff576 100644 --- a/assets/docs/components/attachment.md +++ b/assets/docs/components/attachment.md @@ -173,18 +173,20 @@ class Button(BaseButton, CoreComponent): def create(cls, *children, **props) -> BaseButton: variant = props.pop("variant", "default") size = props.pop("size", "default") - custom_classes = props.pop("class_name", "") data_slot = props.pop("data_slot", "button") - return super().create( - *children, - data_slot=data_slot, - class_name=cn( + cls.set_class_name( + cn( DEFAULT_CLASS_NAME, BUTTON_VARIANTS["variant"].get(variant, ""), BUTTON_VARIANTS["size"].get(size, ""), - custom_classes, ), + props, + ) + + return super().create( + *children, + data_slot=data_slot, **props, ) diff --git a/assets/docs/components/avatar.md b/assets/docs/components/avatar.md index eac989d3..6555878c 100644 --- a/assets/docs/components/avatar.md +++ b/assets/docs/components/avatar.md @@ -108,13 +108,11 @@ class ClassNames: class AvatarRoot(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") size = props.pop("size", "default") props["data-slot"] = "avatar" props["data-size"] = size - return super().create( - *children, class_name=cn(ClassNames.ROOT, custom_classes), **props - ) + cls.set_class_name(ClassNames.ROOT, props) + return super().create(*children, **props) class AvatarImage(CoreComponent): @@ -156,42 +154,34 @@ class AvatarImage(CoreComponent): class AvatarFallback(Span, CoreComponent): @classmethod def create(cls, *children, **props) -> Span: - custom_classes = props.pop("class_name", "") props["data-slot"] = "avatar-fallback" props["style"] = {"display": "none", **props.get("style", {})} - return super().create( - *children, class_name=cn(ClassNames.FALLBACK, custom_classes), **props - ) + cls.set_class_name(ClassNames.FALLBACK, props) + return super().create(*children, **props) class AvatarBadge(Span, CoreComponent): @classmethod def create(cls, *children, **props) -> Span: - custom_classes = props.pop("class_name", "") props["data-slot"] = "avatar-badge" - return super().create( - *children, class_name=cn(ClassNames.BADGE, custom_classes), **props - ) + cls.set_class_name(ClassNames.BADGE, props) + return super().create(*children, **props) class AvatarGroup(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") props["data-slot"] = "avatar-group" - return super().create( - *children, class_name=cn(ClassNames.GROUP, custom_classes), **props - ) + cls.set_class_name(ClassNames.GROUP, props) + return super().create(*children, **props) class AvatarGroupCount(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") props["data-slot"] = "avatar-group-count" - return super().create( - *children, class_name=cn(ClassNames.GROUP_COUNT, custom_classes), **props - ) + cls.set_class_name(ClassNames.GROUP_COUNT, props) + return super().create(*children, **props) class Avatar(ComponentNamespace): diff --git a/assets/docs/components/badge.md b/assets/docs/components/badge.md index 4a6fd3b0..479ebc5f 100644 --- a/assets/docs/components/badge.md +++ b/assets/docs/components/badge.md @@ -108,16 +108,13 @@ def badge_variants(variant: str = "default") -> Var: class BadgeComponent(Span, CoreComponent): @classmethod def create(cls, *children, **props) -> Span: - custom_classes = props.pop("class_name", "") variant = props.pop("variant", "default") props["data-slot"] = "badge" props["data-variant"] = variant - return super().create( - *children, - class_name=cn(badge_variants(variant), custom_classes), - **props, - ) + cls.set_class_name(badge_variants(variant), props) + + return super().create(*children, **props) class Badge(CoreComponent): diff --git a/assets/docs/components/button.md b/assets/docs/components/button.md index 293d4c34..d08b52a7 100644 --- a/assets/docs/components/button.md +++ b/assets/docs/components/button.md @@ -173,18 +173,20 @@ class Button(BaseButton, CoreComponent): def create(cls, *children, **props) -> BaseButton: variant = props.pop("variant", "default") size = props.pop("size", "default") - custom_classes = props.pop("class_name", "") data_slot = props.pop("data_slot", "button") - return super().create( - *children, - data_slot=data_slot, - class_name=cn( + cls.set_class_name( + cn( DEFAULT_CLASS_NAME, BUTTON_VARIANTS["variant"].get(variant, ""), BUTTON_VARIANTS["size"].get(size, ""), - custom_classes, ), + props, + ) + + return super().create( + *children, + data_slot=data_slot, **props, ) diff --git a/assets/docs/components/frame.md b/assets/docs/components/frame.md index d69f870e..e5264eb2 100644 --- a/assets/docs/components/frame.md +++ b/assets/docs/components/frame.md @@ -108,7 +108,6 @@ class FrameRoot(CoreComponent): spacing: LiteralSpacing = props.pop("spacing", "default") stacked: bool = props.pop("stacked", False) dense: bool = props.pop("dense", False) - class_name: str = props.pop("class_name", "") stacked_class = ( "gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none *:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none *:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0" @@ -125,78 +124,58 @@ class FrameRoot(CoreComponent): props["data-slot"] = "frame" props["data-spacing"] = spacing - return rx.el.div( - *children, - class_name=cn( + cls.set_class_name( + cn( ClassNames.ROOT, VARIANT_CLASSES.get(variant, ""), SPACING_CLASSES.get(spacing, ""), stacked_class, dense_class, - class_name, ), - **props, + props, ) + return rx.el.div(*children, **props) + class FramePanel(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel" - return rx.el.div( - *children, - class_name=cn(ClassNames.PANEL, class_name), - **props, - ) + cls.set_class_name(ClassNames.PANEL, props) + return rx.el.div(*children, **props) class FrameHeader(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel-header" - return rx.el.header( - *children, - class_name=cn(ClassNames.HEADER, class_name), - **props, - ) + cls.set_class_name(ClassNames.HEADER, props) + return rx.el.header(*children, **props) class FrameTitle(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel-title" - return rx.el.div( - *children, - class_name=cn(ClassNames.TITLE, class_name), - **props, - ) + cls.set_class_name(ClassNames.TITLE, props) + return rx.el.div(*children, **props) class FrameDescription(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel-description" - return rx.el.div( - *children, - class_name=cn(ClassNames.DESCRIPTION, class_name), - **props, - ) + cls.set_class_name(ClassNames.DESCRIPTION, props) + return rx.el.div(*children, **props) class FrameFooter(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel-footer" - return rx.el.footer( - *children, - class_name=cn(ClassNames.FOOTER, class_name), - **props, - ) + cls.set_class_name(ClassNames.FOOTER, props) + return rx.el.footer(*children, **props) class Frame(ComponentNamespace): diff --git a/assets/docs/components/input-group.md b/assets/docs/components/input-group.md index c19daf4a..a987ab3e 100644 --- a/assets/docs/components/input-group.md +++ b/assets/docs/components/input-group.md @@ -173,18 +173,20 @@ class Button(BaseButton, CoreComponent): def create(cls, *children, **props) -> BaseButton: variant = props.pop("variant", "default") size = props.pop("size", "default") - custom_classes = props.pop("class_name", "") data_slot = props.pop("data_slot", "button") - return super().create( - *children, - data_slot=data_slot, - class_name=cn( + cls.set_class_name( + cn( DEFAULT_CLASS_NAME, BUTTON_VARIANTS["variant"].get(variant, ""), BUTTON_VARIANTS["size"].get(size, ""), - custom_classes, ), + props, + ) + + return super().create( + *children, + data_slot=data_slot, **props, ) @@ -207,7 +209,7 @@ button = Button.create from reflex.components.component import ComponentNamespace from reflex_components_core.el import Input as BaseInput -from ..core.core import CoreComponent, cn +from ..core.core import CoreComponent class ClassNames: @@ -230,15 +232,13 @@ class InputComponent(BaseInput, CoreComponent): @classmethod def create(cls, *children, **props) -> BaseInput: - existing_class = props.get("class_name", "") - - props["class_name"] = cn(ClassNames.INPUT, existing_class) - props.setdefault("data_slot", "input") if "type" not in props: props["type"] = "text" + cls.set_class_name(ClassNames.INPUT, props) + return super().create(*children, **props) diff --git a/assets/docs/components/input.md b/assets/docs/components/input.md index 048076e7..5bad1613 100644 --- a/assets/docs/components/input.md +++ b/assets/docs/components/input.md @@ -65,7 +65,7 @@ def cn(*classes: Var | str | tuple | list | None) -> Var: from reflex.components.component import ComponentNamespace from reflex_components_core.el import Input as BaseInput -from ..core.core import CoreComponent, cn +from ..core.core import CoreComponent class ClassNames: @@ -88,15 +88,13 @@ class InputComponent(BaseInput, CoreComponent): @classmethod def create(cls, *children, **props) -> BaseInput: - existing_class = props.get("class_name", "") - - props["class_name"] = cn(ClassNames.INPUT, existing_class) - props.setdefault("data_slot", "input") if "type" not in props: props["type"] = "text" + cls.set_class_name(ClassNames.INPUT, props) + return super().create(*children, **props) diff --git a/assets/docs/components/select.md b/assets/docs/components/select.md index 63d6959e..e03e15d6 100644 --- a/assets/docs/components/select.md +++ b/assets/docs/components/select.md @@ -173,14 +173,23 @@ class NativeSelectComponent(CoreComponent): size = props.pop("size", "default") wrapper_class_name = props.pop("wrapper_class_name", "") data_slot = props.pop("data_slot", "native-select") + unstyled = props.pop("unstyled", False) - select_class_name = cn(ClassNames.SELECT, props.get("class_name", "")) - props["class_name"] = select_class_name + select_user_class = props.get("class_name", "") + props["class_name"] = ( + select_user_class if unstyled else cn(ClassNames.SELECT, select_user_class) + ) props["data-slot"] = data_slot props["data-size"] = size select_el = rx.el.select(*children, **props) + wrapper_class = ( + wrapper_class_name + if unstyled + else cn(ClassNames.WRAPPER, wrapper_class_name) + ) + return rx.el.div( select_el, hi( @@ -191,7 +200,7 @@ class NativeSelectComponent(CoreComponent): ), data_slot="native-select-wrapper", data_size=size, - class_name=cn(ClassNames.WRAPPER, wrapper_class_name), + class_name=wrapper_class, ) diff --git a/assets/docs/components/table.md b/assets/docs/components/table.md index be241025..0f74a4c6 100644 --- a/assets/docs/components/table.md +++ b/assets/docs/components/table.md @@ -65,7 +65,7 @@ def cn(*classes: Var | str | tuple | list | None) -> Var: from reflex.components.component import ComponentNamespace from reflex_components_core.el import Input as BaseInput -from ..core.core import CoreComponent, cn +from ..core.core import CoreComponent class ClassNames: @@ -88,15 +88,13 @@ class InputComponent(BaseInput, CoreComponent): @classmethod def create(cls, *children, **props) -> BaseInput: - existing_class = props.get("class_name", "") - - props["class_name"] = cn(ClassNames.INPUT, existing_class) - props.setdefault("data_slot", "input") if "type" not in props: props["type"] = "text" + cls.set_class_name(ClassNames.INPUT, props) + return super().create(*children, **props) diff --git a/assets/docs/components/tabs.md b/assets/docs/components/tabs.md index e60f9828..8a2376e9 100644 --- a/assets/docs/components/tabs.md +++ b/assets/docs/components/tabs.md @@ -100,7 +100,6 @@ class ClassNames: class TabsRoot(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") orientation = props.pop("orientation", "horizontal") default_value = props.pop("default_value", "") @@ -108,23 +107,22 @@ class TabsRoot(Div, CoreComponent): props["data-orientation"] = orientation props["data-value"] = default_value - return super().create( - *children, class_name=cn(ClassNames.ROOT, custom_classes), **props - ) + cls.set_class_name(ClassNames.ROOT, props) + + return super().create(*children, **props) class TabsList(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") variant = props.pop("variant", "default") props["data-slot"] = "tabs-list" props["data-variant"] = variant - return super().create( - *children, class_name=cn(ClassNames.LIST, custom_classes), **props - ) + cls.set_class_name(ClassNames.LIST, props) + + return super().create(*children, **props) class TabsTrigger(CoreComponent): @@ -192,7 +190,6 @@ class TabsTrigger(CoreComponent): class TabsContent(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") value = props.pop("value", "") props["data-slot"] = "tabs-content" @@ -200,11 +197,9 @@ class TabsContent(Div, CoreComponent): active_toggle_class = f"group-data-[value='{value}']/tabs:block" - return super().create( - *children, - class_name=cn(ClassNames.CONTENT, active_toggle_class, custom_classes), - **props, - ) + cls.set_class_name(cn(ClassNames.CONTENT, active_toggle_class), props) + + return super().create(*children, **props) class Tabs(ComponentNamespace): diff --git a/components/ui/avatar.py b/components/ui/avatar.py index c8cb82c5..6efa2014 100644 --- a/components/ui/avatar.py +++ b/components/ui/avatar.py @@ -44,13 +44,11 @@ class ClassNames: class AvatarRoot(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") size = props.pop("size", "default") props["data-slot"] = "avatar" props["data-size"] = size - return super().create( - *children, class_name=cn(ClassNames.ROOT, custom_classes), **props - ) + cls.set_class_name(ClassNames.ROOT, props) + return super().create(*children, **props) class AvatarImage(CoreComponent): @@ -92,42 +90,34 @@ def create(cls, **props) -> rx.Component: class AvatarFallback(Span, CoreComponent): @classmethod def create(cls, *children, **props) -> Span: - custom_classes = props.pop("class_name", "") props["data-slot"] = "avatar-fallback" props["style"] = {"display": "none", **props.get("style", {})} - return super().create( - *children, class_name=cn(ClassNames.FALLBACK, custom_classes), **props - ) + cls.set_class_name(ClassNames.FALLBACK, props) + return super().create(*children, **props) class AvatarBadge(Span, CoreComponent): @classmethod def create(cls, *children, **props) -> Span: - custom_classes = props.pop("class_name", "") props["data-slot"] = "avatar-badge" - return super().create( - *children, class_name=cn(ClassNames.BADGE, custom_classes), **props - ) + cls.set_class_name(ClassNames.BADGE, props) + return super().create(*children, **props) class AvatarGroup(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") props["data-slot"] = "avatar-group" - return super().create( - *children, class_name=cn(ClassNames.GROUP, custom_classes), **props - ) + cls.set_class_name(ClassNames.GROUP, props) + return super().create(*children, **props) class AvatarGroupCount(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") props["data-slot"] = "avatar-group-count" - return super().create( - *children, class_name=cn(ClassNames.GROUP_COUNT, custom_classes), **props - ) + cls.set_class_name(ClassNames.GROUP_COUNT, props) + return super().create(*children, **props) class Avatar(ComponentNamespace): diff --git a/components/ui/badge.py b/components/ui/badge.py index 5b016164..815c1551 100644 --- a/components/ui/badge.py +++ b/components/ui/badge.py @@ -44,16 +44,13 @@ def badge_variants(variant: str = "default") -> Var: class BadgeComponent(Span, CoreComponent): @classmethod def create(cls, *children, **props) -> Span: - custom_classes = props.pop("class_name", "") variant = props.pop("variant", "default") props["data-slot"] = "badge" props["data-variant"] = variant - return super().create( - *children, - class_name=cn(badge_variants(variant), custom_classes), - **props, - ) + cls.set_class_name(badge_variants(variant), props) + + return super().create(*children, **props) class Badge(CoreComponent): diff --git a/components/ui/button.py b/components/ui/button.py index 9ee036a6..fccf96f0 100644 --- a/components/ui/button.py +++ b/components/ui/button.py @@ -109,18 +109,20 @@ class Button(BaseButton, CoreComponent): def create(cls, *children, **props) -> BaseButton: variant = props.pop("variant", "default") size = props.pop("size", "default") - custom_classes = props.pop("class_name", "") data_slot = props.pop("data_slot", "button") - return super().create( - *children, - data_slot=data_slot, - class_name=cn( + cls.set_class_name( + cn( DEFAULT_CLASS_NAME, BUTTON_VARIANTS["variant"].get(variant, ""), BUTTON_VARIANTS["size"].get(size, ""), - custom_classes, ), + props, + ) + + return super().create( + *children, + data_slot=data_slot, **props, ) diff --git a/components/ui/frame.py b/components/ui/frame.py index fb81c07d..bb566534 100644 --- a/components/ui/frame.py +++ b/components/ui/frame.py @@ -44,7 +44,6 @@ def create(cls, *children, **props) -> rx.Component: spacing: LiteralSpacing = props.pop("spacing", "default") stacked: bool = props.pop("stacked", False) dense: bool = props.pop("dense", False) - class_name: str = props.pop("class_name", "") stacked_class = ( "gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none *:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none *:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0" @@ -61,78 +60,58 @@ def create(cls, *children, **props) -> rx.Component: props["data-slot"] = "frame" props["data-spacing"] = spacing - return rx.el.div( - *children, - class_name=cn( + cls.set_class_name( + cn( ClassNames.ROOT, VARIANT_CLASSES.get(variant, ""), SPACING_CLASSES.get(spacing, ""), stacked_class, dense_class, - class_name, ), - **props, + props, ) + return rx.el.div(*children, **props) + class FramePanel(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel" - return rx.el.div( - *children, - class_name=cn(ClassNames.PANEL, class_name), - **props, - ) + cls.set_class_name(ClassNames.PANEL, props) + return rx.el.div(*children, **props) class FrameHeader(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel-header" - return rx.el.header( - *children, - class_name=cn(ClassNames.HEADER, class_name), - **props, - ) + cls.set_class_name(ClassNames.HEADER, props) + return rx.el.header(*children, **props) class FrameTitle(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel-title" - return rx.el.div( - *children, - class_name=cn(ClassNames.TITLE, class_name), - **props, - ) + cls.set_class_name(ClassNames.TITLE, props) + return rx.el.div(*children, **props) class FrameDescription(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel-description" - return rx.el.div( - *children, - class_name=cn(ClassNames.DESCRIPTION, class_name), - **props, - ) + cls.set_class_name(ClassNames.DESCRIPTION, props) + return rx.el.div(*children, **props) class FrameFooter(CoreComponent): @classmethod def create(cls, *children, **props) -> rx.Component: - class_name: str = props.pop("class_name", "") props["data-slot"] = "frame-panel-footer" - return rx.el.footer( - *children, - class_name=cn(ClassNames.FOOTER, class_name), - **props, - ) + cls.set_class_name(ClassNames.FOOTER, props) + return rx.el.footer(*children, **props) class Frame(ComponentNamespace): diff --git a/components/ui/input.py b/components/ui/input.py index c7c98351..d43a8843 100644 --- a/components/ui/input.py +++ b/components/ui/input.py @@ -1,7 +1,7 @@ from reflex.components.component import ComponentNamespace from reflex_components_core.el import Input as BaseInput -from ..core.core import CoreComponent, cn +from ..core.core import CoreComponent class ClassNames: @@ -24,15 +24,13 @@ class InputComponent(BaseInput, CoreComponent): @classmethod def create(cls, *children, **props) -> BaseInput: - existing_class = props.get("class_name", "") - - props["class_name"] = cn(ClassNames.INPUT, existing_class) - props.setdefault("data_slot", "input") if "type" not in props: props["type"] = "text" + cls.set_class_name(ClassNames.INPUT, props) + return super().create(*children, **props) diff --git a/components/ui/select.py b/components/ui/select.py index ce04b7b2..94e75f2e 100644 --- a/components/ui/select.py +++ b/components/ui/select.py @@ -42,14 +42,23 @@ def create(cls, *children, **props) -> rx.Component: size = props.pop("size", "default") wrapper_class_name = props.pop("wrapper_class_name", "") data_slot = props.pop("data_slot", "native-select") + unstyled = props.pop("unstyled", False) - select_class_name = cn(ClassNames.SELECT, props.get("class_name", "")) - props["class_name"] = select_class_name + select_user_class = props.get("class_name", "") + props["class_name"] = ( + select_user_class if unstyled else cn(ClassNames.SELECT, select_user_class) + ) props["data-slot"] = data_slot props["data-size"] = size select_el = rx.el.select(*children, **props) + wrapper_class = ( + wrapper_class_name + if unstyled + else cn(ClassNames.WRAPPER, wrapper_class_name) + ) + return rx.el.div( select_el, hi( @@ -60,7 +69,7 @@ def create(cls, *children, **props) -> rx.Component: ), data_slot="native-select-wrapper", data_size=size, - class_name=cn(ClassNames.WRAPPER, wrapper_class_name), + class_name=wrapper_class, ) diff --git a/components/ui/tabs.py b/components/ui/tabs.py index 8fc8d490..e3fab4f8 100644 --- a/components/ui/tabs.py +++ b/components/ui/tabs.py @@ -36,7 +36,6 @@ class ClassNames: class TabsRoot(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") orientation = props.pop("orientation", "horizontal") default_value = props.pop("default_value", "") @@ -44,23 +43,22 @@ def create(cls, *children, **props) -> Div: props["data-orientation"] = orientation props["data-value"] = default_value - return super().create( - *children, class_name=cn(ClassNames.ROOT, custom_classes), **props - ) + cls.set_class_name(ClassNames.ROOT, props) + + return super().create(*children, **props) class TabsList(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") variant = props.pop("variant", "default") props["data-slot"] = "tabs-list" props["data-variant"] = variant - return super().create( - *children, class_name=cn(ClassNames.LIST, custom_classes), **props - ) + cls.set_class_name(ClassNames.LIST, props) + + return super().create(*children, **props) class TabsTrigger(CoreComponent): @@ -128,7 +126,6 @@ def create(cls, *children, **props) -> rx.Component: class TabsContent(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: - custom_classes = props.pop("class_name", "") value = props.pop("value", "") props["data-slot"] = "tabs-content" @@ -136,11 +133,9 @@ def create(cls, *children, **props) -> Div: active_toggle_class = f"group-data-[value='{value}']/tabs:block" - return super().create( - *children, - class_name=cn(ClassNames.CONTENT, active_toggle_class, custom_classes), - **props, - ) + cls.set_class_name(cn(ClassNames.CONTENT, active_toggle_class), props) + + return super().create(*children, **props) class Tabs(ComponentNamespace): diff --git a/tests/test_unstyled.py b/tests/test_unstyled.py new file mode 100644 index 00000000..3211bcdb --- /dev/null +++ b/tests/test_unstyled.py @@ -0,0 +1,381 @@ +"""Regression tests for the ``unstyled`` prop across CoreComponent subclasses. + +Background: ``CoreComponent`` in ``components/core/core.py`` declares +``unstyled: Var[bool]``. When ``True``, the component should strip its own +default Tailwind classes and render only user-supplied ``class_name``. The +mechanism lives in ``CoreComponent.set_class_name``: any ``create()`` that +routes its ``class_name`` through ``set_class_name`` gets the behavior for +free; ``create()`` methods that build ``class_name`` directly with ``cn(...)`` +and pass ``**props`` through to ``super().create(...)`` previously bypassed +``set_class_name`` and silently ignored ``unstyled``. + +This file guards against that regression by asserting, for every +``CoreComponent`` whose ``create()`` previously bypassed ``set_class_name``: + + 1. By default (``unstyled`` not set) the component's default classes are + applied alongside any user-supplied ``class_name``. + 2. With ``unstyled=True`` and user ``class_name`` set, the rendered + ``class_name`` contains ONLY the user-supplied classes; no component + default class survives. + 3. With ``unstyled=True`` and no user ``class_name``, the component + renders with an empty/blank ``class_name`` (no defaults leak). + 4. With ``unstyled=True`` no ``unstyled`` attribute leaks into the + component's rendered custom_attrs (it remains excluded from the DOM). + +It also re-runs the same checks against ``NativeSelectOptionComponent`` as a +control case: that component already routed through ``set_class_name`` before +this fix, so its behavior must not regress. + +For ``NativeSelectComponent``, which renders both a ```` (using the user's +``class_name``) and the outer wrapper (using ``wrapper_class_name``). +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +import pytest + +from components.ui.avatar import ( + AvatarBadge, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarRoot, +) +from components.ui.badge import BadgeComponent +from components.ui.button import Button +from components.ui.frame import ( + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameRoot, + FrameTitle, +) +from components.ui.input import InputComponent +from components.ui.select import ( + NativeSelectComponent, + NativeSelectOptionComponent, +) +from components.ui.tabs import TabsContent, TabsList, TabsRoot + +if TYPE_CHECKING: + import reflex as rx + from reflex_components_core.el import Div, Span + +USER_CLASS = "u-test-class" + + +def _class_name_str(component: object) -> str: + cls = getattr(component, "class_name", None) + return "" if cls is None else str(cls) + + +def _assert_only_user_classes(component: object, user_class: str = USER_CLASS) -> None: + rendered = _class_name_str(component) + assert user_class in rendered, ( + f"Expected user class {user_class!r} in stripped class_name, got {rendered!r}" + ) + assert "cn(" not in rendered, ( + f"With unstyled=True no default classes should be merged via cn(...); " + f"got {rendered!r}" + ) + + +def _assert_defaults_and_user_classes( + component: object, + default_marker: str, + user_class: str = USER_CLASS, +) -> None: + rendered = _class_name_str(component) + assert default_marker in rendered, ( + f"Default marker {default_marker!r} must be present in default class_name; " + f"got {rendered!r}" + ) + assert user_class in rendered, ( + f"User class {user_class!r} must be present in default class_name; " + f"got {rendered!r}" + ) + assert "cn(" in rendered, ( + f"Default class_name should be a cn(...) merge of default + user; " + f"got {rendered!r}" + ) + + +def _assert_unstyled_not_rendered(component: object) -> None: + custom_attrs = getattr(component, "custom_attrs", None) or {} + assert "unstyled" not in custom_attrs, ( + f"`unstyled` must not leak into rendered custom_attrs; got {custom_attrs!r}" + ) + + +ComponentFactory = Callable[..., Any] + + +def _button(**kw: Any) -> rx.Component: + return Button.create("Click", **kw) + + +def _tabs_content(**kw: Any) -> Div: + return TabsContent.create("Body", value="tab", **kw) + + +def _avatar_fallback(**kw: Any) -> Span: + return AvatarFallback.create("F", **kw) + + +def _avatar_badge(**kw: Any) -> Span: + return AvatarBadge.create("+", **kw) + + +def _avatar_group_count(**kw: Any) -> Div: + return AvatarGroupCount.create("+1", **kw) + + +def _badge(**kw: Any) -> Span: + return BadgeComponent.create("B", **kw) + + +def _frame_panel(**kw: Any) -> rx.Component: + return FramePanel.create("P", **kw) + + +def _frame_header(**kw: Any) -> rx.Component: + return FrameHeader.create("H", **kw) + + +def _frame_title(**kw: Any) -> rx.Component: + return FrameTitle.create("T", **kw) + + +def _frame_description(**kw: Any) -> rx.Component: + return FrameDescription.create("D", **kw) + + +def _frame_footer(**kw: Any) -> rx.Component: + return FrameFooter.create("F", **kw) + + +def _select_option(**kw: Any) -> rx.Component: + return NativeSelectOptionComponent.create("Option", **kw) + + +SIMPLE_COMPONENTS: list[tuple[str, str, ComponentFactory]] = [ + ("Button", "bg-primary", _button), + ("TabsRoot", "group/tabs", TabsRoot.create), + ("TabsList", "group/tabs-list", TabsList.create), + ("TabsContent", "flex-1 text-sm outline-none hidden", _tabs_content), + ("AvatarRoot", "group/avatar", AvatarRoot.create), + ("AvatarFallback", "bg-muted", _avatar_fallback), + ("AvatarBadge", "bg-primary", _avatar_badge), + ("AvatarGroup", "group/avatar-group", AvatarGroup.create), + ("AvatarGroupCount", "bg-muted", _avatar_group_count), + ("BadgeComponent", "border-transparent", _badge), + ("FrameRoot", "rounded-xl", FrameRoot.create), + ("FramePanel", "rounded-xl", _frame_panel), + ("FrameHeader", "flex flex-col", _frame_header), + ("FrameTitle", "text-sm font-semibold", _frame_title), + ("FrameDescription", "text-muted-foreground", _frame_description), + ("FrameFooter", "flex flex-col gap-1", _frame_footer), + ("InputComponent", "rounded-lg", InputComponent.create), + ("NativeSelectOptionComponent", "bg-[Canvas] text-[CanvasText]", _select_option), +] + + +@pytest.mark.parametrize( # pyright: ignore[reportUntypedFunctionDecorator] + ("name", "default_marker", "factory"), + SIMPLE_COMPONENTS, + ids=[row[0] for row in SIMPLE_COMPONENTS], +) +def test_default_class_name_merges_with_user_class( + name: str, + default_marker: str, + factory: ComponentFactory, +) -> None: + del name + comp = factory(class_name=USER_CLASS) + _assert_defaults_and_user_classes(comp, default_marker) + + +@pytest.mark.parametrize( # pyright: ignore[reportUntypedFunctionDecorator] + ("name", "default_marker", "factory"), + SIMPLE_COMPONENTS, + ids=[row[0] for row in SIMPLE_COMPONENTS], +) +def test_unstyled_false_matches_default_behavior( + name: str, + default_marker: str, + factory: ComponentFactory, +) -> None: + del name + default_comp = factory(class_name=USER_CLASS) + explicit_false_comp = factory(class_name=USER_CLASS, unstyled=False) + assert _class_name_str(default_comp) == _class_name_str(explicit_false_comp), ( + "Passing unstyled=False must reproduce the default (unstyled not passed) " + "class_name so that unstyled=False is not a silent no-op." + ) + assert default_marker in _class_name_str(explicit_false_comp), ( + f"Default marker {default_marker!r} must remain with explicit unstyled=False" + ) + + +@pytest.mark.parametrize( # pyright: ignore[reportUntypedFunctionDecorator] + ("name", "default_marker", "factory"), + SIMPLE_COMPONENTS, + ids=[row[0] for row in SIMPLE_COMPONENTS], +) +def test_unstyled_strips_defaults_keeps_user_classes( + name: str, + default_marker: str, + factory: ComponentFactory, +) -> None: + del name, default_marker + comp = factory(class_name=USER_CLASS, unstyled=True) + _assert_only_user_classes(comp) + + +@pytest.mark.parametrize( # pyright: ignore[reportUntypedFunctionDecorator] + ("name", "default_marker", "factory"), + SIMPLE_COMPONENTS, + ids=[row[0] for row in SIMPLE_COMPONENTS], +) +def test_unstyled_without_user_class_is_blank( + name: str, + default_marker: str, + factory: ComponentFactory, +) -> None: + del name, default_marker + comp = factory(unstyled=True) + rendered = _class_name_str(comp) + assert rendered.strip() == "", ( + f"With unstyled=True and no user class_name, class must be blank; " + f"got {rendered!r}" + ) + + +@pytest.mark.parametrize( # pyright: ignore[reportUntypedFunctionDecorator] + ("name", "default_marker", "factory"), + SIMPLE_COMPONENTS, + ids=[row[0] for row in SIMPLE_COMPONENTS], +) +def test_unstyled_is_not_rendered_into_attrs( + name: str, + default_marker: str, + factory: ComponentFactory, +) -> None: + del name, default_marker + comp = factory(unstyled=True) + _assert_unstyled_not_rendered(comp) + + +def test_native_select_honors_unstyled_on_select_and_wrapper() -> None: + user_select = "u-select" + user_wrapper = "u-wrapper" + + default_sel = NativeSelectComponent.create( + class_name=user_select, + wrapper_class_name=user_wrapper, + ) + unstyled_sel = NativeSelectComponent.create( + class_name=user_select, + wrapper_class_name=user_wrapper, + unstyled=True, + ) + + inner_select_default = default_sel.children[0] + inner_select_unstyled = unstyled_sel.children[0] + + _assert_defaults_and_user_classes( + inner_select_default, + default_marker="appearance-none rounded-lg", + user_class=user_select, + ) + rendered_inner_default = _class_name_str(inner_select_default) + assert user_wrapper not in rendered_inner_default, ( + "wrapper_class_name must not bleed into the inner class_name must match between default and unstyled=False" + assert _class_name_str(default_comp) == _class_name_str( + explicit_false_comp, + ), "Wrapper class_name must match between default and unstyled=False"