Focus Zone

Configuration for focusable containers with navigable items.

FocusZone

A container that can receive focus and contains navigable items. Each zone tracks its own focus state and can have independent navigation patterns.


FocusZone


def FocusZone(
    id:str, label:Optional[str]=None, item_selector:Optional[str]=None,
    navigation:Union[NavigationPattern, LinearVertical]=<factory>, navigation_throttle_ms:int=0,
    item_focus_classes:tuple[str, ...]=('ring-2', 'ring-primary'), item_focus_attribute:str='data-focused',
    zone_focus_classes:tuple[str, ...]=('ring-2', 'ring-primary', 'inset-ring-2'),
    data_attributes:tuple[str, ...]=(), on_focus_change:Optional[str]=None, on_navigate:Optional[str]=None,
    on_zone_enter:Optional[str]=None, on_zone_leave:Optional[str]=None, activate_child_id:Optional[str]=None,
    activate_child_callback:Optional[str]=None, activate_description:Optional[str]=None,
    scroll_behavior:str='smooth', scroll_block:str='nearest', hidden_input_prefix:str='', initial_index:int=0
)->None:

A focusable container with navigable items.

# Test FocusZone basics
zone = FocusZone(
    id="source-browser",
    item_selector="tr[data-selectable='true']",
    data_attributes=("job-id", "plugin-name"),
    on_focus_change="updatePreview"
)

assert zone.id == "source-browser"
assert zone.has_items() == True
assert zone.get_hidden_input_id("job-id") == "source-browser-job-id"

# Test to_js_config
config = zone.to_js_config()
assert config["id"] == "source-browser"
assert config["itemSelector"] == "tr[data-selectable='true']"
assert config["onFocusChange"] == "updatePreview"
# Test scroll-only zone
from cjm_fasthtml_keyboard_navigation.core.navigation import ScrollOnly

preview_zone = FocusZone(
    id="preview-panel",
    item_selector=None,
    navigation=ScrollOnly()
)

assert preview_zone.has_items() == False
assert preview_zone.navigation.name == "scroll_only"
# Test with custom hidden input prefix
zone_with_prefix = FocusZone(
    id="my-zone",
    hidden_input_prefix="kb",
    data_attributes=("file-path",)
)

assert zone_with_prefix.get_hidden_input_id("file-path") == "kb-file-path"
# Test label defaults to None and get_display_label falls back to id
zone_no_label = FocusZone(id="seg-zone")
assert zone_no_label.label is None
assert zone_no_label.get_display_label() == "seg-zone"  # falls back to id

# Test custom label is preserved and used by get_display_label
zone_with_label = FocusZone(id="seg-zone", label="Text Segmentation")
assert zone_with_label.label == "Text Segmentation"
assert zone_with_label.get_display_label() == "Text Segmentation"  # label wins over id

# Label is display-only — should NOT appear in to_js_config (no runtime JS use)
config = zone_with_label.to_js_config()
assert "label" not in config

# Regression guard: label-less FocusZone construction still works (single-positional id)
zone_positional = FocusZone(id="align-zone")
assert zone_positional.get_display_label() == "align-zone"
# Test child-activation wiring fields
# Default: both unset, has_activation() returns False, no hint should emit downstream
zone_no_activation = FocusZone(id="ghost-z")
assert zone_no_activation.activate_child_id is None
assert zone_no_activation.activate_child_callback is None
assert zone_no_activation.activate_description is None
assert zone_no_activation.has_activation() is False

# Declarative path: activate_child_id set → has_activation()=True, JS config carries the id
zone_declarative = FocusZone(id="ghost-browser", activate_child_id="sb-collection")
assert zone_declarative.has_activation() is True
assert zone_declarative.to_js_config()["activateChildId"] == "sb-collection"
assert zone_declarative.to_js_config()["activateChildCallback"] is None

# Callback path: activate_child_callback set → has_activation()=True, JS config carries the callback
zone_callback = FocusZone(id="ghost-browser", activate_child_callback="activateBrowserChild")
assert zone_callback.has_activation() is True
assert zone_callback.to_js_config()["activateChildId"] is None
assert zone_callback.to_js_config()["activateChildCallback"] == "activateBrowserChild"

# Both set: precedence is the JS dispatcher's job; the data class carries both faithfully so
# the dispatcher can resolve. has_activation() stays True. Sentinel guard: callback wins downstream.
zone_both = FocusZone(
    id="ghost-z",
    activate_child_id="fallback-child",
    activate_child_callback="dynamicDispatch",
)
assert zone_both.has_activation() is True
js = zone_both.to_js_config()
assert js["activateChildId"] == "fallback-child"
assert js["activateChildCallback"] == "dynamicDispatch"

# activate_description: per-zone hint override (no JS-runtime use, render-only)
zone_with_desc = FocusZone(
    id="ghost-browser",
    activate_child_callback="activateBrowserChild",
    activate_description="Activate browser",
)
assert zone_with_desc.activate_description == "Activate browser"
# Description stays Python-side; not emitted to JS config (render-only)
assert "activateDescription" not in zone_with_desc.to_js_config()