Keyboard Hints Modal

Modal-based keyboard shortcut reference with scannable grouped layout and ? key trigger.

Key Display

from fasthtml.common import to_xml

# Single key
html = to_xml(_render_key_combo("Space"))
assert "kbd" in html
assert "Space" in html
assert "+" not in html.split("kbd")[0]  # no plus before first kbd

# Multi-key combo
html = to_xml(_render_key_combo("Ctrl+Shift+\u2191"))
assert html.count("kbd") >= 3  # 3 kbd elements (tag appears in open+close)
assert "Ctrl" in html
assert "Shift" in html
assert "\u2191" in html
print("Key display tests passed")
Key display tests passed

Hint Row & Group

# Test hint row (without mode chip)
row_html = to_xml(_render_hint_row("Ctrl+Z", "Undo last action"))
assert "Ctrl" in row_html
assert "Z" in row_html
assert "Undo last action" in row_html
# No mode chip when mode_label is omitted (defaults to None)
assert "badge-soft" not in row_html, "No mode chip should render when mode_label is None"

# Test hint row WITH mode chip
chip_row_html = to_xml(_render_hint_row("Enter", "Split at caret", mode_label="split"))
assert "Split at caret" in chip_row_html
assert ">split<" in chip_row_html, "Mode chip text must appear in the rendered row"
assert "badge-soft" in chip_row_html, "Mode chip must use the daisyui soft badge style"
assert "badge-xs" in chip_row_html, "Mode chip must use the xs badge size"

# Test modal group (new 3-tuple signature: (key, desc, mode_label))
group_html = to_xml(_render_modal_group("Editing", [
    ("Enter", "Enter split mode", None),
    ("Escape", "Exit split mode", "split"),  # mode-restricted row gets a chip
]))
assert "Editing" in group_html
assert "Enter split mode" in group_html
assert "Exit split mode" in group_html
assert ">split<" in group_html  # mode chip rendered on second row only

# Group container must carry break-inside-avoid-column for CSS-columns layout
assert "break-inside-avoid-column" in group_html, \
    "Modal group container must use break-inside-avoid-column to stay intact across columns"

print("Hint row and group tests passed")

Trigger Button


render_keyboard_hints_trigger


def render_keyboard_hints_trigger(
    modal_id:str='kb-hints-modal', # ID of the modal dialog to open
    icon_size:IconSize='full', # lucide icon size (V11.R3 ghost-button: "full" — pairs with V1.modal_disclosure at btn-xs)
)->Button: # ghost button with keyboard icon

Render a keyboard icon button that opens the hints modal.

trigger = render_keyboard_hints_trigger()
html = to_xml(trigger)
assert "keyboard" in html.lower() or "svg" in html  # has icon
assert "showModal" in html
assert "kb-hints-modal" in html
assert 'title="Keyboard shortcuts (?)"' in html
print("Trigger button tests passed")
Trigger button tests passed

Question Mark Key Listener

listener = _render_question_mark_listener("kb-hints-modal")
html = to_xml(listener)
assert "keydown" in html
assert "e.key === '?'" in html
assert "showModal" in html
assert "INPUT" in html  # skips input fields
assert "TEXTAREA" in html
assert "isContentEditable" in html
print("Question mark listener tests passed")
Question mark listener tests passed

Full Modal Component


render_keyboard_hints_modal


def render_keyboard_hints_modal(
    manager:ZoneManager, # primary keyboard zone manager
    modal_id:str='kb-hints-modal', # HTML ID for the modal dialog
    include_navigation:bool=True, # DEPRECATED: no-op kept for backward compat. See _render_modal_body.
    include_zone_switch:bool=True, # include zone-switch hint (auto-hidden for single zone)
    enable_question_mark_key:bool=True, # add global `?` key listener
    title:str='Keyboard Shortcuts', # modal title text
    child_managers:Optional[Sequence[ZoneManager]]=None, # child managers for hierarchical hint display (each rendered as a labeled section)
)->tuple[FT, FT, FT]: # (modal_dialog, trigger_button, question_mark_script)

Render a modal-based keyboard shortcut reference.

Returns three components: - modal_dialog: The Dialog element (place anywhere in page) - trigger_button: Small keyboard icon button (place in step header) - question_mark_script: Global ? key listener Script (place in page)

If enable_question_mark_key is False, question_mark_script is an empty Div.

