# 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"Zone Manager
Coordinates keyboard navigation across multiple zones, modes, and actions.
ZoneManager
The main coordinator that brings together zones, modes, actions, and key mappings.
ZoneManager
def ZoneManager(
zones:tuple[FocusZone, ...], system_id:Optional[str]=None, label: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, ...]=(),
activate_keys:tuple[str, ...]=('Enter', ' '), activate_description:str='Activate panel',
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 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"# Test label field + get_display_label fallback
z = FocusZone(id="my-zone")
# Default: label is None, get_display_label falls back to system_id
m_no_label = ZoneManager(zones=(z,))
assert m_no_label.label is None
assert m_no_label.get_display_label() == "my-zone" # falls back to system_id (= initial zone id)
# Custom label
m_with_label = ZoneManager(zones=(z,), label="Source Browser")
assert m_with_label.label == "Source Browser"
assert m_with_label.get_display_label() == "Source Browser" # label wins over system_id
# Custom system_id + no label: display falls back to system_id
m_sysid = ZoneManager(zones=(z,), system_id="sb-system")
assert m_sysid.get_display_label() == "sb-system"
# Custom system_id + custom label: label still wins
m_both = ZoneManager(zones=(z,), system_id="sb-system", label="Source Browser")
assert m_both.get_display_label() == "Source Browser"
# Label is display-only — should NOT appear in to_js_config (no runtime JS use)
config = m_with_label.to_js_config()
assert "label" not in config# Test child-activation defaults + has_activatable_zone + JS config plumbing
# --- Defaults ---
# Manager with no activatable zones: activate_keys still has defaults, but the predicate is False
zone_plain = FocusZone(id="plain-zone")
mgr_plain = ZoneManager(zones=(zone_plain,))
assert mgr_plain.activate_keys == ("Enter", " ") # default: Enter + Space
assert mgr_plain.activate_description == "Activate panel"
assert mgr_plain.has_activatable_zone() is False
# --- Activation predicate triggers when any zone declares wiring ---
zone_activatable = FocusZone(id="ghost-browser", activate_child_id="sb-collection")
mgr_with_activation = ZoneManager(zones=(zone_plain, zone_activatable))
assert mgr_with_activation.has_activatable_zone() is True
# --- Override defaults ---
mgr_override = ZoneManager(
zones=(zone_activatable,),
activate_keys=("Enter",), # Enter only, no Space
activate_description="Activate area",
)
assert mgr_override.activate_keys == ("Enter",)
assert mgr_override.activate_description == "Activate area"
# --- Opt out entirely (empty tuple) ---
mgr_no_activate = ZoneManager(zones=(zone_activatable,), activate_keys=())
assert mgr_no_activate.activate_keys == ()
# has_activatable_zone is data-driven (zone-level field), not affected by manager opt-out.
# The hints renderer / JS dispatcher must consult BOTH:
# has_activatable_zone() AND len(activate_keys) > 0
# to decide whether the activation feature is live.
assert mgr_no_activate.has_activatable_zone() is True
# --- JS config plumbing ---
cfg = mgr_override.to_js_config()
assert cfg["activateKeys"] == ["Enter"] # list, not tuple, for JSON serialization
cfg_default = mgr_plain.to_js_config()
assert cfg_default["activateKeys"] == ["Enter", " "]
cfg_opted_out = mgr_no_activate.to_js_config()
assert cfg_opted_out["activateKeys"] == []
# activate_description is render-only — does NOT appear in JS config
assert "activateDescription" not in cfg
assert "activateDescription" not in cfg_default
print("ZoneManager activate_keys + has_activatable_zone tests passed")