Card stack viewport with 3-section CSS Grid layout, slot rendering,
Mode Sync Script
Synchronizes the keyboard navigation mode with the rendered UI state after HTMX swaps.
Click-to-Focus Overlay
Transparent overlay added to context card slots when click_to_focus is enabled. Intercepts all clicks, enforcing the navigate-first-then-interact pattern.
render_slot_card
Renders a single card for a viewport slot. Handles focused/context styling, placeholder rendering, click-to-focus overlays, and CardRenderContext construction.
def render_slot_card( slot_index:int, # Index of this slot in the viewport (0-based) focus_slot:int, # Which slot is the focused position card_items:List, # Full items list item_index:int, # Item index (negative or >= len for placeholder) render_card:Callable, # Callback: (item, CardRenderContext) -> FT state:CardStackState, # Current card stack state config:CardStackConfig, # Card stack configuration ids:CardStackHtmlIds, # HTML IDs for this instance urls:CardStackUrls, # URL bundle for navigation oob:bool=False, # Whether to render as OOB swap)->Any: # Slot content wrapper
Render a single card for a viewport slot.
# Test render_slot_cardfrom fasthtml.common import to_xml, P as FPfrom cjm_fasthtml_card_stack.core.config import _reset_prefix_counter_reset_prefix_counter()config = CardStackConfig(prefix="test")ids = CardStackHtmlIds(prefix="test")state = CardStackState()urls = CardStackUrls(nav_to_index="/card-stack/nav_to_index")items_list = ["Item A", "Item B", "Item C", "Item D", "Item E"]def simple_render(item, ctx):return FP(f"{item} [{ctx.card_role}]")# Test focused card (slot 1, center of 3, item_index=2)card_el = render_slot_card( slot_index=1, focus_slot=1, card_items=items_list, item_index=2, render_card=simple_render, state=state, config=config, ids=ids, urls=urls)html = to_xml(card_el)assert'id="test-item-slot-2"'in html # Item-based IDassert'tabindex="0"'in html # Focused is tabbableassert"Item C [focused]"in htmlassert"shadow-lg"in html # Focus shadowprint("Focused card test passed!")
Focused card test passed!
# Test context card (slot 0, item_index=1)card_el = render_slot_card( slot_index=0, focus_slot=1, card_items=items_list, item_index=1, render_card=simple_render, state=state, config=config, ids=ids, urls=urls)html = to_xml(card_el)assert'id="test-item-slot-1"'in html # Item-based IDassert'tabindex="-1"'in html # Context not tabbableassert"Item B [context]"in htmlassert"shadow-lg"notin html # No focus shadowassert'p-[var(--test-slot-padding)]'in html # Configurable slot paddingprint("Context card test passed!")
Context card test passed!
# Test placeholder card (negative item_index, slot 0 before focus)card_el = render_slot_card( slot_index=0, focus_slot=1, card_items=items_list, item_index=-1, render_card=simple_render, state=state, config=config, ids=ids, urls=urls)html = to_xml(card_el)assert'id="test-item-slot--1"'in html # Negative index for before-start placeholderassert"Beginning"in html # Before focus -> "start" placeholderprint("Placeholder card (before) test passed!")# Test placeholder card (item_index >= total_items, after focus)card_el = render_slot_card( slot_index=2, focus_slot=1, card_items=items_list, item_index=5, render_card=simple_render, state=state, config=config, ids=ids, urls=urls)html = to_xml(card_el)assert'id="test-item-slot-5"'in html # Index >= total for after-end placeholderassert"End"in html # After focus -> "end" placeholderprint("Placeholder card (after) test passed!")
Placeholder card (before) test passed!
Placeholder card (after) test passed!
# Test click-to-focus overlayclick_config = CardStackConfig(prefix="click", click_to_focus=True)click_ids = CardStackHtmlIds(prefix="click")card_el = render_slot_card( slot_index=0, focus_slot=1, card_items=items_list, item_index=1, render_card=simple_render, state=state, config=click_config, ids=click_ids, urls=urls)html = to_xml(card_el)assert'hx-post="/card-stack/nav_to_index"'in html # Overlay presentassert'relative'in html # Container has relative positioningassert'inset-0'in html # Overlay covers full area# Focused card should NOT have overlay even with click_to_focus=Truecard_el = render_slot_card( slot_index=1, focus_slot=1, card_items=items_list, item_index=2, render_card=simple_render, state=state, config=click_config, ids=click_ids, urls=urls)html = to_xml(card_el)assert'hx-post'notin html # No overlay on focused card# Placeholder should NOT have overlay even with click_to_focus=Truecard_el = render_slot_card( slot_index=0, focus_slot=1, card_items=items_list, item_index=-1, render_card=simple_render, state=state, config=click_config, ids=click_ids, urls=urls)html = to_xml(card_el)assert'hx-post'notin html # No overlay on placeholderprint("Click-to-focus overlay tests passed!")
def render_all_slots_oob( card_items:List, # All data items state:CardStackState, # Current card stack state config:CardStackConfig, # Card stack configuration ids:CardStackHtmlIds, # HTML IDs for this instance urls:CardStackUrls, # URL bundle for navigation render_card:Callable, # Card renderer callback)->List: # List of OOB elements (3 sections)
Render all viewport sections with OOB swap for granular updates.
The CSS Grid template is determined by the focus position intent and stays stable across visible_count changes: - Center (None): 1fr auto 1fr - Bottom (-1): 1fr auto - Top (0): auto 1fr
# Test grid template computation — stable across visible_count changesassert _grid_template_rows(None) =="1fr auto 1fr"# Center: always 3-sectionassert _grid_template_rows(-1) =="1fr auto"# Bottom: focused lastassert _grid_template_rows(0) =="auto 1fr"# Top: focused firstassert _grid_template_rows(2) =="1fr auto 1fr"# Custom positive: 3-sectionprint("Grid template tests passed!")
Grid template tests passed!
Scrollbar Integration
Maps card stack types to scrollbar lib types and renders the scrollbar alongside the card stack viewport.
def render_card_stack_scrollbar( state:CardStackState, # Card stack state config:CardStackConfig, # Card stack config total_items:int, # Total item count oob:bool=False, # Whether to include hx-swap-oob)->Any: # Scrollbar element (or hidden div if not needed)
Render the virtual scrollbar for a card stack instance.
render_viewport
Main viewport renderer. Builds the 3-section CSS Grid layout with opacity reveal pattern.
def render_viewport( card_items:List, # All data items state:CardStackState, # Current card stack state config:CardStackConfig, # Card stack configuration ids:CardStackHtmlIds, # HTML IDs for this instance urls:CardStackUrls, # URL bundle for navigation render_card:Callable, # Card renderer callback form_input_name:str='focused_index', # Name for the focused index hidden input)->Any: # Viewport component with 3-section layout
Render the card stack viewport with 3-section CSS Grid layout.
# Test render_viewportstate = CardStackState(focused_index=2, visible_count=5, card_width=60)viewport = render_viewport( card_items=items_list, state=state, config=config, ids=ids, urls=urls, render_card=simple_render)html = to_xml(viewport)assert'id="test-card-stack"'in htmlassert'id="test-card-stack-inner"'in htmlassert'id="test-viewport-section-before"'in htmlassert'id="test-viewport-section-focused"'in htmlassert'id="test-viewport-section-after"'in htmlassert'opacity-0'in html # Opacity reveal via Tailwind classassert'transition-opacity'in htmlassert'duration-150'in htmlassert'ease-in'in htmlassert'data-focused-index="2"'in htmlassert'data-total-items="5"'in htmlassert'data-visible-count="5"'in htmlassert'1fr auto 1fr'in html # Center focus gridassert'max-width: 60rem'in htmlassert'touch-none'in html # Touch gesture capture# Verify focused_index hidden input is includedassert'id="test-focused-index"'in htmlassert'name="focused_index"'in htmlassert'value="2"'in html# CSS custom properties on outer containerassert'--test-section-gap: 1rem'in htmlassert'--test-slot-padding: 0.25rem'in htmlassert'--test-viewport-padding-x: 0.5rem'in htmlassert'--test-viewport-padding-y: 0.5rem'in htmlassert'--test-focus-padding-x: 0.5rem'in htmlassert'--test-focus-padding-b: 1rem'in html# Arbitrary value Tailwind classes referencing CSS custom propertiesassert'gap-[var(--test-section-gap)]'in htmlassert'px-[var(--test-viewport-padding-x)]'in htmlassert'py-[var(--test-viewport-padding-y)]'in htmlassert'px-[var(--test-focus-padding-x)]'in htmlassert'pb-[var(--test-focus-padding-b)]'in html# Scrollbar always present with auto_hide=False (card stack default)# visible_count=5, total_items=5 → scrollbar still rendersassert'test-scrollbar-track'in htmlprint("render_viewport tests passed!")# Test scrollbar with many itemsstate_many = CardStackState(focused_index=2, visible_count=3, card_width=60)many_items = [f"Item {i}"for i inrange(20)]viewport = render_viewport( card_items=many_items, state=state_many, config=config, ids=ids, urls=urls, render_card=simple_render)html = to_xml(viewport)assert'test-scrollbar-track'in htmlassert'test-scrollbar-thumb'in htmlassert'data-total-items="20"'in htmlprint("render_viewport scrollbar with many items test passed!")# Test scrollbar hidden when show_scrollbar=Falsefrom cjm_fasthtml_card_stack.core.config import _reset_prefix_counter_reset_prefix_counter()no_sb_config = CardStackConfig(prefix="nosb", show_scrollbar=False)no_sb_ids = CardStackHtmlIds(prefix="nosb")viewport = render_viewport( card_items=many_items, state=state_many, config=no_sb_config, ids=no_sb_ids, urls=urls, render_card=simple_render)html = to_xml(viewport)assert'scrollbar-track'notin htmlprint("render_viewport show_scrollbar=False test passed!")
render_viewport tests passed!
render_viewport scrollbar with many items test passed!
render_viewport show_scrollbar=False test passed!
# Test bottom-anchored viewportstate = CardStackState(focused_index=2, visible_count=3, focus_position=-1)viewport = render_viewport( card_items=items_list, state=state, config=config, ids=ids, urls=urls, render_card=simple_render)html = to_xml(viewport)assert'grid-template-rows: 1fr auto;'in html # Bottom focus gridassert'Item C [focused]'in html # Item at index 2 is focusedprint("Bottom-anchored viewport test passed!")
Bottom-anchored viewport test passed!
# Test viewport content correctness with center focusstate = CardStackState(focused_index=2, visible_count=3)viewport = render_viewport( card_items=items_list, state=state, config=config, ids=ids, urls=urls, render_card=simple_render)html = to_xml(viewport)assert"Item B [context]"in html # Before focusedassert"Item C [focused]"in html # Focusedassert"Item D [context]"in html # After focusedassert"Item A"notin html # Not visibleassert"Item E"notin html # Not visibleprint("Viewport content correctness test passed!")