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 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"
# 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")