Skip to content

Commit c5b99e0

Browse files
committed
feat: introduce reactive router.path as a State object to enable dynamic UI styling based on the current route
1 parent 2644ad9 commit c5b99e0

7 files changed

Lines changed: 88 additions & 20 deletions

File tree

‎.agent/skills/htag-development/SKILL.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ For multi-page behavior in a Single Page Application (SPA), use the built-in `Ro
352352
- **Automatic Lifecycle**: `on_mount()` and `on_unmount()` are called automatically when swapping pages.
353353
- **Route Parameters**: Dynamic segments (e.g., `:id`) are injected directly as keyword arguments into the page's `init()`.
354354
- **Programmatic Navigation**: Use `router.navigate("/new-path")` to steer the UI from Python.
355+
- **Reactive Path**: `router.path` is a `State("")` reflecting the current route, enabling reactive tab/link styling.
355356

356357
```python
357358
from htag import Tag, Router
@@ -379,7 +380,12 @@ class App(Tag.App):
379380
self.router.add_route("/", HomePage)
380381
self.router.add_route("/users/:id", UserPage)
381382

382-
# 3. Add it to the UI
383+
# 3. Navigation with active link highlighting (reactive!)
384+
with Tag.nav():
385+
Tag.a("Home", _href="#/", _class=lambda: "active" if self.router.path == "/" else "")
386+
Tag.a("Users", _href="#/users/42", _class=lambda: "active" if str(self.router.path).startswith("/users") else "")
387+
388+
# 4. Add it to the UI
383389
self <= self.router
384390
```
385391

‎docs/routing.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,27 @@ def on_login_success(self, e):
7878

7979
This method updates the browser hash, ensuring that the **Back/Forward buttons** continue to work as expected.
8080

81+
## Active Route Styling (`router.path`)
82+
83+
The `router.path` attribute is a reactive `State("")` that always reflects the current route. Use it in lambda attributes to **automatically highlight active navigation links** or tabs:
84+
85+
```python
86+
class MyApp(Tag.App):
87+
def init(self):
88+
self.router = Router()
89+
self.router.add_route("/", HomePage)
90+
self.router.add_route("/settings", SettingsPage)
91+
92+
# Navigation links with automatic "active" class
93+
with Tag.nav():
94+
Tag.a("Home", _href="#/", _class=lambda: "active" if self.router.path == "/" else "")
95+
Tag.a("Settings", _href="#/settings", _class=lambda: "active" if self.router.path == "/settings" else "")
96+
97+
self <= self.router
98+
```
99+
100+
Since `path` is a standard htag `State`, it integrates with the full reactivity system — any component observing it will re-render automatically when the route changes.
101+
81102
## Custom 404 Pages
82103

83104
If no route matches the current path, a default "404 Not Found" message is displayed. You can customize this by providing your own component:

‎examples/main_router.py‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,41 @@ class HomePage(Tag.div):
44
def init(self) -> None:
55
Tag.h1("Home")
66
Tag.p("Welcome to this simple SPA demo.")
7-
Tag.a("Go to Page 1", _href="#/p1")
8-
Tag.br()
9-
Tag.a("Go to Hello/World", _href="#/hello/World")
107

118
class Page1(Tag.div):
129
def init(self) -> None:
1310
Tag.h1("Page 1")
14-
Tag.a("Back Home", _href="#/")
11+
Tag.p("This is page 1 content.")
1512

1613
class HelloPage(Tag.div):
1714
def init(self, name: str) -> None:
1815
Tag.h1(f"Hello {name}!")
19-
Tag.a("Back Home", _href="#/")
2016

2117
class App(Tag.App):
18+
statics = [Tag.style("""
19+
body { font-family: system-ui; margin: 0; background: #1a1a2e; color: #e0e0e0; }
20+
nav { display: flex; gap: 0; background: #16213e; border-bottom: 2px solid #0f3460; }
21+
nav a { padding: 12px 24px; text-decoration: none; color: #8899aa;
22+
transition: all 0.2s; border-bottom: 2px solid transparent; }
23+
nav a:hover { color: #e94560; }
24+
nav a.active { color: #e94560; border-bottom-color: #e94560; font-weight: bold; }
25+
.content { padding: 32px; }
26+
""")]
27+
2228
def init(self) -> None:
2329
self.router = Router()
24-
2530
self.router.add_route("/", HomePage)
2631
self.router.add_route("/p1", Page1)
2732
self.router.add_route("/hello/:name", HelloPage)
28-
29-
self += self.router
33+
34+
# Navigation with reactive "active" class thanks to router.path (State)
35+
with Tag.nav():
36+
Tag.a("Home", _href="#/", _class=lambda: "active" if self.router.path == "/" else "")
37+
Tag.a("Page 1", _href="#/p1", _class=lambda: "active" if self.router.path == "/p1" else "")
38+
Tag.a("Hello", _href="#/hello/World", _class=lambda: "active" if self.router.path == "/hello/World" else "")
39+
40+
with Tag.div(_class="content"):
41+
self <= self.router
3042

3143
if __name__ == "__main__":
3244
ChromeApp(App).run(reload=True, port=0)

‎htag/router.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def init(self):
2222
from dataclasses import dataclass, field
2323
from typing import Any
2424

25-
from .core import GTag
25+
from .core import GTag, State
2626

2727
logger = logging.getLogger("htag")
2828

@@ -73,13 +73,18 @@ class Router(GTag):
7373
7474
Route parameters (e.g. `:id` in `/tasks/:id`) are passed as keyword
7575
arguments to the page component's init().
76+
77+
The `path` attribute is a reactive `State("")` holding the current route.
78+
Use it in lambdas to style active navigation links::
79+
80+
Tag.a("Home", _href="#/", _class=lambda: "active" if router.path == "/" else "")
7681
"""
7782

7883
tag: str = "div"
7984

8085
def init(self, **kwargs: Any) -> None:
8186
self._routes: list[_Route] = []
82-
self._current_path: str = ""
87+
self.path: State = State("")
8388
self._not_found_component: type[GTag] | None = None
8489

8590
def add_route(self, path: str, component: type[GTag]) -> None:
@@ -137,8 +142,8 @@ def _on_hash_change(self, event: Any) -> None:
137142

138143
def _navigate_to(self, path: str) -> None:
139144
"""Match a path against registered routes and swap the view."""
140-
logger.debug("Router: navigating to '%s' (current='%s')", path, self._current_path)
141-
if path == self._current_path and self._current_path != "":
145+
logger.debug("Router: navigating to '%s' (current='%s')", path, self.path)
146+
if path == self.path and self.path != "":
142147
return # Already on this page
143148

144149
for route in self._routes:
@@ -147,13 +152,13 @@ def _navigate_to(self, path: str) -> None:
147152
params = match.groupdict()
148153
logger.info("Router match: %s → %s(%s)", path, route.component.__name__, params)
149154
self._swap(route.component, params)
150-
self._current_path = path
155+
self.path.value = path
151156
return
152157

153158
# No match → 404
154159
logger.warning("Router: no route matched for '%s'", path)
155160
self._swap_not_found(path)
156-
self._current_path = path
161+
self.path.value = path
157162

158163
def _swap(self, component_class: type[GTag], params: dict[str, str]) -> None:
159164
"""Replace the current view with a new component instance."""

‎tests/test_router.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,29 @@ def test_router_initial_route_event():
7272
router.add_route("/", Page1)
7373

7474
# Simulate htag_event from JS
75-
router._on_init_route(type('Event', (object,), {'value': {'hash': '#/'}}))
75+
router._on_init_route(type('Event', (object,), {'value': {'hash': '#/'}})())
7676
assert isinstance(router.childs[0], Page1)
77+
78+
def test_router_path_is_reactive_state():
79+
"""router.path should be a State, enabling reactive tab styling."""
80+
from htag.core import State
81+
router = Router()
82+
router.add_route("/", Page1)
83+
router.add_route("/hello/:name", Page2)
84+
85+
# path is a State instance
86+
assert isinstance(router.path, State)
87+
88+
# Initially empty
89+
assert router.path == ""
90+
91+
# After navigation, path reflects the route
92+
router._navigate_to("/")
93+
assert router.path == "/"
94+
95+
router._navigate_to("/hello/World")
96+
assert router.path == "/hello/World"
97+
98+
# Comparison operators work (essential for lambda: "active" if router.path == "/" else "")
99+
assert router.path != "/"
100+
assert router.path == "/hello/World"

‎tests/test_router_coverage.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ class MockEvent:
1313
def __init__(self, url): self.newURL = url
1414

1515
router._on_hash_change(MockEvent("http://localhost/#/p"))
16-
assert router._current_path == "/p"
16+
assert router.path == "/p"
1717

1818
# Test line 137: fallback to / (already on /p, so it should clear and hit / which is 404)
1919
router._on_hash_change(MockEvent("http://localhost/"))
20-
assert router._current_path == "/"
20+
assert router.path == "/"
2121
assert "404" in str(router.childs[0])
2222

2323
def test_router_init_route_legacy_event():
@@ -28,7 +28,7 @@ def test_router_init_route_legacy_event():
2828
class LegacyEvent:
2929
hash = "#/home"
3030
router._on_init_route(LegacyEvent())
31-
assert router._current_path == "/home"
31+
assert router.path == "/home"
3232

3333
def test_router_redundant_navigation():
3434
router = Router()

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)