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:
2025-12-06 19:05:13 +01:00
parent 990ca322f3
commit b0db4d26d8
6 changed files with 2138 additions and 1 deletions

View 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 %}

View 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>
#}

View 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 %}

View 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 %}

View File

@@ -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 %}

View 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 %}