Skip to content

Commit ea8ae47

Browse files
authored
Merge pull request #6202 from Textualize/trap-focus
trap focus
2 parents 3427dba + 6fbc1d1 commit ea8ae47

File tree

4 files changed

+70
-2
lines changed

4 files changed

+70
-2
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [6.5.1] - Unreleased
9+
10+
### Added
11+
12+
- Added `DOMNode.trap_focus` https://github.com/Textualize/textual/pull/6202
13+
814
## [6.4.0] - 2025-10-22
915

1016
### Fixed

src/textual/dom.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ def __init__(
228228
) = None
229229
self._pruning = False
230230
self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024)
231+
self._trap_focus = False
231232

232233
super().__init__()
233234

@@ -475,6 +476,17 @@ def workers(self) -> WorkerManager:
475476
"""The app's worker manager. Shortcut for `self.app.workers`."""
476477
return self.app.workers
477478

479+
def trap_focus(self, trap_focus: bool = True) -> None:
480+
"""Trap the focus.
481+
482+
When applied to a container, pressing tab to change focus will be limited to the container's
483+
children if one of the children currently has focus.
484+
485+
Args:
486+
trap_focus: `True` to trap focus. `False` to restore default behavior.
487+
"""
488+
self._trap_focus = trap_focus
489+
478490
def run_worker(
479491
self,
480492
work: WorkType[ResultType],

src/textual/screen.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -743,9 +743,18 @@ def focus_chain(self) -> list[Widget]:
743743
# Additionally, we manually keep track of the visibility of the DOM
744744
# instead of relying on the property `.visible` to save on DOM traversals.
745745
# node_stack: list[tuple[iterator over node children, node visibility]]
746+
747+
root_node = self.screen
748+
749+
if (focused := self.focused) is not None:
750+
for node in focused.ancestors_with_self:
751+
if node._trap_focus:
752+
root_node = node
753+
break
754+
746755
node_stack: list[tuple[Iterator[Widget], bool]] = [
747756
(
748-
iter(sorted(self.displayed_children, key=focus_sorter)),
757+
iter(sorted(root_node.displayed_children, key=focus_sorter)),
749758
self.visible,
750759
)
751760
]

tests/test_focus.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from textual.app import App, ComposeResult
2-
from textual.containers import Container, ScrollableContainer
2+
from textual.containers import Container, ScrollableContainer, Vertical
33
from textual.widget import Widget
44
from textual.widgets import Button, Label
55
from textual.widgets._placeholder import Placeholder
@@ -507,3 +507,44 @@ def compose(self) -> ComposeResult:
507507
assert isinstance(app.focused, Focusable)
508508
# Check focus chain
509509
assert app.screen.focus_chain == [app.query_one(Focusable)]
510+
511+
512+
async def test_trap_focus():
513+
class TrapApp(App):
514+
CSS = """
515+
Screen {
516+
layout: horizontal;
517+
}
518+
"""
519+
520+
def compose(self) -> ComposeResult:
521+
with Vertical(id="left"):
522+
yield Button("1", id="one")
523+
yield Button("2", id="two")
524+
with Vertical(id="right"):
525+
yield Button("A", id="a")
526+
yield Button("B", id="b")
527+
528+
app = TrapApp()
529+
async with app.run_test():
530+
# Normal focus chain reports all focusable widgets
531+
focus_ids = [node.id for node in app.screen.focus_chain]
532+
assert focus_ids == ["one", "two", "a", "b"]
533+
534+
# Trap the focus on the left container
535+
# Since Button#one is focused, the focus chain will be limited to the left vertical
536+
app.screen.query_one("#left").trap_focus()
537+
focus_ids = [node.id for node in app.screen.focus_chain]
538+
assert focus_ids == ["one", "two"]
539+
540+
# Trap focus on the right container
541+
# Since the right container doesn't contain a focused widget, we would expect no change
542+
app.screen.query_one("#right").trap_focus()
543+
focus_ids = [node.id for node in app.screen.focus_chain]
544+
assert focus_ids == ["one", "two"]
545+
546+
# Un-trap the focus on the left container
547+
# Should restore original focus chain
548+
app.screen.query_one("#left").trap_focus(False)
549+
focus_ids = [node.id for node in app.screen.focus_chain]
550+
assert focus_ids == ["one", "two", "a", "b"]

0 commit comments

Comments
 (0)