JS: Core

Master composer for card stack JavaScript. Combines viewport height,

Master Coordinator

Applies all viewport settings (width, height, scroll, touch) then reveals the viewport using a double-RAF pattern. Also handles HTMX afterSettle events.

Global Callback Registration

The cjm-fasthtml-keyboard-navigation library looks up JS callbacks via window[action.jsCallback](). Since our functions live on the namespaced ns object, we register global wrappers that delegate to the namespace.


global_callback_name


def global_callback_name(
    prefix:str, # Card stack instance prefix
    callback:str, # Base callback name (e.g., "jumpPageUp")
)->str: # Global function name (e.g., "cs0_jumpPageUp")

Generate a prefix-unique global callback name for keyboard navigation.

generate_card_stack_js

The main entry point that composes all JS fragments into a single namespaced IIFE. The consumer calls this once in their step renderer.


generate_card_stack_js


def generate_card_stack_js(
    ids:CardStackHtmlIds, # HTML IDs for this instance
    button_ids:CardStackButtonIds, # Button IDs for keyboard triggers
    config:CardStackConfig, # Card stack configuration
    urls:CardStackUrls, # URL bundle for routing
    container_id:str='', # Consumer's parent container ID (for height calc)
    extra_scripts:Tuple=(), # Additional JS to include in the IIFE
    focus_position:Optional=None, # Focus slot offset (None=center, -1=bottom, 0=top)
)->Any: # Script element with all card stack JavaScript

Compose all card stack JS into a single namespaced IIFE.

from cjm_fasthtml_card_stack.core.config import _reset_prefix_counter

# Test setup: shared fixtures for composition tests
_reset_prefix_counter()
config = CardStackConfig()
ids = CardStackHtmlIds(prefix=config.prefix)
btn = CardStackButtonIds(prefix=config.prefix)
urls = CardStackUrls(
    nav_up="/cs/nav_up", nav_down="/cs/nav_down",
    nav_first="/cs/nav_first", nav_last="/cs/nav_last",
    nav_page_up="/cs/nav_page_up", nav_page_down="/cs/nav_page_down",
    nav_to_index="/cs/nav_to_index",
    update_viewport="/cs/update_viewport",
    save_width="/cs/save_width", save_scale="/cs/save_scale",
)

script = generate_card_stack_js(ids, btn, config, urls, container_id="my-app")
js_text = script.children[0] if script.children else ""

# Namespace setup
assert "window.cardStacks" in js_text
assert f"'{config.prefix}'" in js_text

# All sections present in composed output
for section in [
    "Viewport Height", "Scroll Navigation", "Touch Navigation",
    "Page Navigation", "Width Management", "Scale Management",
    "Card Count Management", "Auto Visible Count Adjustment",
    "Grid Template Management", "Focused Section Constraint",
    "Boundary Index Helpers",
    "Global Keyboard Callbacks", "Master Coordinator", "HTMX Event Listeners",
]:
    assert section in js_text, f"Missing section: {section}"

# Focused section constraint reads rowGap and sets maxHeight
assert f"'{ids.viewport_section_focused}'" in js_text
assert "rowGap" in js_text
assert "maxHeight" in js_text
# constrainFocusedSection called after recalculateHeight in coordinator
assert "ns.constrainFocusedSection" in js_text

# Touch-action toggle: pan-y for overflow, none for normal
assert "scrollHeight" in js_text
assert "touchAction" in js_text
assert "'pan-y'" in js_text

# Scroll-to-top before height calculation (fixes HTMX navigation scroll position issue)
assert "window.scrollTo(0, 0)" in js_text, "Missing scroll-to-top in initialization"

# Zone activation in scroll and touch handlers
assert f"setActiveZone('{ids.card_stack}')" in js_text

# Scrollbar JS IIFE included (show_scrollbar=True by default)
assert "Virtual Scrollbar" in js_text
assert f"{config.prefix}-scrollbar-track" in js_text
assert f"{config.prefix}-scrollbar-thumb" in js_text
assert "dataset.position" in js_text  # Self-contained position sync from track
assert "/cs/nav_to_index" in js_text  # Posts to nav_to_index URL

# Scrollbar zone activation callback
assert "scrollbarActivate" in js_text

# --- Boundary no-op guard ---
# Helpers read from focused_index_input (always fresh via OOB), NOT card-stack
# data attrs (stale — only set on initial render).
assert "ns._getFocusedIndex" in js_text
assert "ns._getTotalItems" in js_text
assert f"'{ids.focused_index_input}'" in js_text
assert "input.value" in js_text
assert "dataset.totalItems" in js_text
# Up/down button ID sets populated with all six nav buttons
assert "_UP_BTN_IDS" in js_text
assert "_DOWN_BTN_IDS" in js_text
for bid in (btn.nav_up, btn.nav_page_up, btn.nav_first,
            btn.nav_down, btn.nav_page_down, btn.nav_last):
    assert f"'{bid}'" in js_text, f"Missing nav button ID: {bid}"
# htmx:beforeRequest listener wired with preventDefault on boundary
assert "htmx:beforeRequest" in js_text
assert "_beforeRequestHandler" in js_text
assert "evt.preventDefault()" in js_text

