mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 14:11:08 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
165
docs/ux/README.md
Normal file
165
docs/ux/README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# ThrillWiki UX Documentation
|
||||
|
||||
This directory contains comprehensive UX documentation for the ThrillWiki Django application.
|
||||
|
||||
## Overview
|
||||
|
||||
ThrillWiki uses a modern stack combining:
|
||||
- **Django Templates** for server-side rendering
|
||||
- **HTMX** for seamless AJAX interactions
|
||||
- **Alpine.js** for client-side state management
|
||||
- **Tailwind CSS** for styling
|
||||
|
||||
## Documentation Index
|
||||
|
||||
### Core Patterns
|
||||
|
||||
- [Interaction Patterns](./interaction-patterns.md) - Alpine.js stores, HTMX patterns, event handling
|
||||
- [HTMX Conventions](./htmx-conventions.md) - Standardized HTMX usage patterns
|
||||
- [CTA Guidelines](./cta-guidelines.md) - Button placement and action patterns
|
||||
|
||||
### Components
|
||||
|
||||
- [Component Library](./component-library.md) - Catalog of all reusable components
|
||||
- [Navigation Components](../backend/templates/components/navigation/README.md)
|
||||
- [Form Components](../backend/templates/forms/README.md)
|
||||
- [HTMX Components](../backend/templates/htmx/components/README.md)
|
||||
|
||||
### Developer Resources
|
||||
|
||||
- [Developer Guidelines](./developer-guidelines.md) - Best practices for UX development
|
||||
- [Migration Guide](./migration-guide.md) - Updating existing templates
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Breadcrumbs
|
||||
|
||||
```python
|
||||
# In your view
|
||||
from apps.core.utils.breadcrumbs import build_breadcrumb
|
||||
|
||||
def park_detail(request, slug):
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
request.breadcrumbs = [
|
||||
build_breadcrumb('Home', '/', icon='fas fa-home'),
|
||||
build_breadcrumb('Parks', reverse('parks:list')),
|
||||
build_breadcrumb(park.name, is_current=True),
|
||||
]
|
||||
return render(request, 'parks/detail.html', {'park': park})
|
||||
```
|
||||
|
||||
### Using Toast Notifications
|
||||
|
||||
```javascript
|
||||
// From JavaScript
|
||||
Alpine.store('toast').success('Item saved successfully!');
|
||||
Alpine.store('toast').error('Something went wrong');
|
||||
|
||||
// With action
|
||||
Alpine.store('toast').success('Item deleted', 5000, {
|
||||
action: { label: 'Undo', onClick: () => undoDelete() }
|
||||
});
|
||||
```
|
||||
|
||||
```python
|
||||
# From Django view
|
||||
from apps.core.htmx_utils import htmx_success
|
||||
|
||||
def save_item(request):
|
||||
# ... save logic ...
|
||||
return htmx_success('Item saved successfully!')
|
||||
```
|
||||
|
||||
### Using Skeleton Screens
|
||||
|
||||
```django
|
||||
{# Show skeleton while loading #}
|
||||
<div id="parks-list"
|
||||
hx-get="/parks/"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Using Page Headers
|
||||
|
||||
```django
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Parks'
|
||||
subtitle='Browse theme parks worldwide'
|
||||
icon='fas fa-map-marker-alt'
|
||||
%}
|
||||
```
|
||||
|
||||
### Using Modals
|
||||
|
||||
```django
|
||||
<div x-data="{ showModal: false }">
|
||||
<button @click="showModal = true" class="btn btn-primary">Open Modal</button>
|
||||
|
||||
{% include 'components/modals/modal_base.html' with
|
||||
modal_id='my-modal'
|
||||
title='Modal Title'
|
||||
show_var='showModal'
|
||||
%}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Django Templates │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Pages │ │ Partials │ │ Components │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ - parks/ │ │ - _list.html│ │ - navigation/ │ │
|
||||
│ │ - rides/ │ │ - _form.html│ │ - forms/ │ │
|
||||
│ │ - auth/ │ │ - _card.html│ │ - modals/ │ │
|
||||
│ └─────────────┘ └─────────────┘ │ - skeletons/ │ │
|
||||
│ │ - ui/ │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Client-Side Layer │
|
||||
│ ┌─────────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ HTMX │ │ Alpine.js │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Partial updates │ │ - UI state management │ │
|
||||
│ │ - Form validation │ │ - Toast notifications │ │
|
||||
│ │ - Infinite scroll │ │ - Search components │ │
|
||||
│ │ - Modal loading │ │ - Theme switching │ │
|
||||
│ └─────────────────────┘ └────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### 1. Progressive Enhancement
|
||||
|
||||
All interactions work without JavaScript, with HTMX and Alpine.js enhancing the experience.
|
||||
|
||||
### 2. Consistent Feedback
|
||||
|
||||
Every user action provides immediate feedback:
|
||||
- Loading states during HTMX requests
|
||||
- Toast notifications for success/error
|
||||
- Inline validation for forms
|
||||
|
||||
### 3. Accessibility First
|
||||
|
||||
All components include:
|
||||
- Proper ARIA attributes
|
||||
- Keyboard navigation
|
||||
- Screen reader support
|
||||
- Focus management
|
||||
|
||||
### 4. Responsive Design
|
||||
|
||||
Components adapt to screen size:
|
||||
- Mobile-first approach
|
||||
- Stacked layouts on small screens
|
||||
- Collapsed navigation on mobile
|
||||
453
docs/ux/component-library.md
Normal file
453
docs/ux/component-library.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# ThrillWiki Component Library
|
||||
|
||||
This document catalogs all reusable UI components available in ThrillWiki.
|
||||
|
||||
## Layout Components
|
||||
|
||||
### Page Header
|
||||
|
||||
Standardized header for pages with optional actions and breadcrumbs.
|
||||
|
||||
```django
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Parks'
|
||||
subtitle='Browse theme parks worldwide'
|
||||
icon='fas fa-map-marker-alt'
|
||||
primary_action_url='/parks/create/'
|
||||
primary_action_text='Add Park'
|
||||
primary_action_icon='fas fa-plus'
|
||||
%}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `title` | string | Page title (required) |
|
||||
| `subtitle` | string | Optional subtitle |
|
||||
| `icon` | string | Icon class for title |
|
||||
| `primary_action_url` | string | URL for primary action button |
|
||||
| `primary_action_text` | string | Text for primary action |
|
||||
| `primary_action_icon` | string | Icon for primary action |
|
||||
| `secondary_action_url` | string | URL for secondary action |
|
||||
| `secondary_action_text` | string | Text for secondary action |
|
||||
|
||||
### Action Bar
|
||||
|
||||
Container for action buttons with consistent layout.
|
||||
|
||||
```django
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
align='between'
|
||||
primary_action_text='Save'
|
||||
secondary_action_text='Preview'
|
||||
tertiary_action_text='Cancel'
|
||||
tertiary_action_url='/back/'
|
||||
%}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `align` | string | 'right' | 'left', 'right', 'center', 'between' |
|
||||
| `mobile_stack` | boolean | true | Stack vertically on mobile |
|
||||
| `show_border` | boolean | false | Show top border |
|
||||
| `primary_action_*` | various | - | Primary button configuration |
|
||||
| `secondary_action_*` | various | - | Secondary button configuration |
|
||||
| `tertiary_action_*` | various | - | Tertiary button configuration |
|
||||
|
||||
## Navigation Components
|
||||
|
||||
### Breadcrumbs
|
||||
|
||||
Hierarchical navigation with Schema.org support.
|
||||
|
||||
```django
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
```
|
||||
|
||||
Set breadcrumbs in your view:
|
||||
|
||||
```python
|
||||
from apps.core.utils import build_breadcrumb
|
||||
|
||||
def park_detail(request, slug):
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
request.breadcrumbs = [
|
||||
build_breadcrumb('Home', '/', icon='fas fa-home'),
|
||||
build_breadcrumb('Parks', reverse('parks:list')),
|
||||
build_breadcrumb(park.name, is_current=True),
|
||||
]
|
||||
return render(request, 'parks/detail.html', {'park': park})
|
||||
```
|
||||
|
||||
## Modal Components
|
||||
|
||||
### Modal Base
|
||||
|
||||
Base modal component with customizable content.
|
||||
|
||||
```django
|
||||
{% include 'components/modals/modal_base.html' with
|
||||
modal_id='edit-modal'
|
||||
show_var='showEditModal'
|
||||
title='Edit Park'
|
||||
subtitle='Update park information'
|
||||
icon='fas fa-edit'
|
||||
size='lg'
|
||||
animation='scale'
|
||||
%}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `modal_id` | string | required | Unique identifier |
|
||||
| `show_var` | string | required | Alpine.js show variable |
|
||||
| `title` | string | - | Modal title |
|
||||
| `subtitle` | string | - | Modal subtitle |
|
||||
| `icon` | string | - | Title icon class |
|
||||
| `size` | string | 'md' | 'sm', 'md', 'lg', 'xl', 'full' |
|
||||
| `animation` | string | 'scale' | 'scale', 'slide-up', 'fade' |
|
||||
| `close_on_backdrop` | boolean | true | Close when clicking backdrop |
|
||||
| `close_on_escape` | boolean | true | Close on Escape key |
|
||||
| `show_close_button` | boolean | true | Show X button |
|
||||
| `prevent_scroll` | boolean | true | Prevent body scroll |
|
||||
|
||||
### Confirmation Modal
|
||||
|
||||
Pre-styled confirmation dialog.
|
||||
|
||||
```django
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='delete-confirm'
|
||||
show_var='showDeleteModal'
|
||||
title='Delete Park'
|
||||
message='Are you sure? This cannot be undone.'
|
||||
confirm_text='Delete'
|
||||
confirm_variant='destructive'
|
||||
confirm_hx_delete='/parks/123/'
|
||||
%}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `modal_id` | string | required | Unique identifier |
|
||||
| `show_var` | string | required | Alpine.js show variable |
|
||||
| `title` | string | required | Modal title |
|
||||
| `message` | string | required | Confirmation message |
|
||||
| `icon` | string | auto | Icon class |
|
||||
| `icon_variant` | string | 'warning' | 'destructive', 'warning', 'info', 'success' |
|
||||
| `confirm_text` | string | 'Confirm' | Confirm button text |
|
||||
| `confirm_variant` | string | 'primary' | 'destructive', 'primary', 'warning' |
|
||||
| `cancel_text` | string | 'Cancel' | Cancel button text |
|
||||
| `confirm_url` | string | - | URL for confirm (makes it a link) |
|
||||
| `confirm_hx_post` | string | - | HTMX POST URL |
|
||||
| `confirm_hx_delete` | string | - | HTMX DELETE URL |
|
||||
| `on_confirm` | string | - | Alpine.js expression |
|
||||
|
||||
## Skeleton Components
|
||||
|
||||
### List Skeleton
|
||||
|
||||
```django
|
||||
{% include 'components/skeletons/list_skeleton.html' with rows=5 %}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `rows` | number | 5 | Number of skeleton rows |
|
||||
| `show_avatar` | boolean | false | Show avatar placeholder |
|
||||
| `show_meta` | boolean | true | Show metadata line |
|
||||
| `show_action` | boolean | true | Show action button placeholder |
|
||||
|
||||
### Card Grid Skeleton
|
||||
|
||||
```django
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 cols=3 %}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `cards` | number | 6 | Number of skeleton cards |
|
||||
| `cols` | number | 3 | Grid columns (sm screens) |
|
||||
| `show_image` | boolean | true | Show image placeholder |
|
||||
| `show_badge` | boolean | false | Show badge placeholder |
|
||||
|
||||
### Detail Skeleton
|
||||
|
||||
```django
|
||||
{% include 'components/skeletons/detail_skeleton.html' %}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `show_image` | boolean | true | Show hero image placeholder |
|
||||
| `show_sidebar` | boolean | true | Show sidebar section |
|
||||
| `content_lines` | number | 8 | Number of content lines |
|
||||
|
||||
### Table Skeleton
|
||||
|
||||
```django
|
||||
{% include 'components/skeletons/table_skeleton.html' with rows=10 columns=5 %}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `rows` | number | 5 | Number of table rows |
|
||||
| `columns` | number | 4 | Number of columns |
|
||||
| `show_checkbox` | boolean | false | Show checkbox column |
|
||||
| `show_actions` | boolean | true | Show actions column |
|
||||
|
||||
### Form Skeleton
|
||||
|
||||
```django
|
||||
{% include 'components/skeletons/form_skeleton.html' with fields=4 %}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `fields` | number | 4 | Number of form fields |
|
||||
| `show_textarea` | boolean | true | Include textarea field |
|
||||
| `columns` | number | 1 | Form columns (1 or 2) |
|
||||
|
||||
## Form Components
|
||||
|
||||
### Form Field
|
||||
|
||||
Standardized form field with label, input, and validation.
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/form_field.html' with
|
||||
field=form.name
|
||||
help_text='Enter the full park name'
|
||||
size='lg'
|
||||
%}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `field` | Field | required | Django form field |
|
||||
| `label` | string | field.label | Override label |
|
||||
| `help_text` | string | field.help_text | Help text |
|
||||
| `size` | string | 'md' | 'sm', 'md', 'lg' |
|
||||
| `show_label` | boolean | true | Show label |
|
||||
| `inline` | boolean | false | Inline layout |
|
||||
|
||||
### Form Actions
|
||||
|
||||
Form submission buttons.
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/form_actions.html' with
|
||||
submit_text='Save Park'
|
||||
cancel_url='/parks/'
|
||||
%}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `submit_text` | string | 'Submit' | Submit button text |
|
||||
| `submit_icon` | string | - | Submit button icon |
|
||||
| `cancel_url` | string | - | Cancel button URL |
|
||||
| `cancel_text` | string | 'Cancel' | Cancel button text |
|
||||
| `show_cancel` | boolean | true | Show cancel button |
|
||||
|
||||
### Field Error
|
||||
|
||||
Display field validation errors.
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors %}
|
||||
```
|
||||
|
||||
### Field Success
|
||||
|
||||
Display field validation success.
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/field_success.html' with message='Available' %}
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### Button
|
||||
|
||||
```html
|
||||
<button class="btn btn-primary">Primary</button>
|
||||
<button class="btn btn-secondary">Secondary</button>
|
||||
<button class="btn btn-outline">Outline</button>
|
||||
<button class="btn btn-ghost">Ghost</button>
|
||||
<button class="btn btn-link">Link</button>
|
||||
<button class="btn btn-destructive">Destructive</button>
|
||||
```
|
||||
|
||||
**Sizes:**
|
||||
```html
|
||||
<button class="btn btn-primary btn-sm">Small</button>
|
||||
<button class="btn btn-primary">Default</button>
|
||||
<button class="btn btn-primary btn-lg">Large</button>
|
||||
```
|
||||
|
||||
### Card
|
||||
|
||||
```django
|
||||
{% include 'components/ui/card.html' with
|
||||
title='Card Title'
|
||||
subtitle='Card subtitle'
|
||||
%}
|
||||
<p>Card content goes here.</p>
|
||||
{% endinclude %}
|
||||
```
|
||||
|
||||
### Input
|
||||
|
||||
```django
|
||||
{% include 'components/ui/input.html' with
|
||||
name='email'
|
||||
type='email'
|
||||
placeholder='Enter email'
|
||||
required=True
|
||||
%}
|
||||
```
|
||||
|
||||
### Status Badge
|
||||
|
||||
```django
|
||||
{% include 'components/status_badge.html' with
|
||||
status='published'
|
||||
label='Published'
|
||||
%}
|
||||
```
|
||||
|
||||
**Status variants:**
|
||||
- `published` / `active` / `approved` - Green
|
||||
- `draft` / `pending` - Yellow
|
||||
- `rejected` / `archived` - Red
|
||||
- `review` - Blue
|
||||
|
||||
### Icon
|
||||
|
||||
```django
|
||||
{% include 'components/ui/icon.html' with
|
||||
icon='fas fa-star'
|
||||
size='lg'
|
||||
color='primary'
|
||||
%}
|
||||
```
|
||||
|
||||
### Dialog (Alert Dialog)
|
||||
|
||||
```django
|
||||
{% include 'components/ui/dialog.html' with
|
||||
dialog_id='alert-dialog'
|
||||
show_var='showAlert'
|
||||
title='Alert'
|
||||
message='This is an alert message.'
|
||||
confirm_text='OK'
|
||||
%}
|
||||
```
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
### From JavaScript
|
||||
|
||||
```javascript
|
||||
// Success toast
|
||||
Alpine.store('toast').success('Saved successfully!');
|
||||
|
||||
// Error toast
|
||||
Alpine.store('toast').error('Something went wrong');
|
||||
|
||||
// Warning toast
|
||||
Alpine.store('toast').warning('Session expiring soon');
|
||||
|
||||
// Info toast
|
||||
Alpine.store('toast').info('New features available');
|
||||
|
||||
// With custom duration (ms)
|
||||
Alpine.store('toast').success('Quick message', 2000);
|
||||
|
||||
// With action button
|
||||
Alpine.store('toast').success('Item deleted', 5000, {
|
||||
action: { label: 'Undo', onClick: () => undoAction() }
|
||||
});
|
||||
```
|
||||
|
||||
### From Django Views
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import htmx_success, htmx_error
|
||||
|
||||
# Success toast
|
||||
return htmx_success('Park saved successfully!')
|
||||
|
||||
# Error toast
|
||||
return htmx_error('Validation failed', status=422)
|
||||
|
||||
# With action
|
||||
return htmx_success('Item deleted', action={
|
||||
'label': 'Undo',
|
||||
'onClick': 'undoDelete()'
|
||||
})
|
||||
```
|
||||
|
||||
## Loading Components
|
||||
|
||||
### Loading Indicator
|
||||
|
||||
```django
|
||||
{% include 'htmx/components/loading_indicator.html' with
|
||||
id='my-loading'
|
||||
text='Loading...'
|
||||
size='md'
|
||||
%}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `id` | string | - | Element ID for hx-indicator |
|
||||
| `text` | string | 'Loading...' | Loading text |
|
||||
| `size` | string | 'md' | 'sm', 'md', 'lg' |
|
||||
| `type` | string | 'spinner' | 'spinner', 'dots', 'pulse' |
|
||||
|
||||
## Pagination
|
||||
|
||||
### Standard Pagination
|
||||
|
||||
```django
|
||||
{% include 'components/pagination.html' with
|
||||
page_obj=page_obj
|
||||
show_first_last=True
|
||||
%}
|
||||
```
|
||||
|
||||
### HTMX Pagination
|
||||
|
||||
```django
|
||||
{% include 'components/pagination.html' with
|
||||
page_obj=page_obj
|
||||
hx_target='#items-list'
|
||||
hx_swap='innerHTML'
|
||||
hx_push_url='true'
|
||||
%}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `page_obj` | Page | required | Django paginator page |
|
||||
| `show_first_last` | boolean | true | Show first/last buttons |
|
||||
| `show_page_numbers` | boolean | true | Show page numbers |
|
||||
| `max_pages` | number | 5 | Max visible page numbers |
|
||||
| `hx_target` | string | - | HTMX target selector |
|
||||
| `hx_swap` | string | - | HTMX swap method |
|
||||
| `hx_push_url` | boolean | false | Push URL on navigation |
|
||||
423
docs/ux/cta-guidelines.md
Normal file
423
docs/ux/cta-guidelines.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Call-to-Action (CTA) Guidelines
|
||||
|
||||
This document outlines the standardized patterns for buttons, links, and action elements in ThrillWiki.
|
||||
|
||||
## Button Hierarchy
|
||||
|
||||
### Primary Actions
|
||||
|
||||
Use for the main action on a page or in a section:
|
||||
|
||||
```html
|
||||
<button class="btn btn-primary">Save Park</button>
|
||||
<a href="/create/" class="btn btn-primary">Create New</a>
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Form submissions
|
||||
- Main page actions
|
||||
- Completing a flow
|
||||
|
||||
### Secondary Actions
|
||||
|
||||
Use for important but non-primary actions:
|
||||
|
||||
```html
|
||||
<button class="btn btn-outline">Preview</button>
|
||||
<button class="btn btn-secondary">Save Draft</button>
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Alternative actions
|
||||
- Non-destructive secondary options
|
||||
- Cancel with importance
|
||||
|
||||
### Tertiary Actions
|
||||
|
||||
Use for low-emphasis actions:
|
||||
|
||||
```html
|
||||
<button class="btn btn-ghost">Cancel</button>
|
||||
<a href="/back/" class="btn btn-link">Go Back</a>
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Cancel buttons
|
||||
- Navigation links
|
||||
- Dismissive actions
|
||||
|
||||
### Destructive Actions
|
||||
|
||||
Use for actions that delete or remove data:
|
||||
|
||||
```html
|
||||
<button class="btn btn-destructive">Delete Park</button>
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Delete operations
|
||||
- Remove actions
|
||||
- Destructive confirmations
|
||||
|
||||
## Button Sizes
|
||||
|
||||
```html
|
||||
<!-- Small - for compact UIs -->
|
||||
<button class="btn btn-primary btn-sm">Small</button>
|
||||
|
||||
<!-- Default -->
|
||||
<button class="btn btn-primary">Default</button>
|
||||
|
||||
<!-- Large - for prominent CTAs -->
|
||||
<button class="btn btn-primary btn-lg">Large</button>
|
||||
```
|
||||
|
||||
## Button States
|
||||
|
||||
### Loading State
|
||||
|
||||
```html
|
||||
<button class="btn btn-primary" disabled>
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
Saving...
|
||||
</button>
|
||||
```
|
||||
|
||||
### Disabled State
|
||||
|
||||
```html
|
||||
<button class="btn btn-primary" disabled>
|
||||
Submit
|
||||
</button>
|
||||
```
|
||||
|
||||
### With Icon
|
||||
|
||||
```html
|
||||
<button class="btn btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Add Park
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline">
|
||||
Download
|
||||
<i class="fas fa-download ml-2"></i>
|
||||
</button>
|
||||
```
|
||||
|
||||
## Action Bar Component
|
||||
|
||||
Use the action bar for consistent button placement:
|
||||
|
||||
```django
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
primary_action_text='Save Changes'
|
||||
primary_action_icon='fas fa-save'
|
||||
secondary_action_text='Preview'
|
||||
tertiary_action_text='Cancel'
|
||||
tertiary_action_url='/parks/'
|
||||
%}
|
||||
```
|
||||
|
||||
### Alignment Options
|
||||
|
||||
```django
|
||||
{# Right-aligned (default) #}
|
||||
{% include 'components/ui/action_bar.html' with align='right' ... %}
|
||||
|
||||
{# Left-aligned #}
|
||||
{% include 'components/ui/action_bar.html' with align='left' ... %}
|
||||
|
||||
{# Space-between (cancel left, actions right) #}
|
||||
{% include 'components/ui/action_bar.html' with align='between' ... %}
|
||||
|
||||
{# Center-aligned #}
|
||||
{% include 'components/ui/action_bar.html' with align='center' ... %}
|
||||
```
|
||||
|
||||
## Placement Guidelines
|
||||
|
||||
### Page Headers
|
||||
|
||||
Primary CTA in page header, aligned right:
|
||||
|
||||
```django
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Parks'
|
||||
primary_action_url='/parks/create/'
|
||||
primary_action_text='Add Park'
|
||||
primary_action_icon='fas fa-plus'
|
||||
%}
|
||||
```
|
||||
|
||||
### Card Actions
|
||||
|
||||
Actions in card footer, right-aligned:
|
||||
|
||||
```html
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
<div class="card-footer flex justify-end gap-2">
|
||||
<button class="btn btn-ghost btn-sm">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Form Actions
|
||||
|
||||
Form buttons at bottom, with cancel on left or right based on flow:
|
||||
|
||||
```html
|
||||
<!-- Standard form (cancel left, submit right) -->
|
||||
<div class="flex justify-between mt-6">
|
||||
<a href="/cancel/" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
<!-- Simple form (all right-aligned) -->
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" class="btn btn-ghost">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Modal Actions
|
||||
|
||||
Actions in modal footer, right-aligned:
|
||||
|
||||
```html
|
||||
<div class="modal-footer">
|
||||
<button @click="showModal = false" class="btn btn-ghost">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-primary">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Inline Actions
|
||||
|
||||
For table rows or list items:
|
||||
|
||||
```html
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm text-destructive">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Link vs Button
|
||||
|
||||
### Use Links For:
|
||||
|
||||
- Navigation to other pages
|
||||
- Opening in new tabs
|
||||
- Bookmarkable destinations
|
||||
|
||||
```html
|
||||
<a href="/parks/123/" class="btn btn-primary">View Park</a>
|
||||
```
|
||||
|
||||
### Use Buttons For:
|
||||
|
||||
- Form submissions
|
||||
- JavaScript actions
|
||||
- State changes
|
||||
- Toggles
|
||||
|
||||
```html
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button @click="showModal = true" class="btn btn-outline">Open Modal</button>
|
||||
```
|
||||
|
||||
## Icon Guidelines
|
||||
|
||||
### Icon Position
|
||||
|
||||
- **Left icon**: For actions (Add, Create, Save)
|
||||
- **Right icon**: For navigation (Next, Go, External link)
|
||||
|
||||
```html
|
||||
<!-- Action with left icon -->
|
||||
<button class="btn btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Add Park
|
||||
</button>
|
||||
|
||||
<!-- Navigation with right icon -->
|
||||
<a href="/next/" class="btn btn-outline">
|
||||
Next Step
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
```
|
||||
|
||||
### Common Icon Mappings
|
||||
|
||||
| Action | Icon |
|
||||
|--------|------|
|
||||
| Create/Add | `fa-plus` |
|
||||
| Edit | `fa-edit` or `fa-pen` |
|
||||
| Delete | `fa-trash` |
|
||||
| Save | `fa-save` |
|
||||
| Cancel | `fa-times` |
|
||||
| Search | `fa-search` |
|
||||
| Filter | `fa-filter` |
|
||||
| Download | `fa-download` |
|
||||
| Upload | `fa-upload` |
|
||||
| Settings | `fa-cog` |
|
||||
| Back | `fa-arrow-left` |
|
||||
| Next | `fa-arrow-right` |
|
||||
| External Link | `fa-external-link-alt` |
|
||||
|
||||
## Confirmation Patterns
|
||||
|
||||
### Inline Confirmation (Low Risk)
|
||||
|
||||
```html
|
||||
<button hx-delete="/items/123/"
|
||||
hx-confirm="Delete this item?">
|
||||
Delete
|
||||
</button>
|
||||
```
|
||||
|
||||
### Modal Confirmation (High Risk)
|
||||
|
||||
```django
|
||||
<button @click="showDeleteModal = true" class="btn btn-destructive">
|
||||
Delete Park
|
||||
</button>
|
||||
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='delete-confirm'
|
||||
show_var='showDeleteModal'
|
||||
title='Delete Park'
|
||||
message='This will permanently delete the park and all associated data. This action cannot be undone.'
|
||||
confirm_text='Delete'
|
||||
confirm_variant='destructive'
|
||||
confirm_hx_delete='/parks/123/'
|
||||
%}
|
||||
```
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### Mobile Stacking
|
||||
|
||||
Buttons stack vertically on mobile:
|
||||
|
||||
```html
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button class="btn btn-ghost order-2 sm:order-1">Cancel</button>
|
||||
<button class="btn btn-primary order-1 sm:order-2">Save</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Icon-Only on Mobile
|
||||
|
||||
```html
|
||||
<button class="btn btn-outline">
|
||||
<i class="fas fa-filter"></i>
|
||||
<span class="hidden sm:inline ml-2">Filter</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### Full-Width on Mobile
|
||||
|
||||
```html
|
||||
<button class="btn btn-primary w-full sm:w-auto">
|
||||
Save Changes
|
||||
</button>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Button Labels
|
||||
|
||||
Always provide accessible labels:
|
||||
|
||||
```html
|
||||
<!-- Icon-only buttons need aria-label -->
|
||||
<button class="btn btn-ghost" aria-label="Edit park">
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Or use visually hidden text -->
|
||||
<button class="btn btn-ghost">
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
<span class="sr-only">Edit park</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### Focus States
|
||||
|
||||
All buttons must have visible focus states:
|
||||
|
||||
```css
|
||||
.btn:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-offset: 2px;
|
||||
ring-color: var(--ring);
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
Ensure buttons are keyboard accessible:
|
||||
|
||||
```html
|
||||
<!-- Use native button elements -->
|
||||
<button class="btn btn-primary">Click Me</button>
|
||||
|
||||
<!-- If using div/span, add role and tabindex -->
|
||||
<div role="button" tabindex="0" class="btn btn-primary"
|
||||
@keydown.enter="handleClick"
|
||||
@keydown.space.prevent="handleClick">
|
||||
Click Me
|
||||
</div>
|
||||
```
|
||||
|
||||
## HTMX Integration
|
||||
|
||||
### Loading Indicators
|
||||
|
||||
Show loading state during requests:
|
||||
|
||||
```html
|
||||
<button hx-post="/save/"
|
||||
hx-indicator="this"
|
||||
class="btn btn-primary">
|
||||
<span class="htmx-indicator hidden">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
</span>
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
### Disable During Request
|
||||
|
||||
```html
|
||||
<button hx-post="/save/"
|
||||
hx-disabled-elt="this"
|
||||
class="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
### Success Feedback
|
||||
|
||||
```python
|
||||
# In view
|
||||
from apps.core.htmx_utils import htmx_success
|
||||
|
||||
def save_item(request):
|
||||
# ... save logic ...
|
||||
return htmx_success('Item saved successfully!')
|
||||
```
|
||||
510
docs/ux/developer-guidelines.md
Normal file
510
docs/ux/developer-guidelines.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# Developer Guidelines
|
||||
|
||||
Best practices for UX development in ThrillWiki.
|
||||
|
||||
## General Principles
|
||||
|
||||
### 1. Progressive Enhancement
|
||||
|
||||
All features should work without JavaScript, with HTMX and Alpine.js enhancing the experience:
|
||||
|
||||
```html
|
||||
<!-- Form works with or without JS -->
|
||||
<form method="post" action="/parks/create/"
|
||||
hx-post="/parks/create/"
|
||||
hx-target="#form-container">
|
||||
{% csrf_token %}
|
||||
<!-- Form fields -->
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### 2. Consistent Feedback
|
||||
|
||||
Every user action should provide immediate feedback:
|
||||
|
||||
- **Loading states**: Show progress during requests
|
||||
- **Success messages**: Confirm completed actions
|
||||
- **Error messages**: Explain what went wrong
|
||||
- **Validation**: Inline feedback on form fields
|
||||
|
||||
### 3. Accessibility First
|
||||
|
||||
All components must be accessible:
|
||||
|
||||
- Proper ARIA attributes
|
||||
- Keyboard navigation
|
||||
- Screen reader support
|
||||
- Focus management
|
||||
- Sufficient color contrast
|
||||
|
||||
### 4. Mobile-First Design
|
||||
|
||||
Design for mobile first, then enhance for larger screens:
|
||||
|
||||
```html
|
||||
<!-- Mobile: stacked, Desktop: side-by-side -->
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="w-full md:w-1/2">Left</div>
|
||||
<div class="w-full md:w-1/2">Right</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
### Template Structure
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base/
|
||||
│ └── base.html # Base template
|
||||
├── components/
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── modals/ # Modal components
|
||||
│ ├── navigation/ # Navigation components
|
||||
│ ├── skeletons/ # Skeleton screens
|
||||
│ └── ui/ # UI primitives
|
||||
├── forms/
|
||||
│ ├── layouts/ # Form layouts
|
||||
│ └── partials/ # Form field components
|
||||
├── htmx/
|
||||
│ └── components/ # HTMX-specific components
|
||||
└── [app_name]/
|
||||
├── list.html # Full page templates
|
||||
├── list_partial.html # HTMX partials
|
||||
└── _card.html # Reusable fragments
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Full page | `name.html` | `list.html` |
|
||||
| HTMX partial | `name_partial.html` | `list_partial.html` |
|
||||
| Fragment | `_name.html` | `_card.html` |
|
||||
| Component | `name.html` in components/ | `button.html` |
|
||||
|
||||
## Django Integration
|
||||
|
||||
### Context Processors
|
||||
|
||||
Use context processors for global data:
|
||||
|
||||
```python
|
||||
# apps/core/context_processors.py
|
||||
def breadcrumbs(request):
|
||||
return {'breadcrumbs': getattr(request, 'breadcrumbs', [])}
|
||||
|
||||
def page_meta(request):
|
||||
return {'page_meta': getattr(request, 'page_meta', {})}
|
||||
```
|
||||
|
||||
### View Patterns
|
||||
|
||||
#### Standard View
|
||||
|
||||
```python
|
||||
def park_list(request):
|
||||
parks = Park.objects.all()
|
||||
return render(request, 'parks/list.html', {'parks': parks})
|
||||
```
|
||||
|
||||
#### HTMX-Aware View
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import is_htmx_request
|
||||
|
||||
def park_list(request):
|
||||
parks = Park.objects.all()
|
||||
template = 'parks/list_partial.html' if is_htmx_request(request) else 'parks/list.html'
|
||||
return render(request, template, {'parks': parks})
|
||||
```
|
||||
|
||||
#### Using Decorator
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import htmx_partial
|
||||
|
||||
@htmx_partial('parks/list.html')
|
||||
def park_list(request):
|
||||
parks = Park.objects.all()
|
||||
return ({'parks': parks},)
|
||||
```
|
||||
|
||||
### Breadcrumbs
|
||||
|
||||
Set breadcrumbs in views:
|
||||
|
||||
```python
|
||||
from apps.core.utils import BreadcrumbBuilder
|
||||
|
||||
def park_detail(request, slug):
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
|
||||
request.breadcrumbs = (
|
||||
BreadcrumbBuilder()
|
||||
.add_home()
|
||||
.add('Parks', reverse('parks:list'))
|
||||
.add_current(park.name)
|
||||
.build()
|
||||
)
|
||||
|
||||
return render(request, 'parks/detail.html', {'park': park})
|
||||
```
|
||||
|
||||
### Page Meta
|
||||
|
||||
Set meta information in views:
|
||||
|
||||
```python
|
||||
from apps.core.utils import build_meta_context
|
||||
|
||||
def park_detail(request, slug):
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
|
||||
request.page_meta = build_meta_context(
|
||||
title=park.name,
|
||||
description=park.description,
|
||||
instance=park,
|
||||
request=request,
|
||||
)
|
||||
|
||||
return render(request, 'parks/detail.html', {'park': park})
|
||||
```
|
||||
|
||||
## HTMX Best Practices
|
||||
|
||||
### 1. Use Appropriate HTTP Methods
|
||||
|
||||
```html
|
||||
<!-- GET for read operations -->
|
||||
<button hx-get="/parks/">Load Parks</button>
|
||||
|
||||
<!-- POST for create -->
|
||||
<form hx-post="/parks/create/">...</form>
|
||||
|
||||
<!-- PUT/PATCH for update -->
|
||||
<form hx-put="/parks/123/edit/">...</form>
|
||||
|
||||
<!-- DELETE for remove -->
|
||||
<button hx-delete="/parks/123/">Delete</button>
|
||||
```
|
||||
|
||||
### 2. Target Specific Elements
|
||||
|
||||
```html
|
||||
<!-- Good: Specific target -->
|
||||
<button hx-get="/parks/" hx-target="#parks-list">Load</button>
|
||||
|
||||
<!-- Avoid: Replacing large sections unnecessarily -->
|
||||
<button hx-get="/parks/" hx-target="#main-content">Load</button>
|
||||
```
|
||||
|
||||
### 3. Use Loading Indicators
|
||||
|
||||
```html
|
||||
<button hx-get="/parks/"
|
||||
hx-target="#parks-list"
|
||||
hx-indicator="#loading">
|
||||
Load Parks
|
||||
</button>
|
||||
|
||||
<div id="loading" class="htmx-indicator">
|
||||
{% include 'htmx/components/loading_indicator.html' %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Handle Errors Gracefully
|
||||
|
||||
```python
|
||||
def create_park(request):
|
||||
form = ParkForm(request.POST)
|
||||
|
||||
if not form.is_valid():
|
||||
# Return form with errors, status 422
|
||||
return render(
|
||||
request,
|
||||
'parks/_form.html',
|
||||
{'form': form},
|
||||
status=422
|
||||
)
|
||||
|
||||
park = form.save()
|
||||
return htmx_success(f'{park.name} created!')
|
||||
```
|
||||
|
||||
### 5. Use Response Headers
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import (
|
||||
htmx_success,
|
||||
htmx_error,
|
||||
htmx_redirect,
|
||||
htmx_modal_close,
|
||||
)
|
||||
|
||||
# Success with toast
|
||||
return htmx_success('Park saved!')
|
||||
|
||||
# Error with toast
|
||||
return htmx_error('Validation failed', status=422)
|
||||
|
||||
# Redirect
|
||||
return htmx_redirect('/parks/')
|
||||
|
||||
# Close modal and refresh
|
||||
return htmx_modal_close(
|
||||
message='Saved!',
|
||||
refresh_target='#parks-list'
|
||||
)
|
||||
```
|
||||
|
||||
## Alpine.js Best Practices
|
||||
|
||||
### 1. Keep Components Small
|
||||
|
||||
```html
|
||||
<!-- Good: Focused component -->
|
||||
<div x-data="{ open: false }">
|
||||
<button @click="open = !open">Toggle</button>
|
||||
<div x-show="open">Content</div>
|
||||
</div>
|
||||
|
||||
<!-- Avoid: Large monolithic components -->
|
||||
<div x-data="{
|
||||
open: false,
|
||||
items: [],
|
||||
selectedItem: null,
|
||||
loading: false,
|
||||
// ... many more properties
|
||||
}">
|
||||
```
|
||||
|
||||
### 2. Use Stores for Global State
|
||||
|
||||
```javascript
|
||||
// stores/index.js
|
||||
Alpine.store('toast', {
|
||||
messages: [],
|
||||
success(message, duration = 5000) { /* ... */ },
|
||||
error(message, duration = 0) { /* ... */ },
|
||||
});
|
||||
|
||||
// Usage anywhere
|
||||
Alpine.store('toast').success('Saved!');
|
||||
```
|
||||
|
||||
### 3. Initialize from Server Data
|
||||
|
||||
```html
|
||||
<div x-data="{ items: {{ items_json|safe }} }">
|
||||
<template x-for="item in items">
|
||||
<div x-text="item.name"></div>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Use Magic Properties
|
||||
|
||||
```html
|
||||
<div x-data="{ message: '' }">
|
||||
<!-- $refs for DOM access -->
|
||||
<input x-ref="input" x-model="message">
|
||||
<button @click="$refs.input.focus()">Focus</button>
|
||||
|
||||
<!-- $watch for reactive updates -->
|
||||
<div x-init="$watch('message', value => console.log(value))">
|
||||
|
||||
<!-- $nextTick for DOM updates -->
|
||||
<button @click="items.push('new'); $nextTick(() => scrollToBottom())">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS/Tailwind Best Practices
|
||||
|
||||
### 1. Use Design Tokens
|
||||
|
||||
```css
|
||||
/* Use CSS variables */
|
||||
.custom-element {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use Component Classes
|
||||
|
||||
```html
|
||||
<!-- Good: Semantic classes -->
|
||||
<button class="btn btn-primary btn-lg">Save</button>
|
||||
|
||||
<!-- Avoid: Long utility chains for common patterns -->
|
||||
<button class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium">
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
### 3. Responsive Design
|
||||
|
||||
```html
|
||||
<!-- Mobile-first -->
|
||||
<div class="p-4 md:p-6 lg:p-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Items -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Dark Mode Support
|
||||
|
||||
```html
|
||||
<!-- Automatic dark mode via CSS variables -->
|
||||
<div class="bg-background text-foreground">
|
||||
Content adapts to theme
|
||||
</div>
|
||||
|
||||
<!-- Explicit dark mode variants -->
|
||||
<div class="bg-white dark:bg-gray-800">
|
||||
Explicit dark override
|
||||
</div>
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### 1. Test User Flows
|
||||
|
||||
```python
|
||||
def test_create_park_flow(self):
|
||||
# Navigate to create page
|
||||
response = self.client.get('/parks/create/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Submit form
|
||||
response = self.client.post('/parks/create/', {
|
||||
'name': 'Test Park',
|
||||
'location': 'Test Location',
|
||||
})
|
||||
|
||||
# Verify redirect or success
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(Park.objects.filter(name='Test Park').exists())
|
||||
```
|
||||
|
||||
### 2. Test HTMX Responses
|
||||
|
||||
```python
|
||||
def test_htmx_park_list(self):
|
||||
response = self.client.get('/parks/', HTTP_HX_REQUEST='true')
|
||||
|
||||
# Should return partial template
|
||||
self.assertTemplateUsed(response, 'parks/list_partial.html')
|
||||
|
||||
# Should not include base template chrome
|
||||
self.assertNotContains(response, '<html')
|
||||
```
|
||||
|
||||
### 3. Test Error States
|
||||
|
||||
```python
|
||||
def test_form_validation_errors(self):
|
||||
response = self.client.post('/parks/create/', {
|
||||
'name': '', # Required field
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertContains(response, 'This field is required')
|
||||
```
|
||||
|
||||
## Performance Guidelines
|
||||
|
||||
### 1. Minimize DOM Updates
|
||||
|
||||
```html
|
||||
<!-- Good: Update specific element -->
|
||||
<button hx-get="/count/" hx-target="#count-badge">Refresh</button>
|
||||
|
||||
<!-- Avoid: Replacing entire page section -->
|
||||
<button hx-get="/dashboard/" hx-target="#main">Refresh</button>
|
||||
```
|
||||
|
||||
### 2. Use Skeleton Screens
|
||||
|
||||
```html
|
||||
<div id="content"
|
||||
hx-get="/parks/"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Lazy Load Content
|
||||
|
||||
```html
|
||||
<!-- Load when visible -->
|
||||
<div hx-get="/stats/"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="innerHTML">
|
||||
<div class="skeleton h-20"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Debounce Input
|
||||
|
||||
```html
|
||||
<!-- Debounce search input -->
|
||||
<input hx-get="/search/"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#results">
|
||||
```
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
### 1. CSRF Protection
|
||||
|
||||
```html
|
||||
<!-- Include token in body -->
|
||||
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
|
||||
<!-- Or in forms -->
|
||||
<form hx-post="/create/">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
```
|
||||
|
||||
### 2. Validate on Server
|
||||
|
||||
Always validate data server-side, even with client-side validation:
|
||||
|
||||
```python
|
||||
def create_park(request):
|
||||
form = ParkForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return htmx_error('Invalid data', status=422)
|
||||
# Process valid data
|
||||
```
|
||||
|
||||
### 3. Escape User Content
|
||||
|
||||
```django
|
||||
<!-- Auto-escaped by Django -->
|
||||
{{ user_input }}
|
||||
|
||||
<!-- Explicitly mark as safe only for trusted content -->
|
||||
{{ trusted_html|safe }}
|
||||
```
|
||||
|
||||
### 4. Use Appropriate Permissions
|
||||
|
||||
```python
|
||||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
|
||||
@login_required
|
||||
@permission_required('parks.add_park')
|
||||
def create_park(request):
|
||||
# Only authenticated users with permission
|
||||
pass
|
||||
```
|
||||
528
docs/ux/htmx-conventions.md
Normal file
528
docs/ux/htmx-conventions.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# HTMX Conventions
|
||||
|
||||
This document outlines the standardized HTMX patterns and conventions used in ThrillWiki.
|
||||
|
||||
## Request Attributes
|
||||
|
||||
### hx-get / hx-post / hx-put / hx-delete
|
||||
|
||||
Use the appropriate HTTP method for the action:
|
||||
|
||||
```html
|
||||
<!-- Read operations -->
|
||||
<button hx-get="/parks/">Load Parks</button>
|
||||
|
||||
<!-- Create operations -->
|
||||
<form hx-post="/parks/create/">...</form>
|
||||
|
||||
<!-- Update operations -->
|
||||
<form hx-put="/parks/{{ park.id }}/edit/">...</form>
|
||||
|
||||
<!-- Delete operations -->
|
||||
<button hx-delete="/parks/{{ park.id }}/">Delete</button>
|
||||
```
|
||||
|
||||
### hx-target
|
||||
|
||||
Specify where the response should be inserted:
|
||||
|
||||
```html
|
||||
<!-- Target by ID -->
|
||||
<button hx-get="/content/" hx-target="#content-area">Load</button>
|
||||
|
||||
<!-- Target closest ancestor -->
|
||||
<button hx-delete="/item/" hx-target="closest .item-row">Delete</button>
|
||||
|
||||
<!-- Target this element -->
|
||||
<div hx-get="/refresh/" hx-target="this">Content</div>
|
||||
```
|
||||
|
||||
### hx-swap
|
||||
|
||||
Control how content is inserted:
|
||||
|
||||
```html
|
||||
<!-- Replace inner HTML (default) -->
|
||||
<div hx-get="/content/" hx-swap="innerHTML">...</div>
|
||||
|
||||
<!-- Replace entire element -->
|
||||
<div hx-get="/content/" hx-swap="outerHTML">...</div>
|
||||
|
||||
<!-- Insert before/after -->
|
||||
<div hx-get="/content/" hx-swap="beforeend">...</div>
|
||||
<div hx-get="/content/" hx-swap="afterbegin">...</div>
|
||||
|
||||
<!-- Delete element -->
|
||||
<button hx-delete="/item/" hx-swap="delete">Remove</button>
|
||||
|
||||
<!-- No swap (for side effects only) -->
|
||||
<button hx-post="/action/" hx-swap="none">Action</button>
|
||||
```
|
||||
|
||||
### hx-trigger
|
||||
|
||||
Define when requests are made:
|
||||
|
||||
```html
|
||||
<!-- On click (default for buttons) -->
|
||||
<button hx-get="/content/" hx-trigger="click">Load</button>
|
||||
|
||||
<!-- On page load -->
|
||||
<div hx-get="/content/" hx-trigger="load">...</div>
|
||||
|
||||
<!-- When element becomes visible -->
|
||||
<div hx-get="/more/" hx-trigger="revealed">Load More</div>
|
||||
|
||||
<!-- On form input with debounce -->
|
||||
<input hx-get="/search/" hx-trigger="keyup changed delay:300ms">
|
||||
|
||||
<!-- On blur for validation -->
|
||||
<input hx-post="/validate/" hx-trigger="blur changed">
|
||||
|
||||
<!-- Custom event -->
|
||||
<div hx-get="/refresh/" hx-trigger="refresh from:body">...</div>
|
||||
```
|
||||
|
||||
## Response Headers
|
||||
|
||||
### Server-Side Response Helpers
|
||||
|
||||
Use the standardized HTMX utility functions in views:
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import (
|
||||
htmx_success,
|
||||
htmx_error,
|
||||
htmx_redirect,
|
||||
htmx_modal_close,
|
||||
htmx_refresh,
|
||||
)
|
||||
|
||||
def create_park(request):
|
||||
# On success
|
||||
return htmx_success('Park created successfully!')
|
||||
|
||||
# On error
|
||||
return htmx_error('Validation failed', status=422)
|
||||
|
||||
# Redirect
|
||||
return htmx_redirect('/parks/')
|
||||
|
||||
# Close modal with refresh
|
||||
return htmx_modal_close(
|
||||
message='Park saved!',
|
||||
refresh_target='#parks-list'
|
||||
)
|
||||
```
|
||||
|
||||
### HX-Trigger
|
||||
|
||||
Trigger client-side events from the server:
|
||||
|
||||
```python
|
||||
# Single event
|
||||
response['HX-Trigger'] = 'parkCreated'
|
||||
|
||||
# Event with data
|
||||
response['HX-Trigger'] = json.dumps({
|
||||
'showToast': {
|
||||
'type': 'success',
|
||||
'message': 'Park created!'
|
||||
}
|
||||
})
|
||||
|
||||
# Multiple events
|
||||
response['HX-Trigger'] = json.dumps({
|
||||
'closeModal': True,
|
||||
'refreshList': {'target': '#parks-list'},
|
||||
'showToast': {'type': 'success', 'message': 'Done!'}
|
||||
})
|
||||
```
|
||||
|
||||
### HX-Redirect
|
||||
|
||||
Perform client-side navigation:
|
||||
|
||||
```python
|
||||
response['HX-Redirect'] = '/parks/'
|
||||
```
|
||||
|
||||
### HX-Refresh
|
||||
|
||||
Trigger a full page refresh:
|
||||
|
||||
```python
|
||||
response['HX-Refresh'] = 'true'
|
||||
```
|
||||
|
||||
### HX-Retarget / HX-Reswap
|
||||
|
||||
Override target and swap method:
|
||||
|
||||
```python
|
||||
response['HX-Retarget'] = '#different-target'
|
||||
response['HX-Reswap'] = 'outerHTML'
|
||||
```
|
||||
|
||||
## Partial Templates
|
||||
|
||||
### Convention
|
||||
|
||||
For each full template, create a corresponding partial:
|
||||
|
||||
```
|
||||
templates/
|
||||
parks/
|
||||
list.html # Full page
|
||||
list_partial.html # Just the content (for HTMX)
|
||||
detail.html
|
||||
detail_partial.html
|
||||
```
|
||||
|
||||
### Usage with Decorator
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import htmx_partial
|
||||
|
||||
@htmx_partial('parks/list.html')
|
||||
def park_list(request):
|
||||
parks = Park.objects.all()
|
||||
return ({'parks': parks},) # Returns context tuple
|
||||
```
|
||||
|
||||
### Manual Detection
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import is_htmx_request
|
||||
|
||||
def park_list(request):
|
||||
parks = Park.objects.all()
|
||||
|
||||
if is_htmx_request(request):
|
||||
return render(request, 'parks/list_partial.html', {'parks': parks})
|
||||
|
||||
return render(request, 'parks/list.html', {'parks': parks})
|
||||
```
|
||||
|
||||
## Loading States
|
||||
|
||||
### Indicator Attribute
|
||||
|
||||
```html
|
||||
<button hx-get="/content/"
|
||||
hx-indicator="#loading-spinner">
|
||||
Load Content
|
||||
</button>
|
||||
|
||||
<div id="loading-spinner" class="htmx-indicator">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
```
|
||||
|
||||
### CSS Classes
|
||||
|
||||
HTMX automatically adds classes during requests:
|
||||
|
||||
```css
|
||||
/* Added to element making request */
|
||||
.htmx-request {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Added to indicator elements */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator {
|
||||
display: inline-block;
|
||||
}
|
||||
```
|
||||
|
||||
### Skeleton Screens
|
||||
|
||||
Use skeleton components for better UX:
|
||||
|
||||
```html
|
||||
<div id="parks-list"
|
||||
hx-get="/parks/"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
### Standard Form Pattern
|
||||
|
||||
```html
|
||||
<form id="park-form"
|
||||
hx-post="{% url 'parks:create' %}"
|
||||
hx-target="#park-form"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#form-loading">
|
||||
{% csrf_token %}
|
||||
|
||||
{% include 'forms/partials/form_field.html' with field=form.name %}
|
||||
{% include 'forms/partials/form_field.html' with field=form.location %}
|
||||
|
||||
<div id="form-loading" class="htmx-indicator">
|
||||
Saving...
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Inline Validation
|
||||
|
||||
```html
|
||||
<input type="text"
|
||||
name="name"
|
||||
hx-post="{% url 'parks:validate_name' %}"
|
||||
hx-trigger="blur changed delay:500ms"
|
||||
hx-target="#name-feedback"
|
||||
hx-swap="innerHTML">
|
||||
<div id="name-feedback"></div>
|
||||
```
|
||||
|
||||
### Server Response
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import htmx_validation_response
|
||||
|
||||
def validate_name(request):
|
||||
name = request.POST.get('name', '')
|
||||
|
||||
if not name:
|
||||
return htmx_validation_response('name', errors=['Name is required'])
|
||||
|
||||
if Park.objects.filter(name=name).exists():
|
||||
return htmx_validation_response('name', errors=['Name already exists'])
|
||||
|
||||
return htmx_validation_response('name', success_message='Name is available')
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
### Load More Pattern
|
||||
|
||||
```html
|
||||
<div id="parks-list">
|
||||
{% for park in parks %}
|
||||
{% include 'parks/_card.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<button hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#parks-list"
|
||||
hx-swap="beforeend"
|
||||
hx-select="#parks-list > *"
|
||||
class="btn btn-outline">
|
||||
Load More
|
||||
</button>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Infinite Scroll
|
||||
|
||||
```html
|
||||
{% if page_obj.has_next %}
|
||||
<div hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-trigger="revealed"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="oob-swap">
|
||||
<div class="htmx-indicator text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading...
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Traditional Pagination
|
||||
|
||||
```html
|
||||
{% include 'components/pagination.html' with
|
||||
page_obj=page_obj
|
||||
hx_target='#parks-list'
|
||||
hx_swap='innerHTML'
|
||||
hx_push_url='true'
|
||||
%}
|
||||
```
|
||||
|
||||
## Modal Integration
|
||||
|
||||
### Opening Modal with Content
|
||||
|
||||
```html
|
||||
<button hx-get="{% url 'parks:edit' park.id %}"
|
||||
hx-target="#modal-content"
|
||||
hx-swap="innerHTML"
|
||||
@click="showEditModal = true">
|
||||
Edit Park
|
||||
</button>
|
||||
|
||||
<div x-show="showEditModal" @close-modal.window="showEditModal = false">
|
||||
<div id="modal-content">
|
||||
<!-- Content loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Closing Modal from Server
|
||||
|
||||
```python
|
||||
def save_park(request, pk):
|
||||
park = get_object_or_404(Park, pk=pk)
|
||||
form = ParkForm(request.POST, instance=park)
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return htmx_modal_close(
|
||||
message='Park updated successfully!',
|
||||
refresh_target='#park-detail'
|
||||
)
|
||||
|
||||
return render(request, 'parks/_edit_form.html', {'form': form})
|
||||
```
|
||||
|
||||
## Out-of-Band Updates
|
||||
|
||||
### Multiple Element Updates
|
||||
|
||||
```html
|
||||
<!-- Main response -->
|
||||
<div id="main-content">
|
||||
Updated content here
|
||||
</div>
|
||||
|
||||
<!-- Out-of-band updates -->
|
||||
<div id="notification-count" hx-swap-oob="true">
|
||||
<span class="badge">5</span>
|
||||
</div>
|
||||
|
||||
<div id="sidebar-status" hx-swap-oob="true">
|
||||
<span class="status">Online</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Server-Side OOB
|
||||
|
||||
```python
|
||||
def update_item(request, pk):
|
||||
item = get_object_or_404(Item, pk=pk)
|
||||
# ... update logic ...
|
||||
|
||||
# Render multiple fragments
|
||||
main_html = render_to_string('items/_detail.html', {'item': item})
|
||||
count_html = render_to_string('items/_count.html', {'count': Item.objects.count()})
|
||||
|
||||
return HttpResponse(
|
||||
main_html +
|
||||
f'<div id="item-count" hx-swap-oob="true">{count_html}</div>'
|
||||
)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
HTMX handles different status codes:
|
||||
|
||||
- **2xx**: Normal swap
|
||||
- **204**: No swap (success, no content)
|
||||
- **4xx**: Swap (show validation errors)
|
||||
- **5xx**: Trigger error event
|
||||
|
||||
### Client-Side Error Handling
|
||||
|
||||
```javascript
|
||||
document.body.addEventListener('htmx:responseError', (event) => {
|
||||
const status = event.detail.xhr.status;
|
||||
|
||||
if (status === 401) {
|
||||
Alpine.store('toast').error('Please log in to continue');
|
||||
window.location.href = '/login/';
|
||||
} else if (status >= 500) {
|
||||
Alpine.store('toast').error('Server error. Please try again.');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
Return form HTML with errors on 422:
|
||||
|
||||
```python
|
||||
def create_park(request):
|
||||
form = ParkForm(request.POST)
|
||||
|
||||
if not form.is_valid():
|
||||
return render(
|
||||
request,
|
||||
'parks/_form.html',
|
||||
{'form': form},
|
||||
status=422
|
||||
)
|
||||
|
||||
park = form.save()
|
||||
return htmx_success(f'{park.name} created!')
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Semantic URLs
|
||||
|
||||
```html
|
||||
<!-- Good -->
|
||||
<button hx-delete="/parks/123/">Delete</button>
|
||||
|
||||
<!-- Avoid -->
|
||||
<button hx-post="/parks/123/delete/">Delete</button>
|
||||
```
|
||||
|
||||
### 2. Include CSRF Token
|
||||
|
||||
```html
|
||||
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
```
|
||||
|
||||
### 3. Use hx-confirm for Destructive Actions
|
||||
|
||||
```html
|
||||
<button hx-delete="/parks/123/"
|
||||
hx-confirm="Are you sure you want to delete this park?">
|
||||
Delete
|
||||
</button>
|
||||
```
|
||||
|
||||
### 4. Preserve Scroll Position
|
||||
|
||||
```html
|
||||
<div hx-get="/parks/"
|
||||
hx-swap="innerHTML show:top">
|
||||
```
|
||||
|
||||
### 5. Push URL for Navigation
|
||||
|
||||
```html
|
||||
<a hx-get="/parks/123/"
|
||||
hx-target="#main"
|
||||
hx-push-url="true">
|
||||
View Park
|
||||
</a>
|
||||
```
|
||||
|
||||
### 6. Handle Browser Back Button
|
||||
|
||||
```javascript
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (event.state && event.state.htmx) {
|
||||
htmx.ajax('GET', window.location.href, '#main');
|
||||
}
|
||||
});
|
||||
```
|
||||
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');
|
||||
});
|
||||
```
|
||||
437
docs/ux/migration-guide.md
Normal file
437
docs/ux/migration-guide.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# Migration Guide
|
||||
|
||||
This guide helps migrate existing templates to use the new standardized UX patterns.
|
||||
|
||||
## Overview
|
||||
|
||||
The ThrillWiki UX standardization introduces:
|
||||
|
||||
1. **Breadcrumb system** with Schema.org support
|
||||
2. **Skeleton screens** for loading states
|
||||
3. **Standardized form validation** with HTMX
|
||||
4. **Toast notifications** with action support
|
||||
5. **Page headers** with consistent layout
|
||||
6. **Action bars** for button placement
|
||||
7. **Enhanced modals** with focus trapping
|
||||
8. **HTMX response utilities** for views
|
||||
|
||||
## Step-by-Step Migration
|
||||
|
||||
### Step 1: Update Base Template Usage
|
||||
|
||||
Ensure your templates extend the base template correctly:
|
||||
|
||||
```django
|
||||
{% extends 'base/base.html' %}
|
||||
|
||||
{% block title %}Your Page Title{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Your content -->
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### Step 2: Add Breadcrumbs
|
||||
|
||||
#### In Your View
|
||||
|
||||
```python
|
||||
from apps.core.utils import BreadcrumbBuilder
|
||||
|
||||
def your_view(request):
|
||||
request.breadcrumbs = (
|
||||
BreadcrumbBuilder()
|
||||
.add_home()
|
||||
.add('Section', '/section/')
|
||||
.add_current('Current Page')
|
||||
.build()
|
||||
)
|
||||
return render(request, 'your_template.html')
|
||||
```
|
||||
|
||||
#### In Your Template
|
||||
|
||||
The breadcrumbs are automatically rendered if you're using the base template.
|
||||
If you need to place them elsewhere:
|
||||
|
||||
```django
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
```
|
||||
|
||||
### Step 3: Replace Page Headers
|
||||
|
||||
#### Before
|
||||
|
||||
```html
|
||||
<div class="page-header">
|
||||
<h1>Parks</h1>
|
||||
<a href="/parks/create/" class="btn">Add Park</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### After
|
||||
|
||||
```django
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Parks'
|
||||
subtitle='Browse theme parks worldwide'
|
||||
primary_action_url='/parks/create/'
|
||||
primary_action_text='Add Park'
|
||||
primary_action_icon='fas fa-plus'
|
||||
%}
|
||||
```
|
||||
|
||||
### Step 4: Update Form Fields
|
||||
|
||||
#### Before
|
||||
|
||||
```html
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<span class="error">{{ form.name.errors.0 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### After
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/form_field.html' with field=form.name %}
|
||||
```
|
||||
|
||||
For multiple fields with consistent styling:
|
||||
|
||||
```django
|
||||
{% for field in form %}
|
||||
{% include 'forms/partials/form_field.html' with field=field %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Step 5: Update Form Actions
|
||||
|
||||
#### Before
|
||||
|
||||
```html
|
||||
<div class="form-buttons">
|
||||
<a href="/cancel/">Cancel</a>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### After
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/form_actions.html' with
|
||||
submit_text='Save Park'
|
||||
cancel_url='/parks/'
|
||||
%}
|
||||
```
|
||||
|
||||
Or use the action bar for more options:
|
||||
|
||||
```django
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
align='between'
|
||||
primary_action_text='Save'
|
||||
tertiary_action_text='Cancel'
|
||||
tertiary_action_url='/parks/'
|
||||
%}
|
||||
```
|
||||
|
||||
### Step 6: Add Loading States
|
||||
|
||||
#### For Page Sections
|
||||
|
||||
```django
|
||||
<div id="parks-list"
|
||||
hx-get="{% url 'parks:list' %}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### For Forms
|
||||
|
||||
```django
|
||||
<form hx-post="{% url 'parks:create' %}"
|
||||
hx-target="#form-container"
|
||||
hx-indicator="#form-loading">
|
||||
{% csrf_token %}
|
||||
<!-- Form fields -->
|
||||
|
||||
<div id="form-loading" class="htmx-indicator">
|
||||
{% include 'htmx/components/loading_indicator.html' with text='Saving...' %}
|
||||
</div>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Step 7: Update Toast Messages
|
||||
|
||||
#### Before (Django Messages)
|
||||
|
||||
```python
|
||||
from django.contrib import messages
|
||||
|
||||
def your_view(request):
|
||||
messages.success(request, 'Park saved!')
|
||||
return redirect('parks:list')
|
||||
```
|
||||
|
||||
#### After (HTMX Response)
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import htmx_success
|
||||
|
||||
def your_view(request):
|
||||
# For HTMX requests
|
||||
if request.headers.get('HX-Request'):
|
||||
return htmx_success('Park saved!')
|
||||
|
||||
# For regular requests, still use messages
|
||||
messages.success(request, 'Park saved!')
|
||||
return redirect('parks:list')
|
||||
```
|
||||
|
||||
### Step 8: Update Modals
|
||||
|
||||
#### Before
|
||||
|
||||
```html
|
||||
<div class="modal" id="edit-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Edit Park</h2>
|
||||
<button onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### After
|
||||
|
||||
```django
|
||||
<div x-data="{ showEditModal: false }">
|
||||
<button @click="showEditModal = true">Edit</button>
|
||||
|
||||
{% include 'components/modals/modal_base.html' with
|
||||
modal_id='edit-modal'
|
||||
show_var='showEditModal'
|
||||
title='Edit Park'
|
||||
size='lg'
|
||||
%}
|
||||
</div>
|
||||
```
|
||||
|
||||
For confirmation dialogs:
|
||||
|
||||
```django
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='delete-confirm'
|
||||
show_var='showDeleteModal'
|
||||
title='Delete Park'
|
||||
message='Are you sure? This cannot be undone.'
|
||||
confirm_text='Delete'
|
||||
confirm_variant='destructive'
|
||||
confirm_hx_delete='/parks/123/'
|
||||
%}
|
||||
```
|
||||
|
||||
### Step 9: Update HTMX Views
|
||||
|
||||
#### Before
|
||||
|
||||
```python
|
||||
from django.http import HttpResponse
|
||||
import json
|
||||
|
||||
def create_park(request):
|
||||
park = Park.objects.create(**form.cleaned_data)
|
||||
|
||||
response = HttpResponse('')
|
||||
response['HX-Trigger'] = json.dumps({
|
||||
'showMessage': {'message': 'Park created!'}
|
||||
})
|
||||
return response
|
||||
```
|
||||
|
||||
#### After
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import htmx_success, htmx_error, htmx_modal_close
|
||||
|
||||
def create_park(request):
|
||||
form = ParkForm(request.POST)
|
||||
|
||||
if not form.is_valid():
|
||||
return htmx_error('Validation failed', status=422)
|
||||
|
||||
park = form.save()
|
||||
|
||||
# Simple success
|
||||
return htmx_success(f'{park.name} created!')
|
||||
|
||||
# Or close modal and refresh list
|
||||
return htmx_modal_close(
|
||||
message=f'{park.name} created!',
|
||||
refresh_target='#parks-list'
|
||||
)
|
||||
```
|
||||
|
||||
### Step 10: Add Page Meta
|
||||
|
||||
#### In Your View
|
||||
|
||||
```python
|
||||
from apps.core.utils import build_meta_context
|
||||
|
||||
def park_detail(request, slug):
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
|
||||
request.page_meta = build_meta_context(
|
||||
title=park.name,
|
||||
description=park.description,
|
||||
instance=park,
|
||||
request=request,
|
||||
)
|
||||
|
||||
return render(request, 'parks/detail.html', {'park': park})
|
||||
```
|
||||
|
||||
The meta tags are automatically rendered in the base template.
|
||||
|
||||
## Component Replacements
|
||||
|
||||
| Old Pattern | New Component |
|
||||
|-------------|---------------|
|
||||
| Custom page header | `components/layout/page_header.html` |
|
||||
| Form field div | `forms/partials/form_field.html` |
|
||||
| Form buttons | `forms/partials/form_actions.html` or `components/ui/action_bar.html` |
|
||||
| Loading spinner | `htmx/components/loading_indicator.html` |
|
||||
| Placeholder content | `components/skeletons/*.html` |
|
||||
| Custom modal | `components/modals/modal_base.html` |
|
||||
| Confirm dialog | `components/modals/modal_confirm.html` |
|
||||
| Custom breadcrumbs | `components/navigation/breadcrumbs.html` |
|
||||
| Status labels | `components/status_badge.html` |
|
||||
|
||||
## View Helper Replacements
|
||||
|
||||
| Old Pattern | New Helper |
|
||||
|-------------|------------|
|
||||
| `HttpResponse + HX-Trigger` | `htmx_success()` / `htmx_error()` |
|
||||
| `HttpResponse + HX-Redirect` | `htmx_redirect()` |
|
||||
| Custom modal close | `htmx_modal_close()` |
|
||||
| Inline validation | `htmx_validation_response()` |
|
||||
| Check HX-Request header | `is_htmx_request()` |
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue: Breadcrumbs Not Showing
|
||||
|
||||
1. Ensure context processor is added to settings:
|
||||
|
||||
```python
|
||||
TEMPLATES = [{
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
# ...
|
||||
'apps.core.context_processors.breadcrumbs',
|
||||
],
|
||||
},
|
||||
}]
|
||||
```
|
||||
|
||||
2. Ensure you set `request.breadcrumbs` in your view.
|
||||
|
||||
### Issue: Toast Not Appearing
|
||||
|
||||
1. Ensure toast container is in base template:
|
||||
|
||||
```django
|
||||
{% include 'components/ui/toast-container.html' %}
|
||||
```
|
||||
|
||||
2. Ensure Alpine.js is loaded and toast store is initialized.
|
||||
|
||||
3. For HTMX responses, use the helper functions:
|
||||
|
||||
```python
|
||||
return htmx_success('Message here')
|
||||
```
|
||||
|
||||
### Issue: Modal Not Closing
|
||||
|
||||
1. Ensure you're using the correct `show_var`:
|
||||
|
||||
```django
|
||||
<div x-data="{ showModal: false }">
|
||||
{% include 'components/modals/modal_base.html' with show_var='showModal' %}
|
||||
</div>
|
||||
```
|
||||
|
||||
2. For HTMX responses, use:
|
||||
|
||||
```python
|
||||
return htmx_modal_close(message='Done!')
|
||||
```
|
||||
|
||||
### Issue: Form Validation Not Working
|
||||
|
||||
1. Ensure HTMX attributes are correct:
|
||||
|
||||
```html
|
||||
<form hx-post="/create/"
|
||||
hx-target="#form-container"
|
||||
hx-swap="outerHTML">
|
||||
```
|
||||
|
||||
2. Return proper status code on errors:
|
||||
|
||||
```python
|
||||
if not form.is_valid():
|
||||
return render(request, 'form.html', {'form': form}, status=422)
|
||||
```
|
||||
|
||||
### Issue: Skeleton Not Matching Content
|
||||
|
||||
Adjust skeleton parameters to match your content:
|
||||
|
||||
```django
|
||||
{# For a 3-column grid with 9 cards #}
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=9 cols=3 %}
|
||||
|
||||
{# For a table with checkboxes #}
|
||||
{% include 'components/skeletons/table_skeleton.html' with rows=10 columns=5 show_checkbox=True %}
|
||||
```
|
||||
|
||||
## Testing Migrations
|
||||
|
||||
After migrating a template:
|
||||
|
||||
1. **Visual Check**: Ensure layout matches design
|
||||
2. **Functionality**: Test all interactive elements
|
||||
3. **Accessibility**: Test keyboard navigation and screen readers
|
||||
4. **Responsive**: Check on mobile, tablet, and desktop
|
||||
5. **Loading States**: Verify skeletons and indicators work
|
||||
6. **Error States**: Test form validation and error messages
|
||||
7. **Success States**: Verify toast notifications appear
|
||||
|
||||
## Rollback
|
||||
|
||||
If issues arise, components are designed to be backwards compatible.
|
||||
You can temporarily revert to old patterns while debugging:
|
||||
|
||||
```django
|
||||
{% comment %}
|
||||
{% include 'components/layout/page_header.html' with title='Parks' %}
|
||||
{% endcomment %}
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Parks</h1>
|
||||
</div>
|
||||
```
|
||||
Reference in New Issue
Block a user