mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 10:11:09 -05:00
376 lines
8.2 KiB
Markdown
376 lines
8.2 KiB
Markdown
# 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');
|
|
});
|
|
```
|