print("Composition: namespace, section presence, zone activation, scrollbar, and boundary guard tests passed!")
Composition: namespace, section presence, zone activation, scrollbar, and boundary guard tests passed!
# Test key namespace functions are exposed
for fn in [
    "ns.updateWidth", "ns.updateScale", "ns.updateCardCount",
    "ns.handleCountChange", "ns._autoUpdateCount", "ns._runAutoAdjust",
    "ns.triggerAutoAdjust", "ns.applyGridTemplate", "ns.constrainFocusedSection",
    "ns.jumpToFirstItem", "ns.jumpToLastItem", "ns.applyAllViewportSettings",
    "ns.recalculateHeight", "ns.decreaseWidth", "ns.increaseWidth",
    "ns.decreaseScale", "ns.increaseScale",
    "ns._setupScrollNav", "ns._setupTouchNav", "ns.syncCountDropdown",
    "ns._setupSiblingObserver",
]:
    assert fn in js_text, f"Missing function: {fn}"

# Touch navigation (Pointer Events API)
assert "pointerdown" in js_text
assert "pointermove" in js_text
assert "setPointerCapture" in js_text
assert "momentumTick" in js_text
print("Composition: namespace function exposure tests passed!")
# Test auto-adjust and growth validation in composed output
assert "_autoAdjusting" in js_text
assert "_autoGrowing" in js_text
assert "_preGrowthItemIds" in js_text
assert "_snapshotItemIds" in js_text
assert "_hideNewItems" in js_text
assert "_revealNewItems" in js_text
assert "_validateGrowth" in js_text
assert "ns._cancelAutoGrowth" in js_text
assert "htmx:afterSwap" in js_text

# Card count uses swap:'none' for OOB updates
assert "swap: 'none',\n                    values: { visible_count:" in js_text
print("Composition: auto-adjust and growth validation tests passed!")
Composition: auto-adjust and growth validation tests passed!
# Test global callback wrappers and HTMX listener cleanup
prefix = config.prefix
for cb in ["jumpPageUp", "jumpToFirstItem", "decreaseWidth", "increaseWidth",
           "decreaseScale", "increaseScale"]:
    assert f"window['{prefix}_{cb}']" in js_text, f"Missing global callback: {cb}"

# HTMX listeners use remove-and-replace pattern
handler_key = f"_csHandlers_{config.prefix.replace('-', '_')}"
assert f"window.{handler_key}" in js_text
assert "removeEventListener" in js_text
assert "_afterSwapHandler" in js_text
assert "_afterSettleHandler" in js_text
print("Composition: global callbacks and HTMX listener tests passed!")
Composition: global callbacks and HTMX listener tests passed!
# Test focus_position parameter produces correct JS literals
_reset_prefix_counter()
_fp_config = CardStackConfig()
_fp_ids = CardStackHtmlIds(prefix=_fp_config.prefix)
_fp_btn = CardStackButtonIds(prefix=_fp_config.prefix)

# Default (None) → JS null (center focus)
_fp_script = generate_card_stack_js(_fp_ids, _fp_btn, _fp_config, urls)
_fp_js = _fp_script.children[0] if _fp_script.children else ""
assert "const focusPosRaw = null;" in _fp_js

# Bottom focus (-1)
_fp_script_bottom = generate_card_stack_js(_fp_ids, _fp_btn, _fp_config, urls, focus_position=-1)
_fp_js_bottom = _fp_script_bottom.children[0] if _fp_script_bottom.children else ""
assert "const focusPosRaw = -1;" in _fp_js_bottom

# Top focus (0)
_fp_script_top = generate_card_stack_js(_fp_ids, _fp_btn, _fp_config, urls, focus_position=0)
_fp_js_top = _fp_script_top.children[0] if _fp_script_top.children else ""
assert "const focusPosRaw = 0;" in _fp_js_top

print("Focus position parameter tests passed!")
Focus position parameter tests passed!
# Test extra_scripts injection
_reset_prefix_counter()
config2 = CardStackConfig()
ids2 = CardStackHtmlIds(prefix=config2.prefix)
btn2 = CardStackButtonIds(prefix=config2.prefix)

script2 = generate_card_stack_js(
    ids2, btn2, config2, urls,
    extra_scripts=("ns.customFunction = function() { console.log('custom'); };",)
)
js2 = script2.children[0] if script2.children else ""
assert "ns.customFunction" in js2
print("Extra scripts injection test passed!")
Extra scripts injection test passed!
# Test with disable_scroll_in_modes
config3 = CardStackConfig(prefix="split-test", disable_scroll_in_modes=("split",))
ids3 = CardStackHtmlIds(prefix=config3.prefix)
btn3 = CardStackButtonIds(prefix=config3.prefix)

script3 = generate_card_stack_js(ids3, btn3, config3, urls)
js3 = script3.children[0] if script3.children else ""
assert "isScrollDisabled" in js3
assert "isTouchDisabled" in js3
assert "'split'" in js3
print("Scroll/touch mode disabling in composed JS test passed!")

# Auto-adjust is always included (no longer optional)
assert "Auto Visible Count Adjustment" in js3
assert "_snapshotItemIds" in js3
assert "_revealNewItems" in js3
assert "ns.handleCountChange" in js3
print("Auto-adjust always included test passed!")