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
98 changes: 46 additions & 52 deletions assets/docs/components/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand All @@ -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` |
Expand Down
94 changes: 44 additions & 50 deletions components/ui/tabs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import uuid

import reflex as rx
from reflex.components.component import ComponentNamespace
from reflex_components_core.el import Div
Expand Down Expand Up @@ -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:
Expand All @@ -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
)
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions docs/components/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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` |
Expand Down
76 changes: 76 additions & 0 deletions tests/test_tabs.py
Original file line number Diff line number Diff line change
@@ -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