Hierarchical hints (child_managers=[...]): when working with multiple ZoneManagers coordinated by window.kbCoordinator (parent + N children), pass the parent as manager and the children as child_managers. The modal will render each as a labeled section using manager.get_display_label() for section headers. Set label on each ZoneManager for human-readable headers; falls back to system_id otherwise.

Modal width ladder (R2 cap + optimal-space response — see layout-system.md M1–M6 modes): grows responsively with viewport. Combined with columns.sm on the body, this gives 1 column at narrow widths, 2 columns at laptop full-screen (lg breakpoint with max_w._4xl), and 3 columns at desktop full-screen (2xl breakpoint with max_w._7xl). Modal width is an upper bound; DaisyUI’s modal_box sizes the actual modal to its content within that bound, so short content stays compact.

# Test dual-zone modal: zone-aware sectioning + dropped hardcoded nav row + mode chips
modal_dialog, trigger, qm_script = render_keyboard_hints_modal(test_manager)

modal_html = to_xml(modal_dialog)
trigger_html = to_xml(trigger)
script_html = to_xml(qm_script)

# Modal structure
assert 'id="kb-hints-modal"' in modal_html
assert 'modal-box' in modal_html
assert 'modal-backdrop' in modal_html
assert 'Keyboard Shortcuts' in modal_html
assert 'Navigation' in modal_html  # manager-derived Switch-panel group label
assert 'Editing' in modal_html
assert 'Audio' in modal_html
assert '✕' in modal_html  # close button

# Note: test_manager's zones have no item_selector, so derive_navigation_hints
# returns []. The modal's "Navigation" group renders only the Switch-panel row.
# (Tests for the derived nav row WITH item_selectors live in the modal-body
# test cell above — including the WASD regression guard.)
assert 'Navigate items' not in modal_html, \
    "test_manager has no item_selectors → no derived nav rows expected"

# The Switch-panel hint IS still emitted (key-mapping-derived, correct under custom mappings)
assert 'Switch panel' in modal_html

# Optimal-space layout: CSS columns + responsive max_w + break-inside guard.
# Width-ladder values tuned via cross-device retest 2026-05-11 — laptop wants
# 2-col at lg with max_w._4xl, desktop wants 3-col at 2xl with max_w._7xl.
assert 'columns-sm' in modal_html, "Modal body must use columns-sm for CSS auto-distribution"
assert 'break-inside-avoid-column' in modal_html, "Groups must use break-inside-avoid-column"
assert 'max-w-md' in modal_html        # M1 base
assert 'sm:max-w-lg' in modal_html      # M2 step
assert 'lg:max-w-4xl' in modal_html     # M3 step — 2-col on laptop
assert '2xl:max-w-7xl' in modal_html    # M4+ step — 3-col on desktop

# Trigger
assert 'showModal' in trigger_html
assert 'kb-hints-modal' in trigger_html

# Question mark listener
assert 'keydown' in script_html
assert "e.key === '?'" in script_html

# Test with question mark key disabled
_, _, no_qm = render_keyboard_hints_modal(test_manager, enable_question_mark_key=False)
no_qm_html = to_xml(no_qm)
assert 'keydown' not in no_qm_html  # no listener
assert 'display:none' in no_qm_html  # empty placeholder

# Test single-zone manager (no zone switch hint, no zone-label prefix)
single_manager = ZoneManager(
    zones=(z1,),
    actions=(KeyAction(key=" ", js_callback="x", description="Select", hint_group="Actions"),),
)
single_modal, _, _ = render_keyboard_hints_modal(single_manager)
single_html = to_xml(single_modal)
assert 'Switch panel' not in single_html  # no zone switch for single zone
assert 'Select' in single_html  # consumer action surfaces
# Single-zone managers should NOT emit "seg — Actions" style headers
assert ' — ' not in single_html, "Single-zone manager must not emit zone-label-prefixed group headers"
print("Full modal component tests passed")

# --- G4 regression-guard pattern: dual-zone shared-factory ---
# Reproduces segment-align's exact shape. Without zone-scoping in the grouper,
# rows like "Previous item" would duplicate inside a single "Navigation" header.
zone_seg = FocusZone(id="seg", label="Text Segmentation")
zone_align = FocusZone(id="align", label="VAD Alignment")

