From 60c9db57bd5277ce78de3cb9509bc3dda99684c0 Mon Sep 17 00:00:00 2001 From: elvis kahoro Date: Sat, 24 Oct 2026 13:06:16 +0000 Subject: [PATCH 1/2] fix(tabs): scope trigger highlight rewrite to the clicked tabs group --- assets/docs/components/tabs.md | 1 + components/ui/tabs.py | 1 + tests/test_tabs.py | 222 +++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 tests/test_tabs.py diff --git a/assets/docs/components/tabs.md b/assets/docs/components/tabs.md index e60f9828..626a4048 100644 --- a/assets/docs/components/tabs.md +++ b/assets/docs/components/tabs.md @@ -152,6 +152,7 @@ class TabsTrigger(CoreComponent): 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'); }}); }})() diff --git a/components/ui/tabs.py b/components/ui/tabs.py index 8fc8d490..461ec45d 100644 --- a/components/ui/tabs.py +++ b/components/ui/tabs.py @@ -88,6 +88,7 @@ def create(cls, *children, **props) -> rx.Component: 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'); }}); }})() diff --git a/tests/test_tabs.py b/tests/test_tabs.py new file mode 100644 index 00000000..cdf8a3eb --- /dev/null +++ b/tests/test_tabs.py @@ -0,0 +1,222 @@ +from components.ui.tabs import tabs + +DEMO_MODULES = { + "basic": ("native.lib.components.tabs_basic", "tabs_basic"), + "disabled": ("native.lib.components.tabs_disabled", "tabs_disabled"), + "icons": ("native.lib.components.tabs_icons", "tabs_icons"), + "line": ("native.lib.components.tabs_line_variant", "tabs_line"), + "vertical": ("native.lib.components.tabs_vertical", "tabs_vertical"), +} + +GUARD_SUBSTRING = "closest('[data-slot=\"tabs\"]') !== root" + + +def _walk_components(comp): + yield comp + for child in getattr(comp, "children", []) or []: + if hasattr(child, "custom_attrs") or hasattr(child, "event_triggers"): + yield from _walk_components(child) + + +def _extract_on_click_js(comp): + out = [] + for node in _walk_components(comp): + et = getattr(node, "event_triggers", {}) or {} + spec = et.get("on_click") + if spec is None: + continue + events = getattr(spec, "events", None) or [spec] + for ev in events: + stack = list(getattr(ev, "args", None) or ()) + while stack: + item = stack.pop() + if isinstance(item, (tuple, list)): + stack.extend(item) + continue + value = getattr(item, "_var_value", None) + if isinstance(value, str) and "querySelectorAll" in value: + out.append(value) + return out + + +def _build_nested_tree(default_inner="profile"): + return tabs.root( + tabs.list( + tabs.trigger("Home", value="home", id="outer-home"), + tabs.trigger("Settings", value="settings", id="outer-settings"), + ), + tabs.content( + tabs.root( + tabs.list( + tabs.trigger("Profile", value="profile", id="inner-prof"), + tabs.trigger("Security", value="security", id="inner-sec"), + ), + tabs.content("Profile panel", value="profile"), + tabs.content("Security panel", value="security"), + default_value=default_inner, + ), + value="settings", + ), + default_value="settings", + ) + + +def _dom_tree(comp): + ca = getattr(comp, "custom_attrs", {}) or {} + return { + "slot": ca.get("data-slot"), + "value": ca.get("data-value"), + "id": getattr(comp, "id", None), + "active": None, + "children": [ + _dom_tree(child) + for child in (getattr(comp, "children", []) or []) + if hasattr(child, "custom_attrs") or hasattr(child, "event_triggers") + ], + } + + +def _index_parents(node, parent=None, mapping=None): + if mapping is None: + mapping = {} + mapping[id(node)] = parent + for child in node["children"]: + _index_parents(child, node, mapping) + return mapping + + +def _closest(node, slot, parents): + cur = node + while cur is not None: + if cur.get("slot") == slot: + return cur + cur = parents.get(id(cur)) + return None + + +def _descendants_with_slot(node, slot): + out = [] + for child in node["children"]: + if child.get("slot") == slot: + out.append(child) + out.extend(_descendants_with_slot(child, slot)) + return out + + +def _find_by_id(node, node_id): + if node.get("id") == node_id: + return node + for child in node["children"]: + found = _find_by_id(child, node_id) + if found: + return found + return None + + +def _all_triggers(node): + out = [] + for child in node["children"]: + if child.get("slot") == "tabs-trigger": + out.append(child) + out.extend(_all_triggers(child)) + return out + + +def _simulate_mount(root): + parents = _index_parents(root) + for trigger in _all_triggers(root): + group = _closest(trigger, "tabs", parents) + trigger["active"] = ( + "true" if group and group.get("value") == trigger.get("value") else "false" + ) + + +def _simulate_click(root, trigger_node, with_guard): + parents = _index_parents(root) + group = _closest(trigger_node, "tabs", parents) + if group is None: + return + clicked_value = trigger_node.get("value") + group["value"] = clicked_value + for target in _descendants_with_slot(group, "tabs-trigger"): + own_group = _closest(target, "tabs", parents) + if with_guard and own_group is not group: + continue + target["active"] = "true" if target.get("value") == clicked_value else "false" + + +def test_on_click_handler_contains_scope_guard(): + trigger = tabs.trigger("Home", value="home", id="t1") + handlers = _extract_on_click_js(trigger) + assert len(handlers) == 1 + assert GUARD_SUBSTRING in handlers[0] + + +def test_guard_preserves_nested_highlight_after_outer_click(): + nested = _build_nested_tree(default_inner="profile") + dom = _dom_tree(nested) + _simulate_mount(dom) + outer_home = _find_by_id(dom, "outer-home") + inner_prof = _find_by_id(dom, "inner-prof") + inner_sec = _find_by_id(dom, "inner-sec") + assert outer_home is not None + assert inner_prof is not None + assert inner_sec is not None + assert inner_prof["active"] == "true" + assert inner_sec["active"] == "false" + _simulate_click(dom, outer_home, with_guard=True) + assert outer_home["active"] == "true" + outer_settings = _find_by_id(dom, "outer-settings") + assert outer_settings is not None + assert outer_settings["active"] == "false" + assert inner_prof["active"] == "true", ( + "outer click must not clobber inner highlight" + ) + assert inner_sec["active"] == "false" + inner_root = _closest(inner_prof, "tabs", _index_parents(dom)) + assert inner_root is not None + assert inner_root["value"] == "profile", ( + "outer click must not change inner root value" + ) + + +def test_nested_inner_group_remains_independently_switchable_with_guard(): + nested = _build_nested_tree(default_inner="profile") + dom = _dom_tree(nested) + _simulate_mount(dom) + outer_settings = _find_by_id(dom, "outer-settings") + assert outer_settings is not None + _simulate_click(dom, outer_settings, with_guard=True) + inner_sec = _find_by_id(dom, "inner-sec") + inner_prof = _find_by_id(dom, "inner-prof") + assert inner_sec is not None + assert inner_prof is not None + assert inner_prof["active"] == "true" + _simulate_click(dom, inner_sec, with_guard=True) + assert inner_sec["active"] == "true" + assert inner_prof["active"] == "false" + outer_root = _closest(outer_settings, "tabs", _index_parents(dom)) + assert outer_root is not None + assert outer_root["value"] == "settings" + + +def test_flat_demo_guard_is_noop_for_outer_only_group(): + import importlib + + for label, (module_path, fn_name) in DEMO_MODULES.items(): + mod = importlib.import_module(module_path) + comp = getattr(mod, fn_name)() + dom = _dom_tree(comp) + _simulate_mount(dom) + triggers = _all_triggers(dom) + for trigger in triggers: + _simulate_click(dom, trigger, with_guard=True) + expected = { + t["id"]: ("true" if t["value"] == trigger["value"] else "false") + for t in triggers + } + snapshot_after = {t["id"]: t["active"] for t in triggers} + assert snapshot_after == expected, ( + f"{label}: click {trigger['id']} did not yield a single active trigger" + ) + assert trigger["active"] == "true" From 9abeba6497fd4b15ae64d3ed53fae3d8d2d2766b Mon Sep 17 00:00:00 2001 From: elvis kahoro Date: Sat, 24 Oct 2026 13:11:26 +0000 Subject: [PATCH 2/2] fix(tabs): stop baking trigger id/value; delegate highlight to root listener Triggers no longer generate a build-time id or interpolate the value into per-trigger JS (broken under rx.foreach). tabs.root attaches one delegated click listener that reads data-value from the DOM. Adds regression tests and regenerated docs. Co-Authored-By: Claude Sonnet 5.5 --- assets/docs/components/tabs.md | 99 ++++++------- components/ui/tabs.py | 95 ++++++------- docs/components/tabs.md | 4 +- tests/test_tabs.py | 248 +++++++-------------------------- 4 files changed, 143 insertions(+), 303 deletions(-) diff --git a/assets/docs/components/tabs.md b/assets/docs/components/tabs.md index 626a4048..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,55 +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) {{ - if (t.closest('[data-slot="tabs"]') !== root) return; - 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) @@ -423,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( @@ -437,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 461ec45d..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,55 +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) {{ - if (t.closest('[data-slot="tabs"]') !== root) return; - 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 index cdf8a3eb..dbdaa65f 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -1,222 +1,76 @@ -from components.ui.tabs import tabs - -DEMO_MODULES = { - "basic": ("native.lib.components.tabs_basic", "tabs_basic"), - "disabled": ("native.lib.components.tabs_disabled", "tabs_disabled"), - "icons": ("native.lib.components.tabs_icons", "tabs_icons"), - "line": ("native.lib.components.tabs_line_variant", "tabs_line"), - "vertical": ("native.lib.components.tabs_vertical", "tabs_vertical"), -} +import reflex as rx GUARD_SUBSTRING = "closest('[data-slot=\"tabs\"]') !== root" +from components.ui.tabs import GLOBAL_HANDLERS_JS, tabs + -def _walk_components(comp): +def _walk(comp): yield comp for child in getattr(comp, "children", []) or []: - if hasattr(child, "custom_attrs") or hasattr(child, "event_triggers"): - yield from _walk_components(child) + if hasattr(child, "event_triggers"): + yield from _walk(child) -def _extract_on_click_js(comp): +def _js(comp, trigger): out = [] - for node in _walk_components(comp): - et = getattr(node, "event_triggers", {}) or {} - spec = et.get("on_click") + for node in _walk(comp): + spec = (getattr(node, "event_triggers", {}) or {}).get(trigger) if spec is None: continue - events = getattr(spec, "events", None) or [spec] - for ev in events: - stack = list(getattr(ev, "args", None) or ()) - while stack: - item = stack.pop() - if isinstance(item, (tuple, list)): - stack.extend(item) - continue - value = getattr(item, "_var_value", None) - if isinstance(value, str) and "querySelectorAll" in value: - out.append(value) + 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 _build_nested_tree(default_inner="profile"): +def _root(**kw): return tabs.root( - tabs.list( - tabs.trigger("Home", value="home", id="outer-home"), - tabs.trigger("Settings", value="settings", id="outer-settings"), - ), - tabs.content( - tabs.root( - tabs.list( - tabs.trigger("Profile", value="profile", id="inner-prof"), - tabs.trigger("Security", value="security", id="inner-sec"), - ), - tabs.content("Profile panel", value="profile"), - tabs.content("Security panel", value="security"), - default_value=default_inner, - ), - value="settings", - ), - default_value="settings", + tabs.list(tabs.trigger("Home", value="home")), + tabs.content("p", value="home"), + default_value="home", + **kw, ) -def _dom_tree(comp): - ca = getattr(comp, "custom_attrs", {}) or {} - return { - "slot": ca.get("data-slot"), - "value": ca.get("data-value"), - "id": getattr(comp, "id", None), - "active": None, - "children": [ - _dom_tree(child) - for child in (getattr(comp, "children", []) or []) - if hasattr(child, "custom_attrs") or hasattr(child, "event_triggers") - ], - } - - -def _index_parents(node, parent=None, mapping=None): - if mapping is None: - mapping = {} - mapping[id(node)] = parent - for child in node["children"]: - _index_parents(child, node, mapping) - return mapping - - -def _closest(node, slot, parents): - cur = node - while cur is not None: - if cur.get("slot") == slot: - return cur - cur = parents.get(id(cur)) - return None - - -def _descendants_with_slot(node, slot): - out = [] - for child in node["children"]: - if child.get("slot") == slot: - out.append(child) - out.extend(_descendants_with_slot(child, slot)) - return out - +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 _find_by_id(node, node_id): - if node.get("id") == node_id: - return node - for child in node["children"]: - found = _find_by_id(child, node_id) - if found: - return found - return None +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 _all_triggers(node): - out = [] - for child in node["children"]: - if child.get("slot") == "tabs-trigger": - out.append(child) - out.extend(_all_triggers(child)) - return out +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 _simulate_mount(root): - parents = _index_parents(root) - for trigger in _all_triggers(root): - group = _closest(trigger, "tabs", parents) - trigger["active"] = ( - "true" if group and group.get("value") == trigger.get("value") else "false" - ) - - -def _simulate_click(root, trigger_node, with_guard): - parents = _index_parents(root) - group = _closest(trigger_node, "tabs", parents) - if group is None: - return - clicked_value = trigger_node.get("value") - group["value"] = clicked_value - for target in _descendants_with_slot(group, "tabs-trigger"): - own_group = _closest(target, "tabs", parents) - if with_guard and own_group is not group: - continue - target["active"] = "true" if target.get("value") == clicked_value else "false" - - -def test_on_click_handler_contains_scope_guard(): - trigger = tabs.trigger("Home", value="home", id="t1") - handlers = _extract_on_click_js(trigger) - assert len(handlers) == 1 - assert GUARD_SUBSTRING in handlers[0] - - -def test_guard_preserves_nested_highlight_after_outer_click(): - nested = _build_nested_tree(default_inner="profile") - dom = _dom_tree(nested) - _simulate_mount(dom) - outer_home = _find_by_id(dom, "outer-home") - inner_prof = _find_by_id(dom, "inner-prof") - inner_sec = _find_by_id(dom, "inner-sec") - assert outer_home is not None - assert inner_prof is not None - assert inner_sec is not None - assert inner_prof["active"] == "true" - assert inner_sec["active"] == "false" - _simulate_click(dom, outer_home, with_guard=True) - assert outer_home["active"] == "true" - outer_settings = _find_by_id(dom, "outer-settings") - assert outer_settings is not None - assert outer_settings["active"] == "false" - assert inner_prof["active"] == "true", ( - "outer click must not clobber inner highlight" - ) - assert inner_sec["active"] == "false" - inner_root = _closest(inner_prof, "tabs", _index_parents(dom)) - assert inner_root is not None - assert inner_root["value"] == "profile", ( - "outer click must not change inner root value" - ) +def test_delegated_listener_scopes_to_clicked_tabs_group(): + from components.ui.tabs import GLOBAL_HANDLERS_JS -def test_nested_inner_group_remains_independently_switchable_with_guard(): - nested = _build_nested_tree(default_inner="profile") - dom = _dom_tree(nested) - _simulate_mount(dom) - outer_settings = _find_by_id(dom, "outer-settings") - assert outer_settings is not None - _simulate_click(dom, outer_settings, with_guard=True) - inner_sec = _find_by_id(dom, "inner-sec") - inner_prof = _find_by_id(dom, "inner-prof") - assert inner_sec is not None - assert inner_prof is not None - assert inner_prof["active"] == "true" - _simulate_click(dom, inner_sec, with_guard=True) - assert inner_sec["active"] == "true" - assert inner_prof["active"] == "false" - outer_root = _closest(outer_settings, "tabs", _index_parents(dom)) - assert outer_root is not None - assert outer_root["value"] == "settings" - - -def test_flat_demo_guard_is_noop_for_outer_only_group(): - import importlib - - for label, (module_path, fn_name) in DEMO_MODULES.items(): - mod = importlib.import_module(module_path) - comp = getattr(mod, fn_name)() - dom = _dom_tree(comp) - _simulate_mount(dom) - triggers = _all_triggers(dom) - for trigger in triggers: - _simulate_click(dom, trigger, with_guard=True) - expected = { - t["id"]: ("true" if t["value"] == trigger["value"] else "false") - for t in triggers - } - snapshot_after = {t["id"]: t["active"] for t in triggers} - assert snapshot_after == expected, ( - f"{label}: click {trigger['id']} did not yield a single active trigger" - ) - assert trigger["active"] == "true" + assert GUARD_SUBSTRING in GLOBAL_HANDLERS_JS