# Test multi-manager (hierarchical) hints modal
# Reproduces the hierarchy demo pattern: parent + two children, coordinated
# via window.kbCoordinator at runtime. The modal renders each as a labeled
# section using `manager.get_display_label()`.
from cjm_fasthtml_keyboard_navigation.core.navigation import LinearVertical, ScrollOnly
# Parent: ghost zones (ScrollOnly) for switching between areas.
# Includes a consumer action with hint_group="Navigation" — this exercises the
# Navigation-merge behavior: the consumer's Navigation action must fold into
# the manager-derived Navigation group (Switch panel), NOT produce a second
# "Navigation" header.
ghost_a = FocusZone(id="ghost-a", item_selector=None, navigation=ScrollOnly())
ghost_b = FocusZone(id="ghost-b", item_selector=None, navigation=ScrollOnly())
parent_mgr = ZoneManager(
zones=(ghost_a, ghost_b),
actions=(
KeyAction(key="Enter", js_callback="activateChild",
description="Activate area", hint_group="Navigation"),
),
label="Parent — Hierarchy Coordinator",
)
# Child A: LinearVertical list
child_a_zone = FocusZone(id="child-a-list", item_selector="li", navigation=LinearVertical())
child_a_mgr = ZoneManager(
zones=(child_a_zone,),
actions=(
KeyAction(key=" ", htmx_trigger="child-a-toggle",
description="Toggle selection", hint_group="Selection"),
),
label="Alpha List",
)
# Child B: LinearVertical list
child_b_zone = FocusZone(id="child-b-list", item_selector="li", navigation=LinearVertical())
child_b_mgr = ZoneManager(
zones=(child_b_zone,),
actions=(
KeyAction(key=" ", htmx_trigger="child-b-toggle",
description="Toggle selection", hint_group="Selection"),
),
label="Beta List",
)
# Render the hierarchical modal
hier_modal, _, _ = render_keyboard_hints_modal(
parent_mgr,
child_managers=(child_a_mgr, child_b_mgr),
)
hier_html = to_xml(hier_modal)
# Each manager's label appears as a section header
assert "Parent — Hierarchy Coordinator" in hier_html
assert "Alpha List" in hier_html
assert "Beta List" in hier_html
# Parent's content: Switch panel (multi-zone) + the Enter "Activate area" action
assert "Switch panel" in hier_html
assert "Activate area" in hier_html
# Children's content: their own derived nav rows ('↑ / ↓' for LinearVertical)
# AND their own action rows (Space — Toggle selection)
assert "↑ / ↓" in hier_html, "Child managers' LinearVertical pattern must surface ↑/↓"
# Toggle selection should appear TWICE (once per child)
assert hier_html.count(">Toggle selection<") == 2, \
f"'Toggle selection' must appear twice (once per child), got {hier_html.count('>Toggle selection<')}"
# --- Regression guard: Navigation-merge behavior ---
# Per manager, the modal should emit exactly ONE "Navigation" group header
# (containing both manager-derived rows and consumer actions with
# hint_group="Navigation"). Across the 3 managers (parent + 2 children),
# we expect EXACTLY 3 "Navigation" group headers total — NOT 4 (which would
# indicate the parent's consumer Navigation action is producing its own
# duplicate header).
assert hier_html.count(">Navigation<") == 3, \
f"Each manager must emit exactly one 'Navigation' header (3 total: parent + 2 children), got {hier_html.count('>Navigation<')}"
# Hierarchy sentinel: section headers appear in (parent → children) declaration order
parent_pos = hier_html.index("Parent — Hierarchy Coordinator")
alpha_pos = hier_html.index("Alpha List")
beta_pos = hier_html.index("Beta List")
assert parent_pos < alpha_pos < beta_pos, \
"Section headers must appear in (parent, child_managers[0], child_managers[1], ...) order"
# Section headers must use break-after-avoid to stay attached to their groups
# (prevents orphan headers in CSS columns layout — `break-after-avoid` is the
# correct CSS for "don't break right after this element").
assert "break-after-avoid" in hier_html, \
"Section headers must carry break-after-avoid to prevent orphan headers in CSS columns"
# --- Regression guard: single-manager call still works (no children) ---
# Without child_managers, no section headers should render (preserves the
# common-case modal layout).
single_modal, _, _ = render_keyboard_hints_modal(parent_mgr)
single_html = to_xml(single_modal)
assert "Alpha List" not in single_html # children NOT rendered
assert "Beta List" not in single_html
# The parent's label SHOULDN'T appear either — single-manager mode is implicit context
assert "Parent — Hierarchy Coordinator" not in single_html, \
"Single-manager mode must not emit a section header for the primary manager"
# break-after-avoid should NOT appear in single-manager output (no section headers)
assert "break-after-avoid" not in single_html, \
"Single-manager mode must not render section headers (no break-after class)"
# Navigation-merge: still ONE "Navigation" header in single-manager mode too
assert single_html.count(">Navigation<") == 1, \
f"Single-manager mode must emit exactly one 'Navigation' header, got {single_html.count('>Navigation<')}"
# --- Per-zone Navigation must STAY separate (zone-scoped vs shared) ---
# When a consumer registers actions with hint_group="Navigation" AND zone_ids
# tying them to specific zones, those zone-scoped Navigation groups must NOT
# merge into the manager-derived shared Navigation group. They render as
# their own "<zone label> — Navigation" headers.
z_seg = FocusZone(id="z-seg", label="Text Seg", item_selector="li", navigation=LinearVertical())
z_align = FocusZone(id="z-align", label="VAD", item_selector="li", navigation=LinearVertical())
zone_scoped_mgr = ZoneManager(
zones=(z_seg, z_align),
actions=(
# Zone-scoped Navigation action — should land under "Text Seg — Navigation"
# NOT merge into the manager-derived top-level Navigation group.
KeyAction(key="ArrowUp", htmx_trigger="seg-prev", zone_ids=("z-seg",),
description="Previous item", hint_group="Navigation"),
),
)
zs_modal, _, _ = render_keyboard_hints_modal(zone_scoped_mgr)
zs_html = to_xml(zs_modal)
# Expect TWO Navigation headers: manager-derived top one + zone-scoped "Text Seg — Navigation"
assert ">Navigation<" in zs_html # manager-derived
assert "Text Seg — Navigation" in zs_html # zone-scoped, must stay separate
# --- Label fallback: child without explicit label uses system_id ---
unlabeled_child = ZoneManager(
zones=(FocusZone(id="raw-zone"),),
label=None,
)
fallback_modal, _, _ = render_keyboard_hints_modal(parent_mgr, child_managers=(unlabeled_child,))
fallback_html = to_xml(fallback_modal)
assert "raw-zone" in fallback_html, \
"Child without explicit label must fall back to system_id (auto-derived from initial zone id)"
print("Hierarchical hints modal tests passed (incl. Navigation-merge regression guard)")