def _shared_factory_g4(zid):
    return (
        KeyAction(key="ArrowUp", htmx_trigger=f"{zid}-up", zone_ids=(zid,),
                  description="Previous item", hint_group="Navigation"),
        KeyAction(key="ArrowDown", htmx_trigger=f"{zid}-down", zone_ids=(zid,),
                  description="Next item", hint_group="Navigation"),
    )

g4_manager = ZoneManager(
    zones=(zone_seg, zone_align),
    actions=(
        *_shared_factory_g4("seg"),
        *_shared_factory_g4("align"),
        # Mode-restricted action — must render with a mode chip
        KeyAction(key="Enter", htmx_trigger="x", zone_ids=("seg",),
                  mode_names=("split",),
                  description="Split at caret", hint_group="Split Mode"),
        # not_modes action — must render with "default" chip
        KeyAction(key="Backspace", htmx_trigger="m", zone_ids=("seg",),
                  not_modes=("split",),
                  description="Merge with previous", hint_group="Segmentation"),
    ),
)

g4_modal, _, _ = render_keyboard_hints_modal(g4_manager)
g4_html = to_xml(g4_modal)

# Per-zone section headers: explicit zone-label prefix
assert "Text Segmentation — Navigation" in g4_html
assert "VAD Alignment — Navigation" in g4_html
assert "Text Segmentation — Split Mode" in g4_html
assert "Text Segmentation — Segmentation" in g4_html

# Mode chip presence on mode-restricted rows
assert ">split<" in g4_html, "Mode chip 'split' must appear on mode_names=('split',) action"
assert ">default<" in g4_html, "Mode chip 'default' must appear on not_modes=('split',) action"

# Hierarchy sentinel: Text Segmentation must appear BEFORE VAD Alignment
# in the rendered HTML (matches manager.zones declaration order). Prevents
# silent re-ordering by future renderer changes.
seg_first = g4_html.index("Text Segmentation — Navigation")
align_first = g4_html.index("VAD Alignment — Navigation")
assert seg_first < align_first, \
    "Per-zone sections must appear in zone declaration order (Text Segmentation before VAD Alignment)"

# Regression guard: "Previous item" must appear EXACTLY TWICE in the modal
# (once under "Text Segmentation — Navigation", once under "VAD Alignment —
# Navigation") — NOT 4 times (which would indicate the pre-fix duplication
# bug where both zones' actions collapsed into a single header).
assert g4_html.count(">Previous item<") == 2, \
    f"'Previous item' must appear exactly twice (once per zone), got {g4_html.count('>Previous item<')}"
assert g4_html.count(">Next item<") == 2, \
    f"'Next item' must appear exactly twice (once per zone), got {g4_html.count('>Next item<')}"

print("G4 regression-guard pattern tests passed")
# 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)")
Hierarchical hints modal tests passed (incl. Navigation-merge regression guard)
# L1 regression-guards: library-baked hierarchy + mode-exit rows render in the modal
# These assertions cover the gap surfaced by the hierarchy-demo screenshot
# (2026-05-12) — Esc and Enter/Space were absent from the modal even though
# the JS dispatcher implements both.

from cjm_fasthtml_keyboard_navigation.core.modes import KeyboardMode


# --- Parent with activatable zones renders Enter/Space activate-panel row ---
# Reproduces the hierarchy-demo's parent shape: two ghost zones with
# activate_child_id wiring + Enter/Space activate_keys (the default).
parent_ghost_a = FocusZone(
    id="hd-ghost-a", item_selector=None, navigation=ScrollOnly(),
    activate_child_id="hd-child-a",
)
parent_ghost_b = FocusZone(
    id="hd-ghost-b", item_selector=None, navigation=ScrollOnly(),
    activate_child_id="hd-child-b",
)
parent_mgr_with_activation = ZoneManager(
    zones=(parent_ghost_a, parent_ghost_b),
    label="Hierarchy Parent",
)

parent_modal, _, _ = render_keyboard_hints_modal(parent_mgr_with_activation)
parent_html = to_xml(parent_modal)

# The Enter/Space row + manager.activate_description ("Activate panel") must appear
assert "Activate panel" in parent_html, \
    "Parent with activatable zones must surface the Enter/Space activate-panel row"
# The Esc row must NOT appear on the parent — it's not a child in any hierarchy
# (this guards against accidentally emitting Esc for every manager regardless of role)
assert "Deactivate panel" not in parent_html, \
    "Parent (non-child) must NOT emit the Esc deactivate-panel row"


