diff --git a/10 b/10 new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.py index b8ec0369b73..b0d64aae38a 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.py @@ -4,15 +4,20 @@ from collections.abc import Sequence from types import SimpleNamespace -from typing import ClassVar, Literal +from typing import Any, ClassVar, Literal, cast -from reflex_base.components.component import field +from reflex_base.components.component import Component, field from reflex_base.event import EventHandler -from reflex_base.vars.base import Var +from reflex_base.vars.base import CustomVarOperationReturn, Var, var_operation +from reflex_base.vars.sequence import ArrayVar, LiteralArrayVar from reflex_components_core.core.breakpoints import Responsive +from reflex_components_core.core.foreach import Foreach from reflex_components_radix.themes.base import LiteralAccentColor, RadixThemesComponent +_COUNT_VAR = "--rx-sc-count" +_IDX_VAR = "--rx-sc-idx" + def on_value_change( value: Var[str | list[str]], @@ -28,6 +33,51 @@ def on_value_change( return (value,) +def _collect_item_values(children: Sequence[Any]) -> ArrayVar | None: + """Return the ordered item values, or None if the children shape isn't one we handle. + + Supports a single `rx.foreach` producing items, or a flat list of + `SegmentedControlItem`. Everything else falls back to Radix's default. + + Args: + children: Children passed to `SegmentedControlRoot.create`. + + Returns: + ArrayVar of item values, or None. + """ + if len(children) == 1 and isinstance(children[0], Foreach): + foreach = children[0] + iterable = cast("ArrayVar", foreach.iterable) + return iterable.foreach( + lambda element: ( + cast("SegmentedControlItem", foreach.render_fn(element)).value + ) + ) + + if not all(isinstance(c, SegmentedControlItem) for c in children): + return None + return LiteralArrayVar.create([c.value for c in children]) + + +@var_operation +def _array_index_of_operation( + haystack: ArrayVar, needle: Var +) -> CustomVarOperationReturn[int]: + """Build a JS `haystack.indexOf(needle)` expression. + + Args: + haystack: The array to search. + needle: The value to find. + + Returns: + Var yielding the 0-based index, or -1. + """ + return CustomVarOperationReturn.create( + js_expression=f"({haystack}.indexOf({needle}))", + _var_type=int, + ) + + class SegmentedControlRoot(RadixThemesComponent): """Root element for a SegmentedControl component.""" @@ -65,6 +115,58 @@ class SegmentedControlRoot(RadixThemesComponent): _rename_props = {"onChange": "onValueChange"} + @classmethod + def create(cls, *children: Any, **props: Any) -> Component: + """Create a SegmentedControlRoot. + + Radix Themes 3.3.0 hardcodes indicator width/translate CSS rules for up + to 10 items (see radix-ui/themes#730). When there are more items the + indicator collapses to zero width. Work around that by exposing the + selected item's index and the item count as CSS custom properties on + the root so the style override in `add_style` can position the + indicator using `calc()` regardless of item count. + + Args: + *children: The children of the component. + **props: The properties of the component. + + Returns: + The SegmentedControlRoot component. + """ + # type="multiple" is skipped: a single sliding indicator can't + # represent multiple selections, and Radix's nth-child rules still + # apply to it regardless of item count. + if props.get("type") != "multiple": + values_var = _collect_item_values(children) + selected = props.get("value", props.get("default_value")) + if values_var is not None and selected is not None: + selected_var = Var.create(selected) + style = dict(props.get("style") or {}) + style.setdefault(_COUNT_VAR, values_var.length()) + style.setdefault( + _IDX_VAR, _array_index_of_operation(values_var, selected_var) + ) + props["style"] = style + return super().create(*children, **props) + + def add_style(self) -> dict[str, Any] | None: + """Override Radix's hardcoded nth-child indicator rules. + + Returns: + Style targeting the indicator so its width and translation depend + on the custom properties set in `create`, or ``None`` when those + properties were not injected — in which case Radix's default + nth-child rules still apply (correct for ≤10 items). + """ + if _COUNT_VAR not in self.style: + return None + return { + "& .rt-SegmentedControlIndicator": { + "width": f"calc(100% / var({_COUNT_VAR}))", + "transform": f"translateX(calc(var({_IDX_VAR}) * 100%))", + }, + } + class SegmentedControlItem(RadixThemesComponent): """An item in the SegmentedControl component.""" diff --git a/pyi_hashes.json b/pyi_hashes.json index 5c8a1ad6665..81048b07505 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -79,7 +79,7 @@ "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_cards.pyi": "d84b16ac16083a534199fd23659aaa06", "packages/reflex-components-radix/src/reflex_components_radix/themes/components/radio_group.pyi": "51fda6313f1ce86d5b1ffdfd68ae8b74", "packages/reflex-components-radix/src/reflex_components_radix/themes/components/scroll_area.pyi": "bba40e5eae75314157378c9e8b0eea73", - "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "bf9f751a701137bfedc254657d4c5be4", + "packages/reflex-components-radix/src/reflex_components_radix/themes/components/segmented_control.pyi": "44770b1f5eb91502bfef3aadd209d0b8", "packages/reflex-components-radix/src/reflex_components_radix/themes/components/select.pyi": "605479e11d19dd7730c90125b198c9b6", "packages/reflex-components-radix/src/reflex_components_radix/themes/components/separator.pyi": "519781d33b99c675a12014d400e54d08", "packages/reflex-components-radix/src/reflex_components_radix/themes/components/skeleton.pyi": "f4848f7d89abb4c78f6db52c624cdabf", diff --git a/tests/integration/tests_playwright/test_appearance.py b/tests/integration/tests_playwright/test_appearance.py index 9090dac672d..a0252d6da46 100644 --- a/tests/integration/tests_playwright/test_appearance.py +++ b/tests/integration/tests_playwright/test_appearance.py @@ -173,6 +173,83 @@ def test_appearance_system_mode(system_mode_app: AppHarness, page: Page): expect(page.get_by_text("system")).to_be_visible() +def SegmentedControlManyItemsApp(): + import reflex as rx + + class SegmentedState(rx.State): + options: list[str] = [str(i) for i in range(1, 12)] + control: str = "1" + + @rx.event + def set_control(self, value: str | list[str]): + self.control = value if isinstance(value, str) else value[0] + + app = rx.App(theme=rx.theme(appearance="light")) + + @app.add_page + def index(): + return rx.box( + rx.segmented_control.root( + rx.foreach( + SegmentedState.options, + lambda label: rx.segmented_control.item(label, value=label), + ), + on_change=SegmentedState.set_control, + value=SegmentedState.control, + id="segmented_control", + ), + rx.text(SegmentedState.control, id="selected_value"), + ) + + +@pytest.fixture +def segmented_control_many_items_app( + tmp_path_factory, +) -> Generator[AppHarness, None, None]: + with AppHarness.create( + root=tmp_path_factory.mktemp("segmented_many"), + app_source=SegmentedControlManyItemsApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +def test_segmented_control_indicator_with_11_items( + segmented_control_many_items_app: AppHarness, page: Page +): + assert segmented_control_many_items_app.frontend_url is not None + page.goto(segmented_control_many_items_app.frontend_url) + + selected_value = page.locator("id=selected_value") + expect(selected_value).to_have_text("1") + + last_item = page.get_by_role("radio").nth(10) + last_item.click() + expect(selected_value).to_have_text("11") + + indicator = page.locator("#segmented_control .rt-SegmentedControlIndicator") + expect(indicator).to_be_visible() + + # Radix runs a CSS transform transition on selection change; await every + # in-flight animation so the bounding box reflects the final position. + indicator.evaluate("el => Promise.all(el.getAnimations().map(a => a.finished))") + + indicator_box = indicator.bounding_box() + last_item_box = last_item.bounding_box() + assert indicator_box is not None + assert last_item_box is not None + + assert indicator_box["width"] > 0, ( + f"indicator width is {indicator_box['width']}; indicator CSS failed to " + "apply for the 11th item (Radix Themes 3.3.0 only ships nth-child rules " + "for up to 10 items)" + ) + assert abs(indicator_box["x"] - last_item_box["x"]) < 2, ( + f"indicator x={indicator_box['x']} does not align with 11th item " + f"x={last_item_box['x']}" + ) + + def test_appearance_color_toggle(color_toggle_app: AppHarness, page: Page): assert color_toggle_app.frontend_url is not None page.goto(color_toggle_app.frontend_url)