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.
render_slot_card
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.
Focus emphasis styling (ring, shadow, border-radius) is applied on the focused section div, not on the slot itself. This keeps the shadow outside the section’s overflow-y-auto clipping boundary.
# 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)# Focus emphasis (ring/shadow/border-radius) is on the section, not the slotcard_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"notin html # Shadow moved to focused sectionassert"ring-1"notin html # Ring moved to focused sectionprint("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!")
Renders all viewport sections with OOB swap for granular updates. Returns OOB elements for the 3-section layout.
render_all_slots_oob
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.
# Test render_all_slots_oobstate = CardStackState(focused_index=2, visible_count=3)sections = render_all_slots_oob( card_items=items_list, state=state, config=config, ids=ids, urls=urls, render_card=simple_render)assertlen(sections) ==3html_before = to_xml(sections[0])assert'id="test-viewport-section-before"'in html_beforeassert'hx-swap-oob="innerHTML"'in html_beforeassert'touch-none'in html_before # Custom touch nav on before sectionhtml_focused = to_xml(sections[1])assert'id="test-viewport-section-focused"'in html_focusedassert'overflow-y-auto'in html_focusedassert'items-start'in html_focusedassert'touch-none'in html_focused # Default; JS toggles to pan-y on overflow# Focus emphasis styling on section (not slot)assert'shadow-lg'in html_focusedassert'ring-1'in html_focusedassert'rounded-box'in html_focusedhtml_after = to_xml(sections[2])assert'id="test-viewport-section-after"'in html_afterassert'touch-none'in html_after # Custom touch nav on after sectionprint("render_all_slots_oob tests passed!")
Grid Template Helpers
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.
render_card_stack_scrollbar
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.
render_viewport
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 html# touch-none on sections (not outer container) for per-section touch controlassert'touch-none'in html # Present on sections# Verify outer container does NOT have touch-noneouter_div = html.split(f'id="test-card-stack"')[1].split('>')[0]assert'touch-none'notin outer_div# Focused section overflow constraint for oversized cardsassert'overflow-y-auto'in htmlassert'items-start'in html # Top-aligned to prevent scroll origin clipping# Focus emphasis styling on focused section (not slot)assert'shadow-lg'in htmlassert'ring-1'in htmlassert'rounded-box'in html# No focus padding CSS vars — shadow/ring on section, not inside itassert'focus-padding'notin html# 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 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 html# Scrollbar always present with auto_hide=False (card stack default)assert'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!")