Viewport

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.


source

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.

# Test render_slot_card
from fasthtml.common import to_xml, P as FP
from 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 ID
assert 'tabindex="0"' in html  # Focused is tabbable
assert "Item C [focused]" in html
assert "shadow-lg" in html  # Focus shadow
print("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 ID
assert 'tabindex="-1"' in html  # Context not tabbable
assert "Item B [context]" in html
assert "shadow-lg" not in html  # No focus shadow
assert 'p-[var(--test-slot-padding)]' in html  # Configurable slot padding
print("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 placeholder
assert "Beginning" in html  # Before focus -> "start" placeholder
print("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 placeholder
assert "End" in html  # After focus -> "end" placeholder
print("Placeholder card (after) test passed!")
Placeholder card (before) test passed!
Placeholder card (after) test passed!
# Test click-to-focus overlay
click_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 present
assert 'relative' in html  # Container has relative positioning
assert 'inset-0' in html  # Overlay covers full area

# Focused card should NOT have overlay even with click_to_focus=True
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=click_config, ids=click_ids, urls=urls
)
html = to_xml(card_el)
assert 'hx-post' not in html  # No overlay on focused card

# Placeholder should NOT have overlay even with click_to_focus=True
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' not in html  # No overlay on placeholder
print("Click-to-focus overlay tests passed!")
Click-to-focus overlay tests passed!
# Test CardRenderContext is correctly populated
captured_contexts = []

def capturing_render(item, ctx):
    captured_contexts.append(ctx)
    return FP(item)

captured_contexts.clear()
render_slot_card(
    slot_index=1, focus_slot=1, card_items=items_list, item_index=0,
    render_card=capturing_render, state=CardStackState(active_mode="edit", card_scale=75),
    config=config, ids=ids, urls=urls
)
ctx = captured_contexts[0]
assert ctx.card_role == "focused"
assert ctx.index == 0
assert ctx.total_items == 5
assert ctx.is_first == True
assert ctx.is_last == False
assert ctx.active_mode == "edit"
assert ctx.card_scale == 75
assert ctx.distance_from_focus == 0
print("CardRenderContext population test passed!")
CardRenderContext population test passed!

render_all_slots_oob

Renders all viewport sections with OOB swap for granular updates. Returns OOB elements for the 3-section layout.


source

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_oob
state = 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
)
assert len(sections) == 3

html_before = to_xml(sections[0])
assert 'id="test-viewport-section-before"' in html_before
assert 'hx-swap-oob="innerHTML"' in html_before

html_focused = to_xml(sections[1])
assert 'id="test-viewport-section-focused"' in html_focused

html_after = to_xml(sections[2])
assert 'id="test-viewport-section-after"' in html_after
print("render_all_slots_oob tests passed!")
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 changes
assert _grid_template_rows(None) == "1fr auto 1fr"   # Center: always 3-section
assert _grid_template_rows(-1) == "1fr auto"          # Bottom: focused last
assert _grid_template_rows(0) == "auto 1fr"           # Top: focused first
assert _grid_template_rows(2) == "1fr auto 1fr"       # Custom positive: 3-section
print("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.


source

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.


source

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_viewport
state = 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 html
assert 'id="test-card-stack-inner"' in html
assert 'id="test-viewport-section-before"' in html
assert 'id="test-viewport-section-focused"' in html
assert 'id="test-viewport-section-after"' in html
assert 'opacity-0' in html  # Opacity reveal via Tailwind class
assert 'transition-opacity' in html
assert 'duration-150' in html
assert 'ease-in' in html
assert 'data-focused-index="2"' in html
assert 'data-total-items="5"' in html
assert 'data-visible-count="5"' in html
assert '1fr auto 1fr' in html  # Center focus grid
assert 'max-width: 60rem' in html
assert 'touch-none' in html  # Touch gesture capture

# Verify focused_index hidden input is included
assert 'id="test-focused-index"' in html
assert 'name="focused_index"' in html
assert 'value="2"' in html

# CSS custom properties on outer container
assert '--test-section-gap: 1rem' in html
assert '--test-slot-padding: 0.25rem' in html
assert '--test-viewport-padding-x: 0.5rem' in html
assert '--test-viewport-padding-y: 0.5rem' in html
assert '--test-focus-padding-x: 0.5rem' in html
assert '--test-focus-padding-b: 1rem' in html

# Arbitrary value Tailwind classes referencing CSS custom properties
assert 'gap-[var(--test-section-gap)]' in html
assert 'px-[var(--test-viewport-padding-x)]' in html
assert 'py-[var(--test-viewport-padding-y)]' in html
assert 'px-[var(--test-focus-padding-x)]' in html
assert '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 renders
assert 'test-scrollbar-track' in html
print("render_viewport tests passed!")

# Test scrollbar with many items
state_many = CardStackState(focused_index=2, visible_count=3, card_width=60)
many_items = [f"Item {i}" for i in range(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 html
assert 'test-scrollbar-thumb' in html
assert 'data-total-items="20"' in html
print("render_viewport scrollbar with many items test passed!")

# Test scrollbar hidden when show_scrollbar=False
from 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' not in html
print("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 viewport
state = 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 grid
assert 'Item C [focused]' in html  # Item at index 2 is focused
print("Bottom-anchored viewport test passed!")
Bottom-anchored viewport test passed!
# Test viewport content correctness with center focus
state = 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 focused
assert "Item C [focused]" in html   # Focused
assert "Item D [context]" in html   # After focused
assert "Item A" not in html          # Not visible
assert "Item E" not in html          # Not visible
print("Viewport content correctness test passed!")
Viewport content correctness test passed!