Merge branch 'development'

This commit is contained in:
Ronan Berder
2025-11-05 11:37:34 +08:00
35 changed files with 4606 additions and 307 deletions
+246 -46
View File
@@ -146,10 +146,6 @@
border-radius: var(--scrollbar-radius);
}
}
/* Alpine.js */
[x-cloak] {
display: none !important;
}
}
/* Alert */
@@ -182,6 +178,30 @@
}
}
/* Badge */
@layer components {
.badge,
.badge-primary,
.badge-secondary,
.badge-destructive,
.badge-outline {
@apply inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden;
}
.badge,
.badge-primary {
@apply border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90;
}
.badge-secondary {
@apply border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90;
}
.badge-destructive {
@apply border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60;
}
.badge-outline {
@apply text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground;
}
}
/* Button */
@layer components {
.btn,
@@ -360,27 +380,44 @@
}
}
/* Badge */
@layer components {
.badge,
.badge-primary,
.badge-secondary,
.badge-destructive,
.badge-outline {
@apply inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden;
/* Button Group */
.button-group {
@apply inline-flex w-fit items-stretch isolate;
> *:focus-visible,
> :is(.dropdown-menu, .popover, .select) > button:focus-visible {
@apply relative z-10;
}
.badge,
.badge-primary {
@apply border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90;
> hr[role='separator'] {
@apply w-0 h-auto self-stretch border border-input shrink-0 m-0;
}
.badge-secondary {
@apply border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90;
}
.badge-destructive {
@apply border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60;
}
.badge-outline {
@apply text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground;
&:not([data-orientation='vertical']) {
> *:not(:first-child),
> :is(.dropdown-menu, .popover, .select):not(:first-child) > button {
@apply rounded-l-none border-l-0;
}
> *:not(:last-child),
> :is(.dropdown-menu, .popover, .select):not(:last-child) > button {
@apply rounded-r-none;
}
}
&[data-orientation='vertical'] {
@apply flex-col;
> hr[role='separator'] {
@apply w-auto h-px;
}
> *:not(:first-child),
> :is(.dropdown-menu, .popover, .select):not(:first-child) > button {
@apply rounded-t-none border-t-0;
}
> *:not(:last-child),
> :is(.dropdown-menu, .popover, .select):not(:last-child) > button {
@apply rounded-b-none;
}
}
}
@@ -411,7 +448,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;
@@ -443,6 +480,115 @@
}
}
/* Command */
@layer components {
.command-dialog {
@apply inset-y-0 opacity-0 transition-all transition-discrete;
&:is([open],:popover-open) {
@apply opacity-100;
&::backdrop {
@apply opacity-100;
}
> * {
@apply scale-100;
}
@starting-style {
@apply opacity-0;
&::backdrop {
@apply opacity-0;
}
> * {
@apply scale-95;
}
}
}
&::backdrop {
@apply bg-black/50 opacity-0 transition-all transition-discrete;
}
> * {
@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;
> button,
> form[method='dialog'] {
@apply absolute top-4 right-4;
}
> button,
> form[method='dialog'] > button {
@apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
}
}
.command {
> header input {
@apply h-12;
}
[role='menu'] {
@apply px-2;
[role='menuitem'] {
@apply py-3 px-2 [&_svg]:size-5;
}
[role='heading'] {
@apply px-2 py-1.5 font-medium text-muted-foreground;
}
}
}
}
.command {
> header {
@apply flex items-center border-b px-3 gap-2;
svg {
@apply size-4 shrink-0 opacity-50;
}
input {
@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='menu'] {
@apply max-h-[300px] overflow-y-auto overflow-x-hidden p-1;
[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;
}
}
[role='heading'] {
@apply text-muted-foreground flex px-2 py-1.5 text-xs font-medium;
}
[role='group']:not(:has([role='menuitem']:not([aria-hidden='true']))) {
@apply hidden;
}
[role='separator'] {
@apply border-border -mx-1 my-1;
}
&:not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply flex items-center justify-center py-6 px-3 text-sm truncate -m-1;
}
&[data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply content-[attr(data-empty)];
}
&:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply content-['No_results_found'];
}
}
&:not([data-command-initialized]) [role='menuitem'] {
@apply hover:bg-accent hover:text-accent-foreground;
}
&:has(> header input:not(:placeholder-shown)) [role='separator'] {
@apply hidden;
}
}
}
/* Dialog */
@layer components {
.dialog {
@@ -454,7 +600,7 @@
&::backdrop {
@apply opacity-100;
}
> article {
> * {
@apply scale-100;
}
@@ -464,7 +610,7 @@
&::backdrop {
@apply opacity-0;
}
> article {
> * {
@apply scale-95;
}
}
@@ -472,7 +618,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;
@@ -543,21 +689,65 @@
}
}
/* Field */
@layer components {
.fieldset {
@apply flex flex-col gap-6;
> legend {
@apply text-base font-medium mb-3;
}
}
.field {
@apply flex flex-col w-full gap-3 data-[invalid=true]:text-destructive [&>*]:w-full [&>.sr-only]:w-auto;
h2,
h3 {
@apply flex w-fit items-center gap-2 text-sm leading-snug font-medium;
}
[role="alert"] {
@apply text-sm text-destructive font-normal;
ul {
@apply ml-4 flex list-disc flex-col gap-1;
}
}
section {
@apply flex flex-1 flex-col gap-1.5 leading-snug;
}
&[data-orientation='horizontal'] {
@apply flex-row items-center [&>label]:flex-auto has-[>section]:items-start has-[>section]:[&>[type=checkbox],[type=radio]]:mt-px [&_p]:text-balance;
}
}
.fieldset legend + p,
.field > p,
.field section > p {
@apply text-muted-foreground text-sm leading-normal font-normal [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4;
}
.fieldset legend + p {
@apply -mt-1.5;
}
.field > p {
@apply last:mt-0 nth-last-2:-mt-1;
}
}
/* 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'],
@@ -577,9 +767,16 @@
}
}
/* Kbd */
@layer components {
.kbd {
@apply bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none [&_svg:not([class*='size-'])]:size-3;
}
}
/* 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;
@@ -664,7 +861,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;
@@ -676,7 +873,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%;
@@ -725,7 +922,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];
@@ -781,6 +978,9 @@
@apply content-['No_results_found'];
}
}
[data-popover]:has(> header input:not(:placeholder-shown)) [role='separator'] {
@apply hidden;
}
&:not([data-select-initialized]) [data-popover] [role='option'] {
@apply hover:bg-accent hover:text-accent-foreground;
}
@@ -909,7 +1109,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;
@@ -971,7 +1171,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;
}
+187
View File
@@ -0,0 +1,187 @@
{#
Renders a standalone command menu component with search and keyboard navigation.
@param id {string} [optional] - Unique identifier for the command component.
@param items {array} [optional] - Array of items to render (alternative to using caller).
@param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
@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 menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
#}
{% macro command(
id=None,
items=None,
placeholder="Type a command or search...",
empty_text="No results found.",
main_attrs={},
input_attrs={},
menu_attrs={}
) %}
{% set id = id or ("command-" ~ range(100000, 999999) | random | string) %}
<div
id="{{ id }}"
class="command {{ main_attrs.class }}"
aria-label="Command menu"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<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 %}
>
</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>
</div>
{% endmacro %}
{#
Renders a command dialog (modal command palette).
@param id {string} [optional] - Unique identifier for the command dialog.
@param items {array} [optional] - Array of items to render (alternative to using caller).
@param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
@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 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(
id=None,
items=None,
placeholder="Type a command or search...",
empty_text="No results found.",
dialog_attrs={},
input_attrs={},
menu_attrs={},
open=False
) %}
{% set id = id or ("command-dialog-" ~ range(100000, 999999) | random | string) %}
<dialog
id="{{ id }}"
class="dialog command-dialog {{ dialog_attrs.class }}"
aria-label="Command menu"
{% if open %}open{% endif %}
onclick="if (event.target === this) this.close()"
{% for key, value in dialog_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<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 %}
>
</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>
</div>
</dialog>
{% endmacro %}
{#
Renders a list of items for the command component.
@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") %}
{% for item in 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 }}"
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<span role="heading" id="{{ group_label_id }}">{{ item.label }}</span>
{{ render_command_items(item.items, 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="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.content | safe if item.content else item.label }}
</div>
{% endif %}
{% endfor %}
{% endmacro %}
+3 -3
View File
@@ -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 %}
+148
View File
@@ -0,0 +1,148 @@
(() => {
const initCommand = (container) => {
const input = container.querySelector('header input');
const menu = container.querySelector('[role="menu"]');
if (!input || !menu) {
const missing = [];
if (!input) missing.push('input');
if (!menu) missing.push('menu');
console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container);
return;
}
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 setActiveItem = (index) => {
if (activeIndex > -1 && menuItems[activeIndex]) {
menuItems[activeIndex].classList.remove('active');
}
activeIndex = index;
if (activeIndex > -1) {
const activeItem = menuItems[activeIndex];
activeItem.classList.add('active');
if (activeItem.id) {
input.setAttribute('aria-activedescendant', activeItem.id);
} else {
input.removeAttribute('aria-activedescendant');
}
} else {
input.removeAttribute('aria-activedescendant');
}
};
const filterMenuItems = () => {
const searchTerm = input.value.trim().toLowerCase();
setActiveItem(-1);
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 (visibleMenuItems.length > 0) {
setActiveItem(menuItems.indexOf(visibleMenuItems[0]));
visibleMenuItems[0].scrollIntoView({ block: 'nearest' });
}
};
input.addEventListener('input', filterMenuItems);
const handleKeyNavigation = (event) => {
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (activeIndex > -1) {
menuItems[activeIndex]?.click();
}
return;
}
if (visibleMenuItems.length === 0) return;
event.preventDefault();
const currentVisibleIndex = activeIndex > -1 ? visibleMenuItems.indexOf(menuItems[activeIndex]) : -1;
let nextVisibleIndex = currentVisibleIndex;
switch (event.key) {
case 'ArrowDown':
if (currentVisibleIndex < visibleMenuItems.length - 1) {
nextVisibleIndex = currentVisibleIndex + 1;
}
break;
case 'ArrowUp':
if (currentVisibleIndex > 0) {
nextVisibleIndex = currentVisibleIndex - 1;
} else if (currentVisibleIndex === -1) {
nextVisibleIndex = 0;
}
break;
case 'Home':
nextVisibleIndex = 0;
break;
case 'End':
nextVisibleIndex = visibleMenuItems.length - 1;
break;
}
if (nextVisibleIndex !== currentVisibleIndex) {
const newActiveItem = visibleMenuItems[nextVisibleIndex];
setActiveItem(menuItems.indexOf(newActiveItem));
newActiveItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
};
menu.addEventListener('mousemove', (event) => {
const menuItem = event.target.closest('[role="menuitem"]');
if (menuItem && visibleMenuItems.includes(menuItem)) {
const index = menuItems.indexOf(menuItem);
if (index !== activeIndex) {
setActiveItem(index);
}
}
});
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 (visibleMenuItems.length > 0) {
setActiveItem(menuItems.indexOf(visibleMenuItems[0]));
visibleMenuItems[0].scrollIntoView({ block: 'nearest' });
}
container.dataset.commandInitialized = true;
container.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
if (window.basecoat) {
window.basecoat.register('command', '.command:not([data-command-initialized])', initCommand);
}
})();
+5 -4
View File
@@ -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);
}
});
+187
View File
@@ -0,0 +1,187 @@
{#
Renders a standalone command menu component with search and keyboard navigation.
@param id {string} [optional] - Unique identifier for the command component.
@param items {array} [optional] - Array of items to render (alternative to using caller).
@param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
@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 menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
#}
{% macro command(
id=None,
items=None,
placeholder="Type a command or search...",
empty_text="No results found.",
main_attrs={},
input_attrs={},
menu_attrs={}
) %}
{% set id = id or ("command-" + (range(100000, 999999) | random | string)) %}
<div
id="{{ id }}"
class="command {{ main_attrs.class }}"
aria-label="Command menu"
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<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 %}
>
</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>
</div>
{% endmacro %}
{#
Renders a command dialog (modal command palette).
@param id {string} [optional] - Unique identifier for the command dialog.
@param items {array} [optional] - Array of items to render (alternative to using caller).
@param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
@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 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(
id=None,
items=None,
placeholder="Type a command or search...",
empty_text="No results found.",
dialog_attrs={},
input_attrs={},
menu_attrs={},
open=false
) %}
{% set id = id or ("command-dialog-" + (range(100000, 999999) | random | string)) %}
<dialog
id="{{ id }}"
class="command-dialog {{ dialog_attrs.class }}"
aria-label="Command menu"
{% if open %}open{% endif %}
onclick="if (event.target === this) this.close()"
{% for key, value in dialog_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<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 %}
>
</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>
</div>
</dialog>
{% endmacro %}
{#
Renders a list of items for the command component.
@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") %}
{% for item in 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 }}"
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<span role="heading" id="{{ group_label_id }}">{{ item.label }}</span>
{{ render_command_items(item.items, 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="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.content | safe if item.content else item.label }}
</div>
{% endif %}
{% endfor %}
{% endmacro %}
+3 -3
View File
@@ -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 %}