More components and refactoring select

This commit is contained in:
Ronan Berder
2025-04-21 10:37:15 +08:00
parent ec2f68d3b0
commit 88af4152ab
23 changed files with 2357 additions and 1576 deletions
+15 -16
View File
@@ -1,20 +1,19 @@
{% macro dialog(
id,
trigger=None,
title=None,
description=None,
footer=None,
main_attrs={},
trigger_attrs={},
content_attrs={},
content_header_attrs={},
content_body_attrs={},
content_footer_attrs={},
open=false,
close_button=true,
close_on_overlay_click=true
)
%}
id,
trigger=None,
title=None,
description=None,
footer=None,
main_attrs={},
trigger_attrs={},
content_attrs={},
content_header_attrs={},
content_body_attrs={},
content_footer_attrs={},
open=false,
close_button=true,
close_on_overlay_click=true
) %}
{% if registerComponent('dialog') %}
<script>
document.addEventListener('alpine:init', () => {
+129
View File
@@ -0,0 +1,129 @@
{% macro popover(
id=None,
trigger=None,
main_attrs=None,
trigger_attrs=None,
content_attrs=None
) %}
{% if registerComponent('popover') %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('popover', () => ({
open: false,
focusedIndex: null,
menuItems: [],
init() {
this.$nextTick(() => {
this.menuItems = Array.from(this.$el.querySelectorAll('[role=menuitem]'));
});
},
handleTriggerClick() {
this.open = !this.open;
},
handleEscape() {
if (this.open) this.open = false;
},
focusNavItem() {
if (this.menuItems.length === 0) return;
if (this.focusedIndex >= this.menuItems.length) {
this.focusedIndex = this.menuItems.length - 1;
}
if (this.focusedIndex < 0) {
this.focusedIndex = 0;
}
this.menuItems[this.focusedIndex].focus();
},
nextNavItem() {
if (this.menuItems.length === 0) return;
if (this.focusedIndex === null) {
this.focusedIndex = 0;
} else {
this.focusedIndex++;
}
this.focusNavItem();
},
prevNavItem() {
if (this.menuItems.length === 0) return;
if (this.focusedIndex === null) {
this.focusedIndex = 0;
} else {
this.focusedIndex--;
}
this.focusNavItem();
},
handleNavItemClick(event) {
if (this.menuItems.length === 0) return;
const menuItem = event.target.closest('[role=menuitem]');
if (menuItem && !menuItem.disabled) {
menuItem.blur();
this.open = false;
}
},
$trigger: {
'@click'() { this.handleTriggerClick() },
'@keydown.down.prevent'() { this.open && this.nextNavItem() },
':aria-expanded'() { return this.open },
},
$content: {
'@click'(e) { this.handleNavItemClick(e) },
'@keydown.escape.window'() { this.handleEscape() },
'@keydown.down.prevent'() { this.nextNavItem() },
'@keydown.up.prevent'() { this.prevNavItem() },
'@keydown.home.prevent'() { this.focusNavItem(0) },
'@keydown.end.prevent'() { this.focusNavItem(this.menuItems.length - 1) },
':aria-hidden'() { return !this.open },
'x-cloak': ''
},
}));
});
</script>
{% endif %}
<div
class="popover {% if main_attrs %}{{ main_attrs.pop('class', '') }}{% endif %}"
x-data="popover"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
{% for key, value in main_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if trigger %}
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-controls="{{ id }}-menu"
{% endif %}
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<div
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in content_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
</div>
</div>
{% endmacro %}
+177 -97
View File
@@ -1,144 +1,224 @@
{% macro select(
id=None,
main_attrs=None,
trigger_attrs=None,
menu_attrs=None,
value=None,
input_name=None
)
%}
id=None,
name=None,
selected=None,
main_attrs=None,
trigger_attrs=None,
content_attrs=None,
listbox_attrs=None,
is_combobox=False
) %}
{% if registerComponent('select') %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('select', (initialValue = null) => ({
Alpine.data('select', (name = null, initialValue = null) => ({
open: false,
focusedIndex: -1,
name: null,
options: [],
selectedLabel: initialValue,
selectedValue: initialValue,
disabledOptions: [],
focusedIndex: null,
selectedLabel: null,
selectedValue: null,
query: '',
init() {
this.$nextTick(() => {
const listboxElement = this.$el.querySelector('[role="listbox"]');
this.options = Array.from(listboxElement.querySelectorAll('[role=option]'));
this.selectOptionByValue(initialValue);
if (name) this.name = name;
this.options = Array.from(this.$el.querySelectorAll('[role=option]:not([aria-disabled])'));
this.disabledOptions = Array.from(this.$el.querySelectorAll('[role=option][aria-disabled=true]'));
if (this.options.length === 0) return;
if (initialValue) {
const option = this.options.find(opt => opt.getAttribute('data-value') === initialValue);
this.selectOption(option);
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
} else {
this.selectOption(this.options[0]);
}
});
},
$trigger: {
'@click'() { this.handleClick() },
'@keydown.down.prevent'() { this.open && this.nextOption() },
':aria-expanded'() { return this.open },
':aria-pressed'() { return this.open }
focusOption() {
if (this.options.length === 0) return;
if (this.focusedIndex >= this.options.length) {
this.focusedIndex = this.options.length - 1;
} else if (this.focusedIndex < 0 || this.focusedIndex === null) {
this.focusedIndex = 0;
}
this.options.forEach(opt => opt.removeAttribute('data-focus'));
this.options[this.focusedIndex].setAttribute('data-focus', '');
},
$listbox: {
'@click'(e) { this.handleOptionClick(e) },
'@keydown.escape.window'() { this.handleEscape() },
'@keydown.down.prevent'() { this.nextOption() },
'@keydown.up.prevent'() { this.prevOption() },
focusOnSelectedOption() {
if (this.options.length === 0 || this.selectedValue === null) return;
const option = this.options.find(opt => opt.getAttribute('data-value') === this.selectedValue);
if (!option) return;
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
},
moveOptionFocus(delta) {
if (this.options.length === 0) return;
if (!this.open) {
this.open = true;
this.focusOnSelectedOption();
} else {
this.focusedIndex = this.focusedIndex === null
? 0
: this.focusedIndex + delta;
}
this.focusOption();
},
handleOptionClick(event) {
const option = event.target.closest('[role=option]');
if (option && option.getAttribute('aria-disabled') !== 'true') {
this.selectOption(option);
this.open = false;
this.$nextTick(() => this.$refs.trigger.focus());
}
},
handleOptionMousemove(event) {
console.log("here");
const option = event.target.closest('[role=option]');
if (option && option.getAttribute('aria-disabled') !== 'true') {
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
}
},
handleOptionEnter(event) {
this.selectOption(this.options[this.focusedIndex]);
this.open = !this.open;
},
selectOption(option) {
if (this.options.length === 0 || !option || option.disabled || !option.getAttribute('data-value')
) return;
this.options.forEach(opt => {
opt.setAttribute('aria-selected', opt === option);
});
this.selectedLabel = option.innerHTML;
this.selectedValue = option.getAttribute('data-value');
this.$dispatch('select:change', {
value: this.selectedValue,
label: this.selectedLabel
});
},
filterOptions(query) {
if (query.length > 0) {
this.disabledOptions.forEach(opt => { opt.setAttribute('aria-hidden', 'true'); });
} else {
this.disabledOptions.forEach(opt => { opt.removeAttribute('aria-hidden'); });
}
this.options.forEach(opt => {
opt.removeAttribute('aria-hidden');
if (opt.getAttribute('data-value') != null && !opt.innerHTML.toLowerCase().includes(query.toLowerCase())) {
opt.setAttribute('aria-hidden', 'true');
}
});
},
$trigger: {
'@click'() { this.open = !this.open; this.focusOnSelectedOption(); },
'@keydown.escape.window'() {
this.open = false;
this.$refs.trigger.focus();
},
'@keydown.down.prevent'() { this.moveOptionFocus(+1); },
'@keydown.up.prevent'() { this.moveOptionFocus(-1); },
'@keydown.home.prevent'() { this.focusOption(0) },
'@keydown.end.prevent'() { this.focusOption(this.options.length - 1) },
'@keydown.enter.prevent'() { this.handleOptionEnter() },
':aria-expanded'() { return this.open },
'x-ref': 'trigger'
},
$content: {
'@click'(e) { this.handleOptionClick(e) },
'@mousemove'(e) { this.handleOptionMousemove(e) },
':aria-hidden'() { return !this.open },
'x-cloak': ''
},
selectOption(option) {
if (option && !option.disabled) {
this.options.forEach(opt => {
opt.setAttribute('aria-selected', opt === option);
});
this.selectedLabel = option.innerHTML;
this.selectedValue = option.getAttribute('data-value');
option.blur();
this.open = false;
} else if (this.options.length > 0) {
this.selectOption(this.options[0]);
}
},
selectOptionByValue(value) {
const option = this.options.find(opt => opt.getAttribute('data-value') === value);
this.selectOption(option);
},
focusOption(index) {
if (index >= 0 && index < this.options.length) {
this.focusedIndex = index;
this.options[index].focus();
}
},
nextOption() {
this.focusOption((this.focusedIndex + 1) % this.options.length);
},
prevOption() {
this.focusOption(this.focusedIndex <= 0 ? this.options.length - 1 : this.focusedIndex - 1);
},
handleClick() {
this.open = !this.open;
this.$nextTick(() => {
if(this.open && this.options.length) this.focusOption(0);
});
},
handleEscape() {
if (this.open) this.open = false;
},
handleOptionClick(event) {
const option = event.target.closest('[role=option]');
this.selectOption(option);
},
$filter: {
'@input'(e) { this.filterOptions(e.target.value) },
'@keydown.down.prevent'() { this.moveOptionFocus(+1); },
'@keydown.up.prevent'() { this.moveOptionFocus(-1); },
'@keydown.home.prevent'() { this.focusOption(0) },
'@keydown.end.prevent'() { this.focusOption(this.options.length - 1) },
'@keydown.enter.prevent'() { this.handleOptionEnter() },
}
}));
});
</script>
{% endif %}
<div
class="dropdown {% if main_attrs %}{{ main_attrs.pop('class', '') }}{% endif %}"
x-data="select('{{ value if value else '' }}')"
class="popover {{ main_attrs.class }}"
x-data="select('{{ name }}', '{{ selected }}')"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
{% for key, value in main_attrs %}
{{ key }}="{{ value }}"
{% for key, value in trigger_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
type="button"
aria-haspopup="menu"
aria-haspopup="listbox"
aria-expanded="false"
x-bind="$trigger"
class="btn-outline justify-between font-normal"
{% if id %}
id="{{ id }}-trigger"
aria-controls="{{ id }}-menu"
aria-controls="{{ id }}-content"
{% endif %}
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span x-html="selectedLabel" class="flex items-center gap-x-2"></span>
<span class="text-muted-foreground opacity-50">{% lucide "chevron-down" %}</span>
<div x-html="selectedLabel" class="flex items-center gap-x-2"
></div>
{% lucide "chevron-down", class="text-muted-foreground opacity-50 shrink-0" %}
</button>
<div
class="min-w-full w-fit"
role="listbox"
aria-orientation="vertical"
data-popover
aria-hidden="true"
x-bind="$listbox"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in menu_attrs %}
x-bind="$content"
{% if id %}id="{{ id }}-content"{% endif %}
{% for key, value in content_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
{% if is_combobox %}
<header>
{% lucide "search" %}
<input
type="text"
value=""
placeholder="Search framework..."
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-content"
aria-labelledby="{{ id }}-trigger"
x-model="query"
x-bind="$filter"
>
</header>
{% endif %}
<div
role="listbox"
aria-orientation="vertical"
{% for key, value in listbox_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
</div>
</div>
{% if input_name %}
<input type="hidden" name="{{ input_name }}" x-model="selectedValue">
{% if name %}
<input type="hidden" name="{{ name }}" x-model="selectedValue">
{% endif %}
</div>
{% endmacro %}
@@ -1,15 +1,14 @@
{% macro dropdown(
id=None,
trigger=None,
{% macro tabs(
tabs,
main_attrs=None,
trigger_attrs=None,
menu_attrs=None
content_attrs=None
)
%}
{% if registerComponent('dropdown') %}
{% if registerComponent('toast') %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
Alpine.data('toast', () => ({
open: false,
focusedIndex: -1,
menuItems: [],
@@ -78,8 +77,8 @@
{% endif %}
<div
class="dropdown {% if main_attrs %}{{ main_attrs.pop('class', '') }}{% endif %}"
x-data="dropdown"
class="toast {% if main_attrs %}{{ main_attrs.pop('class', '') }}{% endif %}"
x-data="toast"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
{% for key, value in main_attrs %}
+76 -42
View File
@@ -1,15 +1,12 @@
{% macro dropdown(
id=None,
trigger=None,
main_attrs=None,
trigger_attrs=None,
menu_attrs=None
)
%}
{% if registerComponent('dropdown') %}
{% macro toast(
id='toasts',
messages=None,
main_attrs=None
) %}
{% if registerComponent('toast') %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
Alpine.data('toast', () => ({
open: false,
focusedIndex: -1,
menuItems: [],
@@ -78,44 +75,81 @@
{% endif %}
<div
class="dropdown {% if main_attrs %}{{ main_attrs.pop('class', '') }}{% endif %}"
x-data="dropdown"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
id="{{ id }}"
class="toasts"
x-data="toast"
{% for key, value in main_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if trigger %}
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
aria-pressed="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-controls="{{ id }}-menu"
{% endif %}
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<nav
role="menu"
aria-orientation="vertical"
{% for category, message in toasts %}
<div
x-data="{
active: true,
init() {
if ('{{ category }}' !== 'error') setTimeout(() => this.active = false, 3000)
}
}"
role="{{ 'alert' if category == 'error' else 'status' }}"
aria-atomic="true"
aria-hidden="true"
x-bind="$menu"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in menu_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
:aria-hidden="!active"
:inert="!active"
>
{{ caller() }}
</nav>
<button
x-on:click="active = !active"
aria-label="{{ _('Dismiss') }}"
>
{% include "icons/x.svg" %}
</button>
<section class="w-full text-left flex-1">
{% if message is string %}
<p class="font-medium w-full text-left flex-1">{{ message }}</p>
{% else %}
<h4 class="font-medium mb-1">{{ message.title }}</h4>
<p class="text-muted-foreground">{{ message.description }}</p>
{% if message.action %}
<a
href="{{ message.action.url }}"
class="btn-outline h-8 px-2.5 mt-4"
>{{ message.action.label }}</a>
{% endif %}
{% endif %}
</section>
{% if category in ('error', 'success') %}
{% if category == 'error' %}
<div class="text-danger py-0.5">
{% include "icons/circle-x.svg" %}
</div>
{% elif category == 'success' %}
<div class="text-success py-0.5">
{% include "icons/circle-check.svg" %}
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro toasts(
id=None,
messages=None,
flash=True
) %}
{% if flash %}
{% set toasts = (messages or []) + get_flashed_messages(with_categories=true) %}
{% else %}
{% set toasts = messages or [] %}
{% endif %}
<div
class="toasts"
id="{{ id or 'toasts' }}"
{}
>
</div>
{% endmacro %}