mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-13 21:58:17 +00:00
Adding empty, field and item, working on input group
This commit is contained in:
+61
-44
@@ -146,10 +146,6 @@
|
||||
border-radius: var(--scrollbar-radius);
|
||||
}
|
||||
}
|
||||
/* Alpine.js */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
@@ -385,11 +381,9 @@
|
||||
|
||||
/* Button Group */
|
||||
.button-group {
|
||||
@apply inline-flex isolate;
|
||||
@apply inline-flex w-fit items-stretch isolate;
|
||||
|
||||
> *:hover,
|
||||
> *:focus-visible,
|
||||
> :is(.dropdown-menu, .popover) > button:hover,
|
||||
> :is(.dropdown-menu, .popover) > button:focus-visible {
|
||||
@apply relative z-10;
|
||||
}
|
||||
@@ -401,7 +395,7 @@
|
||||
&:not([data-orientation='vertical']) {
|
||||
> *:not(:first-child),
|
||||
> :is(.dropdown-menu, .popover):not(:first-child) > button {
|
||||
@apply -ml-px rounded-l-none;
|
||||
@apply rounded-l-none border-l-0;
|
||||
}
|
||||
> *:not(:last-child),
|
||||
> :is(.dropdown-menu, .popover):not(:last-child) > button {
|
||||
@@ -416,7 +410,7 @@
|
||||
}
|
||||
|
||||
> *:not(:first-child) {
|
||||
@apply -mt-px ml-0 rounded-t-none;
|
||||
@apply rounded-t-none border-t-0;
|
||||
}
|
||||
> *:not(:last-child) {
|
||||
@apply rounded-b-none;
|
||||
@@ -451,7 +445,7 @@
|
||||
|
||||
/* Checkbox */
|
||||
@layer components {
|
||||
.form input[type='checkbox']:not([role='switch']),
|
||||
:is(.form, .field) input[type='checkbox']:not([role='switch']),
|
||||
.input[type='checkbox']:not([role='switch']) {
|
||||
@apply appearance-none border-input dark:bg-input/30 checked:bg-primary dark:checked:bg-primary checked:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
|
||||
@@ -494,7 +488,7 @@
|
||||
&::backdrop {
|
||||
@apply opacity-100;
|
||||
}
|
||||
> article {
|
||||
> * {
|
||||
@apply scale-100;
|
||||
}
|
||||
|
||||
@@ -504,7 +498,7 @@
|
||||
&::backdrop {
|
||||
@apply opacity-0;
|
||||
}
|
||||
> article {
|
||||
> * {
|
||||
@apply scale-95;
|
||||
}
|
||||
}
|
||||
@@ -512,7 +506,7 @@
|
||||
&::backdrop {
|
||||
@apply bg-black/50 opacity-0 transition-all transition-discrete;
|
||||
}
|
||||
> article {
|
||||
> * {
|
||||
@apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%_-_2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-lg border shadow-lg sm:max-w-lg max-h-[min(640px,calc(100%_-_2rem))];
|
||||
@apply transition-all scale-95;
|
||||
|
||||
@@ -530,10 +524,10 @@
|
||||
> header input {
|
||||
@apply h-12;
|
||||
}
|
||||
[role='listbox'] {
|
||||
[role='menu'] {
|
||||
@apply px-2;
|
||||
|
||||
[role='option'] {
|
||||
[role='menuitem'] {
|
||||
@apply py-3 px-2 [&_svg]:size-5;
|
||||
}
|
||||
[role='heading'] {
|
||||
@@ -554,11 +548,11 @@
|
||||
@apply placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
}
|
||||
[role='listbox'] {
|
||||
[role='menu'] {
|
||||
@apply max-h-[300px] overflow-y-auto overflow-x-hidden p-1;
|
||||
|
||||
[role='option'] {
|
||||
@apply aria-hidden:hidden relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
|
||||
[role='menuitem'] {
|
||||
@apply aria-hidden:hidden relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
|
||||
|
||||
&.active {
|
||||
@apply bg-accent text-accent-foreground;
|
||||
@@ -567,7 +561,7 @@
|
||||
[role='heading'] {
|
||||
@apply text-muted-foreground flex px-2 py-1.5 text-xs font-medium;
|
||||
}
|
||||
[role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
|
||||
[role='group']:not(:has([role='menuitem']:not([aria-hidden='true']))) {
|
||||
@apply hidden;
|
||||
}
|
||||
[role='separator'] {
|
||||
@@ -583,7 +577,7 @@
|
||||
@apply content-['No_results_found'];
|
||||
}
|
||||
}
|
||||
&:not([data-command-initialized]) [role='option'] {
|
||||
&:not([data-command-initialized]) [role='menuitem'] {
|
||||
@apply hover:bg-accent hover:text-accent-foreground;
|
||||
}
|
||||
&:has(> header input:not(:placeholder-shown)) [role='separator'] {
|
||||
@@ -603,7 +597,7 @@
|
||||
&::backdrop {
|
||||
@apply opacity-100;
|
||||
}
|
||||
> article {
|
||||
> * {
|
||||
@apply scale-100;
|
||||
}
|
||||
|
||||
@@ -613,7 +607,7 @@
|
||||
&::backdrop {
|
||||
@apply opacity-0;
|
||||
}
|
||||
> article {
|
||||
> * {
|
||||
@apply scale-95;
|
||||
}
|
||||
}
|
||||
@@ -621,7 +615,7 @@
|
||||
&::backdrop {
|
||||
@apply bg-black/50 opacity-0 transition-all transition-discrete;
|
||||
}
|
||||
> article {
|
||||
> * {
|
||||
@apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%_-_2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg max-h-[calc(100%_-_2rem)];
|
||||
@apply transition-all scale-95;
|
||||
|
||||
@@ -692,21 +686,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Field */
|
||||
@layer components {
|
||||
.fieldset {
|
||||
@apply flex flex-col gap-6;
|
||||
|
||||
> legend {
|
||||
@apply text-base font-medium mb-3;
|
||||
}
|
||||
|
||||
> .description {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
}
|
||||
.field {
|
||||
@apply flex flex-col w-full gap-3 data-[invalid=true]:text-destructive [&>*]:w-full [&>.sr-only]:w-auto;
|
||||
|
||||
.description {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
.error {
|
||||
@apply text-sm text-destructive font-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Input */
|
||||
@layer components {
|
||||
.form input[type='text'],
|
||||
.form input[type='email'],
|
||||
.form input[type='password'],
|
||||
.form input[type='number'],
|
||||
.form input[type='file'],
|
||||
.form input[type='tel'],
|
||||
.form input[type='url'],
|
||||
.form input[type='search'],
|
||||
.form input[type='date'],
|
||||
.form input[type='datetime-local'],
|
||||
.form input[type='month'],
|
||||
.form input[type='week'],
|
||||
.form input[type='time'],
|
||||
:is(.form, .field) input[type='text'],
|
||||
:is(.form, .field) input[type='email'],
|
||||
:is(.form, .field) input[type='password'],
|
||||
:is(.form, .field) input[type='number'],
|
||||
:is(.form, .field) input[type='file'],
|
||||
:is(.form, .field) input[type='tel'],
|
||||
:is(.form, .field) input[type='url'],
|
||||
:is(.form, .field) input[type='search'],
|
||||
:is(.form, .field) input[type='date'],
|
||||
:is(.form, .field) input[type='datetime-local'],
|
||||
:is(.form, .field) input[type='month'],
|
||||
:is(.form, .field) input[type='week'],
|
||||
:is(.form, .field) input[type='time'],
|
||||
.input[type='text'],
|
||||
.input[type='email'],
|
||||
.input[type='password'],
|
||||
@@ -735,7 +754,7 @@
|
||||
|
||||
/* Label */
|
||||
@layer components {
|
||||
.form label,
|
||||
:is(.form, .field) label,
|
||||
.label {
|
||||
@apply flex items-center gap-2 text-sm leading-none font-medium select-none peer-disabled:pointer-events-none peer-disabled:opacity-50;
|
||||
|
||||
@@ -820,7 +839,7 @@
|
||||
|
||||
/* Radio */
|
||||
@layer components {
|
||||
.form input[type='radio'],
|
||||
:is(.form, .field) input[type='radio'],
|
||||
.input[type='radio'] {
|
||||
@apply appearance-none border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 relative;
|
||||
|
||||
@@ -832,7 +851,7 @@
|
||||
|
||||
/* Range */
|
||||
@layer components {
|
||||
.form input[type='range'],
|
||||
:is(.form, .field) input[type='range'],
|
||||
.input[type='range'] {
|
||||
@apply appearance-none flex items-center p-0 outline-none;
|
||||
--slider-value: 20%;
|
||||
@@ -881,7 +900,7 @@
|
||||
|
||||
/* Select */
|
||||
@layer components {
|
||||
.form select,
|
||||
:is(.form, .field) select,
|
||||
select.select {
|
||||
@apply appearance-none border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-9 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 h-9;
|
||||
@apply bg-[image:var(--chevron-down-icon-50)] bg-no-repeat bg-position-[center_right_0.75rem] bg-size-[1rem];
|
||||
@@ -1068,7 +1087,7 @@
|
||||
|
||||
/* Switch */
|
||||
@layer components {
|
||||
.form input[type='checkbox'][role='switch'],
|
||||
:is(.form, .field) input[type='checkbox'][role='switch'],
|
||||
.input[type='checkbox'][role='switch'] {
|
||||
@apply appearance-none focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
@apply bg-input dark:bg-input/80 checked:bg-primary dark:checked:bg-primary;
|
||||
@@ -1130,7 +1149,7 @@
|
||||
|
||||
/* Textarea */
|
||||
@layer components {
|
||||
.form textarea,
|
||||
:is(.form, .field) textarea,
|
||||
.textarea {
|
||||
@apply border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm;
|
||||
}
|
||||
@@ -1196,8 +1215,6 @@
|
||||
|
||||
/* Tooltip */
|
||||
@layer components {
|
||||
|
||||
|
||||
[data-tooltip] {
|
||||
@apply relative;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param input_attrs {object} [optional] - Additional HTML attributes for the search input.
|
||||
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
|
||||
@param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
|
||||
#}
|
||||
{% macro command(
|
||||
id=None,
|
||||
@@ -16,7 +16,7 @@
|
||||
empty_text="No results found.",
|
||||
main_attrs={},
|
||||
input_attrs={},
|
||||
listbox_attrs={}
|
||||
menu_attrs={}
|
||||
) %}
|
||||
{% set id = id or ("command-" ~ range(100000, 999999) | random | string) %}
|
||||
<div
|
||||
@@ -39,19 +39,19 @@
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="{{ id }}-listbox"
|
||||
aria-controls="{{ id }}-menu"
|
||||
{% for key, value in input_attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
</header>
|
||||
<div
|
||||
role="listbox"
|
||||
id="{{ id }}-listbox"
|
||||
role="menu"
|
||||
id="{{ id }}-menu"
|
||||
aria-orientation="vertical"
|
||||
data-empty="{{ empty_text }}"
|
||||
class="scrollbar {{ listbox_attrs.class }}"
|
||||
{% for key, value in listbox_attrs.items() %}
|
||||
class="scrollbar {{ menu_attrs.class }}"
|
||||
{% for key, value in menu_attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
@@ -73,7 +73,7 @@
|
||||
@param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
|
||||
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog element.
|
||||
@param input_attrs {object} [optional] - Additional HTML attributes for the search input.
|
||||
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
|
||||
@param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
|
||||
@param open {boolean} [optional] [default=False] - Whether the command dialog should be open initially.
|
||||
#}
|
||||
{% macro command_dialog(
|
||||
@@ -83,7 +83,7 @@
|
||||
empty_text="No results found.",
|
||||
dialog_attrs={},
|
||||
input_attrs={},
|
||||
listbox_attrs={},
|
||||
menu_attrs={},
|
||||
open=False
|
||||
) %}
|
||||
{% set id = id or ("command-dialog-" ~ range(100000, 999999) | random | string) %}
|
||||
@@ -97,54 +97,56 @@
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<article>
|
||||
<div class="command">
|
||||
<header>
|
||||
<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-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}-input"
|
||||
placeholder="{{ placeholder }}"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="{{ id }}-listbox"
|
||||
{% for key, value in input_attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
</header>
|
||||
<div
|
||||
role="listbox"
|
||||
id="{{ id }}-listbox"
|
||||
aria-orientation="vertical"
|
||||
data-empty="{{ empty_text }}"
|
||||
class="scrollbar {{ listbox_attrs.class }}"
|
||||
{% for key, value in listbox_attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
<div class="command">
|
||||
<header>
|
||||
<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-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}-input"
|
||||
placeholder="{{ placeholder }}"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="{{ id }}-menu"
|
||||
{% for key, value in input_attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items and items|length > 0 %}
|
||||
{{ render_command_items(items, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
role="menu"
|
||||
id="{{ id }}-menu"
|
||||
aria-orientation="vertical"
|
||||
data-empty="{{ empty_text }}"
|
||||
class="scrollbar {{ menu_attrs.class }}"
|
||||
{% for key, value in menu_attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items and items|length > 0 %}
|
||||
{{ render_command_items(items, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
|
||||
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Renders a list of items for the command component.
|
||||
|
||||
@param items {array} - The array of items to render.
|
||||
@param items {array} - Array of items to render. Each item can be:
|
||||
- { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} }
|
||||
- { type: "group", label: "Group Title", items: [...] }
|
||||
- { type: "separator" }
|
||||
Note: Use attrs to add data-label for alternative search text if needed.
|
||||
@param parent_id_prefix {string} [optional] - The prefix for the item id.
|
||||
#}
|
||||
{% macro render_command_items(items, parent_id_prefix="items") %}
|
||||
@@ -169,10 +171,8 @@
|
||||
{% elif item.type == "item" or not item.type %}
|
||||
<div
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
role="menuitem"
|
||||
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
|
||||
{% if item.label %}data-label="{{ item.label }}"{% endif %}
|
||||
{% if item.disabled %}aria-disabled="true"{% endif %}
|
||||
{% if item.attrs %}
|
||||
{% for key, value in item.attrs.items() %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@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 dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
|
||||
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content container.
|
||||
@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.
|
||||
@@ -53,7 +53,7 @@
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<article>
|
||||
<div>
|
||||
{% if title or description %}
|
||||
<header
|
||||
{% for key, value in header_attrs %}
|
||||
@@ -89,6 +89,6 @@
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endmacro %}
|
||||
+53
-64
@@ -1,32 +1,36 @@
|
||||
(() => {
|
||||
const initCommand = (container) => {
|
||||
const input = container.querySelector('header input');
|
||||
const listbox = container.querySelector('[role="listbox"]');
|
||||
const menu = container.querySelector('[role="menu"]');
|
||||
|
||||
if (!input || !listbox) {
|
||||
if (!input || !menu) {
|
||||
const missing = [];
|
||||
if (!input) missing.push('input');
|
||||
if (!listbox) missing.push('listbox');
|
||||
if (!menu) missing.push('menu');
|
||||
console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
|
||||
let visibleOptions = [...options];
|
||||
const allMenuItems = Array.from(menu.querySelectorAll('[role="menuitem"]'));
|
||||
const menuItems = allMenuItems.filter(item =>
|
||||
!item.hasAttribute('disabled') &&
|
||||
item.getAttribute('aria-disabled') !== 'true'
|
||||
);
|
||||
let visibleMenuItems = [...menuItems];
|
||||
let activeIndex = -1;
|
||||
|
||||
const setActiveOption = (index) => {
|
||||
if (activeIndex > -1 && options[activeIndex]) {
|
||||
options[activeIndex].classList.remove('active');
|
||||
const setActiveItem = (index) => {
|
||||
if (activeIndex > -1 && menuItems[activeIndex]) {
|
||||
menuItems[activeIndex].classList.remove('active');
|
||||
}
|
||||
|
||||
activeIndex = index;
|
||||
|
||||
if (activeIndex > -1) {
|
||||
const activeOption = options[activeIndex];
|
||||
activeOption.classList.add('active');
|
||||
if (activeOption.id) {
|
||||
input.setAttribute('aria-activedescendant', activeOption.id);
|
||||
const activeItem = menuItems[activeIndex];
|
||||
activeItem.classList.add('active');
|
||||
if (activeItem.id) {
|
||||
input.setAttribute('aria-activedescendant', activeItem.id);
|
||||
} else {
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
@@ -35,39 +39,29 @@
|
||||
}
|
||||
};
|
||||
|
||||
const filterOptions = () => {
|
||||
const filterMenuItems = () => {
|
||||
const searchTerm = input.value.trim().toLowerCase();
|
||||
|
||||
setActiveOption(-1);
|
||||
setActiveItem(-1);
|
||||
|
||||
visibleOptions = [];
|
||||
options.forEach(option => {
|
||||
const optionText = (option.dataset.label || option.textContent).trim().toLowerCase();
|
||||
const keywords = (option.dataset.keywords || '').toLowerCase();
|
||||
const matches = optionText.includes(searchTerm) || keywords.includes(searchTerm);
|
||||
option.setAttribute('aria-hidden', String(!matches));
|
||||
if (matches) {
|
||||
visibleOptions.push(option);
|
||||
visibleMenuItems = [];
|
||||
allMenuItems.forEach(item => {
|
||||
const itemText = (item.dataset.label || item.textContent).trim().toLowerCase();
|
||||
const keywords = (item.dataset.keywords || '').toLowerCase();
|
||||
const matches = itemText.includes(searchTerm) || keywords.includes(searchTerm);
|
||||
item.setAttribute('aria-hidden', String(!matches));
|
||||
if (matches && menuItems.includes(item)) {
|
||||
visibleMenuItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
if (visibleOptions.length > 0) {
|
||||
setActiveOption(options.indexOf(visibleOptions[0]));
|
||||
visibleOptions[0].scrollIntoView({ block: 'nearest' });
|
||||
if (visibleMenuItems.length > 0) {
|
||||
setActiveItem(menuItems.indexOf(visibleMenuItems[0]));
|
||||
visibleMenuItems[0].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
};
|
||||
|
||||
const selectOption = (option) => {
|
||||
if (!option) return;
|
||||
|
||||
const event = new CustomEvent('command:select', {
|
||||
detail: { value: option.dataset.value },
|
||||
bubbles: true
|
||||
});
|
||||
container.dispatchEvent(event);
|
||||
};
|
||||
|
||||
input.addEventListener('input', filterOptions);
|
||||
input.addEventListener('input', filterMenuItems);
|
||||
|
||||
const handleKeyNavigation = (event) => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) {
|
||||
@@ -77,21 +71,21 @@
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (activeIndex > -1) {
|
||||
selectOption(options[activeIndex]);
|
||||
menuItems[activeIndex]?.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibleOptions.length === 0) return;
|
||||
if (visibleMenuItems.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const currentVisibleIndex = activeIndex > -1 ? visibleOptions.indexOf(options[activeIndex]) : -1;
|
||||
const currentVisibleIndex = activeIndex > -1 ? visibleMenuItems.indexOf(menuItems[activeIndex]) : -1;
|
||||
let nextVisibleIndex = currentVisibleIndex;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
if (currentVisibleIndex < visibleOptions.length - 1) {
|
||||
if (currentVisibleIndex < visibleMenuItems.length - 1) {
|
||||
nextVisibleIndex = currentVisibleIndex + 1;
|
||||
}
|
||||
break;
|
||||
@@ -106,47 +100,42 @@
|
||||
nextVisibleIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
nextVisibleIndex = visibleOptions.length - 1;
|
||||
nextVisibleIndex = visibleMenuItems.length - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextVisibleIndex !== currentVisibleIndex) {
|
||||
const newActiveOption = visibleOptions[nextVisibleIndex];
|
||||
setActiveOption(options.indexOf(newActiveOption));
|
||||
newActiveOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
const newActiveItem = visibleMenuItems[nextVisibleIndex];
|
||||
setActiveItem(menuItems.indexOf(newActiveItem));
|
||||
newActiveItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
listbox.addEventListener('mousemove', (event) => {
|
||||
const option = event.target.closest('[role="option"]');
|
||||
if (option && visibleOptions.includes(option)) {
|
||||
const index = options.indexOf(option);
|
||||
menu.addEventListener('mousemove', (event) => {
|
||||
const menuItem = event.target.closest('[role="menuitem"]');
|
||||
if (menuItem && visibleMenuItems.includes(menuItem)) {
|
||||
const index = menuItems.indexOf(menuItem);
|
||||
if (index !== activeIndex) {
|
||||
setActiveOption(index);
|
||||
setActiveItem(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
listbox.addEventListener('mouseleave', () => {
|
||||
if (visibleOptions.length > 0) {
|
||||
setActiveOption(options.indexOf(visibleOptions[0]));
|
||||
} else {
|
||||
setActiveOption(-1);
|
||||
}
|
||||
});
|
||||
|
||||
listbox.addEventListener('click', (event) => {
|
||||
const clickedOption = event.target.closest('[role="option"]');
|
||||
if (clickedOption && visibleOptions.includes(clickedOption)) {
|
||||
selectOption(clickedOption);
|
||||
menu.addEventListener('click', (event) => {
|
||||
const clickedItem = event.target.closest('[role="menuitem"]');
|
||||
if (clickedItem && visibleMenuItems.includes(clickedItem)) {
|
||||
const dialog = container.closest('dialog.command-dialog');
|
||||
if (dialog && !clickedItem.hasAttribute('data-keep-command-open')) {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', handleKeyNavigation);
|
||||
|
||||
if (visibleOptions.length > 0) {
|
||||
setActiveOption(options.indexOf(visibleOptions[0]));
|
||||
visibleOptions[0].scrollIntoView({ block: 'nearest' });
|
||||
if (visibleMenuItems.length > 0) {
|
||||
setActiveItem(menuItems.indexOf(visibleMenuItems[0]));
|
||||
visibleMenuItems[0].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
container.dataset.commandInitialized = true;
|
||||
|
||||
+5
-4
@@ -16,7 +16,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
|
||||
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
|
||||
const options = allOptions.filter(opt => opt.getAttribute('aria-disabled') !== 'true');
|
||||
let visibleOptions = [...options];
|
||||
let activeIndex = -1;
|
||||
|
||||
@@ -69,7 +70,7 @@
|
||||
const resetFilter = () => {
|
||||
filter.value = '';
|
||||
visibleOptions = [...options];
|
||||
options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
|
||||
allOptions.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
|
||||
};
|
||||
|
||||
if (hasTransition()) {
|
||||
@@ -110,11 +111,11 @@
|
||||
setActiveOption(-1);
|
||||
|
||||
visibleOptions = [];
|
||||
options.forEach(option => {
|
||||
allOptions.forEach(option => {
|
||||
const optionText = (option.dataset.label || option.textContent).trim().toLowerCase();
|
||||
const matches = optionText.includes(searchTerm);
|
||||
option.setAttribute('aria-hidden', String(!matches));
|
||||
if (matches) {
|
||||
if (matches && options.includes(option)) {
|
||||
visibleOptions.push(option);
|
||||
}
|
||||
});
|
||||
|
||||
+48
-48
@@ -7,7 +7,7 @@
|
||||
@param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param input_attrs {object} [optional] - Additional HTML attributes for the search input.
|
||||
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
|
||||
@param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
|
||||
#}
|
||||
{% macro command(
|
||||
id=None,
|
||||
@@ -16,7 +16,7 @@
|
||||
empty_text="No results found.",
|
||||
main_attrs={},
|
||||
input_attrs={},
|
||||
listbox_attrs={}
|
||||
menu_attrs={}
|
||||
) %}
|
||||
{% set id = id or ("command-" + (range(100000, 999999) | random | string)) %}
|
||||
<div
|
||||
@@ -39,19 +39,19 @@
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="{{ id }}-listbox"
|
||||
aria-controls="{{ id }}-menu"
|
||||
{% for key, value in input_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
</header>
|
||||
<div
|
||||
role="listbox"
|
||||
id="{{ id }}-listbox"
|
||||
role="menu"
|
||||
id="{{ id }}-menu"
|
||||
aria-orientation="vertical"
|
||||
data-empty="{{ empty_text }}"
|
||||
class="scrollbar {{ listbox_attrs.class }}"
|
||||
{% for key, value in listbox_attrs %}
|
||||
class="scrollbar {{ menu_attrs.class }}"
|
||||
{% for key, value in menu_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
@@ -73,7 +73,7 @@
|
||||
@param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
|
||||
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog element.
|
||||
@param input_attrs {object} [optional] - Additional HTML attributes for the search input.
|
||||
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
|
||||
@param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
|
||||
@param open {boolean} [optional] [default=false] - Whether the command dialog should be open initially.
|
||||
#}
|
||||
{% macro command_dialog(
|
||||
@@ -83,7 +83,7 @@
|
||||
empty_text="No results found.",
|
||||
dialog_attrs={},
|
||||
input_attrs={},
|
||||
listbox_attrs={},
|
||||
menu_attrs={},
|
||||
open=false
|
||||
) %}
|
||||
{% set id = id or ("command-dialog-" + (range(100000, 999999) | random | string)) %}
|
||||
@@ -97,54 +97,56 @@
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<article>
|
||||
<div class="command">
|
||||
<header>
|
||||
<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-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}-input"
|
||||
placeholder="{{ placeholder }}"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="{{ id }}-listbox"
|
||||
{% for key, value in input_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
</header>
|
||||
<div
|
||||
role="listbox"
|
||||
id="{{ id }}-listbox"
|
||||
aria-orientation="vertical"
|
||||
data-empty="{{ empty_text }}"
|
||||
class="scrollbar {{ listbox_attrs.class }}"
|
||||
{% for key, value in listbox_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
<div class="command">
|
||||
<header>
|
||||
<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-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
id="{{ id }}-input"
|
||||
placeholder="{{ placeholder }}"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="{{ id }}-menu"
|
||||
{% for key, value in input_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items and items.length > 0 %}
|
||||
{{ render_command_items(items, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
role="menu"
|
||||
id="{{ id }}-menu"
|
||||
aria-orientation="vertical"
|
||||
data-empty="{{ empty_text }}"
|
||||
class="scrollbar {{ menu_attrs.class }}"
|
||||
{% for key, value in menu_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items and items.length > 0 %}
|
||||
{{ render_command_items(items, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
|
||||
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Renders a list of items for the command component.
|
||||
|
||||
@param items {array} - The array of items to render.
|
||||
@param items {array} - Array of items to render. Each item can be:
|
||||
- { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} }
|
||||
- { type: "group", label: "Group Title", items: [...] }
|
||||
- { type: "separator" }
|
||||
Note: Use attrs to add data-label for alternative search text if needed.
|
||||
@param parent_id_prefix {string} [optional] - The prefix for the item id.
|
||||
#}
|
||||
{% macro render_command_items(items, parent_id_prefix="items") %}
|
||||
@@ -169,10 +171,8 @@
|
||||
{% elif item.type == "item" or not item.type %}
|
||||
<div
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
role="menuitem"
|
||||
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
|
||||
{% if item.label %}data-label="{{ item.label }}"{% endif %}
|
||||
{% if item.disabled %}aria-disabled="true"{% endif %}
|
||||
{% if item.attrs %}
|
||||
{% for key, value in item.attrs %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@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 dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
|
||||
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content container.
|
||||
@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.
|
||||
@@ -53,7 +53,7 @@
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<article>
|
||||
<div>
|
||||
{% if title or description %}
|
||||
<header
|
||||
{% for key, value in header_attrs %}
|
||||
@@ -87,6 +87,6 @@
|
||||
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endmacro %}
|
||||
Reference in New Issue
Block a user