mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 17:51:09 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
375
docs/ux/interaction-patterns.md
Normal file
375
docs/ux/interaction-patterns.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# 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.
|
||||
|
||||
```javascript
|
||||
// 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.
|
||||
|
||||
```javascript
|
||||
// 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.
|
||||
|
||||
```javascript
|
||||
// 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
|
||||
|
||||
```html
|
||||
<button hx-post="/api/items/create/"
|
||||
hx-target="#items-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#loading">
|
||||
Create Item
|
||||
</button>
|
||||
```
|
||||
|
||||
### Form Submission
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```javascript
|
||||
// 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
|
||||
|
||||
```javascript
|
||||
// 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
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```css
|
||||
.htmx-request .loading-bar {
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### Modal Focus Trap
|
||||
|
||||
Modals automatically trap focus within their bounds:
|
||||
|
||||
```html
|
||||
{% 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:
|
||||
|
||||
```javascript
|
||||
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
|
||||
|
||||
```javascript
|
||||
// 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
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```html
|
||||
<!-- Works with or without JS -->
|
||||
<form method="post" action="/items/create/"
|
||||
hx-post="/items/create/"
|
||||
hx-target="#form-container">
|
||||
<!-- Form content -->
|
||||
</form>
|
||||
```
|
||||
|
||||
### Error Recovery
|
||||
|
||||
```javascript
|
||||
// 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
|
||||
|
||||
```javascript
|
||||
window.addEventListener('offline', () => {
|
||||
Alpine.store('toast').warning('You are offline', 0);
|
||||
});
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
Alpine.store('toast').success('Connection restored');
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user