feat: add advanced UI component macros
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>
This commit is contained in:
373
app/templates/shared/macros/avatars.html
Normal file
373
app/templates/shared/macros/avatars.html
Normal file
@@ -0,0 +1,373 @@
|
||||
{#
|
||||
Avatar Macros
|
||||
=============
|
||||
Reusable avatar components for user profile images.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/avatars.html' import avatar, avatar_with_status, avatar_group, avatar_initials %}
|
||||
|
||||
{# Basic avatar with image #}
|
||||
{{ avatar(src='user.avatar_url', alt='user.name', size='md') }}
|
||||
|
||||
{# Avatar with online status #}
|
||||
{{ avatar_with_status(src='user.avatar', status='online', size='lg') }}
|
||||
|
||||
{# Avatar group (stacked) #}
|
||||
{% call avatar_group(max=3) %}
|
||||
{{ avatar(src='url1', size='sm') }}
|
||||
{{ avatar(src='url2', size='sm') }}
|
||||
{{ avatar(src='url3', size='sm') }}
|
||||
{% endcall %}
|
||||
|
||||
{# Initials fallback #}
|
||||
{{ avatar_initials(initials='JD', size='md', color='purple') }}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Avatar
|
||||
======
|
||||
A basic avatar component.
|
||||
|
||||
Parameters:
|
||||
- src: Image source (static string or Alpine.js expression with :src)
|
||||
- alt: Alt text
|
||||
- size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' (default: 'md')
|
||||
- rounded: 'full' | 'lg' | 'md' (default: 'full')
|
||||
- fallback_icon: Icon to show if no image (default: 'user')
|
||||
- dynamic: Whether src is an Alpine.js expression (default: false)
|
||||
- class_extra: Additional CSS classes
|
||||
#}
|
||||
{% macro avatar(src='', alt='', size='md', rounded='full', fallback_icon='user', dynamic=false, class_extra='') %}
|
||||
{% set sizes = {
|
||||
'xs': 'w-6 h-6',
|
||||
'sm': 'w-8 h-8',
|
||||
'md': 'w-10 h-10',
|
||||
'lg': 'w-12 h-12',
|
||||
'xl': 'w-14 h-14',
|
||||
'2xl': 'w-16 h-16'
|
||||
} %}
|
||||
{% set icon_sizes = {
|
||||
'xs': 'w-3 h-3',
|
||||
'sm': 'w-4 h-4',
|
||||
'md': 'w-5 h-5',
|
||||
'lg': 'w-6 h-6',
|
||||
'xl': 'w-7 h-7',
|
||||
'2xl': 'w-8 h-8'
|
||||
} %}
|
||||
{% set roundeds = {
|
||||
'full': 'rounded-full',
|
||||
'lg': 'rounded-lg',
|
||||
'md': 'rounded-md'
|
||||
} %}
|
||||
<div class="relative inline-flex items-center justify-center {{ sizes[size] }} {{ roundeds[rounded] }} bg-gray-200 dark:bg-gray-700 overflow-hidden {{ class_extra }}">
|
||||
{% if dynamic %}
|
||||
<template x-if="{{ src }}">
|
||||
<img :src="{{ src }}" :alt="{{ alt }}" class="w-full h-full object-cover" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!{{ src }}">
|
||||
<span x-html="$icon('{{ fallback_icon }}', '{{ icon_sizes[size] }} text-gray-500 dark:text-gray-400')"></span>
|
||||
</template>
|
||||
{% elif src %}
|
||||
<img src="{{ src }}" alt="{{ alt }}" class="w-full h-full object-cover" loading="lazy">
|
||||
{% else %}
|
||||
<span x-html="$icon('{{ fallback_icon }}', '{{ icon_sizes[size] }} text-gray-500 dark:text-gray-400')"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Avatar with Status
|
||||
==================
|
||||
An avatar with an online/offline status indicator.
|
||||
|
||||
Parameters:
|
||||
- src: Image source
|
||||
- status: 'online' | 'offline' | 'away' | 'busy' | Alpine.js expression
|
||||
- size: Avatar size (default: 'md')
|
||||
- alt: Alt text
|
||||
- dynamic: Whether values are Alpine.js expressions (default: false)
|
||||
- show_status: Whether to show status indicator (default: true)
|
||||
#}
|
||||
{% macro avatar_with_status(src='', status='online', size='md', alt='', dynamic=false, show_status=true) %}
|
||||
{% set sizes = {
|
||||
'xs': 'w-6 h-6',
|
||||
'sm': 'w-8 h-8',
|
||||
'md': 'w-10 h-10',
|
||||
'lg': 'w-12 h-12',
|
||||
'xl': 'w-14 h-14',
|
||||
'2xl': 'w-16 h-16'
|
||||
} %}
|
||||
{% set icon_sizes = {
|
||||
'xs': 'w-3 h-3',
|
||||
'sm': 'w-4 h-4',
|
||||
'md': 'w-5 h-5',
|
||||
'lg': 'w-6 h-6',
|
||||
'xl': 'w-7 h-7',
|
||||
'2xl': 'w-8 h-8'
|
||||
} %}
|
||||
{% set indicator_sizes = {
|
||||
'xs': 'w-1.5 h-1.5',
|
||||
'sm': 'w-2 h-2',
|
||||
'md': 'w-2.5 h-2.5',
|
||||
'lg': 'w-3 h-3',
|
||||
'xl': 'w-3.5 h-3.5',
|
||||
'2xl': 'w-4 h-4'
|
||||
} %}
|
||||
{% set indicator_positions = {
|
||||
'xs': 'bottom-0 right-0',
|
||||
'sm': 'bottom-0 right-0',
|
||||
'md': 'bottom-0 right-0',
|
||||
'lg': 'bottom-0.5 right-0.5',
|
||||
'xl': 'bottom-0.5 right-0.5',
|
||||
'2xl': 'bottom-1 right-1'
|
||||
} %}
|
||||
{% set status_colors = {
|
||||
'online': 'bg-green-500',
|
||||
'offline': 'bg-gray-400',
|
||||
'away': 'bg-yellow-500',
|
||||
'busy': 'bg-red-500'
|
||||
} %}
|
||||
<div class="relative inline-block">
|
||||
<div class="relative inline-flex items-center justify-center {{ sizes[size] }} rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
{% if dynamic %}
|
||||
<template x-if="{{ src }}">
|
||||
<img :src="{{ src }}" :alt="{{ alt }}" class="w-full h-full object-cover" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!{{ src }}">
|
||||
<span x-html="$icon('user', '{{ icon_sizes[size] }} text-gray-500 dark:text-gray-400')"></span>
|
||||
</template>
|
||||
{% elif src %}
|
||||
<img src="{{ src }}" alt="{{ alt }}" class="w-full h-full object-cover" loading="lazy">
|
||||
{% else %}
|
||||
<span x-html="$icon('user', '{{ icon_sizes[size] }} text-gray-500 dark:text-gray-400')"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if show_status %}
|
||||
<span
|
||||
class="absolute {{ indicator_positions[size] }} block {{ indicator_sizes[size] }} rounded-full ring-2 ring-white dark:ring-gray-800"
|
||||
{% if dynamic %}
|
||||
:class="{
|
||||
'bg-green-500': {{ status }} === 'online',
|
||||
'bg-gray-400': {{ status }} === 'offline',
|
||||
'bg-yellow-500': {{ status }} === 'away',
|
||||
'bg-red-500': {{ status }} === 'busy'
|
||||
}"
|
||||
{% else %}
|
||||
class="{{ status_colors.get(status, 'bg-gray-400') }}"
|
||||
{% endif %}
|
||||
></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Avatar Initials
|
||||
===============
|
||||
An avatar showing initials instead of an image.
|
||||
|
||||
Parameters:
|
||||
- initials: 1-2 character initials (static or Alpine.js expression)
|
||||
- size: Avatar size (default: 'md')
|
||||
- color: 'gray' | 'purple' | 'blue' | 'green' | 'red' | 'yellow' | 'orange' (default: 'purple')
|
||||
- dynamic: Whether initials is an Alpine.js expression (default: false)
|
||||
#}
|
||||
{% macro avatar_initials(initials='', size='md', color='purple', dynamic=false) %}
|
||||
{% set sizes = {
|
||||
'xs': 'w-6 h-6 text-xs',
|
||||
'sm': 'w-8 h-8 text-xs',
|
||||
'md': 'w-10 h-10 text-sm',
|
||||
'lg': 'w-12 h-12 text-base',
|
||||
'xl': 'w-14 h-14 text-lg',
|
||||
'2xl': 'w-16 h-16 text-xl'
|
||||
} %}
|
||||
{% set colors = {
|
||||
'gray': 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
|
||||
'purple': 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
|
||||
'blue': 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
'green': 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
'red': 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
'yellow': 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400',
|
||||
'orange': 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
|
||||
} %}
|
||||
<div class="inline-flex items-center justify-center rounded-full font-semibold {{ sizes[size] }} {{ colors[color] }}">
|
||||
{% if dynamic %}
|
||||
<span x-text="{{ initials }}"></span>
|
||||
{% else %}
|
||||
{{ initials }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Avatar with Fallback
|
||||
====================
|
||||
An avatar that shows initials when no image is available.
|
||||
|
||||
Parameters:
|
||||
- src: Image source (Alpine.js expression)
|
||||
- initials: Fallback initials (Alpine.js expression)
|
||||
- size: Avatar size
|
||||
- color: Initials background color
|
||||
#}
|
||||
{% macro avatar_with_fallback(src, initials, size='md', color='purple') %}
|
||||
{% set sizes = {
|
||||
'xs': 'w-6 h-6 text-xs',
|
||||
'sm': 'w-8 h-8 text-xs',
|
||||
'md': 'w-10 h-10 text-sm',
|
||||
'lg': 'w-12 h-12 text-base',
|
||||
'xl': 'w-14 h-14 text-lg',
|
||||
'2xl': 'w-16 h-16 text-xl'
|
||||
} %}
|
||||
{% set colors = {
|
||||
'gray': 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
|
||||
'purple': 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
|
||||
'blue': 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
'green': 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
'red': 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
|
||||
} %}
|
||||
<div class="inline-flex items-center justify-center rounded-full overflow-hidden {{ sizes[size] }} {{ colors[color] }}">
|
||||
<template x-if="{{ src }}">
|
||||
<img :src="{{ src }}" class="w-full h-full object-cover" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!{{ src }}">
|
||||
<span class="font-semibold" x-text="{{ initials }}"></span>
|
||||
</template>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Avatar Group
|
||||
============
|
||||
A stacked group of avatars.
|
||||
|
||||
Parameters:
|
||||
- max: Maximum number of avatars to show (default: 4)
|
||||
- total_var: Alpine.js variable for total count (optional, for +N indicator)
|
||||
- size: Avatar size (default: 'sm')
|
||||
#}
|
||||
{% macro avatar_group(max=4, total_var=none, size='sm') %}
|
||||
{% set sizes = {
|
||||
'xs': 'w-6 h-6',
|
||||
'sm': 'w-8 h-8',
|
||||
'md': 'w-10 h-10',
|
||||
'lg': 'w-12 h-12'
|
||||
} %}
|
||||
{% set overlaps = {
|
||||
'xs': '-space-x-2',
|
||||
'sm': '-space-x-2',
|
||||
'md': '-space-x-3',
|
||||
'lg': '-space-x-4'
|
||||
} %}
|
||||
<div class="flex {{ overlaps[size] }}">
|
||||
{{ caller() }}
|
||||
{% if total_var %}
|
||||
<div
|
||||
x-show="{{ total_var }} > {{ max }}"
|
||||
class="inline-flex items-center justify-center {{ sizes[size] }} rounded-full bg-gray-200 dark:bg-gray-700 text-xs font-medium text-gray-600 dark:text-gray-300 ring-2 ring-white dark:ring-gray-800"
|
||||
>
|
||||
<span x-text="'+' + ({{ total_var }} - {{ max }})"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Avatar Group Item
|
||||
=================
|
||||
An avatar item for use within avatar_group.
|
||||
Adds the ring styling for proper stacking.
|
||||
|
||||
Parameters:
|
||||
- src: Image source
|
||||
- alt: Alt text
|
||||
- size: Avatar size (default: 'sm')
|
||||
#}
|
||||
{% macro avatar_group_item(src='', alt='', size='sm', dynamic=false) %}
|
||||
{% set sizes = {
|
||||
'xs': 'w-6 h-6',
|
||||
'sm': 'w-8 h-8',
|
||||
'md': 'w-10 h-10',
|
||||
'lg': 'w-12 h-12'
|
||||
} %}
|
||||
{% set icon_sizes = {
|
||||
'xs': 'w-3 h-3',
|
||||
'sm': 'w-4 h-4',
|
||||
'md': 'w-5 h-5',
|
||||
'lg': 'w-6 h-6'
|
||||
} %}
|
||||
<div class="relative inline-flex items-center justify-center {{ sizes[size] }} rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white dark:ring-gray-800 overflow-hidden">
|
||||
{% if dynamic %}
|
||||
<template x-if="{{ src }}">
|
||||
<img :src="{{ src }}" :alt="{{ alt }}" class="w-full h-full object-cover" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!{{ src }}">
|
||||
<span x-html="$icon('user', '{{ icon_sizes[size] }} text-gray-500 dark:text-gray-400')"></span>
|
||||
</template>
|
||||
{% elif src %}
|
||||
<img src="{{ src }}" alt="{{ alt }}" class="w-full h-full object-cover" loading="lazy">
|
||||
{% else %}
|
||||
<span x-html="$icon('user', '{{ icon_sizes[size] }} text-gray-500 dark:text-gray-400')"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
User Avatar Card
|
||||
================
|
||||
An avatar with name and optional subtitle/role.
|
||||
|
||||
Parameters:
|
||||
- src: Image source (Alpine.js expression)
|
||||
- name: User name (Alpine.js expression)
|
||||
- subtitle: Subtitle/role (Alpine.js expression, optional)
|
||||
- size: Avatar size (default: 'md')
|
||||
- href: Link URL (optional)
|
||||
#}
|
||||
{% macro user_avatar_card(src, name, subtitle=none, size='md', href=none) %}
|
||||
{% set sizes = {
|
||||
'sm': 'w-8 h-8',
|
||||
'md': 'w-10 h-10',
|
||||
'lg': 'w-12 h-12'
|
||||
} %}
|
||||
{% set icon_sizes = {
|
||||
'sm': 'w-4 h-4',
|
||||
'md': 'w-5 h-5',
|
||||
'lg': 'w-6 h-6'
|
||||
} %}
|
||||
{% set text_sizes = {
|
||||
'sm': 'text-sm',
|
||||
'md': 'text-sm',
|
||||
'lg': 'text-base'
|
||||
} %}
|
||||
{% if href %}
|
||||
<a href="{{ href }}" class="flex items-center group">
|
||||
{% else %}
|
||||
<div class="flex items-center">
|
||||
{% endif %}
|
||||
<div class="relative inline-flex items-center justify-center {{ sizes[size] }} rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex-shrink-0">
|
||||
<template x-if="{{ src }}">
|
||||
<img :src="{{ src }}" class="w-full h-full object-cover" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!{{ src }}">
|
||||
<span x-html="$icon('user', '{{ icon_sizes[size] }} text-gray-500 dark:text-gray-400')"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="{{ text_sizes[size] }} font-medium text-gray-900 dark:text-white {% if href %}group-hover:text-purple-600 dark:group-hover:text-purple-400{% endif %}" x-text="{{ name }}"></p>
|
||||
{% if subtitle %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="{{ subtitle }}"></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if href %}
|
||||
</a>
|
||||
{% else %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
368
app/templates/shared/macros/charts.html
Normal file
368
app/templates/shared/macros/charts.html
Normal file
@@ -0,0 +1,368 @@
|
||||
{#
|
||||
Chart Macros
|
||||
============
|
||||
Reusable chart components using Chart.js with Alpine.js integration.
|
||||
|
||||
Prerequisites:
|
||||
Add Chart.js CDN to your base template:
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/charts.html' import chart_card, line_chart, bar_chart, doughnut_chart %}
|
||||
|
||||
{# Line chart in a card #}
|
||||
{{ chart_card('salesChart', 'Monthly Sales', 'line', chart_config) }}
|
||||
|
||||
{# Standalone bar chart #}
|
||||
{{ bar_chart('ordersChart', chart_data, height='300px') }}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Chart Card
|
||||
==========
|
||||
A card container with a chart and optional dropdown menu.
|
||||
|
||||
Parameters:
|
||||
- id: Unique chart ID (used for canvas element)
|
||||
- title: Card title
|
||||
- chart_type: 'line' | 'bar' | 'doughnut' | 'pie' (default: 'line')
|
||||
- height: Chart height (default: '300px')
|
||||
- show_menu: Whether to show dropdown menu (default: true)
|
||||
- subtitle: Optional subtitle text
|
||||
#}
|
||||
{% macro chart_card(id, title, chart_type='line', height='300px', show_menu=true, subtitle=none) %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">{{ title }}</h3>
|
||||
{% if subtitle %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ subtitle }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if show_menu %}
|
||||
<div x-data="{ menuOpen: false }" class="relative">
|
||||
<button
|
||||
@click="menuOpen = !menuOpen"
|
||||
@click.outside="menuOpen = 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="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
x-show="menuOpen"
|
||||
x-transition
|
||||
class="absolute right-0 mt-1 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-10"
|
||||
>
|
||||
{{ caller() if caller is defined else '' }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="height: {{ height }};">
|
||||
<canvas id="{{ id }}"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Line Chart
|
||||
==========
|
||||
A standalone line chart canvas.
|
||||
|
||||
Parameters:
|
||||
- id: Unique chart ID
|
||||
- height: Chart height (default: '300px')
|
||||
- class_extra: Additional CSS classes
|
||||
#}
|
||||
{% macro line_chart(id, height='300px', class_extra='') %}
|
||||
<div class="relative {{ class_extra }}" style="height: {{ height }};">
|
||||
<canvas id="{{ id }}"></canvas>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Bar Chart
|
||||
=========
|
||||
A standalone bar chart canvas.
|
||||
|
||||
Parameters:
|
||||
- id: Unique chart ID
|
||||
- height: Chart height (default: '300px')
|
||||
- class_extra: Additional CSS classes
|
||||
#}
|
||||
{% macro bar_chart(id, height='300px', class_extra='') %}
|
||||
<div class="relative {{ class_extra }}" style="height: {{ height }};">
|
||||
<canvas id="{{ id }}"></canvas>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Doughnut Chart
|
||||
==============
|
||||
A standalone doughnut/pie chart canvas.
|
||||
|
||||
Parameters:
|
||||
- id: Unique chart ID
|
||||
- size: Chart size (default: '200px')
|
||||
- class_extra: Additional CSS classes
|
||||
#}
|
||||
{% macro doughnut_chart(id, size='200px', class_extra='') %}
|
||||
<div class="relative inline-block {{ class_extra }}" style="width: {{ size }}; height: {{ size }};">
|
||||
<canvas id="{{ id }}"></canvas>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Stats Chart Card
|
||||
================
|
||||
A card with a stat value and small sparkline chart.
|
||||
|
||||
Parameters:
|
||||
- id: Unique chart ID
|
||||
- title: Stat title
|
||||
- value_var: Alpine.js variable for the value
|
||||
- trend_var: Alpine.js variable for trend ('up' | 'down' | 'neutral')
|
||||
- trend_value_var: Alpine.js variable for trend percentage
|
||||
- chart_height: Sparkline height (default: '60px')
|
||||
#}
|
||||
{% macro stats_chart_card(id, title, value_var, trend_var=none, trend_value_var=none, chart_height='60px') %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ title }}</h4>
|
||||
{% if trend_var %}
|
||||
<span
|
||||
class="inline-flex items-center text-xs font-medium"
|
||||
:class="{
|
||||
'text-green-600': {{ trend_var }} === 'up',
|
||||
'text-red-600': {{ trend_var }} === 'down',
|
||||
'text-gray-500': {{ trend_var }} === 'neutral'
|
||||
}"
|
||||
>
|
||||
<svg
|
||||
x-show="{{ trend_var }} === 'up'"
|
||||
class="w-3 h-3 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="{{ trend_var }} === 'down'"
|
||||
class="w-3 h-3 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/>
|
||||
</svg>
|
||||
<span x-text="{{ trend_value_var }}"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white mb-3" x-text="{{ value_var }}"></p>
|
||||
<div style="height: {{ chart_height }};">
|
||||
<canvas id="{{ id }}"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Chart Legend
|
||||
============
|
||||
A custom chart legend.
|
||||
|
||||
Parameters:
|
||||
- items: List of legend items [{'label': '', 'color': ''}]
|
||||
- layout: 'horizontal' | 'vertical' (default: 'horizontal')
|
||||
#}
|
||||
{% macro chart_legend(items, layout='horizontal') %}
|
||||
<div class="flex {{ 'flex-wrap gap-4' if layout == 'horizontal' else 'flex-col gap-2' }} mt-4">
|
||||
{% for item in items %}
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 rounded-full mr-2" style="background-color: {{ item.color }};"></span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ item.label }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Chart.js Configuration Helper
|
||||
=============================
|
||||
JavaScript template for common chart configurations.
|
||||
|
||||
Include this in a <script> block and customize as needed.
|
||||
#}
|
||||
{% macro chart_config_script() %}
|
||||
<script>
|
||||
// Chart.js default configuration
|
||||
Chart.defaults.font.family = "'Inter', 'Helvetica', 'Arial', sans-serif";
|
||||
Chart.defaults.color = document.documentElement.classList.contains('dark') ? '#9CA3AF' : '#6B7280';
|
||||
|
||||
// Helper function to create responsive chart
|
||||
function createChart(canvasId, type, data, options = {}) {
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return null;
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: document.documentElement.classList.contains('dark') ? '#374151' : '#ffffff',
|
||||
titleColor: document.documentElement.classList.contains('dark') ? '#F9FAFB' : '#111827',
|
||||
bodyColor: document.documentElement.classList.contains('dark') ? '#D1D5DB' : '#4B5563',
|
||||
borderColor: document.documentElement.classList.contains('dark') ? '#4B5563' : '#E5E7EB',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
displayColors: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: type,
|
||||
data: data,
|
||||
options: { ...defaultOptions, ...options }
|
||||
});
|
||||
}
|
||||
|
||||
// Line chart preset
|
||||
function createLineChart(canvasId, labels, datasets, options = {}) {
|
||||
const defaultDatasetOptions = {
|
||||
tension: 0.4,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
fill: true
|
||||
};
|
||||
|
||||
const formattedDatasets = datasets.map(ds => ({
|
||||
...defaultDatasetOptions,
|
||||
...ds
|
||||
}));
|
||||
|
||||
return createChart(canvasId, 'line', { labels, datasets: formattedDatasets }, {
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
border: { display: false }
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: document.documentElement.classList.contains('dark') ? '#374151' : '#F3F4F6'
|
||||
},
|
||||
border: { display: false },
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
// Bar chart preset
|
||||
function createBarChart(canvasId, labels, datasets, options = {}) {
|
||||
return createChart(canvasId, 'bar', { labels, datasets }, {
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
border: { display: false }
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: document.documentElement.classList.contains('dark') ? '#374151' : '#F3F4F6'
|
||||
},
|
||||
border: { display: false },
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
// Doughnut chart preset
|
||||
function createDoughnutChart(canvasId, labels, data, colors, options = {}) {
|
||||
return createChart(canvasId, 'doughnut', {
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
}, {
|
||||
cutout: '70%',
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
// Color presets
|
||||
const chartColors = {
|
||||
purple: {
|
||||
solid: '#9333EA',
|
||||
light: 'rgba(147, 51, 234, 0.1)'
|
||||
},
|
||||
blue: {
|
||||
solid: '#3B82F6',
|
||||
light: 'rgba(59, 130, 246, 0.1)'
|
||||
},
|
||||
green: {
|
||||
solid: '#10B981',
|
||||
light: 'rgba(16, 185, 129, 0.1)'
|
||||
},
|
||||
red: {
|
||||
solid: '#EF4444',
|
||||
light: 'rgba(239, 68, 68, 0.1)'
|
||||
},
|
||||
yellow: {
|
||||
solid: '#F59E0B',
|
||||
light: 'rgba(245, 158, 11, 0.1)'
|
||||
},
|
||||
gray: {
|
||||
solid: '#6B7280',
|
||||
light: 'rgba(107, 114, 128, 0.1)'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Example Usage Comment
|
||||
=====================
|
||||
Copy this to your page to see how to use the chart macros:
|
||||
|
||||
{% from 'shared/macros/charts.html' import chart_card, chart_config_script %}
|
||||
|
||||
{{ chart_card('monthlySales', 'Monthly Sales', 'line') }}
|
||||
|
||||
{{ chart_config_script() }}
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
createLineChart('monthlySales',
|
||||
['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
[{
|
||||
label: 'Sales',
|
||||
data: [30, 40, 35, 50, 49, 60],
|
||||
borderColor: chartColors.purple.solid,
|
||||
backgroundColor: chartColors.purple.light
|
||||
}]
|
||||
);
|
||||
});
|
||||
</script>
|
||||
#}
|
||||
355
app/templates/shared/macros/datepicker.html
Normal file
355
app/templates/shared/macros/datepicker.html
Normal file
@@ -0,0 +1,355 @@
|
||||
{#
|
||||
Datepicker Macros
|
||||
=================
|
||||
Date and time picker components using Flatpickr with Alpine.js integration.
|
||||
|
||||
Prerequisites:
|
||||
Add Flatpickr CDN to your base template:
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/datepicker.html' import datepicker, daterange_picker, datetime_picker %}
|
||||
|
||||
{# Basic date picker #}
|
||||
{{ datepicker('startDate', 'formData.startDate', label='Start Date') }}
|
||||
|
||||
{# Date range picker #}
|
||||
{{ daterange_picker('dateRange', 'formData.dateRange', label='Date Range') }}
|
||||
|
||||
{# Date and time picker #}
|
||||
{{ datetime_picker('scheduledAt', 'formData.scheduledAt', label='Schedule') }}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Datepicker
|
||||
==========
|
||||
A single date picker input.
|
||||
|
||||
Parameters:
|
||||
- id: Unique ID for the input
|
||||
- x_model: Alpine.js x-model binding
|
||||
- label: Input label (optional)
|
||||
- placeholder: Placeholder text (default: 'Select date')
|
||||
- format: Date format (default: 'Y-m-d')
|
||||
- min_date: Minimum selectable date (default: none)
|
||||
- max_date: Maximum selectable date (default: none)
|
||||
- disabled: Alpine.js expression for disabled state
|
||||
- required: Whether the field is required
|
||||
- error: Alpine.js expression for error message
|
||||
- class_extra: Additional CSS classes
|
||||
#}
|
||||
{% macro datepicker(id, x_model, label=none, placeholder='Select date', format='Y-m-d', min_date=none, max_date=none, disabled=none, required=false, error=none, class_extra='') %}
|
||||
<div class="mb-4 {{ class_extra }}">
|
||||
{% if label %}
|
||||
<label for="{{ id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1.5">
|
||||
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}"
|
||||
x-model="{{ x_model }}"
|
||||
x-init="flatpickr($el, {
|
||||
dateFormat: '{{ format }}',
|
||||
{% if min_date %}minDate: '{{ min_date }}',{% endif %}
|
||||
{% if max_date %}maxDate: '{{ max_date }}',{% endif %}
|
||||
disableMobile: true,
|
||||
onChange: function(selectedDates, dateStr) {
|
||||
{{ x_model }} = dateStr;
|
||||
}
|
||||
})"
|
||||
placeholder="{{ placeholder }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
|
||||
class="block w-full pl-10 pr-4 py-2.5 text-sm text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
{% if error %}:class="{ 'border-red-500 focus:border-red-500 focus:ring-red-500/20': {{ error }} }"{% endif %}
|
||||
readonly
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% if error %}
|
||||
<p x-show="{{ error }}" class="mt-1 text-xs text-red-600 dark:text-red-400" x-text="{{ error }}"></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Date Range Picker
|
||||
=================
|
||||
A date range picker for selecting start and end dates.
|
||||
|
||||
Parameters:
|
||||
- id: Unique ID for the input
|
||||
- x_model: Alpine.js x-model binding (will contain "YYYY-MM-DD to YYYY-MM-DD")
|
||||
- label: Input label (optional)
|
||||
- placeholder: Placeholder text (default: 'Select date range')
|
||||
- format: Date format (default: 'Y-m-d')
|
||||
- min_date: Minimum selectable date
|
||||
- max_date: Maximum selectable date
|
||||
- disabled: Alpine.js expression for disabled state
|
||||
- required: Whether the field is required
|
||||
#}
|
||||
{% macro daterange_picker(id, x_model, label=none, placeholder='Select date range', format='Y-m-d', min_date=none, max_date=none, disabled=none, required=false) %}
|
||||
<div class="mb-4">
|
||||
{% if label %}
|
||||
<label for="{{ id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1.5">
|
||||
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}"
|
||||
x-model="{{ x_model }}"
|
||||
x-init="flatpickr($el, {
|
||||
mode: 'range',
|
||||
dateFormat: '{{ format }}',
|
||||
{% if min_date %}minDate: '{{ min_date }}',{% endif %}
|
||||
{% if max_date %}maxDate: '{{ max_date }}',{% endif %}
|
||||
disableMobile: true,
|
||||
onChange: function(selectedDates, dateStr) {
|
||||
{{ x_model }} = dateStr;
|
||||
}
|
||||
})"
|
||||
placeholder="{{ placeholder }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
|
||||
class="block w-full pl-10 pr-4 py-2.5 text-sm text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
readonly
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
DateTime Picker
|
||||
===============
|
||||
A date and time picker.
|
||||
|
||||
Parameters:
|
||||
- id: Unique ID for the input
|
||||
- x_model: Alpine.js x-model binding
|
||||
- label: Input label (optional)
|
||||
- placeholder: Placeholder text (default: 'Select date and time')
|
||||
- format: DateTime format (default: 'Y-m-d H:i')
|
||||
- enable_time: Whether to enable time selection (default: true)
|
||||
- time_24hr: Use 24-hour time format (default: true)
|
||||
- minute_increment: Minute increment for time picker (default: 5)
|
||||
- min_date: Minimum selectable date
|
||||
- max_date: Maximum selectable date
|
||||
- disabled: Alpine.js expression for disabled state
|
||||
- required: Whether the field is required
|
||||
#}
|
||||
{% macro datetime_picker(id, x_model, label=none, placeholder='Select date and time', format='Y-m-d H:i', enable_time=true, time_24hr=true, minute_increment=5, min_date=none, max_date=none, disabled=none, required=false) %}
|
||||
<div class="mb-4">
|
||||
{% if label %}
|
||||
<label for="{{ id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1.5">
|
||||
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}"
|
||||
x-model="{{ x_model }}"
|
||||
x-init="flatpickr($el, {
|
||||
enableTime: {{ 'true' if enable_time else 'false' }},
|
||||
time_24hr: {{ 'true' if time_24hr else 'false' }},
|
||||
minuteIncrement: {{ minute_increment }},
|
||||
dateFormat: '{{ format }}',
|
||||
{% if min_date %}minDate: '{{ min_date }}',{% endif %}
|
||||
{% if max_date %}maxDate: '{{ max_date }}',{% endif %}
|
||||
disableMobile: true,
|
||||
onChange: function(selectedDates, dateStr) {
|
||||
{{ x_model }} = dateStr;
|
||||
}
|
||||
})"
|
||||
placeholder="{{ placeholder }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
|
||||
class="block w-full pl-10 pr-4 py-2.5 text-sm text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
readonly
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Time Picker
|
||||
===========
|
||||
A time-only picker.
|
||||
|
||||
Parameters:
|
||||
- id: Unique ID for the input
|
||||
- x_model: Alpine.js x-model binding
|
||||
- label: Input label (optional)
|
||||
- placeholder: Placeholder text (default: 'Select time')
|
||||
- format: Time format (default: 'H:i')
|
||||
- time_24hr: Use 24-hour time format (default: true)
|
||||
- minute_increment: Minute increment (default: 5)
|
||||
- disabled: Alpine.js expression for disabled state
|
||||
- required: Whether the field is required
|
||||
#}
|
||||
{% macro time_picker(id, x_model, label=none, placeholder='Select time', format='H:i', time_24hr=true, minute_increment=5, disabled=none, required=false) %}
|
||||
<div class="mb-4">
|
||||
{% if label %}
|
||||
<label for="{{ id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1.5">
|
||||
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}"
|
||||
x-model="{{ x_model }}"
|
||||
x-init="flatpickr($el, {
|
||||
enableTime: true,
|
||||
noCalendar: true,
|
||||
time_24hr: {{ 'true' if time_24hr else 'false' }},
|
||||
minuteIncrement: {{ minute_increment }},
|
||||
dateFormat: '{{ format }}',
|
||||
disableMobile: true,
|
||||
onChange: function(selectedDates, dateStr) {
|
||||
{{ x_model }} = dateStr;
|
||||
}
|
||||
})"
|
||||
placeholder="{{ placeholder }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
|
||||
class="block w-full pl-10 pr-4 py-2.5 text-sm text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
readonly
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Month Picker
|
||||
============
|
||||
A month-only picker for selecting year and month.
|
||||
|
||||
Parameters:
|
||||
- id: Unique ID for the input
|
||||
- x_model: Alpine.js x-model binding
|
||||
- label: Input label (optional)
|
||||
- placeholder: Placeholder text (default: 'Select month')
|
||||
- format: Month format (default: 'F Y')
|
||||
- disabled: Alpine.js expression for disabled state
|
||||
- required: Whether the field is required
|
||||
#}
|
||||
{% macro month_picker(id, x_model, label=none, placeholder='Select month', format='F Y', disabled=none, required=false) %}
|
||||
<div class="mb-4">
|
||||
{% if label %}
|
||||
<label for="{{ id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1.5">
|
||||
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}"
|
||||
x-model="{{ x_model }}"
|
||||
x-init="flatpickr($el, {
|
||||
plugins: [new monthSelectPlugin({ shorthand: false, dateFormat: '{{ format }}', altFormat: '{{ format }}' })],
|
||||
disableMobile: true,
|
||||
onChange: function(selectedDates, dateStr) {
|
||||
{{ x_model }} = dateStr;
|
||||
}
|
||||
})"
|
||||
placeholder="{{ placeholder }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
|
||||
class="block w-full pl-10 pr-4 py-2.5 text-sm text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
readonly
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Inline Datepicker
|
||||
=================
|
||||
An inline calendar picker (always visible).
|
||||
|
||||
Parameters:
|
||||
- id: Unique ID for the element
|
||||
- x_model: Alpine.js x-model binding
|
||||
- format: Date format (default: 'Y-m-d')
|
||||
- min_date: Minimum selectable date
|
||||
- max_date: Maximum selectable date
|
||||
#}
|
||||
{% macro inline_datepicker(id, x_model, format='Y-m-d', min_date=none, max_date=none) %}
|
||||
<div
|
||||
id="{{ id }}"
|
||||
x-init="flatpickr($el, {
|
||||
inline: true,
|
||||
dateFormat: '{{ format }}',
|
||||
{% if min_date %}minDate: '{{ min_date }}',{% endif %}
|
||||
{% if max_date %}maxDate: '{{ max_date }}',{% endif %}
|
||||
onChange: function(selectedDates, dateStr) {
|
||||
{{ x_model }} = dateStr;
|
||||
}
|
||||
})"
|
||||
class="flatpickr-inline"
|
||||
></div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Flatpickr Dark Mode Styles
|
||||
==========================
|
||||
Include this in your base template for dark mode support.
|
||||
The dark theme CSS is loaded conditionally.
|
||||
#}
|
||||
{% macro datepicker_dark_mode_script() %}
|
||||
<script>
|
||||
// Apply dark theme to Flatpickr when dark mode is active
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.attributeName === 'class') {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.querySelectorAll('.flatpickr-calendar').forEach(calendar => {
|
||||
calendar.classList.toggle('dark', isDark);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
});
|
||||
</script>
|
||||
{% endmacro %}
|
||||
350
app/templates/shared/macros/dropdowns.html
Normal file
350
app/templates/shared/macros/dropdowns.html
Normal file
@@ -0,0 +1,350 @@
|
||||
{#
|
||||
Dropdown Macros
|
||||
===============
|
||||
Reusable dropdown menu components with Alpine.js integration.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/dropdowns.html' import dropdown, dropdown_menu, context_menu %}
|
||||
|
||||
{# Basic dropdown #}
|
||||
{% call dropdown('Actions', 'isDropdownOpen') %}
|
||||
{{ dropdown_item('Edit', 'edit()', icon='pencil') }}
|
||||
{{ dropdown_item('Delete', 'delete()', icon='trash', variant='danger') }}
|
||||
{% endcall %}
|
||||
|
||||
{# Context menu (3-dot icon) #}
|
||||
{% call context_menu('itemMenu', 'isMenuOpen') %}
|
||||
{{ dropdown_item('View', 'view()') }}
|
||||
{{ dropdown_divider() }}
|
||||
{{ dropdown_item('Delete', 'delete()', variant='danger') }}
|
||||
{% endcall %}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Dropdown
|
||||
========
|
||||
A dropdown menu triggered by a button.
|
||||
|
||||
Parameters:
|
||||
- label: Button label
|
||||
- open_var: Alpine.js variable for open state (default: 'isDropdownOpen')
|
||||
- position: 'left' | 'right' (default: 'right')
|
||||
- icon: Button icon (default: 'chevron-down')
|
||||
- variant: 'primary' | 'secondary' | 'ghost' (default: 'secondary')
|
||||
- size: 'sm' | 'md' (default: 'md')
|
||||
- width: Dropdown width class (default: 'w-48')
|
||||
#}
|
||||
{% macro dropdown(label, open_var='isDropdownOpen', position='right', icon='chevron-down', variant='secondary', size='md', width='w-48') %}
|
||||
{% set variants = {
|
||||
'primary': 'text-white bg-purple-600 hover:bg-purple-700 border-transparent',
|
||||
'secondary': 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600',
|
||||
'ghost': 'text-gray-600 dark:text-gray-400 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 border-transparent'
|
||||
} %}
|
||||
{% set sizes = {
|
||||
'sm': 'px-3 py-1.5 text-xs',
|
||||
'md': 'px-4 py-2 text-sm'
|
||||
} %}
|
||||
{% set positions = {
|
||||
'left': 'left-0',
|
||||
'right': 'right-0'
|
||||
} %}
|
||||
<div x-data="{ {{ open_var }}: false }" class="relative inline-block">
|
||||
<button
|
||||
@click="{{ open_var }} = !{{ open_var }}"
|
||||
@click.outside="{{ open_var }} = false"
|
||||
type="button"
|
||||
class="inline-flex items-center font-medium border rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 {{ variants[variant] }} {{ sizes[size] }}"
|
||||
:class="{ 'ring-2 ring-purple-500 ring-offset-2': {{ open_var }} }"
|
||||
>
|
||||
{{ label }}
|
||||
<span x-html="$icon('{{ icon }}', 'w-4 h-4 ml-2')" :class="{ 'rotate-180': {{ open_var }} }" class="transition-transform"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="{{ open_var }}"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute z-50 mt-2 {{ width }} {{ positions[position] }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Dropdown (External State)
|
||||
=========================
|
||||
A dropdown that uses parent component's state.
|
||||
|
||||
Parameters:
|
||||
- label: Button label
|
||||
- open_var: Alpine.js variable for open state
|
||||
- All other params same as dropdown()
|
||||
#}
|
||||
{% macro dropdown_external(label, open_var='isDropdownOpen', position='right', icon='chevron-down', variant='secondary', size='md', width='w-48') %}
|
||||
{% set variants = {
|
||||
'primary': 'text-white bg-purple-600 hover:bg-purple-700 border-transparent',
|
||||
'secondary': 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600',
|
||||
'ghost': 'text-gray-600 dark:text-gray-400 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 border-transparent'
|
||||
} %}
|
||||
{% set sizes = {
|
||||
'sm': 'px-3 py-1.5 text-xs',
|
||||
'md': 'px-4 py-2 text-sm'
|
||||
} %}
|
||||
{% set positions = {
|
||||
'left': 'left-0',
|
||||
'right': 'right-0'
|
||||
} %}
|
||||
<div class="relative inline-block">
|
||||
<button
|
||||
@click="{{ open_var }} = !{{ open_var }}"
|
||||
@click.outside="{{ open_var }} = false"
|
||||
type="button"
|
||||
class="inline-flex items-center font-medium border rounded-lg transition-colors focus:outline-none {{ variants[variant] }} {{ sizes[size] }}"
|
||||
>
|
||||
{{ label }}
|
||||
<span x-html="$icon('{{ icon }}', 'w-4 h-4 ml-2')" :class="{ 'rotate-180': {{ open_var }} }" class="transition-transform"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="{{ open_var }}"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute z-50 mt-2 {{ width }} {{ positions[position] }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Context Menu (3-dot menu)
|
||||
=========================
|
||||
An icon-only dropdown menu, commonly used for row actions.
|
||||
|
||||
Parameters:
|
||||
- id: Unique ID for the menu
|
||||
- open_var: Alpine.js variable for open state (default: 'isMenuOpen')
|
||||
- position: 'left' | 'right' (default: 'right')
|
||||
- icon: Icon name (default: 'dots-vertical')
|
||||
- width: Dropdown width class (default: 'w-40')
|
||||
#}
|
||||
{% macro context_menu(id='contextMenu', open_var='isMenuOpen', position='right', icon='dots-vertical', width='w-40') %}
|
||||
{% set positions = {
|
||||
'left': 'left-0',
|
||||
'right': 'right-0'
|
||||
} %}
|
||||
<div x-data="{ {{ open_var }}: false }" class="relative">
|
||||
<button
|
||||
@click.stop="{{ open_var }} = !{{ open_var }}"
|
||||
@click.outside="{{ open_var }} = false"
|
||||
type="button"
|
||||
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 focus:outline-none"
|
||||
:class="{ 'text-gray-700 dark:text-white bg-gray-100 dark:bg-gray-700': {{ open_var }} }"
|
||||
>
|
||||
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="{{ open_var }}"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute z-50 mt-1 {{ width }} {{ positions[position] }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Context Menu (External State)
|
||||
=============================
|
||||
A context menu that uses parent component's state.
|
||||
|
||||
Parameters:
|
||||
- open_var: Alpine.js variable for open state
|
||||
- position: 'left' | 'right' (default: 'right')
|
||||
- icon: Icon name (default: 'dots-vertical')
|
||||
- width: Dropdown width class (default: 'w-40')
|
||||
#}
|
||||
{% macro context_menu_external(open_var='isMenuOpen', position='right', icon='dots-vertical', width='w-40') %}
|
||||
{% set positions = {
|
||||
'left': 'left-0',
|
||||
'right': 'right-0'
|
||||
} %}
|
||||
<div class="relative">
|
||||
<button
|
||||
@click.stop="{{ open_var }} = !{{ open_var }}"
|
||||
@click.outside="{{ open_var }} = false"
|
||||
type="button"
|
||||
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 focus:outline-none"
|
||||
:class="{ 'text-gray-700 dark:text-white bg-gray-100 dark:bg-gray-700': {{ open_var }} }"
|
||||
>
|
||||
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="{{ open_var }}"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute z-50 mt-1 {{ width }} {{ positions[position] }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Dropdown Item
|
||||
=============
|
||||
An item within a dropdown menu.
|
||||
|
||||
Parameters:
|
||||
- label: Item label
|
||||
- onclick: Alpine.js click handler
|
||||
- icon: Icon name (optional)
|
||||
- variant: 'default' | 'danger' (default: 'default')
|
||||
- href: URL if this should be a link
|
||||
- disabled: Whether the item is disabled
|
||||
#}
|
||||
{% macro dropdown_item(label, onclick=none, icon=none, variant='default', href=none, disabled=false) %}
|
||||
{% set variants = {
|
||||
'default': 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white',
|
||||
'danger': 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-700 dark:hover:text-red-300'
|
||||
} %}
|
||||
{% if href %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
class="flex items-center w-full px-4 py-2 text-sm font-medium transition-colors {{ variants[variant] }} {{ 'opacity-50 pointer-events-none' if disabled else '' }}"
|
||||
>
|
||||
{% if icon %}
|
||||
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-3')"></span>
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
@click="{{ onclick }}"
|
||||
type="button"
|
||||
{% if disabled %}disabled{% endif %}
|
||||
class="flex items-center w-full px-4 py-2 text-sm font-medium transition-colors {{ variants[variant] }} disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{% if icon %}
|
||||
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-3')"></span>
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Dropdown Divider
|
||||
================
|
||||
A horizontal divider between dropdown items.
|
||||
#}
|
||||
{% macro dropdown_divider() %}
|
||||
<div class="my-1 border-t border-gray-200 dark:border-gray-700"></div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Dropdown Header
|
||||
===============
|
||||
A non-clickable header within a dropdown.
|
||||
|
||||
Parameters:
|
||||
- text: Header text
|
||||
#}
|
||||
{% macro dropdown_header(text) %}
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{{ text }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Select Dropdown
|
||||
===============
|
||||
A dropdown that acts as a select input.
|
||||
|
||||
Parameters:
|
||||
- label: Button label when nothing selected
|
||||
- selected_var: Alpine.js variable for selected value
|
||||
- selected_label_var: Alpine.js variable for selected label display
|
||||
- open_var: Alpine.js variable for open state
|
||||
- placeholder: Placeholder when nothing selected
|
||||
- width: Dropdown width class (default: 'w-full')
|
||||
#}
|
||||
{% macro select_dropdown(label='', selected_var='selected', selected_label_var='selectedLabel', open_var='isSelectOpen', placeholder='Select...', width='w-full') %}
|
||||
<div x-data="{ {{ open_var }}: false }" class="relative {{ width }}">
|
||||
<button
|
||||
@click="{{ open_var }} = !{{ open_var }}"
|
||||
@click.outside="{{ open_var }} = false"
|
||||
type="button"
|
||||
class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium text-left bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors"
|
||||
>
|
||||
<span x-text="{{ selected_label_var }} || '{{ placeholder }}'" :class="{ 'text-gray-400': !{{ selected_var }} }" class="truncate text-gray-700 dark:text-gray-300"></span>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4 ml-2 text-gray-400')" :class="{ 'rotate-180': {{ open_var }} }" class="flex-shrink-0 transition-transform"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="{{ open_var }}"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 max-h-60 overflow-y-auto"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Select Option
|
||||
=============
|
||||
An option within a select dropdown.
|
||||
|
||||
Parameters:
|
||||
- value: Option value
|
||||
- label: Option label
|
||||
- selected_var: Alpine.js variable for selected value
|
||||
- selected_label_var: Alpine.js variable for selected label
|
||||
- open_var: Alpine.js variable to close dropdown
|
||||
#}
|
||||
{% macro select_option(value, label, selected_var='selected', selected_label_var='selectedLabel', open_var='isSelectOpen') %}
|
||||
<button
|
||||
@click="{{ selected_var }} = '{{ value }}'; {{ selected_label_var }} = '{{ label }}'; {{ open_var }} = false"
|
||||
type="button"
|
||||
class="flex items-center justify-between w-full px-4 py-2 text-sm text-left transition-colors"
|
||||
:class="{{ selected_var }} === '{{ value }}' ? 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<span x-show="{{ selected_var }} === '{{ value }}'" x-html="$icon('check', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
@@ -4,10 +4,12 @@
|
||||
Reusable form input components.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/forms.html' import form_input, form_select, form_textarea, form_checkbox %}
|
||||
{% from 'shared/macros/forms.html' import form_input, form_select, form_textarea, form_checkbox, password_input, input_with_icon %}
|
||||
{{ form_input('Email', 'email', 'formData.email', type='email', required=true) }}
|
||||
{{ form_select('Status', 'formData.status', [{'value': 'active', 'label': 'Active'}]) }}
|
||||
{{ form_textarea('Description', 'formData.description', rows=4) }}
|
||||
{{ password_input('Password', 'formData.password', required=true) }}
|
||||
{{ input_with_icon('Search', 'query', 'search', icon='search', icon_position='left') }}
|
||||
#}
|
||||
|
||||
|
||||
@@ -330,3 +332,215 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Password Input
|
||||
==============
|
||||
A password input with show/hide toggle.
|
||||
|
||||
Parameters:
|
||||
- label: Field label
|
||||
- x_model: Alpine.js x-model binding
|
||||
- name: Input name attribute
|
||||
- placeholder: Placeholder text (default: 'Enter password')
|
||||
- required: Whether the field is required (default: false)
|
||||
- disabled: Alpine.js expression for disabled state
|
||||
- error: Alpine.js expression for error message
|
||||
- help: Help text shown below the input
|
||||
- minlength: Minimum password length
|
||||
- autocomplete: Autocomplete attribute (default: 'current-password')
|
||||
- show_strength: Whether to show password strength indicator (default: false)
|
||||
#}
|
||||
{% macro password_input(label, x_model, name='password', placeholder='Enter password', required=false, disabled=none, error=none, help=none, minlength=none, autocomplete='current-password', show_strength=false) %}
|
||||
<div x-data="{ showPassword: false }" class="mb-4">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
|
||||
</span>
|
||||
<div class="relative mt-1">
|
||||
<input
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
name="{{ name }}"
|
||||
x-model="{{ x_model }}"
|
||||
placeholder="{{ placeholder }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if minlength %}minlength="{{ minlength }}"{% endif %}
|
||||
autocomplete="{{ autocomplete }}"
|
||||
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
|
||||
class="block w-full pr-10 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
{% if error %}:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': {{ error }} }"{% endif %}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none"
|
||||
>
|
||||
<template x-if="!showPassword">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="showPassword">
|
||||
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
{% if error %}
|
||||
<span x-show="{{ error }}" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="{{ error }}"></span>
|
||||
{% endif %}
|
||||
{% if help %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ help }}</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% if show_strength %}
|
||||
<div class="mt-2">
|
||||
<div class="flex gap-1">
|
||||
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700"
|
||||
:class="{
|
||||
'bg-red-500': {{ x_model }}.length > 0 && {{ x_model }}.length < 6,
|
||||
'bg-yellow-500': {{ x_model }}.length >= 6 && {{ x_model }}.length < 10,
|
||||
'bg-green-500': {{ x_model }}.length >= 10
|
||||
}"></div>
|
||||
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700"
|
||||
:class="{
|
||||
'bg-yellow-500': {{ x_model }}.length >= 6 && {{ x_model }}.length < 10,
|
||||
'bg-green-500': {{ x_model }}.length >= 10
|
||||
}"></div>
|
||||
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700"
|
||||
:class="{ 'bg-green-500': {{ x_model }}.length >= 10 }"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"
|
||||
x-text="{{ x_model }}.length < 6 ? 'Weak' : ({{ x_model }}.length < 10 ? 'Medium' : 'Strong')"></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Input with Icon
|
||||
===============
|
||||
A text input with an icon on the left or right.
|
||||
|
||||
Parameters:
|
||||
- label: Field label
|
||||
- x_model: Alpine.js x-model binding
|
||||
- name: Input name attribute
|
||||
- icon: Icon name (uses $icon helper)
|
||||
- icon_position: 'left' | 'right' (default: 'left')
|
||||
- type: Input type (default: 'text')
|
||||
- placeholder: Placeholder text
|
||||
- required: Whether the field is required
|
||||
- disabled: Alpine.js expression for disabled state
|
||||
- error: Alpine.js expression for error message
|
||||
- on_click_icon: Alpine.js handler when icon is clicked (makes icon a button)
|
||||
#}
|
||||
{% macro input_with_icon(label, x_model, name, icon, icon_position='left', type='text', placeholder='', required=false, disabled=none, error=none, on_click_icon=none) %}
|
||||
<label class="block mb-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
{{ label }}{% if required %} <span class="text-red-600">*</span>{% endif %}
|
||||
</span>
|
||||
<div class="relative mt-1">
|
||||
{% if icon_position == 'left' %}
|
||||
{% if on_click_icon %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ on_click_icon }}"
|
||||
class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<span x-html="$icon('{{ icon }}', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<input
|
||||
type="{{ type }}"
|
||||
name="{{ name }}"
|
||||
x-model="{{ x_model }}"
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}:disabled="{{ disabled }}"{% endif %}
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input {{ 'pl-10' if icon_position == 'left' else 'pr-10' }}"
|
||||
{% if error %}:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': {{ error }} }"{% endif %}
|
||||
>
|
||||
{% if icon_position == 'right' %}
|
||||
{% if on_click_icon %}
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ on_click_icon }}"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<span x-html="$icon('{{ icon }}', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if error %}
|
||||
<span x-show="{{ error }}" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="{{ error }}"></span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
File Input
|
||||
==========
|
||||
A styled file input with drag and drop support.
|
||||
|
||||
Parameters:
|
||||
- label: Field label
|
||||
- name: Input name attribute
|
||||
- accept: Accepted file types (e.g., 'image/*', '.pdf,.doc')
|
||||
- multiple: Allow multiple files (default: false)
|
||||
- max_size: Maximum file size in MB (for display only)
|
||||
- on_change: Alpine.js handler when files are selected
|
||||
- help: Help text
|
||||
#}
|
||||
{% macro file_input(label, name, accept='*', multiple=false, max_size=none, on_change=none, help=none) %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1.5">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div
|
||||
x-data="{ isDragging: false, files: [] }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="isDragging = false; files = $event.dataTransfer.files; {% if on_change %}{{ on_change }}{% endif %}"
|
||||
class="relative border-2 border-dashed rounded-lg p-6 text-center transition-colors"
|
||||
:class="isDragging ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/10' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="{{ name }}"
|
||||
accept="{{ accept }}"
|
||||
{% if multiple %}multiple{% endif %}
|
||||
@change="files = $event.target.files; {% if on_change %}{{ on_change }}{% endif %}"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<svg class="mx-auto w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium text-purple-600 dark:text-purple-400">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
{% if help %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ help }}</p>
|
||||
{% elif max_size %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Max file size: {{ max_size }}MB</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
477
app/templates/shared/macros/modals.html
Normal file
477
app/templates/shared/macros/modals.html
Normal file
@@ -0,0 +1,477 @@
|
||||
{#
|
||||
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 %}
|
||||
Reference in New Issue
Block a user