Nested {# ... #} comments inside the docstring were breaking
the outer comment block, causing 'dropdown' is undefined error
when rendering the components page.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
429 lines
17 KiB
HTML
429 lines
17 KiB
HTML
{#
|
|
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 %}
|
|
|
|
|
|
{#
|
|
Action Dropdown
|
|
===============
|
|
A dropdown with loading/disabled state support for action buttons.
|
|
Uses external Alpine.js state from parent component.
|
|
|
|
Parameters:
|
|
- label: Button label
|
|
- loading_label: Label shown when loading (default: 'Loading...')
|
|
- open_var: Alpine.js variable for open state
|
|
- loading_var: Alpine.js variable for loading/disabled state
|
|
- icon: Button icon (default: 'chevron-down')
|
|
- position: 'left' | 'right' (default: 'right')
|
|
- variant: 'primary' | 'secondary' (default: 'primary')
|
|
- width: Dropdown width class (default: 'w-48')
|
|
|
|
Usage:
|
|
{% call action_dropdown(
|
|
label='Run Scan',
|
|
loading_label='Scanning...',
|
|
open_var='scanDropdownOpen',
|
|
loading_var='scanning',
|
|
icon='search'
|
|
) %}
|
|
{{ dropdown_item('Run All', 'runScan("all")') }}
|
|
{{ dropdown_item('Architecture Only', 'runScan("architecture")') }}
|
|
{% endcall %}
|
|
#}
|
|
{% macro action_dropdown(label, loading_label='Loading...', open_var='isDropdownOpen', loading_var='isLoading', icon=none, position='right', variant='primary', width='w-48') %}
|
|
{% set variants = {
|
|
'primary': 'text-white bg-purple-600 hover:bg-purple-700 border-transparent focus:shadow-outline-purple',
|
|
'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 focus:shadow-outline-gray'
|
|
} %}
|
|
{% set positions = {
|
|
'left': 'left-0',
|
|
'right': 'right-0'
|
|
} %}
|
|
<div class="relative">
|
|
<button
|
|
@click="{{ open_var }} = !{{ open_var }}"
|
|
@click.outside="{{ open_var }} = false"
|
|
:disabled="{{ loading_var }}"
|
|
type="button"
|
|
class="flex items-center px-4 py-2 text-sm font-medium leading-5 transition-colors duration-150 border rounded-lg focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed {{ variants[variant] }}"
|
|
>
|
|
<template x-if="!{{ loading_var }}">
|
|
<span class="flex items-center">
|
|
{% if icon %}
|
|
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2')"></span>
|
|
{% endif %}
|
|
{{ label }}
|
|
<span x-html="$icon('chevron-down', 'w-4 h-4 ml-1')"></span>
|
|
</span>
|
|
</template>
|
|
<template x-if="{{ loading_var }}">
|
|
<span class="flex items-center">
|
|
<span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
|
{{ loading_label }}
|
|
</span>
|
|
</template>
|
|
</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 %}
|