mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 12:51:09 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
142
backend/templates/components/modals/modal_inner.html
Normal file
142
backend/templates/components/modals/modal_inner.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user