mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-13 13:48:28 +00:00
Getting ready to clean up the package and release 0.2.0
This commit is contained in:
+44
-51
@@ -504,7 +504,9 @@
|
||||
/* Dropdown Menu */
|
||||
@layer components {
|
||||
.dropdown-menu {
|
||||
[popover].popover {
|
||||
@apply relative inline-flex p-1;
|
||||
|
||||
[data-popover] {
|
||||
@apply p-1;
|
||||
min-width: anchor-size(width);
|
||||
|
||||
@@ -517,7 +519,7 @@
|
||||
@apply bg-accent text-accent-foreground;
|
||||
}
|
||||
}
|
||||
[role='menu'] [role='heading'] {
|
||||
[role='menu'] [role="presentation"] {
|
||||
@apply flex px-2 py-1.5 text-sm font-medium;
|
||||
}
|
||||
[role='separator'] {
|
||||
@@ -576,85 +578,74 @@
|
||||
|
||||
/* Popover */
|
||||
@layer components {
|
||||
[popover].popover {
|
||||
@apply absolute inset-auto bg-popover text-popover-foreground overflow-x-hidden overflow-y-auto p-4 rounded-md border shadow-md z-50 visible opacity-0 scale-95 w-max transition-all transition-discrete;
|
||||
[data-popover] {
|
||||
@apply absolute bg-popover text-popover-foreground overflow-x-hidden overflow-y-auto rounded-md border shadow-md z-50 visible opacity-100 scale-100 min-w-full w-max transition-all;
|
||||
|
||||
&:not([data-side]),
|
||||
&[data-side='bottom'] {
|
||||
@apply -translate-y-2;
|
||||
}
|
||||
&[data-side='top'] {
|
||||
@apply translate-y-2;
|
||||
}
|
||||
&[data-side='left'] {
|
||||
@apply translate-x-2;
|
||||
}
|
||||
&[data-side='right'] {
|
||||
@apply -translate-x-2;
|
||||
&[aria-hidden='true'] {
|
||||
@apply invisible opacity-0 scale-95;
|
||||
|
||||
&:not([data-side]),
|
||||
&[data-side='bottom'] {
|
||||
@apply -translate-y-2;
|
||||
}
|
||||
&[data-side='top'] {
|
||||
@apply translate-y-2;
|
||||
}
|
||||
&[data-side='left'] {
|
||||
@apply translate-x-2;
|
||||
}
|
||||
&[data-side='right'] {
|
||||
@apply -translate-x-2;
|
||||
}
|
||||
}
|
||||
&:not([data-side]),
|
||||
&[data-side='bottom'] {
|
||||
@apply mt-1 top-[anchor(bottom)];
|
||||
@apply mt-1 top-full;
|
||||
}
|
||||
&[data-side='top'] {
|
||||
@apply mb-1 bottom-[anchor(top)];
|
||||
@apply mb-1 bottom-full;
|
||||
}
|
||||
&[data-side='left'] {
|
||||
@apply mr-1 right-[anchor(left)];
|
||||
@apply mr-1 right-full;
|
||||
}
|
||||
&[data-side='right'] {
|
||||
@apply ml-1 left-[anchor(right)];
|
||||
@apply ml-1 left-full;
|
||||
}
|
||||
&:not([data-side]),
|
||||
&[data-side='bottom'],
|
||||
&[data-side='top'] {
|
||||
&:not([data-align]),
|
||||
&[data-align='start'] {
|
||||
@apply left-[anchor(left)];
|
||||
@apply left-0;
|
||||
}
|
||||
&[data-align='end'] {
|
||||
@apply right-[anchor(right)];
|
||||
@apply right-0;
|
||||
}
|
||||
&[data-align='center'] {
|
||||
@apply left-[anchor(center)] -translate-x-1/2;
|
||||
@apply left-1/2 -translate-x-1/2;
|
||||
}
|
||||
}
|
||||
&[data-side='left'],
|
||||
&[data-side='right'] {
|
||||
&:not([data-align]),
|
||||
&[data-align='start'] {
|
||||
@apply top-[anchor(top)];
|
||||
@apply top-0;
|
||||
}
|
||||
&[data-align='end'] {
|
||||
@apply bottom-[anchor(bottom)];
|
||||
@apply bottom-0;
|
||||
}
|
||||
&[data-align='center'] {
|
||||
@apply top-[anchor(center)] -translate-y-1/2;
|
||||
@apply top-1/2 -translate-y-1/2;
|
||||
}
|
||||
}
|
||||
&:is(:popover-open) {
|
||||
@apply opacity-100 scale-100 translate-x-0 translate-y-0;
|
||||
|
||||
@starting-style {
|
||||
@apply opacity-0 scale-95;
|
||||
|
||||
&:not([data-side]),
|
||||
&[data-side='bottom'] {
|
||||
@apply -translate-y-2;
|
||||
}
|
||||
&[data-side='top'] {
|
||||
@apply translate-y-2;
|
||||
}
|
||||
&[data-side='left'] {
|
||||
@apply translate-x-2;
|
||||
}
|
||||
&[data-side='right'] {
|
||||
@apply -translate-x-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.popover {
|
||||
@apply relative inline-flex;
|
||||
|
||||
[data-popover] {
|
||||
@apply p-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Radio */
|
||||
@@ -731,8 +722,10 @@
|
||||
}
|
||||
}
|
||||
*:not(select).select {
|
||||
[popover].popover {
|
||||
@apply p-1 min-w-[anchor-size(width)];
|
||||
@apply relative inline-flex;
|
||||
|
||||
[data-popover] {
|
||||
@apply p-1;
|
||||
|
||||
[role='option'] {
|
||||
@apply aria-hidden:hidden [&_svg]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm pl-2 py-1.5 pr-7.5 text-sm outline-hidden select-none [&_svg]:shrink-0 [&_svg]:size-4 aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate;
|
||||
@@ -741,7 +734,7 @@
|
||||
@apply bg-[image:var(--check-icon)] bg-no-repeat bg-position-[center_right_0.5rem] bg-size-[0.875rem];
|
||||
}
|
||||
}
|
||||
[role='listbox'] [role='heading'] {
|
||||
[role='listbox'] [role="presentation"] {
|
||||
@apply flex text-muted-foreground px-2 py-1.5 text-xs;
|
||||
}
|
||||
[role='listbox'] [role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
|
||||
|
||||
+61
-50
@@ -4,53 +4,66 @@
|
||||
@param id {string} [optional] - Unique identifier for the select component.
|
||||
@param selected {string} [optional] - The initially selected value.
|
||||
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
|
||||
@param items {array} [optional] - An array of items (objects with type, label, value, attrs, and items) to render.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
|
||||
@param content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
|
||||
@param input_attrs {object} [optional] - Additional HTML attributes for the hidden input.
|
||||
@param search_placeholder {string} [optional] [default="Search entries..."] - Placeholder text for the search input (combobox only).
|
||||
@param is_combobox {boolean} [optional] [default=False] - Renders a combobox with search functionality if True.
|
||||
@param is_combobox {boolean} [optional] [default=false] - Renders a combobox with search functionality if true.
|
||||
#}
|
||||
{% macro select(
|
||||
id=None,
|
||||
selected=None,
|
||||
name=None,
|
||||
items=None,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
content_attrs={},
|
||||
popover_attrs={},
|
||||
listbox_attrs={},
|
||||
input_attrs={},
|
||||
search_placeholder="Search entries...",
|
||||
is_combobox=False
|
||||
is_combobox=false
|
||||
) %}
|
||||
<div
|
||||
class="popover {{ main_attrs.class }}"
|
||||
x-data="select('{{ name }}', '{{ selected or '' }}')"
|
||||
@click.away="open = false"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
{% for key, value in main_attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
|
||||
|
||||
{% set first_option = [] %}
|
||||
{% set selected_option = [] %}
|
||||
|
||||
{% for item in items.items() %}
|
||||
{% if item.type == "group" %}
|
||||
{% for sub_item in item.items.items() %}
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(sub_item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and sub_item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(sub_item), selected_option) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(item), selected_option) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set default_option = selected_option[0] or first_option[0] or None %}
|
||||
|
||||
<div class="select">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
|
||||
id="{{ id }}-trigger"
|
||||
popovertarget="{{ id }}"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
x-bind="$trigger"
|
||||
{% if id %}
|
||||
id="{{ id }}-trigger"
|
||||
aria-controls="{{ id }}-content"
|
||||
{% endif %}
|
||||
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
|
||||
{% for key, value in trigger_attrs.items() %}
|
||||
aria-controls="{{ id }}-listbox"
|
||||
{% for key, value in trigger_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<div x-html="selectedLabel" class="flex items-center gap-x-2"
|
||||
></div>
|
||||
<span class="truncate">{{ default_option.label }}</span>
|
||||
{% if is_combobox %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
{% else %}
|
||||
@@ -58,12 +71,11 @@
|
||||
{% endif %}
|
||||
</button>
|
||||
<div
|
||||
data-popover
|
||||
aria-hidden="true"
|
||||
x-bind="$content"
|
||||
{% if id %}id="{{ id }}-content"{% endif %}
|
||||
{% for key, value in content_attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
popover
|
||||
id="{{ id }}"
|
||||
class="popover {{ popover_attrs.class }}"
|
||||
{% for key, value in popover_attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{% if is_combobox %}
|
||||
@@ -81,35 +93,33 @@
|
||||
aria-expanded="true"
|
||||
aria-controls="{{ id }}-content"
|
||||
aria-labelledby="{{ id }}-trigger"
|
||||
x-model="query"
|
||||
x-bind="$filter"
|
||||
>
|
||||
</header>
|
||||
{% endif %}
|
||||
<div
|
||||
role="listbox"
|
||||
id="{{ id }}-listbox"
|
||||
aria-orientation="vertical"
|
||||
{% for key, value in listbox_attrs.items() %}
|
||||
aria-labelledby="{{ id }}-trigger"
|
||||
{% for key, value in listbox_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items %}
|
||||
{{ render_select_items(items, id ~ "-items" if id else "items") }}
|
||||
{% if items.length > 0 %}
|
||||
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if name is defined %}
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ name }}"
|
||||
x-model="selectedValue"
|
||||
{% for key, value in input_attrs.items() %}
|
||||
{% if key != 'name' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{% endif %}
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ name or id ~ '-value' }}"
|
||||
value="{{ selected or '' }}"
|
||||
{% for key, value in input_attrs.items() %}
|
||||
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -119,29 +129,30 @@
|
||||
@param items {array} - The array of items to render.
|
||||
@param parent_id_prefix {string} [optional] - The prefix for the item id.
|
||||
#}
|
||||
{% macro render_select_items(items, parent_id_prefix="items") %}
|
||||
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
|
||||
{% for item in items.items() %}
|
||||
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
|
||||
|
||||
{% if item.type == "group" %}
|
||||
{% set group_label_id = item.id if item.id else "group-label-" + item_id %}
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="{{ group_label_id }}"
|
||||
{% for key, value in item.attrs.items() %}
|
||||
{% for key, value in item.attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
{{ render_select_items(item.items, item_id) if item.items }}
|
||||
{{ render_select_items(item.items, selected, item_id) if item.items }}
|
||||
</div>
|
||||
{% elif item.type == "separator" %}
|
||||
<hr role="separator" />
|
||||
{% elif item.type == "item" or not item.type %}
|
||||
<div
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
{% for key, value in item.attrs.items() %}
|
||||
{% if selected == item.value %}aria-selected="true"{% endif %}
|
||||
{% for key, value in item.attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
|
||||
+60
-45
@@ -1,13 +1,35 @@
|
||||
(() => {
|
||||
const initDropdownMenu = (dropdownMenuComponent) => {
|
||||
const trigger = dropdownMenuComponent.querySelector('[popovertarget]');
|
||||
const popover = dropdownMenuComponent.querySelector('[popover]');
|
||||
const menu = popover?.querySelector('[role="menu"]');
|
||||
if (!trigger || !popover || !menu) return;
|
||||
const trigger = dropdownMenuComponent.querySelector(':scope > button');
|
||||
const popover = dropdownMenuComponent.querySelector(':scope > [data-popover]');
|
||||
const menu = popover.querySelector('[role="menu"]');
|
||||
|
||||
if (!trigger || !menu || !popover) {
|
||||
console.error('Dropdown menu component is missing a trigger, a menu, or a popover content element.', dropdownMenuComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
let menuItems = [];
|
||||
let activeIndex = -1;
|
||||
|
||||
const closeMenu = () => {
|
||||
if (trigger.getAttribute('aria-expanded') === 'false') return;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.removeAttribute('aria-activedescendant');
|
||||
popover.setAttribute('aria-hidden', 'true');
|
||||
trigger.focus();
|
||||
activeIndex = -1;
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
popover.setAttribute('aria-hidden', 'false');
|
||||
menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]:not([disabled])'));
|
||||
if (menuItems.length > 0) {
|
||||
setActiveItem(0);
|
||||
}
|
||||
};
|
||||
|
||||
const setActiveItem = (index) => {
|
||||
if (activeIndex > -1 && menuItems[activeIndex]) {
|
||||
menuItems[activeIndex].classList.remove('active');
|
||||
@@ -17,36 +39,48 @@
|
||||
const activeItem = menuItems[activeIndex];
|
||||
activeItem.classList.add('active');
|
||||
trigger.setAttribute('aria-activedescendant', activeItem.id);
|
||||
activeItem.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
trigger.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!popover.matches(':popover-open')) {
|
||||
trigger.addEventListener('click', () => {
|
||||
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
|
||||
if (isExpanded) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
dropdownMenuComponent.addEventListener('keydown', (e) => {
|
||||
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (isExpanded) closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
trigger.click();
|
||||
openMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let nextIndex = activeIndex;
|
||||
if (menuItems.length === 0) return;
|
||||
|
||||
let nextIndex = activeIndex;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (activeIndex < menuItems.length - 1) {
|
||||
nextIndex = activeIndex + 1;
|
||||
}
|
||||
nextIndex = activeIndex < menuItems.length - 1 ? activeIndex + 1 : 0;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (activeIndex > 0) {
|
||||
nextIndex = activeIndex - 1;
|
||||
}
|
||||
nextIndex = activeIndex > 0 ? activeIndex - 1 : menuItems.length - 1;
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
@@ -60,42 +94,24 @@
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
menuItems[activeIndex]?.click();
|
||||
break;
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextIndex !== activeIndex) {
|
||||
setActiveItem(nextIndex);
|
||||
}
|
||||
};
|
||||
|
||||
trigger.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
popover.addEventListener('toggle', (e) => {
|
||||
trigger.setAttribute('aria-expanded', e.newState === 'open');
|
||||
if (e.newState === 'open') {
|
||||
menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]:not([disabled])'));
|
||||
menuItems.forEach((item, index) => {
|
||||
if (!item.id) item.id = `${menu.id}-item-${index}`;
|
||||
});
|
||||
setActiveItem(0);
|
||||
} else {
|
||||
setActiveItem(-1);
|
||||
}
|
||||
});
|
||||
|
||||
menu.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[role^="menuitem"]')) {
|
||||
popover.hidePopover();
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
menu.addEventListener('mouseover', (e) => {
|
||||
const item = e.target.closest('[role^="menuitem"]:not([disabled])');
|
||||
if (item) {
|
||||
const index = menuItems.indexOf(item);
|
||||
if (index > -1 && index !== activeIndex) {
|
||||
setActiveItem(index);
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!dropdownMenuComponent.contains(e.target)) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -107,15 +123,14 @@
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.matches('.dropdown-menu:not([data-dropdown-menu-initialized])')) {
|
||||
initDropdownMenu(node);
|
||||
}
|
||||
node.querySelectorAll('.dropdown-menu:not([data-dropdown-menu-initialized])').forEach(initDropdownMenu);
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (node.matches('.dropdown-menu:not([data-dropdown-menu-initialized])')) {
|
||||
initDropdownMenu(node);
|
||||
}
|
||||
node.querySelectorAll('.dropdown-menu:not([data-dropdown-menu-initialized])').forEach(initDropdownMenu);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
@@ -0,0 +1,69 @@
|
||||
(() => {
|
||||
const initPopover = (popoverComponent) => {
|
||||
const trigger = popoverComponent.querySelector(':scope > button');
|
||||
const content = popoverComponent.querySelector(':scope > [data-popover]');
|
||||
|
||||
if (!trigger || !content) {
|
||||
console.error('Popover component is missing a trigger button or a content element.', popoverComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
const closePopover = () => {
|
||||
if (trigger.getAttribute('aria-expanded') === 'false') return;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
content.setAttribute('aria-hidden', 'true');
|
||||
trigger.focus();
|
||||
};
|
||||
|
||||
const openPopover = () => {
|
||||
const elementToFocus = content.querySelector('[autofocus]');
|
||||
if (elementToFocus) {
|
||||
content.addEventListener('transitionend', () => {
|
||||
elementToFocus.focus();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
content.setAttribute('aria-hidden', 'false');
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', () => {
|
||||
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
|
||||
if (isExpanded) {
|
||||
closePopover();
|
||||
} else {
|
||||
openPopover();
|
||||
}
|
||||
});
|
||||
|
||||
popoverComponent.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!popoverComponent.contains(e.target)) {
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
popoverComponent.dataset.popoverInitialized = true;
|
||||
};
|
||||
|
||||
document.querySelectorAll('.popover:not([data-popover-initialized])').forEach(initPopover);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (node.matches('.popover:not([data-popover-initialized])')) {
|
||||
initPopover(node);
|
||||
}
|
||||
node.querySelectorAll('.popover:not([data-popover-initialized])').forEach(initPopover);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
+59
-49
@@ -1,8 +1,8 @@
|
||||
(() => {
|
||||
const initSelect = (selectComponent) => {
|
||||
const trigger = selectComponent.querySelector(':scope > [popovertarget]');
|
||||
const trigger = selectComponent.querySelector(':scope > button');
|
||||
const selectedValue = trigger.querySelector(':scope > span');
|
||||
const popover = selectComponent.querySelector(':scope > [popover]');
|
||||
const popover = selectComponent.querySelector(':scope > [data-popover]');
|
||||
const listbox = popover.querySelector('[role="listbox"]');
|
||||
const input = selectComponent.querySelector(':scope > input[type="hidden"]');
|
||||
const filter = selectComponent.querySelector('header input[type="text"]');
|
||||
@@ -21,15 +21,26 @@
|
||||
}
|
||||
};
|
||||
|
||||
const closePopover = () => {
|
||||
popover.setAttribute('aria-hidden', 'true');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
if (filter) {
|
||||
filter.value = '';
|
||||
visibleOptions = [...options];
|
||||
options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
|
||||
}
|
||||
trigger.removeAttribute('aria-activedescendant');
|
||||
if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
const selectOption = (option) => {
|
||||
if (!option) return;
|
||||
|
||||
updateValue(option);
|
||||
|
||||
trigger.removeAttribute('aria-activedescendant');
|
||||
options.forEach(opt => opt.classList.remove('active'));
|
||||
activeIndex = -1;
|
||||
popover.hidePopover();
|
||||
if (option.dataset.value) {
|
||||
updateValue(option);
|
||||
}
|
||||
closePopover();
|
||||
};
|
||||
|
||||
if (filter) {
|
||||
@@ -62,12 +73,14 @@
|
||||
updateValue(initialOption);
|
||||
|
||||
const handleKeyNavigation = (e) => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(e.key)) {
|
||||
const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';
|
||||
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape'].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!popover.matches(':popover-open')) {
|
||||
if (e.currentTarget === trigger && e.key !== 'Enter') {
|
||||
if (!isPopoverOpen) {
|
||||
if (e.key !== 'Enter' && e.key !== 'Escape') {
|
||||
e.preventDefault();
|
||||
trigger.click();
|
||||
}
|
||||
@@ -76,9 +89,14 @@
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
closePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
if (activeIndex > -1) {
|
||||
selectOption(options[activeIndex]);
|
||||
selectOption(visibleOptions[activeIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -130,6 +148,31 @@
|
||||
filter.addEventListener('keydown', handleKeyNavigation);
|
||||
}
|
||||
|
||||
trigger.addEventListener('click', () => {
|
||||
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
if (isExpanded) {
|
||||
closePopover();
|
||||
} else {
|
||||
popover.setAttribute('aria-hidden', 'false');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
if (filter) filter.focus();
|
||||
|
||||
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
|
||||
if (selectedOption) {
|
||||
if (activeIndex > -1) {
|
||||
options[activeIndex]?.classList.remove('active');
|
||||
}
|
||||
activeIndex = options.indexOf(selectedOption);
|
||||
selectedOption.classList.add('active');
|
||||
if (selectedOption.id) {
|
||||
trigger.setAttribute('aria-activedescendant', selectedOption.id);
|
||||
}
|
||||
selectedOption.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
listbox.addEventListener('click', (e) => {
|
||||
const clickedOption = e.target.closest('[role="option"]');
|
||||
if (clickedOption) {
|
||||
@@ -137,46 +180,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
popover.addEventListener('toggle', (e) => {
|
||||
trigger.setAttribute('aria-expanded', e.newState === 'open');
|
||||
|
||||
if (e.newState === 'open') {
|
||||
if (filter) filter.focus();
|
||||
|
||||
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
|
||||
let startingOption = null;
|
||||
|
||||
if (selectedOption && visibleOptions.includes(selectedOption)) {
|
||||
startingOption = selectedOption;
|
||||
} else if (visibleOptions.length > 0) {
|
||||
startingOption = visibleOptions[0];
|
||||
}
|
||||
|
||||
if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
|
||||
|
||||
if (startingOption) {
|
||||
activeIndex = options.indexOf(startingOption);
|
||||
startingOption.classList.add('active');
|
||||
if (startingOption.id) {
|
||||
trigger.setAttribute('aria-activedescendant', startingOption.id);
|
||||
}
|
||||
startingOption.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
activeIndex = -1;
|
||||
}
|
||||
} else if (e.newState === 'closed') {
|
||||
if (filter) {
|
||||
filter.value = '';
|
||||
visibleOptions = [...options];
|
||||
options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
|
||||
}
|
||||
|
||||
trigger.removeAttribute('aria-activedescendant');
|
||||
if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
|
||||
activeIndex = -1;
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!selectComponent.contains(e.target)) {
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
popover.setAttribute('aria-hidden', 'true');
|
||||
selectComponent.dataset.selectInitialized = true;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
@param footer {string} [optional] - HTML content for the dialog footer.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
|
||||
@param content_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
|
||||
@param content_header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
|
||||
@param content_body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
|
||||
@param content_footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
|
||||
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
|
||||
@param header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
|
||||
@param body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
|
||||
@param footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
|
||||
@param open {boolean} [optional] [default=false] - Whether the dialog should be open initially.
|
||||
@param close_button {boolean} [optional] [default=true] - Whether to include a close button.
|
||||
@param close_on_overlay_click {boolean} [optional] [default=true] - Whether clicking the overlay closes the dialog.
|
||||
|
||||
@@ -6,23 +6,28 @@
|
||||
@param menu {array} [optional] - Array of menu items for the dropdown.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
|
||||
@param content_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
|
||||
@param popover_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
|
||||
#}
|
||||
{% macro dropdown_menu(
|
||||
id=None,
|
||||
trigger,
|
||||
items=None,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
menu_attrs={}
|
||||
) %}
|
||||
{% set id = id or ("dropdown-menu-" + (range(100000, 999999) | random | string)) %}
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<div
|
||||
class="dropdown-menu {{ main_attrs.class }}"
|
||||
{% for key, value in main_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="{{ id }}-trigger"
|
||||
popovertarget="{{ id }}"
|
||||
aria-haspopup="menu"
|
||||
aria-controls="{{ id }}-menu"
|
||||
aria-expanded="false"
|
||||
@@ -33,14 +38,14 @@
|
||||
{{ trigger | safe }}
|
||||
</button>
|
||||
<div
|
||||
popover
|
||||
class="popover p-1 {% if popover_attrs.class %} {{ popover_attrs.class }}{% endif %}"
|
||||
id="{{ id }}"
|
||||
data-popover
|
||||
aria-hidden="true"
|
||||
{% for key, value in popover_attrs %}
|
||||
{% if key != "class" %}{{ key }}="{{ value }}"{% endif %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
<nav
|
||||
<div
|
||||
role="menu"
|
||||
id="{{ id }}-menu"
|
||||
aria-labelledby="{{ id }}-trigger"
|
||||
@@ -53,7 +58,7 @@
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -77,7 +82,7 @@
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
<div role="presentation" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
{{ render_dropdown_items(item.items, item_id) if item.items }}
|
||||
</div>
|
||||
{% elif item.type == "separator" %}
|
||||
@@ -85,6 +90,7 @@
|
||||
{% elif item.type == "item" or not item.type %}
|
||||
{% if item.url %}
|
||||
<a
|
||||
id="{{ item_id }}"
|
||||
role="menuitem"
|
||||
href="{{ item.url }}"
|
||||
{% for key, value in item.attrs %}
|
||||
@@ -94,15 +100,15 @@
|
||||
{{ item.label | safe }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
<div
|
||||
id="{{ item_id }}"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
{% for key, value in item.attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{{ item.label | safe }}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
+27
-19
@@ -5,35 +5,43 @@
|
||||
@param trigger {string} [optional] - HTML content for the element that triggers the popover.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger element.
|
||||
@param content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
#}
|
||||
{% macro popover(
|
||||
id=None,
|
||||
trigger,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={}
|
||||
) %}
|
||||
{% set id = id or ("popover-" + (range(100000, 999999) | random | string)) %}
|
||||
<button
|
||||
type="button"
|
||||
popovertarget="{{ id }}"
|
||||
id="{{ id }}-trigger"
|
||||
onclick="this.setAttribute('aria-expanded', this.getAttribute('aria-expanded') === 'false' ? 'true' : 'false')"
|
||||
aria-expanded="false"
|
||||
{% for key, value in trigger_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{{ trigger | safe }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
popover
|
||||
id="{{ id }}"
|
||||
class="popover {{ popover_attrs.class }}"
|
||||
{% for key, value in popover_attrs %}
|
||||
class="popover {{ main_attrs.class }}"
|
||||
{% for key, value in main_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{{ caller() if caller }}
|
||||
>
|
||||
<button
|
||||
id="{{ id }}-trigger"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="{{ id }}"
|
||||
{% for key, value in trigger_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{{ trigger | safe }}
|
||||
</button>
|
||||
<div
|
||||
id="{{ id }}"
|
||||
data-popover
|
||||
aria-hidden="true"
|
||||
{% for key, value in popover_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{{ caller() if caller }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
+34
-26
@@ -6,7 +6,7 @@
|
||||
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
|
||||
@param content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
|
||||
@param input_attrs {object} [optional] - Additional HTML attributes for the hidden input.
|
||||
@param search_placeholder {string} [optional] [default="Search entries..."] - Placeholder text for the search input (combobox only).
|
||||
@@ -17,9 +17,11 @@
|
||||
selected=None,
|
||||
name=None,
|
||||
items=None,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
listbox_attrs={},
|
||||
input_attrs={},
|
||||
search_placeholder="Search entries...",
|
||||
is_combobox=false
|
||||
) %}
|
||||
@@ -28,34 +30,40 @@
|
||||
{% set first_option = [] %}
|
||||
{% set selected_option = [] %}
|
||||
|
||||
{% for item in items %}
|
||||
{% if item.type == "group" %}
|
||||
{% for sub_item in item.items %}
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
{% if item.type == "group" %}
|
||||
{% for sub_item in item.items %}
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(sub_item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and sub_item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(sub_item), selected_option) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(sub_item), first_option) %}
|
||||
{% set first_option = (first_option.push(item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and sub_item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(sub_item), selected_option) %}
|
||||
{% if selected and item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(item), selected_option) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(item), selected_option) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% set default_option = selected_option[0] or first_option[0] or None %}
|
||||
|
||||
<div class="select">
|
||||
<div
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% for key, value in main_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
|
||||
id="{{ id }}-trigger"
|
||||
popovertarget="{{ id }}"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
aria-controls="{{ id }}-listbox"
|
||||
@@ -71,11 +79,11 @@
|
||||
{% endif %}
|
||||
</button>
|
||||
<div
|
||||
popover
|
||||
id="{{ id }}"
|
||||
class="popover {{ popover_attrs.class }}"
|
||||
data-popover
|
||||
aria-hidden="true"
|
||||
{% for key, value in popover_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if is_combobox %}
|
||||
@@ -90,8 +98,8 @@
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="{{ id }}-content"
|
||||
aria-expanded="false"
|
||||
aria-controls="{{ id }}-listbox"
|
||||
aria-labelledby="{{ id }}-trigger"
|
||||
>
|
||||
</header>
|
||||
@@ -105,7 +113,7 @@
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items.length > 0 %}
|
||||
{% if items and items.length > 0 %}
|
||||
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
@@ -115,7 +123,7 @@
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ name or id ~ '-value' }}"
|
||||
value="{{ selected or '' }}"
|
||||
value="{{ (default_option.value if default_option) or '' }}"
|
||||
{% for key, value in input_attrs %}
|
||||
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
@@ -141,7 +149,7 @@
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
<div role="presentation" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
{{ render_select_items(item.items, selected, item_id) if item.items }}
|
||||
</div>
|
||||
{% elif item.type == "separator" %}
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
) %}
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="toaster"
|
||||
class="toaster {{ attrs.class }}"
|
||||
{% for key, value in attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{% for item in toasts %}
|
||||
|
||||
Reference in New Issue
Block a user