Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [6.5.1] - Unreleased

### Added

- Added `DOMNode.trap_focus` https://github.com/Textualize/textual/pull/6202

## [6.4.0] - 2025-10-22

### Fixed
Expand Down
12 changes: 12 additions & 0 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ def __init__(
) = None
self._pruning = False
self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024)
self._trap_focus = False

super().__init__()

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

def trap_focus(self, trap_focus: bool = True) -> None:
"""Trap the focus.

When applied to a container, pressing tab to change focus will be limited to the container's
children if one of the children currently has focus.

Args:
trap_focus: `True` to trap focus. `False` to restore default behavior.
"""
self._trap_focus = trap_focus

def run_worker(
self,
work: WorkType[ResultType],
Expand Down
11 changes: 10 additions & 1 deletion src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,9 +743,18 @@ def focus_chain(self) -> list[Widget]:
# Additionally, we manually keep track of the visibility of the DOM
# instead of relying on the property `.visible` to save on DOM traversals.
# node_stack: list[tuple[iterator over node children, node visibility]]

root_node = self.screen

if (focused := self.focused) is not None:
for node in focused.ancestors_with_self:
if node._trap_focus:
root_node = node
break

node_stack: list[tuple[Iterator[Widget], bool]] = [
(
iter(sorted(self.displayed_children, key=focus_sorter)),
iter(sorted(root_node.displayed_children, key=focus_sorter)),
self.visible,
)
]
Expand Down
43 changes: 42 additions & 1 deletion tests/test_focus.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult
from textual.containers import Container, ScrollableContainer
from textual.containers import Container, ScrollableContainer, Vertical
from textual.widget import Widget
from textual.widgets import Button, Label
from textual.widgets._placeholder import Placeholder
Expand Down Expand Up @@ -507,3 +507,44 @@ def compose(self) -> ComposeResult:
assert isinstance(app.focused, Focusable)
# Check focus chain
assert app.screen.focus_chain == [app.query_one(Focusable)]


async def test_trap_focus():
class TrapApp(App):
CSS = """
Screen {
layout: horizontal;
}
"""

def compose(self) -> ComposeResult:
with Vertical(id="left"):
yield Button("1", id="one")
yield Button("2", id="two")
with Vertical(id="right"):
yield Button("A", id="a")
yield Button("B", id="b")

app = TrapApp()
async with app.run_test():
# Normal focus chain reports all focusable widgets
focus_ids = [node.id for node in app.screen.focus_chain]
assert focus_ids == ["one", "two", "a", "b"]

# Trap the focus on the left container
# Since Button#one is focused, the focus chain will be limited to the left vertical
app.screen.query_one("#left").trap_focus()
focus_ids = [node.id for node in app.screen.focus_chain]
assert focus_ids == ["one", "two"]

# Trap focus on the right container
# Since the right container doesn't contain a focused widget, we would expect no change
app.screen.query_one("#right").trap_focus()
focus_ids = [node.id for node in app.screen.focus_chain]
assert focus_ids == ["one", "two"]

# Un-trap the focus on the left container
# Should restore original focus chain
app.screen.query_one("#left").trap_focus(False)
focus_ids = [node.id for node in app.screen.focus_chain]
assert focus_ids == ["one", "two", "a", "b"]
Loading