Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added 10
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
Expand All @@ -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."""

Expand Down Expand Up @@ -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":
Comment thread
FarhanAliRaza marked this conversation as resolved.
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."""
Expand Down
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions tests/integration/tests_playwright/test_appearance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading