Files
thrillwiki_django_no_react/docs/ux/interaction-patterns.md

8.2 KiB

ThrillWiki Interaction Patterns

This document describes the standardized interaction patterns used throughout ThrillWiki.

Alpine.js Stores

ThrillWiki uses Alpine.js stores for global client-side state management.

Toast Store

The toast store manages notification messages displayed to users.

// Show a success toast
Alpine.store('toast').success('Park saved successfully!');

// Show an error toast
Alpine.store('toast').error('Failed to save park');

// Show a warning toast
Alpine.store('toast').warning('Your session will expire soon');

// Show an info toast
Alpine.store('toast').info('New features are available');

// Toast with custom duration (in ms)
Alpine.store('toast').success('Quick message', 2000);

// Toast with action button
Alpine.store('toast').success('Item deleted', 5000, {
    action: {
        label: 'Undo',
        onClick: () => undoDelete()
    }
});

// Persistent toast (duration = 0)
Alpine.store('toast').error('Connection lost', 0);

Theme Store

The theme store manages dark/light mode preferences.

// Get current theme
const isDark = Alpine.store('theme').isDark;

// Toggle theme
Alpine.store('theme').toggle();

// Set specific theme
Alpine.store('theme').set('dark');
Alpine.store('theme').set('light');
Alpine.store('theme').set('system');

Auth Store

The auth store tracks user authentication state.

// Check if user is authenticated
if (Alpine.store('auth').isAuthenticated) {
    // Show authenticated content
}

// Get current user
const user = Alpine.store('auth').user;

HTMX Patterns

Standard Request Pattern

<button hx-post="/api/items/create/"
        hx-target="#items-list"
        hx-swap="innerHTML"
        hx-indicator="#loading">
    Create Item
</button>

Form Submission

<form hx-post="/items/create/"
      hx-target="#form-container"
      hx-swap="outerHTML"
      hx-indicator=".form-loading">
    {% csrf_token %}
    <!-- Form fields -->
    <button type="submit">Save</button>
</form>

Inline Validation

<input type="email"
       name="email"
       hx-post="/validate/email/"
       hx-trigger="blur changed delay:500ms"
       hx-target="next .field-feedback"
       hx-swap="innerHTML">
<div class="field-feedback"></div>

Infinite Scroll

<div id="items-container">
    {% for item in items %}
        {% include 'items/_card.html' %}
    {% endfor %}

    {% if has_next %}
    <div hx-get="?page={{ next_page }}"
         hx-trigger="revealed"
         hx-swap="outerHTML"
         hx-indicator="#scroll-loading">
        <div id="scroll-loading" class="htmx-indicator">
            Loading more...
        </div>
    </div>
    {% endif %}
</div>

Modal Loading

<button hx-get="/items/{{ item.id }}/edit/"
        hx-target="#modal-container"
        hx-swap="innerHTML"
        @click="showModal = true">
    Edit
</button>

<div id="modal-container" x-show="showModal">
    <!-- Modal content loaded here -->
</div>

Event Handling

HTMX Events

// Before request
document.body.addEventListener('htmx:beforeRequest', (event) => {
    // Show loading state
});

// After successful swap
document.body.addEventListener('htmx:afterSwap', (event) => {
    // Initialize new content
});

// Handle errors
document.body.addEventListener('htmx:responseError', (event) => {
    Alpine.store('toast').error('Request failed');
});

Custom Events

// Trigger from server via HX-Trigger header
document.body.addEventListener('showToast', (event) => {
    const { type, message, duration } = event.detail;
    Alpine.store('toast')[type](message, duration);
});

// Close modal event
document.body.addEventListener('closeModal', () => {
    Alpine.store('modal').close();
});

// Refresh section event
document.body.addEventListener('refreshSection', (event) => {
    const { target, url } = event.detail;
    htmx.ajax('GET', url, target);
});

Loading States

Button Loading

