Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX

This commit is contained in:
pacnpal
2025-12-22 16:56:27 -05:00
parent 2e35f8c5d9
commit ae31e889d7
144 changed files with 25792 additions and 4440 deletions

View File

@@ -0,0 +1,172 @@
{% comment %}
History Panel Component
=======================
A reusable history panel component for displaying object change history and FSM transitions.
Purpose:
Displays both regular history records and FSM (Finite State Machine) transition
history for parks, rides, and other entities with historical tracking.
Usage Examples:
Basic history:
{% include 'components/history_panel.html' with history=history %}
With FSM toggle (for moderators):
{% include 'components/history_panel.html' with history=history show_fsm_toggle=True fsm_history_url=fsm_url model_type='park' object_id=park.id can_view_fsm=perms.parks.change_park %}
Ride history:
{% include 'components/history_panel.html' with history=history show_fsm_toggle=True fsm_history_url=fsm_url model_type='ride' object_id=ride.id can_view_fsm=perms.rides.change_ride %}
Parameters:
Required:
- history: QuerySet or list of history records
Optional (FSM):
- show_fsm_toggle: Show toggle button for FSM history (default: False)
- fsm_history_url: URL for loading FSM transition history via HTMX
- model_type: Model type for FSM history (e.g., 'park', 'ride')
- object_id: Object ID for FSM history
- can_view_fsm: Whether user can view FSM history (default: False)
Optional (styling):
- title: Panel title (default: 'History')
- panel_class: Additional CSS classes for panel
- max_height: Maximum height for scrollable area (default: 'max-h-96')
- collapsed: Start collapsed (default: False)
Dependencies:
- Tailwind CSS for styling
- Alpine.js for interactivity
- HTMX (optional, for FSM history lazy loading)
- Font Awesome icons
Accessibility:
- Uses heading structure for panel title
- Toggle button has accessible label
- History items use semantic structure
{% endcomment %}
{% with title=title|default:'History' show_fsm_toggle=show_fsm_toggle|default:False can_view_fsm=can_view_fsm|default:False max_height=max_height|default:'max-h-96' collapsed=collapsed|default:False %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 {{ panel_class }}"
x-data="{ showFsmHistory: false {% if collapsed %}, showHistory: false{% endif %} }">
{# Header with optional FSM toggle #}
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{% if collapsed %}
<button type="button"
@click="showHistory = !showHistory"
class="flex items-center gap-2 hover:text-gray-700 dark:hover:text-gray-300">
<i class="fas fa-chevron-right transition-transform"
:class="{ 'rotate-90': showHistory }"
aria-hidden="true"></i>
{{ title }}
</button>
{% else %}
{{ title }}
{% endif %}
</h2>
{% if show_fsm_toggle and can_view_fsm %}
<button type="button"
@click="showFsmHistory = !showFsmHistory"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-expanded="showFsmHistory"
aria-controls="{{ model_type }}-fsm-history-container">
<i class="mr-2 fas fa-history" aria-hidden="true"></i>
<span x-text="showFsmHistory ? 'Hide Transitions' : 'Show Transitions'"></span>
</button>
{% endif %}
</div>
{# Collapsible wrapper #}
<div {% if collapsed %}x-show="showHistory" x-cloak x-transition{% endif %}>
{# FSM Transition History (Moderators Only) #}
{% if show_fsm_toggle and can_view_fsm and fsm_history_url %}
<div x-show="showFsmHistory" x-cloak x-transition class="mb-4">
<div id="{{ model_type }}-fsm-history-container"
x-show="showFsmHistory"
x-init="$watch('showFsmHistory', value => { if(value && !$el.dataset.loaded) { htmx.trigger($el, 'load-history'); $el.dataset.loaded = 'true'; } })"
hx-get="{{ fsm_history_url }}{% if model_type and object_id %}?model_type={{ model_type }}&object_id={{ object_id }}{% endif %}"
hx-trigger="load-history"
hx-target="this"
hx-swap="innerHTML"
hx-indicator="#{{ model_type }}-fsm-loading"
class="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
{# Loading State #}
<div id="{{ model_type }}-fsm-loading" class="htmx-indicator flex items-center justify-center py-4">
<i class="mr-2 text-blue-500 fas fa-spinner fa-spin" aria-hidden="true"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading transitions...</span>
</div>
</div>
</div>
{% endif %}
{# Regular History #}
<div class="space-y-4 overflow-y-auto {{ max_height }}">
{% for record in history %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
{# Timestamp and user #}
<div class="mb-1 text-sm text-gray-500 dark:text-gray-400">
{# Support both simple_history and pghistory formats #}
{% if record.history_date %}
{{ record.history_date|date:"M d, Y H:i" }}
{% if record.history_user %}
by {{ record.history_user.username }}
{% endif %}
{% elif record.pgh_created_at %}
{{ record.pgh_created_at|date:"M d, Y H:i" }}
{% if record.pgh_context.user %}
by {{ record.pgh_context.user }}
{% endif %}
{% if record.pgh_label %}
- {{ record.pgh_label }}
{% endif %}
{% endif %}
</div>
{# Changes #}
{% if record.diff_against_previous %}
<div class="mt-2 space-y-2">
{# Support both dictionary and method formats #}
{% if record.get_display_changes %}
{% for field, change in record.get_display_changes.items %}
{% if field != "updated_at" %}
<div class="text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ field }}:</span>
<span class="text-red-600 dark:text-red-400">{{ change.old|default:"—" }}</span>
<span class="mx-1 text-gray-400"></span>
<span class="text-green-600 dark:text-green-400">{{ change.new|default:"—" }}</span>
</div>
{% endif %}
{% endfor %}
{% else %}
{% for field, changes in record.diff_against_previous.items %}
{% if field != "updated_at" %}
<div class="text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ field|title }}:</span>
<span class="text-red-600 dark:text-red-400">{{ changes.old|default:"—" }}</span>
<span class="mx-1 text-gray-400"></span>
<span class="text-green-600 dark:text-green-400">{{ changes.new|default:"—" }}</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
{% empty %}
<p class="text-gray-500 dark:text-gray-400 text-center py-4">
<i class="fas fa-history mr-2" aria-hidden="true"></i>
No history available.
</p>
{% endfor %}
</div>
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,141 @@
{% comment %}
Page Header Component
=====================
Standardized page header with title, subtitle, icon, and action buttons.
Purpose:
Provides consistent page header layout across the application with
responsive design and optional breadcrumb integration.
Usage Examples:
Basic header:
{% include 'components/layout/page_header.html' with title='Parks' %}
With subtitle:
{% include 'components/layout/page_header.html' with title='Cedar Point' subtitle='Sandusky, Ohio' %}
With icon:
{% include 'components/layout/page_header.html' with title='Parks' icon='fas fa-map-marker-alt' %}
With actions:
{% include 'components/layout/page_header.html' with title='Parks' %}
{% block page_header_actions %}
<a href="{% url 'parks:create' %}" class="btn btn-primary">
<i class="fas fa-plus mr-2"></i>Add Park
</a>
{% endblock %}
{% endinclude %}
Full example:
{% include 'components/layout/page_header.html' with
title=park.name
subtitle=park.location
icon='fas fa-building'
show_breadcrumbs=True
badge_text='Active'
badge_variant='success'
%}
Parameters:
Required:
- title: Page title text
Optional:
- subtitle: Subtitle or description
- icon: Icon class (e.g., 'fas fa-home')
- show_breadcrumbs: Include breadcrumbs (default: False)
- badge_text: Status badge text
- badge_variant: 'success', 'warning', 'error', 'info' (default: 'info')
- size: 'sm', 'md', 'lg' for title size (default: 'lg')
- align: 'left', 'center' (default: 'left')
- border: Show bottom border (default: True)
- actions_slot: HTML for action buttons
Blocks:
- page_header_actions: Action buttons area
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons (optional)
- breadcrumbs.html component (if show_breadcrumbs=True)
Accessibility:
- Uses semantic heading element
- Actions have proper button semantics
{% endcomment %}
{% with size=size|default:'lg' align=align|default:'left' border=border|default:True show_breadcrumbs=show_breadcrumbs|default:False %}
<header class="page-header mb-6 {% if border %}pb-6 border-b border-border{% endif %}">
{# Breadcrumbs (optional) #}
{% if show_breadcrumbs and breadcrumbs %}
<div class="mb-4">
{% include 'components/navigation/breadcrumbs.html' %}
</div>
{% endif %}
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between {% if align == 'center' %}sm:justify-center text-center{% endif %}">
{# Title Section #}
<div class="flex-1 min-w-0 {% if align == 'center' %}flex flex-col items-center{% endif %}">
<div class="flex items-center gap-3 {% if align == 'center' %}justify-center{% endif %}">
{# Icon #}
{% if icon %}
<div class="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="{{ icon }} text-primary {% if size == 'sm' %}text-lg{% elif size == 'lg' %}text-xl sm:text-2xl{% else %}text-xl{% endif %}" aria-hidden="true"></i>
</div>
{% endif %}
{# Title and Subtitle #}
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="font-bold text-foreground truncate
{% if size == 'sm' %}text-xl sm:text-2xl
{% elif size == 'lg' %}text-2xl sm:text-3xl lg:text-4xl
{% else %}text-2xl sm:text-3xl{% endif %}">
{{ title }}
</h1>
{# Status Badge #}
{% if badge_text %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if badge_variant == 'success' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif badge_variant == 'warning' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
{% elif badge_variant == 'error' %}bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
{% else %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300{% endif %}">
{{ badge_text }}
</span>
{% endif %}
</div>
{# Subtitle #}
{% if subtitle %}
<p class="mt-1 text-muted-foreground truncate
{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-base sm:text-lg{% else %}text-base{% endif %}">
{{ subtitle }}
</p>
{% endif %}
{# Meta info slot #}
{% if meta %}
<div class="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
{{ meta }}
</div>
{% endif %}
</div>
</div>
</div>
{# Actions Section #}
{% if actions_slot or block.page_header_actions %}
<div class="flex-shrink-0 flex flex-wrap items-center gap-3 {% if align == 'center' %}justify-center{% else %}sm:justify-end{% endif %}">
{% if actions_slot %}
{{ actions_slot }}
{% endif %}
{% block page_header_actions %}{% endblock %}
</div>
{% endif %}
</div>
</header>
{% endwith %}

View File

@@ -1,5 +1,94 @@
<div id="modal-container" class="modal" role="dialog" aria-modal="true" tabindex="-1">
<div class="modal-content">
{% block modal_content %}{% endblock %}
</div>
</div>
{% comment %}
Modal Base Component
====================
A flexible, accessible modal dialog component with Alpine.js integration.
Purpose:
Provides a base modal structure with backdrop, header, body, and footer
sections. Includes keyboard navigation (ESC to close), focus trapping,
and proper ARIA attributes for accessibility.
Usage Examples:
Basic modal:
{% include 'components/modals/modal_base.html' with modal_id='my-modal' title='Modal Title' %}
{% block modal_body %}
<p>Modal content here</p>
{% endblock %}
{% endinclude %}
Modal with footer:
<div x-data="{ showModal: false }">
<button @click="showModal = true">Open Modal</button>
{% include 'components/modals/modal_base.html' with modal_id='confirm-modal' title='Confirm Action' show_var='showModal' %}
{% block modal_body %}
<p>Are you sure?</p>
{% endblock %}
{% block modal_footer %}
<button @click="showModal = false" class="btn-secondary">Cancel</button>
<button @click="confirmAction(); showModal = false" class="btn-primary">Confirm</button>
{% endblock %}
{% endinclude %}
</div>
Different sizes:
{% include 'components/modals/modal_base.html' with modal_id='lg-modal' title='Large Modal' size='lg' %}
Parameters:
Required:
- modal_id: Unique identifier for the modal (used for ARIA and targeting)
Optional:
- title: Modal title text (if empty, header section is hidden)
- size: Size variant 'sm', 'md', 'lg', 'xl', 'full' (default: 'md')
- show_close_button: Show X button in header (default: True)
- show_var: Alpine.js variable name for show/hide state (default: 'show')
- close_on_backdrop: Close when clicking backdrop (default: True)
- close_on_escape: Close when pressing Escape (default: True)
- prevent_scroll: Prevent body scroll when open (default: True)
Blocks:
- modal_header: Custom header content (replaces default header)
- modal_body: Main modal content (required)
- modal_footer: Footer content (optional)
Dependencies:
- Alpine.js for interactivity
- Tailwind CSS for styling
- Font Awesome icons (for close button)
Accessibility:
- Uses dialog role with aria-modal="true"
- Focus is trapped within modal when open
- ESC key closes the modal
- aria-labelledby points to title
- aria-describedby available for body content
{% endcomment %}
{# Default values #}
{% with size=size|default:'md' show_close_button=show_close_button|default:True show_var=show_var|default:'show' close_on_backdrop=close_on_backdrop|default:True close_on_escape=close_on_escape|default:True prevent_scroll=prevent_scroll|default:True %}
{# Size classes mapping #}
{% if size == 'sm' %}
{% with size_class='max-w-sm' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% elif size == 'lg' %}
{% with size_class='max-w-2xl' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% elif size == 'xl' %}
{% with size_class='max-w-4xl' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% elif size == 'full' %}
{% with size_class='max-w-full mx-4' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% else %}
{% with size_class='max-w-lg' %}
{% include 'components/modals/modal_inner.html' %}
{% endwith %}
{% endif %}
{% endwith %}

View File

@@ -1,5 +1,184 @@
{% extends "components/modals/modal_base.html" %}
{% comment %}
Confirmation Modal Component
============================
{% block modal_content %}
{% include "htmx/components/confirm_dialog.html" %}
{% endblock %}
Pre-styled confirmation dialog for destructive or important actions.
Purpose:
Provides a standardized confirmation dialog with customizable
title, message, and action buttons.
Usage Examples:
Basic confirmation:
<div x-data="{ showDeleteModal: false }">
<button @click="showDeleteModal = true">Delete</button>
{% include 'components/modals/modal_confirm.html' with
modal_id='delete-confirm'
show_var='showDeleteModal'
title='Delete Park'
message='Are you sure you want to delete this park? This action cannot be undone.'
confirm_text='Delete'
confirm_variant='destructive'
%}
</div>
With icon:
{% include 'components/modals/modal_confirm.html' with
modal_id='publish-confirm'
show_var='showPublishModal'
title='Publish Changes'
message='This will make your changes visible to all users.'
icon='fas fa-globe'
icon_variant='info'
confirm_text='Publish'
%}
With HTMX:
{% include 'components/modals/modal_confirm.html' with
modal_id='archive-confirm'
show_var='showArchiveModal'
title='Archive Item'
message='This will archive the item.'
confirm_hx_post='/api/archive/123/'
%}
Parameters:
Required:
- modal_id: Unique identifier for the modal
- show_var: Alpine.js variable name for show/hide state
- title: Modal title
- message: Confirmation message
Optional:
- icon: Icon class (default: auto based on variant)
- icon_variant: 'destructive', 'warning', 'info', 'success' (default: 'warning')
- confirm_text: Confirm button text (default: 'Confirm')
- confirm_variant: 'destructive', 'primary', 'warning' (default: 'primary')
- cancel_text: Cancel button text (default: 'Cancel')
- confirm_url: URL for confirm action (makes it a link)
- confirm_hx_post: HTMX post URL for confirm action
- confirm_hx_delete: HTMX delete URL for confirm action
- on_confirm: Alpine.js expression to run on confirm
Dependencies:
- modal_base.html component
- Tailwind CSS
- Alpine.js
- HTMX (optional)
{% endcomment %}
{% with icon_variant=icon_variant|default:'warning' confirm_variant=confirm_variant|default:'primary' confirm_text=confirm_text|default:'Confirm' cancel_text=cancel_text|default:'Cancel' %}
{# Determine icon based on variant if not specified #}
{% with default_icon=icon|default:'fas fa-exclamation-triangle' %}
<div id="{{ modal_id }}"
x-show="{{ show_var }}"
x-cloak
@keydown.escape.window="{{ show_var }} = false"
x-init="$watch('{{ show_var }}', value => { document.body.style.overflow = value ? 'hidden' : '' })"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="alertdialog"
aria-modal="true"
aria-labelledby="{{ modal_id }}-title"
aria-describedby="{{ modal_id }}-message">
{# Backdrop #}
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="{{ show_var }} = false"
aria-hidden="true">
</div>
{# Modal Content #}
<div class="relative w-full max-w-md bg-background rounded-xl shadow-2xl overflow-hidden border border-border"
x-show="{{ show_var }}"
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"
@click.stop>
<div class="p-6">
{# Icon and Title #}
<div class="text-center">
{# Icon #}
<div class="mx-auto mb-4 w-14 h-14 rounded-full flex items-center justify-center
{% if icon_variant == 'destructive' or confirm_variant == 'destructive' %}bg-red-100 dark:bg-red-900/30
{% elif icon_variant == 'success' %}bg-green-100 dark:bg-green-900/30
{% elif icon_variant == 'info' %}bg-blue-100 dark:bg-blue-900/30
{% else %}bg-yellow-100 dark:bg-yellow-900/30{% endif %}">
<i class="{{ default_icon }} text-2xl
{% if icon_variant == 'destructive' or confirm_variant == 'destructive' %}text-red-600 dark:text-red-400
{% elif icon_variant == 'success' %}text-green-600 dark:text-green-400
{% elif icon_variant == 'info' %}text-blue-600 dark:text-blue-400
{% else %}text-yellow-600 dark:text-yellow-400{% endif %}"
aria-hidden="true"></i>
</div>
{# Title #}
<h3 id="{{ modal_id }}-title" class="text-lg font-semibold text-foreground mb-2">
{{ title }}
</h3>
{# Message #}
<p id="{{ modal_id }}-message" class="text-muted-foreground">
{{ message }}
</p>
</div>
{# Actions #}
<div class="mt-6 flex flex-col-reverse sm:flex-row gap-3 sm:justify-center">
{# Cancel button #}
<button type="button"
@click="{{ show_var }} = false"
class="btn btn-outline w-full sm:w-auto">
{{ cancel_text }}
</button>
{# Confirm button #}
{% if confirm_url %}
<a href="{{ confirm_url }}"
class="btn w-full sm:w-auto text-center
{% if confirm_variant == 'destructive' %}btn-destructive
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
{% else %}btn-primary{% endif %}">
{{ confirm_text }}
</a>
{% elif confirm_hx_post or confirm_hx_delete %}
<button type="button"
{% if confirm_hx_post %}hx-post="{{ confirm_hx_post }}"{% endif %}
{% if confirm_hx_delete %}hx-delete="{{ confirm_hx_delete }}"{% endif %}
hx-swap="outerHTML"
@htmx:after-request="{{ show_var }} = false"
class="btn w-full sm:w-auto
{% if confirm_variant == 'destructive' %}btn-destructive
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
{% else %}btn-primary{% endif %}">
{{ confirm_text }}
</button>
{% else %}
<button type="button"
@click="{% if on_confirm %}{{ on_confirm }};{% endif %} {{ show_var }} = false"
class="btn w-full sm:w-auto
{% if confirm_variant == 'destructive' %}btn-destructive
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
{% else %}btn-primary{% endif %}">
{{ confirm_text }}
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endwith %}
{% endwith %}

View File

@@ -0,0 +1,142 @@
{# Inner modal template - do not use directly, use modal_base.html instead #}
{# Enhanced with animations, focus trap, and loading states #}
{% with animation=animation|default:'scale' loading=loading|default:False %}
<div id="{{ modal_id }}"
x-show="{{ show_var }}"
x-cloak
{% if close_on_escape %}@keydown.escape.window="{{ show_var }} = false"{% endif %}
x-init="
$watch('{{ show_var }}', value => {
{% if prevent_scroll %}document.body.style.overflow = value ? 'hidden' : '';{% endif %}
if (value) {
$nextTick(() => {
const firstFocusable = $el.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
if (firstFocusable) firstFocusable.focus();
});
}
});
"
@keydown.tab.prevent="
const focusables = $el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
const first = focusables[0];
const last = focusables[focusables.length - 1];
if ($event.shiftKey && document.activeElement === first) {
last.focus();
} else if (!$event.shiftKey && document.activeElement === last) {
first.focus();
} else {
return true;
}
"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
{% if title %}aria-labelledby="{{ modal_id }}-title"{% endif %}
aria-describedby="{{ modal_id }}-body">
{# Backdrop #}
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"
x-show="{{ show_var }}"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
{% if close_on_backdrop %}@click="{{ show_var }} = false"{% endif %}
aria-hidden="true">
</div>
{# Modal Content #}
<div class="relative w-full {{ size_class }} bg-background rounded-xl shadow-2xl overflow-hidden border border-border"
x-show="{{ show_var }}"
{% if animation == 'slide-up' %}
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-8"
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-8"
{% elif animation == 'fade' %}
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
{% else %}
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"
{% endif %}
@click.stop>
{# Loading Overlay #}
{% if loading %}
<div x-show="loading"
class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-4 border-primary rounded-full animate-spin border-t-transparent"></div>
<span class="text-sm text-muted-foreground">Loading...</span>
</div>
</div>
{% endif %}
{# Header #}
{% if title or show_close_button %}
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
{% block modal_header %}
{% if title %}
<div class="flex items-center gap-3">
{% if icon %}
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="{{ icon }} text-primary" aria-hidden="true"></i>
</div>
{% endif %}
<div>
<h3 id="{{ modal_id }}-title" class="text-lg font-semibold text-foreground">
{{ title }}
</h3>
{% if subtitle %}
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
{% endif %}
</div>
</div>
{% else %}
<div></div>
{% endif %}
{% endblock modal_header %}
{% if show_close_button %}
<button type="button"
@click="{{ show_var }} = false"
class="p-2 -mr-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring transition-colors"
aria-label="Close modal">
<i class="fas fa-times text-lg" aria-hidden="true"></i>
</button>
{% endif %}
</div>
{% endif %}
{# Body #}
<div id="{{ modal_id }}-body" class="px-6 py-4 overflow-y-auto max-h-[70vh]">
{% block modal_body %}{% endblock modal_body %}
</div>
{# Footer (optional) #}
{% block modal_footer_wrapper %}
{% if block.modal_footer %}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-border bg-muted/30">
{% block modal_footer %}{% endblock modal_footer %}
</div>
{% endif %}
{% endblock modal_footer_wrapper %}
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,133 @@
# Navigation Components
This directory contains navigation-related template components.
## Components
### breadcrumbs.html
Semantic breadcrumb navigation with Schema.org structured data support.
#### Features
- Accessible navigation with proper ARIA attributes
- Schema.org BreadcrumbList JSON-LD for SEO
- Responsive design with mobile-friendly collapse
- Customizable separators and icons
- Truncation for long labels
#### Basic Usage
```django
{# Breadcrumbs are automatically included from context processor #}
{% include 'components/navigation/breadcrumbs.html' %}
```
#### Setting Breadcrumbs in Views
```python
from apps.core.utils.breadcrumbs import build_breadcrumb, BreadcrumbBuilder
from django.urls import reverse
def park_detail(request, slug):
park = get_object_or_404(Park, slug=slug)
# Option 1: Build breadcrumbs manually
request.breadcrumbs = [
build_breadcrumb('Home', '/', icon='fas fa-home'),
build_breadcrumb('Parks', reverse('parks:list')),
build_breadcrumb(park.name, is_current=True),
]
# Option 2: Use the builder pattern
request.breadcrumbs = (
BreadcrumbBuilder()
.add_home()
.add('Parks', reverse('parks:list'))
.add_current(park.name)
.build()
)
return render(request, 'parks/detail.html', {'park': park})
```
#### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `items` | list | `breadcrumbs` | List of Breadcrumb objects |
| `show_schema` | bool | `True` | Include Schema.org JSON-LD |
| `show_home_icon` | bool | `True` | Show icon on home breadcrumb |
| `separator` | str | chevron | Custom separator character |
| `max_visible` | int | `3` | Max items before mobile collapse |
| `container_class` | str | `""` | Additional CSS classes |
#### Accessibility
- Uses `<nav>` element with `aria-label="Breadcrumb"`
- Ordered list (`<ol>`) for semantic structure
- `aria-current="page"` on current page item
- Hidden separators for screen readers
#### Examples
**Custom separator:**
```django
{% include 'components/navigation/breadcrumbs.html' with separator='/' %}
```
**Without Schema.org:**
```django
{% include 'components/navigation/breadcrumbs.html' with show_schema=False %}
```
**Custom breadcrumbs:**
```django
{% include 'components/navigation/breadcrumbs.html' with items=custom_crumbs %}
```
## Breadcrumb Utilities
### BreadcrumbBuilder
Fluent builder for constructing breadcrumbs:
```python
from apps.core.utils.breadcrumbs import BreadcrumbBuilder
breadcrumbs = (
BreadcrumbBuilder()
.add_home()
.add_from_url('parks:list', 'Parks')
.add_model(park)
.add_from_url('rides:list', 'Rides', {'park_slug': park.slug})
.add_model_current(ride)
.build()
)
```
### get_model_breadcrumb
Generate breadcrumbs for model instances with parent relationships:
```python
from apps.core.utils.breadcrumbs import get_model_breadcrumb
# For a Ride that belongs to a Park
breadcrumbs = get_model_breadcrumb(
ride,
parent_attr='park',
list_url_name='rides:list',
list_label='Rides',
)
# Returns: [Home, Parks, Cedar Point, Rides, Millennium Force]
```
## Context Processor
The `breadcrumbs` context processor (`apps.core.context_processors.breadcrumbs`) provides:
- `breadcrumbs`: List of Breadcrumb objects from view
- `breadcrumbs_json`: Schema.org JSON-LD string
- `BreadcrumbBuilder`: Builder class for templates
- `build_breadcrumb`: Helper function for creating items

View File

@@ -0,0 +1,120 @@
{% comment %}
Breadcrumb Navigation Component
===============================
Semantic breadcrumb navigation with Schema.org structured data support.
Purpose:
Renders accessible breadcrumb navigation with proper ARIA attributes,
Schema.org BreadcrumbList markup, and responsive design.
Usage Examples:
Basic usage (breadcrumbs from context processor):
{% include 'components/navigation/breadcrumbs.html' %}
Custom breadcrumbs:
{% include 'components/navigation/breadcrumbs.html' with items=custom_breadcrumbs %}
Without Schema.org markup:
{% include 'components/navigation/breadcrumbs.html' with show_schema=False %}
Custom separator:
{% include 'components/navigation/breadcrumbs.html' with separator='>' %}
Without home icon:
{% include 'components/navigation/breadcrumbs.html' with show_home_icon=False %}
Parameters:
Optional:
- items: List of Breadcrumb objects (default: breadcrumbs from context)
- show_schema: Include Schema.org JSON-LD (default: True)
- show_home_icon: Show icon on home breadcrumb (default: True)
- separator: Separator character/icon (default: chevron icon)
- max_visible: Maximum items to show on mobile before collapsing (default: 3)
- container_class: Additional CSS classes for container
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons (for home icon and separator)
- breadcrumbs context processor for default breadcrumbs
Accessibility:
- Uses <nav> element with aria-label="Breadcrumb"
- Ordered list for semantic structure
- aria-current="page" on current page item
- Hidden separators (aria-hidden) for screen readers
{% endcomment %}
{% with items=items|default:breadcrumbs show_schema=show_schema|default:True show_home_icon=show_home_icon|default:True max_visible=max_visible|default:3 %}
{% if items %}
{# Main Navigation #}
<nav aria-label="Breadcrumb"
class="breadcrumb-nav py-3 {{ container_class }}"
data-breadcrumb>
<ol class="flex flex-wrap items-center gap-1 text-sm" role="list">
{% for crumb in items %}
<li class="flex items-center {% if not forloop.last %}{% if forloop.counter > 1 and forloop.counter < items|length|add:'-1' %}hidden sm:flex{% endif %}{% endif %}">
{# Separator (except for first item) #}
{% if not forloop.first %}
<span class="mx-2 text-muted-foreground/50" aria-hidden="true">
{% if separator %}
{{ separator }}
{% else %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
{% endif %}
</span>
{% endif %}
{# Breadcrumb Item #}
{% if crumb.is_current %}
{# Current page (not a link) #}
<span class="font-medium text-foreground truncate max-w-[200px] sm:max-w-[300px]"
aria-current="page"
title="{{ crumb.label }}">
{% if crumb.icon %}
<i class="{{ crumb.icon }} mr-1.5" aria-hidden="true"></i>
{% endif %}
{{ crumb.label }}
</span>
{% else %}
{# Clickable breadcrumb #}
<a href="{{ crumb.url }}"
class="text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px] sm:max-w-[200px] inline-flex items-center"
title="{{ crumb.label }}">
{% if crumb.icon and show_home_icon %}
<i class="{{ crumb.icon }} mr-1.5" aria-hidden="true"></i>
<span class="sr-only sm:not-sr-only">{{ crumb.label }}</span>
{% else %}
{{ crumb.label }}
{% endif %}
</a>
{% endif %}
</li>
{# Mobile ellipsis for long breadcrumb trails #}
{% if forloop.counter == 1 and items|length > max_visible %}
<li class="flex items-center sm:hidden" aria-hidden="true">
<span class="mx-2 text-muted-foreground/50">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
<span class="text-muted-foreground">...</span>
</li>
{% endif %}
{% endfor %}
</ol>
</nav>
{# Schema.org Structured Data #}
{% if show_schema and breadcrumbs_json %}
<script type="application/ld+json">{{ breadcrumbs_json|safe }}</script>
{% endif %}
{% endif %}
{% endwith %}

View File

@@ -1,93 +1,61 @@
{% comment %}
Reusable pagination component with accessibility and responsive design.
Usage: {% include 'components/pagination.html' with page_obj=page_obj %}
Pagination Component
====================
A reusable pagination component with accessibility features and HTMX support.
Purpose:
Renders pagination controls for paginated querysets. Supports both
standard page navigation and HTMX-powered dynamic updates.
Usage Examples:
Standard pagination:
{% include 'components/pagination.html' with page_obj=page_obj %}
HTMX-enabled pagination:
{% include 'components/pagination.html' with page_obj=page_obj use_htmx=True hx_target='#results' %}
Custom styling:
{% include 'components/pagination.html' with page_obj=page_obj size='sm' %}
Parameters:
- page_obj: Django Page object from paginator (required)
- use_htmx: Enable HTMX for dynamic updates (optional, default: False)
- hx_target: HTMX target selector (optional, default: '#results')
- hx_swap: HTMX swap strategy (optional, default: 'innerHTML')
- hx_push_url: Whether to push URL to history (optional, default: 'true')
- size: Size variant 'sm', 'md', 'lg' (optional, default: 'md')
- show_info: Show "Showing X to Y of Z" info (optional, default: True)
- base_url: Base URL for pagination (optional, default: request.path)
Dependencies:
- Tailwind CSS for styling
- HTMX (optional, for dynamic pagination)
Accessibility:
- Uses nav element with aria-label="Pagination"
- Current page marked with aria-current="page"
- Previous/Next buttons have aria-labels
- Disabled buttons use aria-disabled
{% endcomment %}
{% if page_obj.has_other_pages %}
<nav class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6" aria-label="Pagination">
<div class="hidden sm:block">
<p class="text-sm text-gray-700">
Showing
<span class="font-medium">{{ page_obj.start_index }}</span>
to
<span class="font-medium">{{ page_obj.end_index }}</span>
of
<span class="font-medium">{{ page_obj.paginator.count }}</span>
results
</p>
</div>
<div class="flex-1 flex justify-between sm:justify-end">
{% if page_obj.has_previous %}
<a
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
aria-label="Go to previous page"
>
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
Previous
</a>
{% else %}
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
Previous
</span>
{% endif %}
<!-- Page numbers for larger screens -->
<div class="hidden md:flex">
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
<span class="relative inline-flex items-center px-4 py-2 border border-blue-500 bg-blue-50 text-sm font-medium text-blue-600 mx-1">
{{ num }}
</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<a
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mx-1 transition-colors"
aria-label="Go to page {{ num }}"
>
{{ num }}
</a>
{% elif num == 1 or num == page_obj.paginator.num_pages %}
<a
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mx-1 transition-colors"
aria-label="Go to page {{ num }}"
>
{{ num }}
</a>
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 mx-1">
...
</span>
{% endif %}
{% endfor %}
</div>
{% if page_obj.has_next %}
<a
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
aria-label="Go to next page"
>
Next
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</a>
{% else %}
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
Next
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</span>
{% endif %}
</div>
</nav>
{% with use_htmx=use_htmx|default:False hx_target=hx_target|default:'#results' hx_swap=hx_swap|default:'innerHTML' size=size|default:'md' show_info=show_info|default:True %}
{# Size-based classes #}
{% if size == 'sm' %}
{% with btn_padding='px-2 py-1 text-xs' info_class='text-xs' %}
{% include 'components/pagination_inner.html' %}
{% endwith %}
{% elif size == 'lg' %}
{% with btn_padding='px-5 py-3 text-base' info_class='text-base' %}
{% include 'components/pagination_inner.html' %}
{% endwith %}
{% else %}
{% with btn_padding='px-4 py-2 text-sm' info_class='text-sm' %}
{% include 'components/pagination_inner.html' %}
{% endwith %}
{% endif %}
{% endwith %}
{% endif %}

View File

@@ -0,0 +1,156 @@
{# Inner pagination template - do not use directly, use pagination.html instead #}
<nav class="bg-white dark:bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-700 sm:px-6 rounded-b-lg"
aria-label="Pagination"
role="navigation">
{# Results info - Hidden on mobile #}
{% if show_info %}
<div class="hidden sm:block">
<p class="{{ info_class }} text-gray-700 dark:text-gray-300">
Showing
<span class="font-medium">{{ page_obj.start_index }}</span>
to
<span class="font-medium">{{ page_obj.end_index }}</span>
of
<span class="font-medium">{{ page_obj.paginator.count }}</span>
results
</p>
</div>
{% endif %}
<div class="flex-1 flex justify-between sm:justify-end gap-2">
{# Previous Button #}
{% if page_obj.has_previous %}
{% if use_htmx %}
<button type="button"
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
hx-target="{{ hx_target }}"
hx-swap="{{ hx_swap }}"
hx-push-url="{{ hx_push_url|default:'true' }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
aria-label="Go to previous page">
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
Previous
</button>
{% else %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
aria-label="Go to previous page">
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
Previous
</a>
{% endif %}
{% else %}
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
aria-disabled="true">
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
Previous
</span>
{% endif %}
{# Page numbers - Hidden on mobile, visible on medium+ screens #}
<div class="hidden md:flex items-center gap-1">
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
{# Current page #}
<span class="relative inline-flex items-center {{ btn_padding }} border border-blue-500 bg-blue-50 dark:bg-blue-900/30 font-medium text-blue-600 dark:text-blue-400 rounded-md"
aria-current="page">
{{ num }}
</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
{# Pages near current #}
{% if use_htmx %}
<button type="button"
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
hx-target="{{ hx_target }}"
hx-swap="{{ hx_swap }}"
hx-push-url="{{ hx_push_url|default:'true' }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
aria-label="Go to page {{ num }}">
{{ num }}
</button>
{% else %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
aria-label="Go to page {{ num }}">
{{ num }}
</a>
{% endif %}
{% elif num == 1 or num == page_obj.paginator.num_pages %}
{# First and last page always visible #}
{% if use_htmx %}
<button type="button"
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
hx-target="{{ hx_target }}"
hx-swap="{{ hx_swap }}"
hx-push-url="{{ hx_push_url|default:'true' }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
aria-label="Go to page {{ num }}">
{{ num }}
</button>
{% else %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
aria-label="Go to page {{ num }}">
{{ num }}
</a>
{% endif %}
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
{# Ellipsis #}
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-500 dark:text-gray-400 rounded-md"
aria-hidden="true">
</span>
{% endif %}
{% endfor %}
</div>
{# Mobile page indicator #}
<div class="flex md:hidden items-center">
<span class="{{ info_class }} text-gray-700 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
</div>
{# Next Button #}
{% if page_obj.has_next %}
{% if use_htmx %}
<button type="button"
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
hx-target="{{ hx_target }}"
hx-swap="{{ hx_swap }}"
hx-push-url="{{ hx_push_url|default:'true' }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
aria-label="Go to next page">
Next
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
{% else %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
aria-label="Go to next page">
Next
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</a>
{% endif %}
{% else %}
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
aria-disabled="true">
Next
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</span>
{% endif %}
</div>
</nav>

View File

@@ -2,6 +2,7 @@
Reusable search form component with filtering capabilities.
Usage: {% include 'components/search_form.html' with placeholder="Search parks..." filters=filter_options %}
{% endcomment %}
{% load common_filters %}
<form method="get" class="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">

View File

@@ -0,0 +1,108 @@
{% comment %}
Card Grid Skeleton Component
============================
Animated skeleton placeholder for card grid layouts while content loads.
Purpose:
Displays pulsing skeleton cards in a grid layout for pages like
parks list, rides list, and search results.
Usage Examples:
Basic card grid:
{% include 'components/skeletons/card_grid_skeleton.html' %}
Custom card count:
{% include 'components/skeletons/card_grid_skeleton.html' with cards=8 %}
Horizontal cards:
{% include 'components/skeletons/card_grid_skeleton.html' with layout='horizontal' %}
Custom columns:
{% include 'components/skeletons/card_grid_skeleton.html' with cols='4' %}
Parameters:
Optional:
- cards: Number of skeleton cards to display (default: 6)
- cols: Grid columns ('2', '3', '4', 'auto') (default: 'auto')
- layout: Card layout ('vertical', 'horizontal') (default: 'vertical')
- show_image: Show image placeholder (default: True)
- show_badge: Show badge placeholder (default: True)
- show_footer: Show footer with stats (default: True)
- image_aspect: Image aspect ratio ('video', 'square', 'portrait') (default: 'video')
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
{% endcomment %}
{% with cards=cards|default:6 cols=cols|default:'auto' layout=layout|default:'vertical' show_image=show_image|default:True show_badge=show_badge|default:True show_footer=show_footer|default:True image_aspect=image_aspect|default:'video' animate=animate|default:True %}
<div class="skeleton-card-grid grid gap-4 sm:gap-6
{% if cols == '2' %}grid-cols-1 sm:grid-cols-2
{% elif cols == '3' %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3
{% elif cols == '4' %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4
{% else %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{% endif %}"
role="status"
aria-busy="true"
aria-label="Loading cards...">
{% for i in "123456789012"|slice:cards %}
<div class="skeleton-card bg-card rounded-xl border border-border overflow-hidden
{% if layout == 'horizontal' %}flex flex-row{% else %}flex flex-col{% endif %}">
{# Image placeholder #}
{% if show_image %}
<div class="{% if layout == 'horizontal' %}w-1/3 flex-shrink-0{% else %}w-full{% endif %}">
<div class="{% if image_aspect == 'square' %}aspect-square{% elif image_aspect == 'portrait' %}aspect-[3/4]{% else %}aspect-video{% endif %} bg-muted {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter0 }}50ms;">
</div>
</div>
{% endif %}
{# Content area #}
<div class="flex-1 p-4 space-y-3">
{# Badge placeholder #}
{% if show_badge %}
<div class="flex items-center gap-2">
<div class="h-5 w-16 bg-muted rounded-full {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter0 }}75ms;"></div>
</div>
{% endif %}
{# Title #}
<div class="space-y-2">
<div class="h-5 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 3 %}5%; animation-delay: {{ forloop.counter }}00ms;">
</div>
<div class="h-4 w-3/4 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter }}25ms;">
</div>
</div>
{# Description lines #}
<div class="space-y-2 pt-2">
<div class="h-3 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter }}50ms;"></div>
<div class="h-3 w-5/6 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter }}75ms;"></div>
</div>
{# Footer with stats #}
{% if show_footer %}
<div class="flex items-center justify-between pt-3 mt-auto border-t border-border">
<div class="flex items-center gap-4">
<div class="h-4 w-16 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-4 w-12 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
<div class="h-4 w-20 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<span class="sr-only">Loading cards, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,118 @@
{% comment %}
Detail Page Skeleton Component
==============================
Animated skeleton placeholder for detail pages while content loads.
Purpose:
Displays pulsing skeleton elements for detail page layouts including
header, image, and content sections.
Usage Examples:
Basic detail skeleton:
{% include 'components/skeletons/detail_skeleton.html' %}
With image placeholder:
{% include 'components/skeletons/detail_skeleton.html' with show_image=True %}
Custom content sections:
{% include 'components/skeletons/detail_skeleton.html' with sections=4 %}
Parameters:
Optional:
- show_image: Show large image placeholder (default: True)
- show_badge: Show status badge placeholder (default: True)
- show_meta: Show metadata row (default: True)
- show_actions: Show action buttons placeholder (default: True)
- sections: Number of content sections (default: 3)
- paragraphs_per_section: Lines per section (default: 4)
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
{% endcomment %}
{% with show_image=show_image|default:True show_badge=show_badge|default:True show_meta=show_meta|default:True show_actions=show_actions|default:True sections=sections|default:3 paragraphs_per_section=paragraphs_per_section|default:4 animate=animate|default:True %}
<div class="skeleton-detail space-y-6"
role="status"
aria-busy="true"
aria-label="Loading page content...">
{# Header Section #}
<div class="skeleton-detail-header space-y-4">
{# Breadcrumb placeholder #}
<div class="flex items-center gap-2">
<div class="h-3 w-12 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-3 w-3 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-3 w-20 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-3 w-3 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-3 w-32 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
{# Title and badge row #}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="space-y-2">
{# Title #}
<div class="h-8 sm:h-10 w-64 sm:w-80 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
{# Subtitle/location #}
<div class="h-4 w-48 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 100ms;"></div>
</div>
{% if show_badge %}
{# Status badge #}
<div class="h-7 w-24 bg-muted rounded-full {% if animate %}animate-pulse{% endif %}"></div>
{% endif %}
</div>
{# Meta row (date, author, etc.) #}
{% if show_meta %}
<div class="flex flex-wrap items-center gap-4 text-sm">
<div class="h-4 w-32 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 150ms;"></div>
<div class="h-4 w-24 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 200ms;"></div>
<div class="h-4 w-28 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 250ms;"></div>
</div>
{% endif %}
{# Action buttons #}
{% if show_actions %}
<div class="flex flex-wrap gap-3">
<div class="h-10 w-28 bg-muted rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
<div class="h-10 w-24 bg-muted/80 rounded-lg {% if animate %}animate-pulse{% endif %}" style="animation-delay: 50ms;"></div>
<div class="h-10 w-10 bg-muted/60 rounded-lg {% if animate %}animate-pulse{% endif %}" style="animation-delay: 100ms;"></div>
</div>
{% endif %}
</div>
{# Image Section #}
{% if show_image %}
<div class="skeleton-detail-image">
<div class="w-full aspect-video bg-muted rounded-xl {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
{# Content Sections #}
<div class="skeleton-detail-content space-y-8">
{% for s in "1234567890"|slice:sections %}
<div class="space-y-3">
{# Section heading #}
<div class="h-6 w-48 bg-muted rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter0 }}50ms;"></div>
{# Paragraph lines #}
{% for p in "12345678"|slice:paragraphs_per_section %}
<div class="h-4 bg-muted/{% if forloop.last %}50{% else %}70{% endif %} rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% if forloop.last %}65{% else %}{% widthratio forloop.counter0 1 5 %}5{% endif %}%; animation-delay: {{ forloop.counter }}00ms;">
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<span class="sr-only">Loading content, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,119 @@
{% comment %}
Form Skeleton Component
=======================
Animated skeleton placeholder for forms while content loads.
Purpose:
Displays pulsing skeleton form elements including labels, inputs,
and action buttons.
Usage Examples:
Basic form skeleton:
{% include 'components/skeletons/form_skeleton.html' %}
Custom field count:
{% include 'components/skeletons/form_skeleton.html' with fields=6 %}
Without textarea:
{% include 'components/skeletons/form_skeleton.html' with show_textarea=False %}
Compact form:
{% include 'components/skeletons/form_skeleton.html' with size='sm' %}
Parameters:
Optional:
- fields: Number of input fields (default: 4)
- show_textarea: Show a textarea field (default: True)
- show_checkbox: Show checkbox fields (default: False)
- show_select: Show select dropdown (default: True)
- checkbox_count: Number of checkboxes (default: 3)
- size: 'sm', 'md', 'lg' for field sizes (default: 'md')
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
{% endcomment %}
{% with fields=fields|default:4 show_textarea=show_textarea|default:True show_checkbox=show_checkbox|default:False show_select=show_select|default:True checkbox_count=checkbox_count|default:3 size=size|default:'md' animate=animate|default:True %}
<div class="skeleton-form space-y-{% if size == 'sm' %}4{% elif size == 'lg' %}8{% else %}6{% endif %}"
role="status"
aria-busy="true"
aria-label="Loading form...">
{# Regular input fields #}
{% for i in "12345678"|slice:fields %}
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
{# Label #}
<div class="{% if size == 'sm' %}h-3 w-20{% elif size == 'lg' %}h-5 w-28{% else %}h-4 w-24{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter0 }}50ms;">
</div>
{# Input #}
<div class="{% if size == 'sm' %}h-8{% elif size == 'lg' %}h-12{% else %}h-10{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter0 }}75ms;">
</div>
{# Help text (occasionally) #}
{% if forloop.counter|divisibleby:2 %}
<div class="{% if size == 'sm' %}h-2 w-48{% elif size == 'lg' %}h-4 w-64{% else %}h-3 w-56{% endif %} bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter }}00ms;">
</div>
{% endif %}
</div>
{% endfor %}
{# Select dropdown #}
{% if show_select %}
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
<div class="{% if size == 'sm' %}h-3 w-24{% elif size == 'lg' %}h-5 w-32{% else %}h-4 w-28{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="{% if size == 'sm' %}h-8{% elif size == 'lg' %}h-12{% else %}h-10{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %} relative">
{# Dropdown arrow indicator #}
<div class="absolute right-3 top-1/2 -translate-y-1/2">
<div class="{% if size == 'sm' %}w-3 h-3{% elif size == 'lg' %}w-5 h-5{% else %}w-4 h-4{% endif %} bg-muted/90 rounded"></div>
</div>
</div>
</div>
{% endif %}
{# Textarea #}
{% if show_textarea %}
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
<div class="{% if size == 'sm' %}h-3 w-28{% elif size == 'lg' %}h-5 w-36{% else %}h-4 w-32{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="{% if size == 'sm' %}h-20{% elif size == 'lg' %}h-40{% else %}h-32{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %}"></div>
<div class="{% if size == 'sm' %}h-2 w-40{% elif size == 'lg' %}h-4 w-56{% else %}h-3 w-48{% endif %} bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
{# Checkboxes #}
{% if show_checkbox %}
<div class="skeleton-form-checkboxes space-y-3">
<div class="{% if size == 'sm' %}h-3 w-32{% elif size == 'lg' %}h-5 w-40{% else %}h-4 w-36{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
{% for c in "12345"|slice:checkbox_count %}
<div class="flex items-center gap-3">
<div class="{% if size == 'sm' %}w-4 h-4{% elif size == 'lg' %}w-6 h-6{% else %}w-5 h-5{% endif %} bg-muted/70 rounded border border-muted {% if animate %}animate-pulse{% endif %}"></div>
<div class="{% if size == 'sm' %}h-3{% elif size == 'lg' %}h-5{% else %}h-4{% endif %} bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 4 %}0%;">
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Form actions #}
<div class="skeleton-form-actions flex items-center justify-end gap-3 pt-{% if size == 'sm' %}3{% elif size == 'lg' %}6{% else %}4{% endif %} mt-{% if size == 'sm' %}3{% elif size == 'lg' %}6{% else %}4{% endif %} border-t border-border">
{# Cancel button #}
<div class="{% if size == 'sm' %}h-8 w-20{% elif size == 'lg' %}h-12 w-28{% else %}h-10 w-24{% endif %} bg-muted/60 rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
{# Submit button #}
<div class="{% if size == 'sm' %}h-8 w-24{% elif size == 'lg' %}h-12 w-32{% else %}h-10 w-28{% endif %} bg-muted rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
</div>
<span class="sr-only">Loading form, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,85 @@
{% comment %}
List Skeleton Component
=======================
Animated skeleton placeholder for list items while content loads.
Purpose:
Displays pulsing skeleton rows to indicate loading state for list views,
reducing perceived loading time and preventing layout shift.
Usage Examples:
Basic list skeleton:
{% include 'components/skeletons/list_skeleton.html' %}
Custom row count:
{% include 'components/skeletons/list_skeleton.html' with rows=10 %}
With avatar placeholder:
{% include 'components/skeletons/list_skeleton.html' with show_avatar=True %}
Compact variant:
{% include 'components/skeletons/list_skeleton.html' with size='sm' %}
Parameters:
Optional:
- rows: Number of skeleton rows to display (default: 5)
- show_avatar: Show circular avatar placeholder (default: False)
- show_meta: Show metadata line below title (default: True)
- show_action: Show action button placeholder (default: False)
- size: 'sm', 'md', 'lg' for padding/spacing (default: 'md')
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
- aria-label describes loading state
{% endcomment %}
{% with rows=rows|default:5 show_avatar=show_avatar|default:False show_meta=show_meta|default:True show_action=show_action|default:False size=size|default:'md' animate=animate|default:True %}
<div class="skeleton-list space-y-{% if size == 'sm' %}2{% elif size == 'lg' %}6{% else %}4{% endif %}"
role="status"
aria-busy="true"
aria-label="Loading list items...">
{% for i in "12345678901234567890"|slice:rows %}
<div class="skeleton-list-item flex items-center gap-{% if size == 'sm' %}2{% elif size == 'lg' %}4{% else %}3{% endif %} {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-5{% else %}p-4{% endif %} bg-card rounded-lg border border-border">
{# Avatar placeholder #}
{% if show_avatar %}
<div class="flex-shrink-0">
<div class="{% if size == 'sm' %}w-8 h-8{% elif size == 'lg' %}w-14 h-14{% else %}w-10 h-10{% endif %} rounded-full bg-muted {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
{# Content area #}
<div class="flex-1 min-w-0 space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}3{% else %}2{% endif %}">
{# Title line #}
<div class="{% if size == 'sm' %}h-3{% elif size == 'lg' %}h-5{% else %}h-4{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 7 %}0%;">
</div>
{# Meta line #}
{% if show_meta %}
<div class="{% if size == 'sm' %}h-2{% elif size == 'lg' %}h-4{% else %}h-3{% endif %} bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 5 %}0%; animation-delay: {{ forloop.counter0 }}00ms;">
</div>
{% endif %}
</div>
{# Action button placeholder #}
{% if show_action %}
<div class="flex-shrink-0">
<div class="{% if size == 'sm' %}w-16 h-6{% elif size == 'lg' %}w-24 h-10{% else %}w-20 h-8{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
</div>
{% endif %}
</div>
{% endfor %}
<span class="sr-only">Loading content, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,137 @@
{% comment %}
Table Skeleton Component
========================
Animated skeleton placeholder for data tables while content loads.
Purpose:
Displays pulsing skeleton table rows for data-heavy pages like
admin dashboards, moderation queues, and data exports.
Usage Examples:
Basic table skeleton:
{% include 'components/skeletons/table_skeleton.html' %}
Custom dimensions:
{% include 'components/skeletons/table_skeleton.html' with rows=10 cols=6 %}
With checkbox column:
{% include 'components/skeletons/table_skeleton.html' with show_checkbox=True %}
With action column:
{% include 'components/skeletons/table_skeleton.html' with show_actions=True %}
Parameters:
Optional:
- rows: Number of table rows (default: 5)
- cols: Number of data columns (default: 4)
- show_header: Show table header row (default: True)
- show_checkbox: Show checkbox column (default: False)
- show_actions: Show actions column (default: True)
- show_avatar: Show avatar in first column (default: False)
- striped: Use striped row styling (default: False)
- animate: Enable pulse animation (default: True)
Dependencies:
- Tailwind CSS for styling and animation
Accessibility:
- Uses role="status" and aria-busy="true" for screen readers
{% endcomment %}
{% with rows=rows|default:5 cols=cols|default:4 show_header=show_header|default:True show_checkbox=show_checkbox|default:False show_actions=show_actions|default:True show_avatar=show_avatar|default:False striped=striped|default:False animate=animate|default:True %}
<div class="skeleton-table overflow-hidden rounded-lg border border-border"
role="status"
aria-busy="true"
aria-label="Loading table data...">
<table class="w-full">
{# Table Header #}
{% if show_header %}
<thead class="bg-muted/30">
<tr>
{# Checkbox header #}
{% if show_checkbox %}
<th class="w-12 p-4">
<div class="w-5 h-5 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
</th>
{% endif %}
{# Data column headers #}
{% for c in "12345678"|slice:cols %}
<th class="p-4 text-left">
<div class="h-4 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 1 4 %}5%; animation-delay: {{ forloop.counter0 }}25ms;">
</div>
</th>
{% endfor %}
{# Actions header #}
{% if show_actions %}
<th class="w-28 p-4 text-right">
<div class="h-4 w-16 bg-muted rounded ml-auto {% if animate %}animate-pulse{% endif %}"></div>
</th>
{% endif %}
</tr>
</thead>
{% endif %}
{# Table Body #}
<tbody class="divide-y divide-border">
{% for r in "12345678901234567890"|slice:rows %}
<tr class="{% if striped and forloop.counter|divisibleby:2 %}bg-muted/10{% endif %}">
{# Checkbox cell #}
{% if show_checkbox %}
<td class="p-4">
<div class="w-5 h-5 bg-muted/70 rounded border border-muted {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.counter0 }}50ms;">
</div>
</td>
{% endif %}
{# Data cells #}
{% for c in "12345678"|slice:cols %}
<td class="p-4">
{% if forloop.first and show_avatar %}
{# First column with avatar #}
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-muted {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.parentloop.counter0 }}25ms;">
</div>
<div class="space-y-1">
<div class="h-4 w-24 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.parentloop.counter0 }}50ms;">
</div>
<div class="h-3 w-32 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"
style="animation-delay: {{ forloop.parentloop.counter0 }}75ms;">
</div>
</div>
</div>
{% else %}
{# Regular data cell #}
<div class="h-4 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
style="width: {% widthratio forloop.counter0 cols 100 %}%; min-width: 40%; animation-delay: {{ forloop.parentloop.counter0 }}{{ forloop.counter0 }}0ms;">
</div>
{% endif %}
</td>
{% endfor %}
{# Actions cell #}
{% if show_actions %}
<td class="p-4">
<div class="flex items-center justify-end gap-2">
<div class="w-8 h-8 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"></div>
<div class="w-8 h-8 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 50ms;"></div>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<span class="sr-only">Loading table data, please wait...</span>
</div>
{% endwith %}

View File

@@ -0,0 +1,87 @@
{% comment %}
Statistics Card Component
=========================
A reusable card component for displaying statistics and metrics.
Purpose:
Renders a consistent statistics card with label, value, optional icon,
and optional link. Used for displaying metrics on detail pages.
Usage Examples:
Basic stat:
{% include 'components/stats_card.html' with label='Total Rides' value=park.ride_count %}
Stat with icon:
{% include 'components/stats_card.html' with label='Rating' value='4.5/5' icon='fas fa-star' %}
Clickable stat:
{% include 'components/stats_card.html' with label='Total Rides' value=42 link=rides_url %}
Priority stat (highlighted):
{% include 'components/stats_card.html' with label='Operator' value=park.operator.name priority=True %}
Stat with subtitle:
{% include 'components/stats_card.html' with label='Height' value='250 ft' subtitle='76 meters' %}
Parameters:
Required:
- label: Stat label/title
- value: Stat value to display
Optional:
- icon: Font Awesome icon class (e.g., 'fas fa-star')
- link: URL to link to (makes card clickable)
- subtitle: Secondary text below value
- priority: Boolean to highlight as priority card (default: False)
- size: Size variant 'sm', 'md', 'lg' (default: 'md')
- value_class: Additional CSS classes for value
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons (optional)
Accessibility:
- Uses semantic dt/dd structure for label/value
- Clickable cards use proper link semantics
- Priority cards use visual emphasis, not just color
{% endcomment %}
{% with priority=priority|default:False size=size|default:'md' %}
{% if link %}
<a href="{{ link }}"
class="block bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats transition-transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-blue-500 {% if priority %}card-stats-priority{% endif %}">
{% else %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats {% if priority %}card-stats-priority{% endif %}">
{% endif %}
<div class="text-center">
{# Label #}
<dt class="{% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-base{% else %}text-sm{% endif %} font-semibold text-gray-900 dark:text-white">
{% if icon %}
<i class="{{ icon }} mr-1 text-gray-500 dark:text-gray-400" aria-hidden="true"></i>
{% endif %}
{{ label }}
</dt>
{# Value #}
<dd class="mt-1 {% if size == 'sm' %}text-lg{% elif size == 'lg' %}text-3xl{% else %}text-2xl{% endif %} font-bold text-sky-900 dark:text-sky-400 {{ value_class }}{% if link %} hover:text-sky-800 dark:hover:text-sky-300{% endif %}">
{{ value|default:"N/A" }}
</dd>
{# Subtitle (optional) #}
{% if subtitle %}
<dd class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ subtitle }}
</dd>
{% endif %}
</div>
{% if link %}
</a>
{% else %}
</div>
{% endif %}
{% endwith %}

View File

@@ -1,22 +1,86 @@
{% comment %}
Reusable status badge component with consistent styling.
Usage: {% include 'components/status_badge.html' with status="OPERATING" %}
Usage (clickable): {% include 'components/status_badge.html' with status="OPERATING" clickable=True %}
Status Badge Component
======================
A unified, reusable status badge component for parks, rides, and other entities.
Purpose:
Displays a status badge with consistent styling across the application.
Supports both static display and interactive HTMX-powered refresh.
Usage Examples:
Basic badge (uses park_tags for config):
{% include 'components/status_badge.html' with status='OPERATING' %}
Clickable badge:
{% include 'components/status_badge.html' with status='OPERATING' clickable=True %}
Interactive badge with HTMX (for moderators):
{% include 'components/status_badge.html' with status=park.status badge_id='park-header-badge' refresh_url=park_badge_url refresh_trigger='park-status-changed' scroll_target='park-status-section' can_edit=perms.parks.change_park %}
Manual status display (without park_tags config lookup):
{% include 'components/status_badge.html' with status=obj.status status_display=obj.get_status_display manual_mode=True %}
Manual mode with custom classes:
{% include 'components/status_badge.html' with status=obj.status status_display=obj.get_status_display status_classes='bg-blue-100 text-blue-800' manual_mode=True %}
Parameters:
Required:
- status: The status value (e.g., 'OPERATING', 'CLOSED_TEMP')
Optional (auto mode - uses park_tags):
- clickable: Enable click interactions (default: False)
Optional (HTMX mode):
- badge_id: ID for HTMX targeting
- refresh_url: URL for HTMX refresh on trigger
- refresh_trigger: HTMX trigger event name (e.g., 'park-status-changed')
- scroll_target: Element ID to scroll to on click
- can_edit: Whether user can edit/click the badge (default: False)
Optional (manual mode):
- manual_mode: Use status_display instead of park_tags config lookup (default: False)
- status_display: Human-readable status text (used when manual_mode=True)
- status_classes: CSS classes for badge styling (default: 'bg-gray-100 text-gray-800')
Optional (styling):
- size: Size variant 'sm', 'md', 'lg' (default: 'md')
Status Classes (auto mode - defined in park_tags):
- OPERATING: Green (bg-green-100 text-green-800)
- CLOSED_TEMP: Yellow (bg-yellow-100 text-yellow-800)
- CLOSED_PERM: Red (bg-red-100 text-red-800)
- CONSTRUCTION: Orange (bg-orange-100 text-orange-800)
- DEMOLISHED: Gray (bg-gray-100 text-gray-800)
- RELOCATED: Purple (bg-purple-100 text-purple-800)
- SBNO: Amber (bg-amber-100 text-amber-800)
Dependencies:
- park_tags template tags (for get_status_config filter, only needed in auto mode)
- HTMX (optional, for interactive features)
- Font Awesome icons (for dropdown indicator)
Accessibility:
- Uses semantic button or span based on interactivity
- Provides appropriate focus states
- Uses color + text for status indication
{% endcomment %}
{% load park_tags %}
{% with status_config=status|get_status_config %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ status_config.classes }}
{% if clickable %}cursor-pointer transition-all hover:ring-2 hover:ring-blue-500{% endif %}">
{% if status_config.icon %}
<svg class="-ml-0.5 mr-1.5 h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ status_config.label }}
{% if clickable %}
<i class="fas fa-chevron-down ml-1.5 text-xs"></i>
{% endif %}
</span>
{# Determine sizing classes #}
{% with size=size|default:'md' %}
{% if size == 'sm' %}
{% with size_classes='px-2 py-0.5 text-xs' icon_size='h-1.5 w-1.5' %}
{% include 'components/status_badge_inner.html' %}
{% endwith %}
{% elif size == 'lg' %}
{% with size_classes='px-4 py-1.5 text-sm' icon_size='h-2.5 w-2.5' %}
{% include 'components/status_badge_inner.html' %}
{% endwith %}
{% else %}
{% with size_classes='px-2.5 py-0.5 text-xs' icon_size='h-2 w-2' %}
{% include 'components/status_badge_inner.html' %}
{% endwith %}
{% endif %}
{% endwith %}

View File

@@ -0,0 +1,99 @@
{# Inner status badge template - do not use directly, use status_badge.html instead #}
{# This template expects: status, size_classes, icon_size, and optionally other params #}
{# When manual_mode is true, use provided status_display and default classes #}
{# Otherwise use get_status_config filter from park_tags #}
{# Wrapper with optional HTMX refresh #}
{% if badge_id %}
<span id="{{ badge_id }}"
{% if refresh_url and refresh_trigger %}
hx-get="{{ refresh_url }}"
hx-trigger="{{ refresh_trigger }} from:body"
hx-swap="outerHTML"
{% endif %}>
{% endif %}
{% if manual_mode %}
{# Manual mode: use provided status_display and derive classes from status value #}
{% with badge_label=status_display|default:status badge_classes=status_classes|default:'bg-gray-100 text-gray-800' show_icon=True %}
{% if can_edit and scroll_target %}
<button type="button"
onclick="document.getElementById('{{ scroll_target }}').scrollIntoView({behavior: 'smooth'})"
class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer {{ badge_classes }}"
aria-label="View status options for {{ badge_label }}">
{% if show_icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ badge_label }}
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
</button>
{% elif clickable %}
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer {{ badge_classes }}"
role="button"
tabindex="0">
{% if show_icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ badge_label }}
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
</span>
{% else %}
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium {{ badge_classes }}">
{% if show_icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ badge_label }}
</span>
{% endif %}
{% endwith %}
{% else %}
{# Auto mode: use get_status_config filter from park_tags #}
{% with status_config=status|get_status_config %}
{% if can_edit and scroll_target %}
<button type="button"
onclick="document.getElementById('{{ scroll_target }}').scrollIntoView({behavior: 'smooth'})"
class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer {{ status_config.classes }}"
aria-label="View status options for {{ status_config.label }}">
{% if status_config.icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ status_config.label }}
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
</button>
{% elif clickable %}
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer {{ status_config.classes }}"
role="button"
tabindex="0">
{% if status_config.icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ status_config.label }}
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
</span>
{% else %}
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium {{ status_config.classes }}">
{% if status_config.icon %}
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="3" />
</svg>
{% endif %}
{{ status_config.label }}
</span>
{% endif %}
{% endwith %}
{% endif %}
{% if badge_id %}
</span>
{% endif %}

View File

@@ -0,0 +1,169 @@
{% comment %}
Action Bar Component
====================
Standardized container for action buttons with consistent layout and spacing.
Purpose:
Provides a consistent action button container for page headers, card footers,
and section actions with responsive layout.
Usage Examples:
Basic action bar:
{% include 'components/ui/action_bar.html' %}
{% block actions %}
<a href="{% url 'item:edit' %}" class="btn btn-primary">Edit</a>
{% endblock %}
{% endinclude %}
With primary and secondary actions:
{% include 'components/ui/action_bar.html' with
primary_action_url='/create/'
primary_action_text='Create Park'
primary_action_icon='fas fa-plus'
secondary_action_url='/import/'
secondary_action_text='Import'
%}
Between alignment (cancel left, submit right):
{% include 'components/ui/action_bar.html' with align='between' %}
Multiple actions via slot:
{% include 'components/ui/action_bar.html' %}
{% block actions %}
<button class="btn btn-ghost">Preview</button>
<button class="btn btn-outline">Save Draft</button>
<button class="btn btn-primary">Publish</button>
{% endblock %}
{% endinclude %}
Parameters:
Optional:
- align: 'left', 'right', 'center', 'between' (default: 'right')
- mobile_stack: Stack vertically on mobile (default: True)
- show_border: Show top border (default: False)
- padding: Add padding (default: True)
Primary action:
- primary_action_url: URL for primary button
- primary_action_text: Primary button text
- primary_action_icon: Primary button icon class
- primary_action_class: Primary button CSS class (default: 'btn-primary')
Secondary action:
- secondary_action_url: URL for secondary button
- secondary_action_text: Secondary button text
- secondary_action_icon: Secondary button icon class
- secondary_action_class: Secondary button CSS class (default: 'btn-outline')
Tertiary action:
- tertiary_action_url: URL for tertiary button
- tertiary_action_text: Tertiary button text
- tertiary_action_class: Tertiary button CSS class (default: 'btn-ghost')
Blocks:
- actions: Custom action buttons slot
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons (optional)
- Button component styles from components.css
Accessibility:
- Uses proper button/link semantics
- Focus states for keyboard navigation
{% endcomment %}
{% with align=align|default:'right' mobile_stack=mobile_stack|default:True show_border=show_border|default:False padding=padding|default:True %}
<div class="action-bar flex flex-wrap items-center gap-3
{% if mobile_stack %}flex-col sm:flex-row{% endif %}
{% if padding %}py-4{% endif %}
{% if show_border %}pt-4 border-t border-border{% endif %}
{% if align == 'left' %}justify-start
{% elif align == 'center' %}justify-center
{% elif align == 'between' %}justify-between
{% else %}justify-end{% endif %}">
{# Left side actions (for 'between' alignment) #}
{% if align == 'between' %}
<div class="flex items-center gap-3 {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{# Tertiary action (left side) #}
{% if tertiary_action_url or tertiary_action_text %}
{% if tertiary_action_url %}
<a href="{{ tertiary_action_url }}"
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ tertiary_action_text }}
</a>
{% else %}
<button type="button"
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}"
onclick="history.back()">
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ tertiary_action_text|default:'Cancel' }}
</button>
{% endif %}
{% endif %}
</div>
{% endif %}
{# Main actions group #}
<div class="flex items-center gap-3 {% if mobile_stack %}w-full sm:w-auto {% if align == 'between' %}justify-end{% endif %}{% endif %}">
{# Custom actions slot #}
{% block actions %}{% endblock %}
{# Tertiary action (non-between alignment) #}
{% if tertiary_action_text and align != 'between' %}
{% if tertiary_action_url %}
<a href="{{ tertiary_action_url }}"
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ tertiary_action_text }}
</a>
{% else %}
<button type="button"
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ tertiary_action_text }}
</button>
{% endif %}
{% endif %}
{# Secondary action #}
{% if secondary_action_text %}
{% if secondary_action_url %}
<a href="{{ secondary_action_url }}"
class="{{ secondary_action_class|default:'btn btn-outline' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if secondary_action_icon %}<i class="{{ secondary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ secondary_action_text }}
</a>
{% else %}
<button type="button"
class="{{ secondary_action_class|default:'btn btn-outline' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if secondary_action_icon %}<i class="{{ secondary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ secondary_action_text }}
</button>
{% endif %}
{% endif %}
{# Primary action #}
{% if primary_action_text %}
{% if primary_action_url %}
<a href="{{ primary_action_url }}"
class="{{ primary_action_class|default:'btn btn-primary' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if primary_action_icon %}<i class="{{ primary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ primary_action_text }}
</a>
{% else %}
<button type="submit"
class="{{ primary_action_class|default:'btn btn-primary' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
{% if primary_action_icon %}<i class="{{ primary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
{{ primary_action_text }}
</button>
{% endif %}
{% endif %}
</div>
</div>
{% endwith %}

View File

@@ -1,63 +1,155 @@
{% comment %}
Button Component - Django Template Version of shadcn/ui Button
Usage: {% include 'components/ui/button.html' with variant='default' size='default' text='Click me' %}
Button Component - Unified Django Template Version of shadcn/ui Button
A versatile button component that supports multiple variants, sizes, icons, and both
button/link elements. Compatible with HTMX and Alpine.js.
Usage Examples:
Basic button:
{% include 'components/ui/button.html' with text='Click me' %}
With variant and size:
{% include 'components/ui/button.html' with text='Submit' variant='default' size='lg' %}
Link button:
{% include 'components/ui/button.html' with href='/path' text='Go' type='link' %}
With HTMX:
{% include 'components/ui/button.html' with text='Load' hx_get='/api/data' hx_target='#target' %}
With Alpine.js:
{% include 'components/ui/button.html' with text='Toggle' x_on_click='open = !open' %}
With SVG icon (preferred):
{% include 'components/ui/button.html' with icon=search_icon_svg text='Search' %}
Icon-only button:
{% include 'components/ui/button.html' with icon=icon_svg size='icon' aria_label='Close' %}
Parameters:
- variant: 'default', 'destructive', 'outline', 'secondary', 'ghost', 'link' (default: 'default')
- size: 'default', 'sm', 'lg', 'icon' (default: 'default')
- type: 'button', 'submit', 'reset', 'link' (default: 'button')
- text: Button text content
- label: Alias for text (for backwards compatibility)
- content: Alias for text (for backwards compatibility)
- href: URL for link buttons (required when type='link')
- icon: SVG icon content (will be sanitized)
- icon_left: Font Awesome class for left icon (deprecated, prefer icon)
- icon_right: Font Awesome class for right icon (deprecated)
- disabled: Boolean to disable the button
- class: Additional CSS classes
- id: Element ID
- aria_label: Accessibility label (required for icon-only buttons)
- onclick: JavaScript click handler
- hx_get, hx_post, hx_target, hx_swap, hx_trigger, hx_indicator, hx_include: HTMX attributes
- x_data, x_on_click, x_bind, x_show: Alpine.js attributes
- attrs: Additional HTML attributes as string
Security: Icon SVGs are sanitized using the sanitize_svg filter to prevent XSS attacks.
{% endcomment %}
{% load static %}
{% load static safe_html %}
{% with variant=variant|default:'default' size=size|default:'default' %}
<button
class="
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium
ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
{% if variant == 'default' %}
bg-primary text-primary-foreground hover:bg-primary/90
{% elif variant == 'destructive' %}
bg-destructive text-destructive-foreground hover:bg-destructive/90
{% elif variant == 'outline' %}
border border-input bg-background hover:bg-accent hover:text-accent-foreground
{% elif variant == 'secondary' %}
bg-secondary text-secondary-foreground hover:bg-secondary/80
{% elif variant == 'ghost' %}
hover:bg-accent hover:text-accent-foreground
{% elif variant == 'link' %}
text-primary underline-offset-4 hover:underline
{% with variant=variant|default:'default' size=size|default:'default' btn_type=type|default:'button' btn_text=text|default:label|default:content %}
{% if btn_type == 'link' or href %}
{# Link element styled as button #}
<a
href="{{ href|default:'#' }}"
{% if id %}id="{{ id }}"{% endif %}
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
{% if variant == 'destructive' %}bg-destructive text-destructive-foreground hover:bg-destructive/90
{% elif variant == 'outline' %}border border-input bg-background hover:bg-accent hover:text-accent-foreground
{% elif variant == 'secondary' %}bg-secondary text-secondary-foreground hover:bg-secondary/80
{% elif variant == 'ghost' %}hover:bg-accent hover:text-accent-foreground
{% elif variant == 'link' %}text-primary underline-offset-4 hover:underline
{% else %}bg-primary text-primary-foreground hover:bg-primary/90{% endif %}
{% if size == 'sm' %}h-9 rounded-md px-3
{% elif size == 'lg' %}h-11 rounded-md px-8
{% elif size == 'icon' %}h-10 w-10
{% else %}h-10 px-4 py-2{% endif %}
{{ class|default:'' }}"
{% if disabled %}aria-disabled="true" tabindex="-1"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if x_on_click %}@click="{{ x_on_click }}"{% endif %}
{% if x_bind %}x-bind="{{ x_bind }}"{% endif %}
{% if x_show %}x-show="{{ x_show }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_indicator %}hx-indicator="{{ hx_indicator }}"{% endif %}
{{ attrs|default:'' }}>
{% if icon %}
<span class="w-4 h-4 flex items-center justify-center">{{ icon|sanitize_svg }}</span>
{% if btn_text %}<span>{{ btn_text }}</span>{% endif %}
{% elif icon_left %}
<i class="{{ icon_left }} w-4 h-4" aria-hidden="true"></i>
{% if btn_text %}{{ btn_text }}{% endif %}
{% else %}
{{ btn_text }}
{% endif %}
{% if size == 'default' %}
h-10 px-4 py-2
{% elif size == 'sm' %}
h-9 rounded-md px-3
{% elif size == 'lg' %}
h-11 rounded-md px-8
{% elif size == 'icon' %}
h-10 w-10
{% if icon_right %}
<i class="{{ icon_right }} w-4 h-4" aria-hidden="true"></i>
{% endif %}
{{ class|default:'' }}
"
{% if type %}type="{{ type }}"{% endif %}
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|default:'' }}
>
{% if icon_left %}
<i class="{{ icon_left }} w-4 h-4"></i>
{% endif %}
{% if text %}
{{ text }}
{% else %}
{{ content|default:'' }}
{% endif %}
{% if icon_right %}
<i class="{{ icon_right }} w-4 h-4"></i>
{% endif %}
{% block link_content %}{% endblock %}
</a>
{% else %}
{# Button element #}
<button
type="{{ btn_type }}"
{% if id %}id="{{ id }}"{% endif %}
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
{% if variant == 'destructive' %}bg-destructive text-destructive-foreground hover:bg-destructive/90
{% elif variant == 'outline' %}border border-input bg-background hover:bg-accent hover:text-accent-foreground
{% elif variant == 'secondary' %}bg-secondary text-secondary-foreground hover:bg-secondary/80
{% elif variant == 'ghost' %}hover:bg-accent hover:text-accent-foreground
{% elif variant == 'link' %}text-primary underline-offset-4 hover:underline
{% else %}bg-primary text-primary-foreground hover:bg-primary/90{% endif %}
{% if size == 'sm' %}h-9 rounded-md px-3
{% elif size == 'lg' %}h-11 rounded-md px-8
{% elif size == 'icon' %}h-10 w-10
{% else %}h-10 px-4 py-2{% endif %}
{{ class|default:'' }}"
{% if disabled %}disabled{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if x_on_click %}@click="{{ x_on_click }}"{% endif %}
{% if x_bind %}x-bind="{{ x_bind }}"{% endif %}
{% if x_show %}x-show="{{ x_show }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_indicator %}hx-indicator="{{ hx_indicator }}"{% endif %}
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
{{ attrs|default:'' }}>
{% if icon %}
<span class="w-4 h-4 flex items-center justify-center">{{ icon|sanitize_svg }}</span>
{% if btn_text %}<span>{{ btn_text }}</span>{% endif %}
{% elif icon_left %}
<i class="{{ icon_left }} w-4 h-4" aria-hidden="true"></i>
{% if btn_text %}{{ btn_text }}{% endif %}
{% else %}
{{ btn_text }}
{% endif %}
{% if icon_right %}
<i class="{{ icon_right }} w-4 h-4" aria-hidden="true"></i>
{% endif %}
{% block button_content %}{% endblock %}
</button>
{% endif %}
{% endwith %}

View File

@@ -1,40 +1,92 @@
{% comment %}
Card Component - Django Template Version of shadcn/ui Card
Usage: {% include 'components/ui/card.html' with title='Card Title' content='Card content' %}
Card Component - Unified Django Template Version of shadcn/ui Card
Security: All content variables are sanitized to prevent XSS attacks.
A flexible card container with optional header, content, and footer sections.
Uses design tokens for consistent styling.
Usage Examples:
Basic card with title:
{% include 'components/ui/card.html' with title='Card Title' content='Card content here' %}
Card with all sections:
{% include 'components/ui/card.html' with title='Title' description='Subtitle' body_content='<p>Content</p>' footer_content='<button>Action</button>' %}
Card with custom header:
{% include 'components/ui/card.html' with header_content='<div>Custom header</div>' content='Content' %}
Card with block content (for more complex layouts):
{% include 'components/ui/card.html' with title='Title' %}
{% block card_content %}
Complex content here
{% endblock %}
Parameters:
- title: Card title text
- description: Card subtitle/description text
- header_content: HTML content for the header area (sanitized)
- content: Main content (sanitized)
- body_content: Alias for content (sanitized)
- footer_content: Footer content (sanitized)
- footer: Alias for footer_content (sanitized)
- header: Alias for header_content (sanitized)
- class: Additional CSS classes for the card container
- id: Element ID
Security: All content variables are sanitized using the sanitize filter to prevent XSS attacks.
Only trusted HTML elements and attributes are allowed.
{% endcomment %}
{% load safe_html %}
<div class="rounded-lg border bg-card text-card-foreground shadow-sm {{ class|default:'' }}">
{% if title or header_content %}
<div class="flex flex-col space-y-1.5 p-6">
{% if title %}
<h3 class="text-2xl font-semibold leading-none tracking-tight">{{ title }}</h3>
{% endif %}
{% if description %}
<p class="text-sm text-muted-foreground">{{ description }}</p>
{% endif %}
{% if header_content %}
{{ header_content|sanitize }}
{% endif %}
</div>
{% endif %}
<div
{% if id %}id="{{ id }}"{% endif %}
class="rounded-lg border bg-card text-card-foreground shadow-sm {{ class|default:'' }}">
{% if content or body_content %}
<div class="p-6 pt-0">
{% if content %}
{{ content|sanitize }}
{% endif %}
{% if body_content %}
{{ body_content|sanitize }}
{% endif %}
</div>
{% endif %}
{# Header Section #}
{% if title or description or header_content or header %}
<div class="flex flex-col space-y-1.5 p-6">
{% if title %}
<h3 class="text-2xl font-semibold leading-none tracking-tight">{{ title }}</h3>
{% endif %}
{% if footer_content %}
<div class="flex items-center p-6 pt-0">
{{ footer_content|sanitize }}
</div>
{% endif %}
{% if description %}
<p class="text-sm text-muted-foreground">{{ description }}</p>
{% endif %}
{% if header_content %}
{{ header_content|sanitize }}
{% elif header %}
{{ header|sanitize }}
{% endif %}
</div>
{% endif %}
{# Content Section #}
{% if content or body_content %}
<div class="p-6 pt-0">
{% if content %}
{{ content|sanitize }}
{% endif %}
{% if body_content %}
{{ body_content|sanitize }}
{% endif %}
{% block card_content %}{% endblock %}
</div>
{% else %}
{# Allow block content even without content parameter #}
<div class="p-6 pt-0">
{% block card_content_fallback %}{% endblock %}
</div>
{% endif %}
{# Footer Section #}
{% if footer_content or footer %}
<div class="flex items-center p-6 pt-0">
{% if footer_content %}
{{ footer_content|sanitize }}
{% elif footer %}
{{ footer|sanitize }}
{% endif %}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,140 @@
{% comment %}
Dialog/Modal Component - Unified Django Template
A flexible dialog/modal component that supports both HTMX-triggered and Alpine.js-controlled modals.
Includes proper accessibility attributes (ARIA) and keyboard navigation support.
Usage Examples:
Alpine.js controlled modal:
{% include 'components/ui/dialog.html' with title='Confirm Action' content='Are you sure?' id='confirm-modal' %}
<button @click="$store.ui.openModal('confirm-modal')">Open Modal</button>
HTMX triggered modal (loads content dynamically):
<button hx-get="/modal/content" hx-target="#modal-container">Load Modal</button>
<div id="modal-container">
{% include 'components/ui/dialog.html' with title='Dynamic Content' %}
</div>
With footer actions:
{% include 'components/ui/dialog.html' with title='Delete Item' description='This cannot be undone.' footer='<button class="btn">Cancel</button><button class="btn btn-destructive">Delete</button>' %}
Parameters:
- id: Modal ID (used for Alpine.js state management)
- title: Dialog title
- description: Dialog subtitle/description
- content: Main content (sanitized)
- footer: Footer content with actions (sanitized)
- open: Boolean to control initial open state (default: true for HTMX-loaded content)
- closable: Boolean to allow closing (default: true)
- size: 'sm', 'default', 'lg', 'xl', 'full' (default: 'default')
- class: Additional CSS classes for the dialog panel
Accessibility:
- role="dialog" and aria-modal="true" for screen readers
- Focus trap within modal when open
- Escape key closes the modal
- Click outside closes the modal (backdrop click)
Security: Content and footer are sanitized to prevent XSS attacks.
{% endcomment %}
{% load safe_html %}
{% with modal_id=id|default:'dialog' is_open=open|default:True %}
<div class="fixed inset-0 z-50 flex items-start justify-center sm:items-center"
role="dialog"
aria-modal="true"
{% if title %}aria-labelledby="{{ modal_id }}-title"{% endif %}
{% if description %}aria-describedby="{{ modal_id }}-description"{% endif %}
x-data="{ open: {{ is_open|yesno:'true,false' }} }"
x-show="open"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@keydown.escape.window="open = false">
{# Backdrop #}
<div class="fixed inset-0 transition-all bg-black/50 backdrop-blur-sm"
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
{% if closable|default:True %}
@click="open = false; setTimeout(() => { if ($el.closest('[hx-history-elt]')) $el.closest('[hx-history-elt]').innerHTML = ''; }, 200)"
{% endif %}
aria-hidden="true"></div>
{# Dialog Panel #}
<div class="fixed z-50 grid w-full gap-4 p-6 duration-200 border shadow-lg bg-background sm:rounded-lg
{% if size == 'sm' %}sm:max-w-sm
{% elif size == 'lg' %}sm:max-w-2xl
{% elif size == 'xl' %}sm:max-w-4xl
{% elif size == 'full' %}sm:max-w-[90vw] sm:max-h-[90vh]
{% else %}sm:max-w-lg{% endif %}
{{ class|default:'' }}"
x-show="open"
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"
@click.stop>
{# Header #}
{% if title or description %}
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
{% if title %}
<h2 id="{{ modal_id }}-title" class="text-lg font-semibold leading-none tracking-tight">
{{ title }}
</h2>
{% endif %}
{% if description %}
<p id="{{ modal_id }}-description" class="text-sm text-muted-foreground">
{{ description }}
</p>
{% endif %}
</div>
{% endif %}
{# Content #}
<div class="py-4">
{% if content %}
{{ content|sanitize }}
{% endif %}
{% block dialog_content %}{% endblock %}
</div>
{# Footer #}
{% if footer %}
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
{{ footer|sanitize }}
</div>
{% endif %}
{% block dialog_footer %}{% endblock %}
{# Close Button #}
{% if closable|default:True %}
<button type="button"
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
@click="open = false; setTimeout(() => { if ($el.closest('[hx-history-elt]')) $el.closest('[hx-history-elt]').innerHTML = ''; }, 200)"
aria-label="Close">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span class="sr-only">Close</span>
</button>
{% endif %}
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,227 @@
{% comment %}
Icon Component - SVG Icon Wrapper
A component for rendering SVG icons consistently. Provides a library of common icons
and supports custom SVG content. Replaces Font Awesome with inline SVGs for better
customization and smaller bundle size.
Usage Examples:
Named icon:
{% include 'components/ui/icon.html' with name='search' %}
With size:
{% include 'components/ui/icon.html' with name='menu' size='lg' %}
With custom class:
{% include 'components/ui/icon.html' with name='user' class='text-primary' %}
Custom SVG content:
{% include 'components/ui/icon.html' with svg='<path d="..."/>' %}
Parameters:
- name: Icon name from the built-in library
- size: 'xs', 'sm', 'md', 'lg', 'xl' (default: 'md')
- class: Additional CSS classes
- svg: Custom SVG path content (for icons not in the library)
- stroke_width: SVG stroke width (default: 2)
- aria_label: Accessibility label (required for meaningful icons)
- aria_hidden: Set to 'false' for meaningful icons (default: 'true' for decorative)
Available Icons:
Navigation: menu, close, chevron-down, chevron-up, chevron-left, chevron-right,
arrow-left, arrow-right, arrow-up, arrow-down, external-link
Actions: search, plus, minus, edit, trash, download, upload, copy, share, refresh
User: user, users, settings, logout, login
Status: check, x, info, warning, error, question
Media: image, video, music, file, folder
Communication: mail, phone, message, bell, send
Social: heart, star, bookmark, thumbs-up, thumbs-down
Misc: home, calendar, clock, map-pin, globe, sun, moon, eye, eye-off, lock, unlock
{% endcomment %}
{% with icon_size=size|default:'md' %}
<svg
class="icon icon-{{ name }}
{% if icon_size == 'xs' %}w-3 h-3
{% elif icon_size == 'sm' %}w-4 h-4
{% elif icon_size == 'lg' %}w-6 h-6
{% elif icon_size == 'xl' %}w-8 h-8
{% else %}w-5 h-5{% endif %}
{{ class|default:'' }}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="{{ stroke_width|default:'2' }}"
stroke-linecap="round"
stroke-linejoin="round"
{% if aria_label %}aria-label="{{ aria_label }}" role="img"{% else %}aria-hidden="{{ aria_hidden|default:'true' }}"{% endif %}>
{% if svg %}
{{ svg|safe }}
{% elif name == 'search' %}
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
{% elif name == 'menu' %}
<path d="M4 6h16M4 12h16M4 18h16"/>
{% elif name == 'close' or name == 'x' %}
<path d="M6 18L18 6M6 6l12 12"/>
{% elif name == 'chevron-down' %}
<path d="M19 9l-7 7-7-7"/>
{% elif name == 'chevron-up' %}
<path d="M5 15l7-7 7 7"/>
{% elif name == 'chevron-left' %}
<path d="M15 19l-7-7 7-7"/>
{% elif name == 'chevron-right' %}
<path d="M9 5l7 7-7 7"/>
{% elif name == 'arrow-left' %}
<path d="M19 12H5M12 19l-7-7 7-7"/>
{% elif name == 'arrow-right' %}
<path d="M5 12h14M12 5l7 7-7 7"/>
{% elif name == 'arrow-up' %}
<path d="M12 19V5M5 12l7-7 7 7"/>
{% elif name == 'arrow-down' %}
<path d="M12 5v14M19 12l-7 7-7-7"/>
{% elif name == 'external-link' %}
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/>
{% elif name == 'plus' %}
<path d="M12 5v14M5 12h14"/>
{% elif name == 'minus' %}
<path d="M5 12h14"/>
{% elif name == 'edit' or name == 'pencil' %}
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
{% elif name == 'trash' or name == 'delete' %}
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
<path d="M10 11v6M14 11v6"/>
{% elif name == 'download' %}
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
{% elif name == 'upload' %}
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
{% elif name == 'copy' %}
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
{% elif name == 'share' %}
<circle cx="18" cy="5" r="3"/>
<circle cx="6" cy="12" r="3"/>
<circle cx="18" cy="19" r="3"/>
<path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/>
{% elif name == 'refresh' %}
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
{% elif name == 'user' %}
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
{% elif name == 'users' %}
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
{% elif name == 'settings' or name == 'cog' %}
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
{% elif name == 'logout' or name == 'sign-out' %}
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
{% elif name == 'login' or name == 'sign-in' %}
<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3"/>
{% elif name == 'check' %}
<path d="M20 6L9 17l-5-5"/>
{% elif name == 'info' %}
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4M12 8h.01"/>
{% elif name == 'warning' or name == 'alert-triangle' %}
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01"/>
{% elif name == 'error' or name == 'alert-circle' %}
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4M12 16h.01"/>
{% elif name == 'question' or name == 'help-circle' %}
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01"/>
{% elif name == 'image' %}
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
{% elif name == 'file' %}
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/>
<path d="M13 2v7h7"/>
{% elif name == 'folder' %}
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
{% elif name == 'mail' or name == 'email' %}
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<path d="M22 6l-10 7L2 6"/>
{% elif name == 'phone' %}
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z"/>
{% elif name == 'message' or name == 'chat' %}
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
{% elif name == 'bell' %}
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0"/>
{% elif name == 'send' %}
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
{% elif name == 'heart' %}
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
{% elif name == 'star' %}
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
{% elif name == 'bookmark' %}
<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
{% elif name == 'thumbs-up' %}
<path d="M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3zM7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3"/>
{% elif name == 'thumbs-down' %}
<path d="M10 15v4a3 3 0 003 3l4-9V2H5.72a2 2 0 00-2 1.7l-1.38 9a2 2 0 002 2.3zm7-13h2.67A2.31 2.31 0 0122 4v7a2.31 2.31 0 01-2.33 2H17"/>
{% elif name == 'home' %}
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
<path d="M9 22V12h6v10"/>
{% elif name == 'calendar' %}
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<path d="M16 2v4M8 2v4M3 10h18"/>
{% elif name == 'clock' %}
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
{% elif name == 'map-pin' or name == 'location' %}
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/>
<circle cx="12" cy="10" r="3"/>
{% elif name == 'globe' %}
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
{% elif name == 'sun' %}
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
{% elif name == 'moon' %}
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
{% elif name == 'eye' %}
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
{% elif name == 'eye-off' %}
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24M1 1l22 22"/>
{% elif name == 'lock' %}
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0110 0v4"/>
{% elif name == 'unlock' %}
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 019.9-1"/>
{% elif name == 'filter' %}
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/>
{% elif name == 'sort' %}
<path d="M3 6h18M6 12h12M9 18h6"/>
{% elif name == 'grid' %}
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
{% elif name == 'list' %}
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
{% elif name == 'more-horizontal' or name == 'dots' %}
<circle cx="12" cy="12" r="1"/>
<circle cx="19" cy="12" r="1"/>
<circle cx="5" cy="12" r="1"/>
{% elif name == 'more-vertical' %}
<circle cx="12" cy="12" r="1"/>
<circle cx="12" cy="5" r="1"/>
<circle cx="12" cy="19" r="1"/>
{% elif name == 'loader' or name == 'spinner' %}
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
{% else %}
{# Default: question mark icon for unknown names #}
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01"/>
{% endif %}
</svg>
{% endwith %}

View File

@@ -1,26 +1,162 @@
{% comment %}
Input Component - Django Template Version of shadcn/ui Input
Usage: {% include 'components/ui/input.html' with type='text' placeholder='Enter text...' name='field_name' %}
Input Component - Unified Django Template Version of shadcn/ui Input
A versatile input component that supports both Django form fields and standalone inputs.
Compatible with HTMX and Alpine.js for dynamic behavior.
Usage Examples:
Standalone input:
{% include 'components/ui/input.html' with type='text' name='email' placeholder='Enter email' %}
With Django form field:
{% include 'components/ui/input.html' with field=form.email label='Email Address' %}
With HTMX validation:
{% include 'components/ui/input.html' with name='username' hx_post='/validate' hx_trigger='blur' %}
With Alpine.js binding:
{% include 'components/ui/input.html' with name='search' x_model='query' %}
Textarea mode:
{% include 'components/ui/input.html' with type='textarea' name='message' rows='4' %}
Parameters:
Standalone Mode:
- type: Input type (text, email, password, number, etc.) or 'textarea' (default: 'text')
- name: Input name attribute
- id: Input ID (auto-generated from name if not provided)
- placeholder: Placeholder text
- value: Initial value
- label: Label text
- help_text: Help text displayed below the input
- error: Error message to display
- required: Boolean for required field
- disabled: Boolean to disable the input
- readonly: Boolean for readonly input
- autocomplete: Autocomplete attribute value
- rows: Number of rows for textarea
- class: Additional CSS classes for the input
Django Form Field Mode:
- field: Django form field object
- label: Override field label
- placeholder: Override field placeholder
- help_text: Override field help text
HTMX Attributes:
- hx_get, hx_post, hx_target, hx_swap, hx_trigger, hx_include: HTMX attributes
Alpine.js Attributes:
- x_model: Two-way binding
- x_on: Event handlers (as string, e.g., "@input=...")
- x_data: Alpine data
Other:
- attrs: Additional HTML attributes as string
- aria_describedby: ID of element describing this input
- aria_invalid: Boolean for invalid state
{% endcomment %}
<input
type="{{ type|default:'text' }}"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {{ class|default:'' }}"
{% if name %}name="{{ name }}"{% endif %}
{% if id %}id="{{ id }}"{% endif %}
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if value %}value="{{ value }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if x_model %}x-model="{{ x_model }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
{{ attrs|default:'' }}
/>
{% load widget_tweaks %}
{% if field %}
{# Django Form Field Mode #}
<div class="space-y-2">
{% if label or field.label %}
<label for="{{ field.id_for_label }}"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{{ label|default:field.label }}
{% if field.field.required %}<span class="text-destructive">*</span>{% endif %}
</label>
{% endif %}
{% render_field field class+="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" placeholder=placeholder|default:field.label aria-describedby=field.id_for_label|add:"-description" aria-invalid=field.errors|yesno:"true,false" %}
{% if help_text or field.help_text %}
<p id="{{ field.id_for_label }}-description" class="text-sm text-muted-foreground">
{{ help_text|default:field.help_text }}
</p>
{% endif %}
{% if field.errors %}
<p class="text-sm font-medium text-destructive" role="alert">
{{ field.errors.0 }}
</p>
{% endif %}
</div>
{% else %}
{# Standalone Mode #}
{% with input_id=id|default:name %}
<div class="space-y-2">
{% if label %}
<label for="{{ input_id }}"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{{ label }}
{% if required %}<span class="text-destructive">*</span>{% endif %}
</label>
{% endif %}
{% if type == 'textarea' %}
<textarea
{% if name %}name="{{ name }}"{% endif %}
{% if input_id %}id="{{ input_id }}"{% endif %}
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {{ class|default:'' }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if rows %}rows="{{ rows }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{% if aria_describedby %}aria-describedby="{{ aria_describedby }}"{% elif help_text %}aria-describedby="{{ input_id }}-description"{% endif %}
{% if aria_invalid or error %}aria-invalid="true"{% endif %}
{% if x_model %}x-model="{{ x_model }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
{{ attrs|default:'' }}>{{ value|default:'' }}</textarea>
{% else %}
<input
type="{{ type|default:'text' }}"
{% if name %}name="{{ name }}"{% endif %}
{% if input_id %}id="{{ input_id }}"{% endif %}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {{ class|default:'' }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if value %}value="{{ value }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if aria_describedby %}aria-describedby="{{ aria_describedby }}"{% elif help_text %}aria-describedby="{{ input_id }}-description"{% endif %}
{% if aria_invalid or error %}aria-invalid="true"{% endif %}
{% if x_model %}x-model="{{ x_model }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
{{ attrs|default:'' }}
/>
{% endif %}
{% if help_text %}
<p id="{{ input_id }}-description" class="text-sm text-muted-foreground">
{{ help_text }}
</p>
{% endif %}
{% if error %}
<p class="text-sm font-medium text-destructive" role="alert">
{{ error }}
</p>
{% endif %}
</div>
{% endwith %}
{% endif %}

View File

@@ -1,34 +1,64 @@
{% comment %}
Toast Notification Container Component
Matches React frontend toast functionality with Sonner-like behavior
======================================
Enhanced toast notification system with Sonner-like behavior.
Features:
- Multiple toast types (success, error, warning, info)
- Progress bar for auto-dismiss countdown
- Action button support (Undo, Retry, View)
- Toast stacking with max limit
- Persistent toast option (duration: 0)
- Accessible announcements
Usage Examples:
Basic toast:
Alpine.store('toast').success('Item saved!')
With action:
Alpine.store('toast').success('Item deleted', 5000, {
action: { label: 'Undo', onClick: () => undoDelete() }
})
Persistent toast:
Alpine.store('toast').error('Connection lost', 0)
From HTMX via HX-Trigger header:
response['HX-Trigger'] = '{"showToast": {"type": "success", "message": "Saved!"}}'
{% endcomment %}
<!-- Toast Container -->
<div
<div
x-data="toast()"
x-show="$store.toast.toasts.length > 0"
class="fixed top-4 right-4 z-50 space-y-2"
class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-h-screen overflow-hidden pointer-events-none"
role="region"
aria-label="Notifications"
x-cloak
>
<template x-for="toast in $store.toast.toasts" :key="toast.id">
<div
<template x-for="(toast, index) in $store.toast.toasts.slice(0, 5)" :key="toast.id">
<div
x-show="toast.visible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="transform opacity-0 translate-x-full"
x-transition:enter-end="transform opacity-100 translate-x-0"
x-transition:enter-start="transform opacity-0 translate-x-full scale-95"
x-transition:enter-end="transform opacity-100 translate-x-0 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="transform opacity-100 translate-x-0"
x-transition:leave-end="transform opacity-0 translate-x-full"
class="relative max-w-sm w-full bg-background border rounded-lg shadow-lg overflow-hidden"
x-transition:leave-start="transform opacity-100 translate-x-0 scale-100"
x-transition:leave-end="transform opacity-0 translate-x-full scale-95"
class="relative max-w-sm w-full bg-background border rounded-lg shadow-lg overflow-hidden pointer-events-auto"
:class="{
'border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-800': toast.type === 'success',
'border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800': toast.type === 'error',
'border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-800': toast.type === 'warning',
'border-blue-200 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-800': toast.type === 'info'
}"
role="alert"
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
>
<!-- Progress Bar -->
<div
<!-- Progress Bar (only show if not persistent) -->
<div
x-show="toast.duration > 0"
class="absolute top-0 left-0 h-1 bg-current opacity-30 transition-all duration-100 ease-linear"
:style="`width: ${toast.progress}%`"
:class="{
@@ -43,29 +73,60 @@ Matches React frontend toast functionality with Sonner-like behavior
<div class="flex items-start">
<!-- Icon -->
<div class="flex-shrink-0 mr-3">
<i
class="w-5 h-5"
<i
class="text-lg"
:class="{
'fas fa-check-circle text-green-500': toast.type === 'success',
'fas fa-exclamation-circle text-red-500': toast.type === 'error',
'fas fa-exclamation-triangle text-yellow-500': toast.type === 'warning',
'fas fa-info-circle text-blue-500': toast.type === 'info'
}"
aria-hidden="true"
></i>
</div>
<!-- Message -->
<!-- Content -->
<div class="flex-1 min-w-0">
<p
class="text-sm font-medium"
<!-- Title (optional) -->
<p
x-show="toast.title"
class="text-sm font-semibold mb-0.5"
:class="{
'text-green-800 dark:text-green-200': toast.type === 'success',
'text-red-800 dark:text-red-200': toast.type === 'error',
'text-yellow-800 dark:text-yellow-200': toast.type === 'warning',
'text-blue-800 dark:text-blue-200': toast.type === 'info'
}"
x-text="toast.title"
></p>
<!-- Message -->
<p
class="text-sm"
:class="{
'text-green-700 dark:text-green-300': toast.type === 'success',
'text-red-700 dark:text-red-300': toast.type === 'error',
'text-yellow-700 dark:text-yellow-300': toast.type === 'warning',
'text-blue-700 dark:text-blue-300': toast.type === 'info'
}"
x-text="toast.message"
></p>
<!-- Action Button (optional) -->
<div x-show="toast.action" class="mt-2">
<button
x-show="toast.action"
@click="toast.action?.onClick?.(); $store.toast.hide(toast.id)"
class="text-xs font-medium underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-offset-1 rounded"
:class="{
'text-green-700 dark:text-green-300 focus:ring-green-500': toast.type === 'success',
'text-red-700 dark:text-red-300 focus:ring-red-500': toast.type === 'error',
'text-yellow-700 dark:text-yellow-300 focus:ring-yellow-500': toast.type === 'warning',
'text-blue-700 dark:text-blue-300 focus:ring-blue-500': toast.type === 'info'
}"
x-text="toast.action?.label || 'Action'"
></button>
</div>
</div>
<!-- Close Button -->
@@ -79,12 +140,21 @@ Matches React frontend toast functionality with Sonner-like behavior
'text-yellow-500 hover:bg-yellow-100 focus:ring-yellow-500 dark:hover:bg-yellow-800': toast.type === 'warning',
'text-blue-500 hover:bg-blue-100 focus:ring-blue-500 dark:hover:bg-blue-800': toast.type === 'info'
}"
aria-label="Dismiss notification"
>
<i class="fas fa-times w-4 h-4"></i>
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<!-- Overflow indicator when more than 5 toasts -->
<div
x-show="$store.toast.toasts.length > 5"
class="text-center text-xs text-muted-foreground pointer-events-auto"
>
<span x-text="`+${$store.toast.toasts.length - 5} more`"></span>
</div>
</div>