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,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>