diff --git a/assets/docs/components/tabs.md b/assets/docs/components/tabs.md index e60f9828..e1c37370 100644 --- a/assets/docs/components/tabs.md +++ b/assets/docs/components/tabs.md @@ -62,8 +62,6 @@ def cn(*classes: Var | str | tuple | list | None) -> Var: ``` ```python -import uuid - import reflex as rx from reflex.components.component import ComponentNamespace from reflex_components_core.el import Div @@ -97,6 +95,41 @@ class ClassNames: CONTENT = "flex-1 text-sm outline-none hidden" +GLOBAL_HANDLERS_JS = """ +(function () { + if (!window.__tabsGlobalHandlersAttached) { + window.__tabsGlobalHandlersAttached = true; + document.addEventListener('click', function (e) { + var target = e.target; + var trigger = target && target.closest + ? target.closest('[data-slot="tabs-trigger"]') + : null; + if (!trigger) return; + var root = trigger.closest('[data-slot="tabs"]'); + if (!root) return; + var value = trigger.getAttribute('data-value'); + root.dataset.value = value; + root.querySelectorAll('[data-slot="tabs-trigger"]').forEach(function (t) { + if (t.closest('[data-slot="tabs"]') !== root) return; + t.setAttribute( + 'data-active', + t.getAttribute('data-value') === value ? 'true' : 'false' + ); + }); + }); + } + document.querySelectorAll('[data-slot="tabs-trigger"]').forEach(function (t) { + var root = t.closest('[data-slot="tabs"]'); + if (!root) return; + t.setAttribute( + 'data-active', + t.getAttribute('data-value') === root.dataset.value ? 'true' : 'false' + ); + }); +})(); +""" + + class TabsRoot(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: @@ -108,6 +141,15 @@ class TabsRoot(Div, CoreComponent): props["data-orientation"] = orientation props["data-value"] = default_value + user_on_mount = props.pop("on_mount", None) + mount_handlers = [rx.call_script(GLOBAL_HANDLERS_JS)] + if user_on_mount is not None: + if isinstance(user_on_mount, list): + mount_handlers.extend(user_on_mount) + else: + mount_handlers.append(user_on_mount) + props["on_mount"] = mount_handlers + return super().create( *children, class_name=cn(ClassNames.ROOT, custom_classes), **props ) @@ -137,54 +179,6 @@ class TabsTrigger(CoreComponent): props["data-value"] = value props["type"] = "button" - trigger_id = props.get("id") or f"tabs-trigger-{uuid.uuid4().hex[:8]}" - props["id"] = trigger_id - - user_on_click = props.pop("on_click", None) - user_on_mount = props.pop("on_mount", None) - - js_on_click = f""" - (function() {{ - var trigger = document.getElementById('{trigger_id}'); - if (!trigger) return; - var root = trigger.closest('[data-slot="tabs"]'); - if (!root) return; - - root.dataset.value = '{value}'; - root.querySelectorAll('[data-slot="tabs-trigger"]').forEach(function(t) {{ - t.setAttribute('data-active', t.getAttribute('data-value') === '{value}' ? 'true' : 'false'); - }}); - }})() - """ - - js_on_mount = f""" - (function() {{ - var trigger = document.getElementById('{trigger_id}'); - if (!trigger) return; - var root = trigger.closest('[data-slot="tabs"]'); - if (!root) return; - - var initialValue = root.dataset.value; - trigger.setAttribute('data-active', trigger.getAttribute('data-value') === initialValue ? 'true' : 'false'); - }})() - """ - - click_handlers = [rx.call_script(js_on_click)] - if user_on_click is not None: - if isinstance(user_on_click, list): - click_handlers.extend(user_on_click) - else: - click_handlers.append(user_on_click) - props["on_click"] = click_handlers - - mount_handlers = [rx.call_script(js_on_mount)] - if user_on_mount is not None: - if isinstance(user_on_mount, list): - mount_handlers.extend(user_on_mount) - else: - mount_handlers.append(user_on_mount) - props["on_mount"] = mount_handlers - cls.set_class_name(cn(ClassNames.TRIGGER, custom_classes), props) return rx.el.button(*children, **props) @@ -422,7 +416,7 @@ tabs.list( ## tabs.trigger -The clickable button elements used to switch active states. They safely merge internal client-side JS actions with optional user-supplied events. +The clickable button elements used to switch active states. Active-tab highlight is driven by a single delegated click listener that `tabs.root` attaches on mount, so user-supplied `on_click` / `on_mount` handlers are passed through untouched. ```python tabs.trigger( @@ -436,7 +430,7 @@ tabs.trigger( | ------------ | ----------------------------------------- | --------------------- | | `value` | `str` | _Required_ | | `class_name` | `str` | `""` | -| `id` | `str` | _Auto-generated UUID_ | +| `id` | `str` | _None_ | | `disabled` | `bool` | `False` | | `on_click` | `Union[EventHandler, list[EventHandler]]` | `None` | | `on_mount` | `Union[EventHandler, list[EventHandler]]` | `None` | diff --git a/components/ui/tabs.py b/components/ui/tabs.py index 8fc8d490..b63830f8 100644 --- a/components/ui/tabs.py +++ b/components/ui/tabs.py @@ -1,5 +1,3 @@ -import uuid - import reflex as rx from reflex.components.component import ComponentNamespace from reflex_components_core.el import Div @@ -33,6 +31,41 @@ class ClassNames: CONTENT = "flex-1 text-sm outline-none hidden" +GLOBAL_HANDLERS_JS = """ +(function () { + if (!window.__tabsGlobalHandlersAttached) { + window.__tabsGlobalHandlersAttached = true; + document.addEventListener('click', function (e) { + var target = e.target; + var trigger = target && target.closest + ? target.closest('[data-slot="tabs-trigger"]') + : null; + if (!trigger) return; + var root = trigger.closest('[data-slot="tabs"]'); + if (!root) return; + var value = trigger.getAttribute('data-value'); + root.dataset.value = value; + root.querySelectorAll('[data-slot="tabs-trigger"]').forEach(function (t) { + if (t.closest('[data-slot="tabs"]') !== root) return; + t.setAttribute( + 'data-active', + t.getAttribute('data-value') === value ? 'true' : 'false' + ); + }); + }); + } + document.querySelectorAll('[data-slot="tabs-trigger"]').forEach(function (t) { + var root = t.closest('[data-slot="tabs"]'); + if (!root) return; + t.setAttribute( + 'data-active', + t.getAttribute('data-value') === root.dataset.value ? 'true' : 'false' + ); + }); +})(); +""" + + class TabsRoot(Div, CoreComponent): @classmethod def create(cls, *children, **props) -> Div: @@ -44,6 +77,15 @@ def create(cls, *children, **props) -> Div: props["data-orientation"] = orientation props["data-value"] = default_value + user_on_mount = props.pop("on_mount", None) + mount_handlers = [rx.call_script(GLOBAL_HANDLERS_JS)] + if user_on_mount is not None: + if isinstance(user_on_mount, list): + mount_handlers.extend(user_on_mount) + else: + mount_handlers.append(user_on_mount) + props["on_mount"] = mount_handlers + return super().create( *children, class_name=cn(ClassNames.ROOT, custom_classes), **props ) @@ -73,54 +115,6 @@ def create(cls, *children, **props) -> rx.Component: props["data-value"] = value props["type"] = "button" - trigger_id = props.get("id") or f"tabs-trigger-{uuid.uuid4().hex[:8]}" - props["id"] = trigger_id - - user_on_click = props.pop("on_click", None) - user_on_mount = props.pop("on_mount", None) - - js_on_click = f""" - (function() {{ - var trigger = document.getElementById('{trigger_id}'); - if (!trigger) return; - var root = trigger.closest('[data-slot="tabs"]'); - if (!root) return; - - root.dataset.value = '{value}'; - root.querySelectorAll('[data-slot="tabs-trigger"]').forEach(function(t) {{ - t.setAttribute('data-active', t.getAttribute('data-value') === '{value}' ? 'true' : 'false'); - }}); - }})() - """ - - js_on_mount = f""" - (function() {{ - var trigger = document.getElementById('{trigger_id}'); - if (!trigger) return; - var root = trigger.closest('[data-slot="tabs"]'); - if (!root) return; - - var initialValue = root.dataset.value; - trigger.setAttribute('data-active', trigger.getAttribute('data-value') === initialValue ? 'true' : 'false'); - }})() - """ - - click_handlers = [rx.call_script(js_on_click)] - if user_on_click is not None: - if isinstance(user_on_click, list): - click_handlers.extend(user_on_click) - else: - click_handlers.append(user_on_click) - props["on_click"] = click_handlers - - mount_handlers = [rx.call_script(js_on_mount)] - if user_on_mount is not None: - if isinstance(user_on_mount, list): - mount_handlers.extend(user_on_mount) - else: - mount_handlers.append(user_on_mount) - props["on_mount"] = mount_handlers - cls.set_class_name(cn(ClassNames.TRIGGER, custom_classes), props) return rx.el.button(*children, **props) diff --git a/docs/components/tabs.md b/docs/components/tabs.md index 130f9135..d3bf6df9 100644 --- a/docs/components/tabs.md +++ b/docs/components/tabs.md @@ -83,7 +83,7 @@ tabs.list( ## tabs.trigger -The clickable button elements used to switch active states. They safely merge internal client-side JS actions with optional user-supplied events. +The clickable button elements used to switch active states. Active-tab highlight is driven by a single delegated click listener that `tabs.root` attaches on mount, so user-supplied `on_click` / `on_mount` handlers are passed through untouched. ```python tabs.trigger( @@ -97,7 +97,7 @@ tabs.trigger( | ------------ | ----------------------------------------- | --------------------- | | `value` | `str` | _Required_ | | `class_name` | `str` | `""` | -| `id` | `str` | _Auto-generated UUID_ | +| `id` | `str` | _None_ | | `disabled` | `bool` | `False` | | `on_click` | `Union[EventHandler, list[EventHandler]]` | `None` | | `on_mount` | `Union[EventHandler, list[EventHandler]]` | `None` | diff --git a/tests/test_tabs.py b/tests/test_tabs.py new file mode 100644 index 00000000..dbdaa65f --- /dev/null +++ b/tests/test_tabs.py @@ -0,0 +1,76 @@ +import reflex as rx + +GUARD_SUBSTRING = "closest('[data-slot=\"tabs\"]') !== root" + +from components.ui.tabs import GLOBAL_HANDLERS_JS, tabs + + +def _walk(comp): + yield comp + for child in getattr(comp, "children", []) or []: + if hasattr(child, "event_triggers"): + yield from _walk(child) + + +def _js(comp, trigger): + out = [] + for node in _walk(comp): + spec = (getattr(node, "event_triggers", {}) or {}).get(trigger) + if spec is None: + continue + stack = list(getattr(spec, "events", None) or [spec]) + while stack: + item = stack.pop() + if isinstance(item, (list, tuple)): + stack.extend(item) + continue + if isinstance(item, rx.event.EventSpec): + stack.extend(item.args) + continue + try: + value = item._var_value + except Exception: + continue + if isinstance(value, str): + out.append(value) + return out + + +def _root(**kw): + return tabs.root( + tabs.list(tabs.trigger("Home", value="home")), + tabs.content("p", value="home"), + default_value="home", + **kw, + ) + + +def test_trigger_bakes_no_id_or_highlight_js(): + t = tabs.trigger("Home", value="home") + assert getattr(t, "id", None) is None + assert _js(t, "on_click") == [] + assert _js(t, "on_mount") == [] + assert getattr(tabs.trigger("H", value="h", id="x"), "id", None) == "x" + + +def test_root_attaches_delegated_runtime_handler(): + js = [j for j in _js(_root(), "on_mount") if "addEventListener" in j] + assert len(js) == 1 + assert js[0] == GLOBAL_HANDLERS_JS + for bad in ("getElementById", "{value}", "{trigger_id}", "=== '", "dataset.value = '"): + assert bad not in GLOBAL_HANDLERS_JS + assert "getAttribute('data-value')" in GLOBAL_HANDLERS_JS + + +def test_user_handlers_preserved(): + blocks = _js(_root(on_mount=rx.call_script("mark_mount")), "on_mount") + assert any("mark_mount" in b for b in blocks) + assert any("addEventListener" in b for b in blocks) + t = tabs.trigger("H", value="h", on_click=rx.call_script("mark_click")) + assert any("mark_click" in b for b in _js(t, "on_click")) + + +def test_delegated_listener_scopes_to_clicked_tabs_group(): + from components.ui.tabs import GLOBAL_HANDLERS_JS + + assert GUARD_SUBSTRING in GLOBAL_HANDLERS_JS