Zone Manager

Coordinates keyboard navigation across multiple zones, modes, and actions.

ZoneManager

The main coordinator that brings together zones, modes, actions, and key mappings.


source

ZoneManager


def ZoneManager(
    zones:tuple[FocusZone, ...], system_id:Optional[str]=None, prev_zone_key:str='ArrowLeft',
    next_zone_key:str='ArrowRight', zone_switch_modifiers:frozenset[str]=<factory>, wrap_zones:bool=True,
    key_mapping:KeyMapping=<factory>, initial_zone_id:Optional[str]=None, modes:tuple[KeyboardMode, ...]=(),
    default_mode:str='navigation', actions:tuple[KeyAction, ...]=(), on_zone_change:Optional[str]=None,
    on_mode_change:Optional[str]=None, on_state_change:Optional[str]=None, skip_when_input_focused:bool=True,
    input_selector:str='input, textarea, select, [contenteditable]', htmx_settle_event:str='htmx:afterSettle',
    expose_state_globally:bool=False, global_state_name:str='keyboardNavState', state_hidden_inputs:bool=False
)->None:

Coordinates keyboard navigation across zones.

# Test basic ZoneManager
from cjm_fasthtml_keyboard_navigation.core.navigation import LinearVertical

browser = FocusZone(
    id="browser",
    item_selector="tr.item",
    data_attributes=("job-id",)
)
queue = FocusZone(
    id="queue",
    item_selector="li.item"
)

manager = ZoneManager(
    zones=(browser, queue),
    actions=(
        KeyAction(key=" ", htmx_trigger="toggle"),
    )
)

assert manager.get_zone("browser") == browser
assert manager.get_zone("queue") == queue
assert manager.get_zone("invalid") is None
assert manager.get_initial_zone_id() == "browser"
# Test modes
from cjm_fasthtml_keyboard_navigation.core.navigation import LinearHorizontal

split_mode = KeyboardMode(
    name="split",
    enter_key="Enter",
    navigation_override=LinearHorizontal()
)

manager_with_modes = ZoneManager(
    zones=(browser,),
    modes=(split_mode,)
)

all_modes = manager_with_modes.get_all_modes()
assert len(all_modes) == 2  # navigation + split
assert manager_with_modes.get_mode("navigation") is not None
assert manager_with_modes.get_mode("split") == split_mode
# Test action filtering
actions = (
    KeyAction(key=" ", htmx_trigger="toggle"),  # all zones/modes
    KeyAction(key="Delete", htmx_trigger="delete", zone_ids=("queue",)),
    KeyAction(key="Enter", htmx_trigger="split", mode_names=("split",)),
)

manager = ZoneManager(zones=(browser, queue), actions=actions)

# Browser in navigation mode
browser_nav_actions = manager.get_actions_for_context("browser", "navigation")
assert len(browser_nav_actions) == 1  # only space toggle

# Queue in navigation mode
queue_nav_actions = manager.get_actions_for_context("queue", "navigation")
assert len(queue_nav_actions) == 2  # space + delete

# Any zone in split mode
split_actions = manager.get_actions_for_context("browser", "split")
assert len(split_actions) == 2  # space + enter
# Test validation
import traceback

# Empty zones should fail
try:
    ZoneManager(zones=())
    assert False, "Should have raised ValueError"
except ValueError as e:
    assert "At least one zone" in str(e)

# Duplicate zone IDs should fail
try:
    ZoneManager(zones=(
        FocusZone(id="same"),
        FocusZone(id="same")
    ))
    assert False, "Should have raised ValueError"
except ValueError as e:
    assert "Duplicate" in str(e)

# Invalid initial zone should fail
try:
    ZoneManager(
        zones=(FocusZone(id="zone1"),),
        initial_zone_id="nonexistent"
    )
    assert False, "Should have raised ValueError"
except ValueError as e:
    assert "not found" in str(e)
# Test JS config generation
config = manager.to_js_config()

assert len(config["zones"]) == 2
assert config["initialZoneId"] == "browser"
assert config["defaultMode"] == "navigation"
assert config["settings"]["skipWhenInputFocused"] == True
# Test custom key mapping
from cjm_fasthtml_keyboard_navigation.core.key_mapping import WASD_KEYS

wasd_manager = ZoneManager(
    zones=(browser,),
    key_mapping=WASD_KEYS
)

config = wasd_manager.to_js_config()
assert config["keyMapping"]["w"] == "up"
assert config["keyMapping"]["s"] == "down"
# Test data attributes collection
z1 = FocusZone(id="z1", data_attributes=("a", "b"))
z2 = FocusZone(id="z2", data_attributes=("b", "c"))

m = ZoneManager(zones=(z1, z2))
attrs = m.get_all_data_attributes()
assert attrs == {"a", "b", "c"}
# Test system_id auto-generation
z = FocusZone(id="my-zone")
m = ZoneManager(zones=(z,))
assert m.system_id == "my-zone"  # defaults to initial zone ID

# Test system_id with explicit initial_zone_id
z1 = FocusZone(id="first")
z2 = FocusZone(id="second")
m = ZoneManager(zones=(z1, z2), initial_zone_id="second")
assert m.system_id == "second"  # follows initial_zone_id

# Test explicit system_id
m = ZoneManager(zones=(z1, z2), system_id="custom-id")
assert m.system_id == "custom-id"

# Test system_id in JS config
config = m.to_js_config()
assert config["systemId"] == "custom-id"