# --- Child manager rendered via child_managers=[...] surfaces Esc row ---
# Reproduces the hierarchy-demo's child shape: a child manager with no
# activatable zones of its own, rendered as part of a hierarchy.
hd_child_a_zone = FocusZone(id="hd-child-a", item_selector="li", navigation=LinearVertical())
hd_child_a = ZoneManager(
    zones=(hd_child_a_zone,),
    label="Alpha List",
    actions=(
        KeyAction(key=" ", htmx_trigger="alpha-toggle",
                  description="Toggle selection", hint_group="Selection"),
    ),
)
hd_child_b_zone = FocusZone(id="hd-child-b", item_selector="li", navigation=LinearVertical())
hd_child_b = ZoneManager(
    zones=(hd_child_b_zone,),
    label="Beta List",
    actions=(
        KeyAction(key=" ", htmx_trigger="beta-toggle",
                  description="Toggle selection", hint_group="Selection"),
    ),
)

hier_modal, _, _ = render_keyboard_hints_modal(
    parent_mgr_with_activation,
    child_managers=(hd_child_a, hd_child_b),
)
hier_html = to_xml(hier_modal)

# The Esc row must appear in each child's Navigation group — once per child.
# Parent does NOT emit it (parent is root of the documented hierarchy).
assert hier_html.count(">Deactivate panel, return to parent<") == 2, \
    f"Esc deactivate-panel row must appear exactly twice (once per child), got {hier_html.count('>Deactivate panel, return to parent<')}"

# The Enter/Space activate row must still appear once on the parent
assert hier_html.count(">Activate panel<") == 1, \
    f"Parent's activate-panel row must appear exactly once, got {hier_html.count('>Activate panel<')}"

# Each child's Navigation group still contains its own derived nav row (↑/↓)
assert hier_html.count("↑ / ↓") >= 2, \
    "Each child manager's LinearVertical zone should surface ↑/↓ in its Navigation group"


# --- Mode-exit rows render with mode chips in the Navigation group ---
# Reproduces the cjm-transcript-segmentation 'split' mode shape: a manager
# with a non-default mode whose exit_key="Escape". The modal should show:
#   - Esc row with the "split" mode chip (V13 mode chip)
#   - The row appears in the same Navigation group as other library-baked rows
split_mode = KeyboardMode(name="split", enter_key="Enter")  # exit_key defaults to "Escape"
mode_z = FocusZone(id="seg-z", item_selector="span", navigation=LinearVertical())
mode_mgr = ZoneManager(
    zones=(mode_z,),
    modes=(split_mode,),
    actions=(
        KeyAction(key="Enter", htmx_trigger="enter-split",
                  description="Enter split mode", hint_group="Editing"),
    ),
)

mode_modal, _, _ = render_keyboard_hints_modal(mode_mgr)
mode_html = to_xml(mode_modal)

# Mode-exit row appears
assert "Exit mode" in mode_html, \
    "Manager with a non-default mode (exit_key='Escape') must surface 'Exit mode' row in the modal"
# Mode chip with the mode name appears in the same row (V13 chip rendering)
assert ">split<" in mode_html, \
    "Mode-exit row must carry the V13 'split' mode chip"
# The mode-exit row lands in the Navigation group (not its own header)
# Sanity check: only one Navigation header in the modal, and it contains Exit mode
nav_count = mode_html.count(">Navigation<")
assert nav_count == 1, f"Manager-derived Navigation must remain a single header, got {nav_count}"


# --- Sentinel: hierarchy and mode-exit rows are decoupled ---
# A manager with BOTH activation wiring AND a mode emits BOTH rows.
combo_zone = FocusZone(
    id="combo-z", item_selector="li", navigation=LinearVertical(),
    activate_child_id="hd-child-a",
)
combo_mgr = ZoneManager(
    zones=(combo_zone,),
    modes=(split_mode,),
)
combo_modal, _, _ = render_keyboard_hints_modal(combo_mgr)
combo_html = to_xml(combo_modal)
assert "Activate panel" in combo_html, \
    "Manager with activation wiring must surface activate row even when modes present"
assert "Exit mode" in combo_html, \
    "Manager with mode must surface mode-exit row even when activation wiring present"

print("L1 hierarchy + mode-exit row regression-guards passed")
L1 hierarchy + mode-exit row regression-guards passed