<button type="submit"
        x-data="{ loading: false }"
        @click="loading = true"
        :disabled="loading"
        :class="{ 'opacity-50 cursor-not-allowed': loading }">
    <span x-show="!loading">Save</span>
    <span x-show="loading" class="flex items-center gap-2">
        <i class="fas fa-spinner fa-spin"></i>
        Saving...
    </span>
</button>

Section Loading

<div id="content"
     hx-get="/content/"
     hx-trigger="load"
     hx-indicator="#content-loading">

    <div id="content-loading" class="htmx-indicator">
        {% include 'components/skeletons/list_skeleton.html' %}
    </div>
</div>

Global Loading Bar

The application includes a global loading bar that appears during HTMX requests:

.htmx-request .loading-bar {
    display: block;
}

Focus Management

Modal Focus Trap

Modals automatically trap focus within their bounds:

{% include 'components/modals/modal_base.html' with
    modal_id='my-modal'
    show_var='showModal'
    title='Modal Title'
%}

The modal component handles:

  • Focus moves to first focusable element on open
  • Tab cycles through modal elements only
  • Escape key closes modal
  • Focus returns to trigger element on close

Form Focus

After validation errors, focus moves to the first invalid field:

document.body.addEventListener('htmx:afterSwap', (event) => {
    const firstError = event.target.querySelector('.field-error input');
    if (firstError) {
        firstError.focus();
    }
});

Keyboard Navigation

Standard Shortcuts

Key Action
Escape Close modal/dropdown
Enter Submit form / Activate button
Tab Move to next focusable element
Shift+Tab Move to previous focusable element
Arrow Up/Down Navigate dropdown options

Custom Shortcuts

// Global keyboard shortcuts
document.addEventListener('keydown', (event) => {
    // Ctrl/Cmd + K: Open search
    if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
        event.preventDefault();
        Alpine.store('search').open();
    }
});

Animation Patterns

Entry Animations

<div x-show="visible"
     x-transition:enter="transition ease-out duration-200"
     x-transition:enter-start="opacity-0 scale-95"
     x-transition:enter-end="opacity-100 scale-100"
     x-transition:leave="transition ease-in duration-150"
     x-transition:leave-start="opacity-100 scale-100"
     x-transition:leave-end="opacity-0 scale-95">
    Content
</div>

Slide Animations

<div x-show="visible"
     x-transition:enter="transition ease-out duration-300"
     x-transition:enter-start="opacity-0 translate-y-4"
     x-transition:enter-end="opacity-100 translate-y-0"
     x-transition:leave="transition ease-in duration-200"
     x-transition:leave-start="opacity-100 translate-y-0"
     x-transition:leave-end="opacity-0 translate-y-4">
    Content
</div>

Stagger Animations

<template x-for="(item, index) in items" :key="item.id">
    <div x-show="visible"
         x-transition:enter="transition ease-out duration-300"
         x-transition:enter-start="opacity-0 translate-y-4"
         x-transition:enter-end="opacity-100 translate-y-0"
         :style="{ transitionDelay: `${index * 50}ms` }">
        <!-- Item content -->
    </div>
</template>

Error Handling

Graceful Degradation

All interactions work without JavaScript:

<!-- Works with or without JS -->
<form method="post" action="/items/create/"
      hx-post="/items/create/"
      hx-target="#form-container">
    <!-- Form content -->
</form>

Error Recovery

// Retry failed requests
document.body.addEventListener('htmx:responseError', (event) => {
    const { xhr, target } = event.detail;

    if (xhr.status >= 500) {
        Alpine.store('toast').error('Server error. Please try again.', 0, {
            action: {
                label: 'Retry',
                onClick: () => htmx.trigger(target, 'htmx:load')
            }
        });
    }
});

Offline Handling

window.addEventListener('offline', () => {
    Alpine.store('toast').warning('You are offline', 0);
});

window.addEventListener('online', () => {
    Alpine.store('toast').success('Connection restored');
});