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