Add new macro files for comprehensive UI coverage: - modals.html: modal, confirm_modal, form_modal, slide_over - dropdowns.html: dropdown, context_menu, dropdown_item, select_dropdown - avatars.html: avatar, avatar_with_status, avatar_initials, avatar_group, user_avatar_card - charts.html: chart_card, line_chart, bar_chart, doughnut_chart (Chart.js) - datepicker.html: datepicker, daterange_picker, datetime_picker, time_picker (Flatpickr) Update forms.html with: - password_input: Password field with show/hide toggle and strength indicator - input_with_icon: Input with left/right icon support - file_input: Drag & drop file upload zone Tech stack: Jinja2 + Alpine.js + Tailwind CSS External libs: Chart.js (optional), Flatpickr (optional) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
478 lines
19 KiB
HTML
478 lines
19 KiB
HTML
{#
|
|
Modal Macros
|
|
============
|
|
Reusable modal dialog components with Alpine.js integration.
|
|
|
|
Usage:
|
|
{% from 'shared/macros/modals.html' import modal, confirm_modal, form_modal %}
|
|
|
|
{# Basic modal #}
|
|
{% call modal('editModal', 'Edit User', 'isEditModalOpen') %}
|
|
<p>Modal content here</p>
|
|
{% endcall %}
|
|
|
|
{# Confirmation modal #}
|
|
{{ confirm_modal('deleteModal', 'Delete User', 'Are you sure?', 'deleteUser()', 'isDeleteModalOpen') }}
|
|
|
|
Required Alpine.js:
|
|
x-data="{ isModalOpen: false }"
|
|
#}
|
|
|
|
|
|
{#
|
|
Modal
|
|
=====
|
|
A flexible modal dialog component.
|
|
|
|
Parameters:
|
|
- id: Unique modal ID
|
|
- title: Modal title
|
|
- show_var: Alpine.js variable controlling visibility (default: 'isModalOpen')
|
|
- size: 'sm' | 'md' | 'lg' | 'xl' | 'full' (default: 'md')
|
|
- show_close: Whether to show close button (default: true)
|
|
- show_footer: Whether to show footer slot (default: true)
|
|
- close_on_backdrop: Close when clicking backdrop (default: true)
|
|
- close_on_escape: Close on Escape key (default: true)
|
|
#}
|
|
{% macro modal(id, title, show_var='isModalOpen', size='md', show_close=true, show_footer=true, close_on_backdrop=true, close_on_escape=true) %}
|
|
{% set sizes = {
|
|
'sm': 'max-w-sm',
|
|
'md': 'max-w-lg',
|
|
'lg': 'max-w-2xl',
|
|
'xl': 'max-w-4xl',
|
|
'full': 'max-w-full mx-4'
|
|
} %}
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-cloak
|
|
{% if close_on_escape %}@keydown.escape.window="{{ show_var }} = false"{% endif %}
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
aria-labelledby="{{ id }}-title"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
{# Backdrop #}
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
|
|
{% if close_on_backdrop %}@click="{{ show_var }} = false"{% endif %}
|
|
></div>
|
|
|
|
{# Modal Container #}
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
@click.stop
|
|
class="relative w-full {{ sizes[size] }} bg-white dark:bg-gray-800 rounded-xl shadow-xl"
|
|
>
|
|
{# Header #}
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h3 id="{{ id }}-title" class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{{ title }}
|
|
</h3>
|
|
{% if show_close %}
|
|
<button
|
|
@click="{{ show_var }} = false"
|
|
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
aria-label="Close modal"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Body #}
|
|
<div class="px-6 py-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
|
{{ caller() }}
|
|
</div>
|
|
|
|
{# Footer (optional) #}
|
|
{% if show_footer %}
|
|
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-b-xl">
|
|
<button
|
|
@click="{{ show_var }} = false"
|
|
type="button"
|
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
{{ caller_footer() if caller_footer is defined else '' }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Modal Simple
|
|
============
|
|
A simpler modal without the caller pattern - just pass content.
|
|
|
|
Parameters:
|
|
- id: Unique modal ID
|
|
- title: Modal title
|
|
- show_var: Alpine.js variable controlling visibility
|
|
- size: Modal size
|
|
- content: HTML content for the modal body
|
|
#}
|
|
{% macro modal_simple(id, title, show_var='isModalOpen', size='md') %}
|
|
{% set sizes = {
|
|
'sm': 'max-w-sm',
|
|
'md': 'max-w-lg',
|
|
'lg': 'max-w-2xl',
|
|
'xl': 'max-w-4xl'
|
|
} %}
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-cloak
|
|
@keydown.escape.window="{{ show_var }} = false"
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
|
|
@click="{{ show_var }} = false"
|
|
></div>
|
|
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.stop
|
|
class="relative w-full {{ sizes[size] }} bg-white dark:bg-gray-800 rounded-xl shadow-xl"
|
|
>
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
|
|
<button
|
|
@click="{{ show_var }} = false"
|
|
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="px-6 py-4">
|
|
{{ caller() }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Confirm Modal
|
|
=============
|
|
A confirmation dialog for destructive actions.
|
|
|
|
Parameters:
|
|
- id: Unique modal ID
|
|
- title: Modal title
|
|
- message: Confirmation message
|
|
- confirm_action: Alpine.js action to execute on confirm
|
|
- show_var: Alpine.js variable controlling visibility
|
|
- confirm_text: Confirm button text (default: 'Confirm')
|
|
- cancel_text: Cancel button text (default: 'Cancel')
|
|
- variant: 'danger' | 'warning' | 'info' (default: 'danger')
|
|
- icon: Icon name (optional, auto-selected based on variant)
|
|
#}
|
|
{% macro confirm_modal(id, title, message, confirm_action, show_var='isConfirmModalOpen', confirm_text='Confirm', cancel_text='Cancel', variant='danger', icon=none) %}
|
|
{% set variants = {
|
|
'danger': {
|
|
'icon_bg': 'bg-red-100 dark:bg-red-900/30',
|
|
'icon_color': 'text-red-600 dark:text-red-400',
|
|
'btn_class': 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
|
},
|
|
'warning': {
|
|
'icon_bg': 'bg-yellow-100 dark:bg-yellow-900/30',
|
|
'icon_color': 'text-yellow-600 dark:text-yellow-400',
|
|
'btn_class': 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500'
|
|
},
|
|
'info': {
|
|
'icon_bg': 'bg-blue-100 dark:bg-blue-900/30',
|
|
'icon_color': 'text-blue-600 dark:text-blue-400',
|
|
'btn_class': 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'
|
|
}
|
|
} %}
|
|
{% set icons = {
|
|
'danger': 'exclamation-triangle',
|
|
'warning': 'exclamation',
|
|
'info': 'information-circle'
|
|
} %}
|
|
{% set modal_icon = icon if icon else icons[variant] %}
|
|
{% set style = variants[variant] %}
|
|
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-cloak
|
|
@keydown.escape.window="{{ show_var }} = false"
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
|
|
@click="{{ show_var }} = false"
|
|
></div>
|
|
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.stop
|
|
class="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6"
|
|
>
|
|
<div class="flex items-start">
|
|
<div class="flex-shrink-0 flex items-center justify-center w-12 h-12 rounded-full {{ style.icon_bg }}">
|
|
<span x-html="$icon('{{ modal_icon }}', 'w-6 h-6 {{ style.icon_color }}')" class="{{ style.icon_color }}"></span>
|
|
</div>
|
|
<div class="ml-4 flex-1">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{{ title }}
|
|
</h3>
|
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
{{ message }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6 flex justify-end gap-3">
|
|
<button
|
|
@click="{{ show_var }} = false"
|
|
type="button"
|
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
{{ cancel_text }}
|
|
</button>
|
|
<button
|
|
@click="{{ confirm_action }}; {{ show_var }} = false"
|
|
type="button"
|
|
class="px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 {{ style.btn_class }}"
|
|
>
|
|
{{ confirm_text }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Form Modal
|
|
==========
|
|
A modal optimized for forms with loading state support.
|
|
|
|
Parameters:
|
|
- id: Unique modal ID
|
|
- title: Modal title
|
|
- show_var: Alpine.js variable controlling visibility
|
|
- submit_action: Alpine.js action for form submission
|
|
- submit_text: Submit button text (default: 'Save')
|
|
- loading_var: Alpine.js variable for loading state (default: 'saving')
|
|
- loading_text: Text shown while loading (default: 'Saving...')
|
|
- size: Modal size (default: 'md')
|
|
#}
|
|
{% macro form_modal(id, title, show_var='isFormModalOpen', submit_action='submitForm()', submit_text='Save', loading_var='saving', loading_text='Saving...', size='md') %}
|
|
{% set sizes = {
|
|
'sm': 'max-w-sm',
|
|
'md': 'max-w-lg',
|
|
'lg': 'max-w-2xl',
|
|
'xl': 'max-w-4xl'
|
|
} %}
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-cloak
|
|
@keydown.escape.window="!{{ loading_var }} && ({{ show_var }} = false)"
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
|
|
@click="!{{ loading_var }} && ({{ show_var }} = false)"
|
|
></div>
|
|
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.stop
|
|
class="relative w-full {{ sizes[size] }} bg-white dark:bg-gray-800 rounded-xl shadow-xl"
|
|
>
|
|
<form @submit.prevent="{{ submit_action }}">
|
|
{# Header #}
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
|
|
<button
|
|
@click="{{ show_var }} = false"
|
|
type="button"
|
|
:disabled="{{ loading_var }}"
|
|
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{# Body #}
|
|
<div class="px-6 py-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
|
{{ caller() }}
|
|
</div>
|
|
|
|
{# Footer #}
|
|
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-b-xl">
|
|
<button
|
|
@click="{{ show_var }} = false"
|
|
type="button"
|
|
:disabled="{{ loading_var }}"
|
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
:disabled="{{ loading_var }}"
|
|
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<span x-show="{{ loading_var }}" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
|
<span x-text="{{ loading_var }} ? '{{ loading_text }}' : '{{ submit_text }}'"></span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Slide-over Panel
|
|
================
|
|
A side panel that slides in from the right.
|
|
|
|
Parameters:
|
|
- id: Unique panel ID
|
|
- title: Panel title
|
|
- show_var: Alpine.js variable controlling visibility
|
|
- width: 'sm' | 'md' | 'lg' | 'xl' (default: 'md')
|
|
#}
|
|
{% macro slide_over(id, title, show_var='isPanelOpen', width='md') %}
|
|
{% set widths = {
|
|
'sm': 'max-w-sm',
|
|
'md': 'max-w-md',
|
|
'lg': 'max-w-lg',
|
|
'xl': 'max-w-xl'
|
|
} %}
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-cloak
|
|
@keydown.escape.window="{{ show_var }} = false"
|
|
class="fixed inset-0 z-50 overflow-hidden"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
{# Backdrop #}
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
|
|
@click="{{ show_var }} = false"
|
|
></div>
|
|
|
|
{# Panel #}
|
|
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="transform transition ease-in-out duration-300"
|
|
x-transition:enter-start="translate-x-full"
|
|
x-transition:enter-end="translate-x-0"
|
|
x-transition:leave="transform transition ease-in-out duration-300"
|
|
x-transition:leave-start="translate-x-0"
|
|
x-transition:leave-end="translate-x-full"
|
|
class="w-screen {{ widths[width] }}"
|
|
>
|
|
<div class="flex h-full flex-col bg-white dark:bg-gray-800 shadow-xl">
|
|
{# Header #}
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h2>
|
|
<button
|
|
@click="{{ show_var }} = false"
|
|
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{# Body #}
|
|
<div class="flex-1 overflow-y-auto px-6 py-4">
|
|
{{ caller() }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|