feat: add number_stepper and tabs macro components
- inputs.html: number_stepper macro with +/- buttons, size variants (sm/md/lg), min/max validation, dark mode support - tabs.html: tabs_nav, tabs_inline, tab_button macros with icon and count badge support, custom click handlers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
233
app/templates/shared/macros/inputs.html
Normal file
233
app/templates/shared/macros/inputs.html
Normal file
@@ -0,0 +1,233 @@
|
||||
{#
|
||||
Input Macros
|
||||
============
|
||||
Reusable input components with Alpine.js integration.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/inputs.html' import search_autocomplete %}
|
||||
|
||||
{{ search_autocomplete(
|
||||
search_var='userSearchQuery',
|
||||
results_var='userSearchResults',
|
||||
show_dropdown_var='showUserDropdown',
|
||||
loading_var='searchingUsers',
|
||||
disabled_var='transferring',
|
||||
select_action='selectUser(user)',
|
||||
display_field='username',
|
||||
secondary_field='email',
|
||||
placeholder='Search by name or email...'
|
||||
) }}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Search Autocomplete
|
||||
===================
|
||||
A search input with dropdown results for autocomplete functionality.
|
||||
|
||||
Parameters:
|
||||
- search_var: Alpine.js variable for search query (required)
|
||||
- results_var: Alpine.js variable containing search results array (required)
|
||||
- show_dropdown_var: Alpine.js variable controlling dropdown visibility (required)
|
||||
- loading_var: Alpine.js variable for loading state (default: 'searching')
|
||||
- disabled_var: Alpine.js variable for disabled state (default: none)
|
||||
- search_action: Alpine.js action on input (default: 'search()')
|
||||
- select_action: Alpine.js action when item selected, receives 'item' (required)
|
||||
- selected_check: Alpine.js expression to check if item is selected (optional)
|
||||
- display_field: Field name for primary display text (default: 'name')
|
||||
- secondary_field: Field name for secondary text (optional)
|
||||
- id_field: Field name for item ID (default: 'id')
|
||||
- placeholder: Input placeholder text (default: 'Search...')
|
||||
- min_chars: Minimum characters before showing results (default: 2)
|
||||
- no_results_text: Text when no results found (default: 'No results found')
|
||||
- loading_text: Text while loading (default: 'Searching...')
|
||||
#}
|
||||
{% macro search_autocomplete(
|
||||
search_var,
|
||||
results_var,
|
||||
show_dropdown_var,
|
||||
loading_var='searching',
|
||||
disabled_var=none,
|
||||
search_action='search()',
|
||||
select_action='selectItem(item)',
|
||||
selected_check=none,
|
||||
display_field='name',
|
||||
secondary_field=none,
|
||||
id_field='id',
|
||||
placeholder='Search...',
|
||||
min_chars=2,
|
||||
no_results_text='No results found',
|
||||
loading_text='Searching...'
|
||||
) %}
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="{{ search_var }}"
|
||||
@input="{{ search_action }}"
|
||||
@focus="{{ show_dropdown_var }} = true"
|
||||
{% if disabled_var %}:disabled="{{ disabled_var }}"{% endif %}
|
||||
placeholder="{{ placeholder }}"
|
||||
class="block w-full mt-1 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"
|
||||
>
|
||||
|
||||
{# Search Results Dropdown #}
|
||||
<div
|
||||
x-show="{{ show_dropdown_var }} && {{ results_var }}.length > 0"
|
||||
x-cloak
|
||||
@click.away="{{ show_dropdown_var }} = false"
|
||||
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<template x-for="item in {{ results_var }}" :key="item.{{ id_field }}">
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ select_action }}"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-purple-50 dark:hover:bg-gray-600 focus:outline-none"
|
||||
{% if selected_check %}:class="{ 'bg-purple-50 dark:bg-gray-600': {{ selected_check }} }"{% endif %}
|
||||
>
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200" x-text="item.{{ display_field }}"></div>
|
||||
{% if secondary_field %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="item.{{ secondary_field }}"></div>
|
||||
{% endif %}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# No Results #}
|
||||
<div
|
||||
x-show="{{ show_dropdown_var }} && {{ search_var }}.length >= {{ min_chars }} && {{ results_var }}.length === 0 && !{{ loading_var }}"
|
||||
class="absolute z-10 w-full mt-1 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ no_results_text }}
|
||||
</div>
|
||||
|
||||
{# Loading #}
|
||||
<div
|
||||
x-show="{{ loading_var }}"
|
||||
class="absolute z-10 w-full mt-1 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span x-html="$icon('spinner', 'w-4 h-4 inline mr-2')"></span>
|
||||
{{ loading_text }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Selected Item Display
|
||||
=====================
|
||||
A display component for showing the currently selected item with clear button.
|
||||
|
||||
Parameters:
|
||||
- selected_var: Alpine.js variable containing selected item (required)
|
||||
- display_field: Field name for primary display text (default: 'name')
|
||||
- secondary_field: Field name for secondary text (optional)
|
||||
- clear_action: Alpine.js action to clear selection (required)
|
||||
- label: Label text before the selection (default: 'Selected:')
|
||||
#}
|
||||
{% macro selected_item_display(
|
||||
selected_var,
|
||||
display_field='name',
|
||||
secondary_field=none,
|
||||
clear_action='clearSelection()',
|
||||
label='Selected:'
|
||||
) %}
|
||||
<div x-show="{{ selected_var }}" class="mt-2 p-2 bg-purple-50 dark:bg-purple-900 rounded-lg text-sm">
|
||||
<span class="text-purple-700 dark:text-purple-300">
|
||||
{{ label }} <strong x-text="{{ selected_var }}?.{{ display_field }}"></strong>
|
||||
{% if secondary_field %}
|
||||
(<span x-text="{{ selected_var }}?.{{ secondary_field }}"></span>)
|
||||
{% endif %}
|
||||
</span>
|
||||
<button type="button" @click="{{ clear_action }}" class="ml-2 text-purple-600 hover:text-purple-800 dark:text-purple-400">
|
||||
<span x-html="$icon('x', 'w-4 h-4 inline')"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Number Stepper
|
||||
==============
|
||||
A number input with +/- buttons for incrementing/decrementing values.
|
||||
Useful for quantity selectors in carts, product pages, batch sizes, etc.
|
||||
|
||||
Parameters:
|
||||
- model: Alpine.js x-model variable (required)
|
||||
- min: Minimum allowed value (default: 1)
|
||||
- max: Maximum allowed value (default: none - unlimited)
|
||||
- step: Increment/decrement step (default: 1)
|
||||
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
||||
- disabled_var: Alpine.js variable for disabled state (optional)
|
||||
- name: Input name for form submission (optional)
|
||||
- id: Input id attribute (optional)
|
||||
- label: Accessible label for screen readers (default: 'Quantity')
|
||||
|
||||
Usage:
|
||||
{{ number_stepper(model='quantity', min=1, max=99) }}
|
||||
{{ number_stepper(model='cart.items[index].qty', min=1, max='item.stock', size='sm') }}
|
||||
{{ number_stepper(model='batchSize', min=100, max=5000, step=100, size='lg') }}
|
||||
#}
|
||||
{% macro number_stepper(
|
||||
model,
|
||||
min=1,
|
||||
max=none,
|
||||
step=1,
|
||||
size='md',
|
||||
disabled_var=none,
|
||||
name=none,
|
||||
id=none,
|
||||
label='Quantity'
|
||||
) %}
|
||||
{% set sizes = {
|
||||
'sm': {
|
||||
'btn': 'px-2 py-1',
|
||||
'input': 'px-2 py-1 text-xs w-12',
|
||||
'icon': 'w-3 h-3'
|
||||
},
|
||||
'md': {
|
||||
'btn': 'px-3 py-2',
|
||||
'input': 'px-3 py-2 text-sm w-16',
|
||||
'icon': 'w-4 h-4'
|
||||
},
|
||||
'lg': {
|
||||
'btn': 'px-4 py-3',
|
||||
'input': 'px-4 py-3 text-base w-20',
|
||||
'icon': 'w-5 h-5'
|
||||
}
|
||||
} %}
|
||||
{% set s = sizes[size] %}
|
||||
<div class="flex" role="group" aria-label="{{ label }}">
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ model }} = Math.max({{ min }}, {{ model }} - {{ step }})"
|
||||
{% if disabled_var %}:disabled="{{ disabled_var }}"{% endif %}
|
||||
{% if max %}:disabled="{{ model }} <= {{ min }}"{% endif %}
|
||||
class="{{ s.btn }} text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 border-r-0 rounded-l-md hover:bg-gray-200 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:z-10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Decrease {{ label|lower }}"
|
||||
>
|
||||
<span x-html="$icon('minus', '{{ s.icon }}')"></span>
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="{{ model }}"
|
||||
{% if name %}name="{{ name }}"{% endif %}
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
min="{{ min }}"
|
||||
{% if max %}:max="{{ max }}" max="{{ max }}"{% endif %}
|
||||
step="{{ step }}"
|
||||
{% if disabled_var %}:disabled="{{ disabled_var }}"{% endif %}
|
||||
class="no-spinner {{ s.input }} text-center text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border-y border-gray-300 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:z-10"
|
||||
aria-label="{{ label }}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="{{ model }} = {% if max %}Math.min({{ max }}, {{ model }} + {{ step }}){% else %}{{ model }} + {{ step }}{% endif %}"
|
||||
{% if disabled_var %}:disabled="{{ disabled_var }}"{% endif %}
|
||||
{% if max %}:disabled="{{ model }} >= {{ max }}"{% endif %}
|
||||
class="{{ s.btn }} text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 border-l-0 rounded-r-md hover:bg-gray-200 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:z-10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Increase {{ label|lower }}"
|
||||
>
|
||||
<span x-html="$icon('plus', '{{ s.icon }}')"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
78
app/templates/shared/macros/tabs.html
Normal file
78
app/templates/shared/macros/tabs.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{# app/templates/shared/macros/tabs.html #}
|
||||
{# Tab navigation components for consistent UI across admin pages #}
|
||||
|
||||
{#
|
||||
Tab navigation wrapper (standalone)
|
||||
Usage:
|
||||
{% call tabs_nav() %}
|
||||
{{ tab_button('tab1', 'Tab 1', icon='home') }}
|
||||
{{ tab_button('tab2', 'Tab 2', icon='cog') }}
|
||||
{% endcall %}
|
||||
#}
|
||||
{% macro tabs_nav(tab_var='activeTab', class='') %}
|
||||
<div class="mb-6 {{ class }}">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
{{ caller() }}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Tab navigation wrapper (inline, for use inside cards/flex containers)
|
||||
Usage:
|
||||
<div class="flex justify-between">
|
||||
{% call tabs_inline() %}
|
||||
{{ tab_button('all', 'All', count_var='items.length') }}
|
||||
{% endcall %}
|
||||
<!-- other content like search -->
|
||||
</div>
|
||||
#}
|
||||
{% macro tabs_inline(tab_var='activeTab') %}
|
||||
<div class="flex space-x-2 border-b border-gray-200 dark:border-gray-700">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Individual tab button
|
||||
Args:
|
||||
id: Tab identifier (used for activeTab comparison)
|
||||
label: Display text
|
||||
tab_var: Alpine.js variable name for active tab (default: 'activeTab')
|
||||
icon: Optional icon name (uses $icon helper)
|
||||
count_var: Optional Alpine.js variable for count badge
|
||||
onclick: Optional custom click handler (overrides default tab switching)
|
||||
#}
|
||||
{% macro tab_button(id, label, tab_var='activeTab', icon=none, count_var=none, onclick=none) %}
|
||||
<button
|
||||
@click="{{ onclick if onclick else (tab_var ~ " = '" ~ id ~ "'") }}"
|
||||
:class="{{ tab_var }} === '{{ id }}' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
{%- if icon %}<span x-html="$icon('{{ icon }}', 'inline w-5 h-5 mr-2')"></span>{% endif -%}
|
||||
{{ label }}
|
||||
{%- if count_var %}<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-gray-100 dark:bg-gray-700" x-text="{{ count_var }}"></span>{% endif -%}
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Tab panel wrapper (for content that shows/hides based on active tab)
|
||||
Usage:
|
||||
{{ tab_panel('tab1') }}
|
||||
<div>Tab 1 content</div>
|
||||
{{ endtab_panel() }}
|
||||
|
||||
Or simply use x-show directly:
|
||||
<div x-show="activeTab === 'tab1'" x-transition>
|
||||
Content
|
||||
</div>
|
||||
#}
|
||||
{% macro tab_panel(id, tab_var='activeTab', transition=true) %}
|
||||
<div x-show="{{ tab_var }} === '{{ id }}'"{% if transition %} x-transition{% endif %}>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro endtab_panel() %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
Reference in New Issue
Block a user