Prepare 1.0.0 beta release

This commit is contained in:
Ronan Berder
2026-06-08 21:42:31 +08:00
parent 4ccacc98a5
commit 4973389fbe
29 changed files with 612 additions and 54567 deletions
+18 -3
View File
@@ -9,6 +9,7 @@
placeholder=None,
close_on_select=false,
auto_highlight=false,
format="value",
main_attrs={},
input_attrs={},
popover_attrs={},
@@ -16,19 +17,33 @@
hidden_input_attrs={}
) %}
{% set id = id or ("combobox-" + (range(100000, 999999) | random | string)) %}
{% set selected_entries = namespace(items=[]) %}
{% if selected is defined and selected is iterable and selected is not string %}
{% set selected_values = selected %}
{% for item in selected %}
{% if item.value is defined %}
{% set selected_entries.items = selected_entries.items + [{'value': item.value, 'label': item.label if item.label is defined else item.value}] %}
{% else %}
{% set selected_entries.items = selected_entries.items + [{'value': item, 'label': item}] %}
{% endif %}
{% endfor %}
{% elif selected is defined and selected %}
{% set selected_values = [selected] %}
{% if selected.value is defined %}
{% set selected_values = [selected.value] %}
{% set selected_entries.items = [{'value': selected.value, 'label': selected.label if selected.label is defined else selected.value}] %}
{% else %}
{% set selected_values = [selected] %}
{% set selected_entries.items = [{'value': selected, 'label': selected}] %}
{% endif %}
{% else %}
{% set selected_values = [] %}
{% endif %}
{% set hidden_value = (selected_values | tojson if multiple else (selected_values[0] if selected_values else '')) %}
{% set hidden_value = ((selected_entries.items | tojson if multiple else ((selected_entries.items[0] if selected_entries.items else none) | tojson)) if format == "object" else (selected_values | tojson if multiple else (selected_values[0] if selected_values else ''))) %}
<div
id="{{ id }}"
class="combobox {{ main_attrs.class }}"
{% if multiple %}data-multiple="true"{% endif %}
{% if format == "object" %}data-format="object"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% if auto_highlight %}data-auto-highlight="true"{% endif %}
{% for key, value in main_attrs.items() %}
+11 -1
View File
@@ -21,6 +21,7 @@
multiple=false,
placeholder=None,
close_on_select=false,
format="value",
main_attrs={},
trigger_attrs={},
popover_attrs={},
@@ -69,10 +70,19 @@
{% endfor %}
{% endif %}
{% set default_label = (', '.join(labels) if (multiple and selected_options.items) else ((placeholder or '') if (multiple and not selected_options.items) else (default_option.label if default_option else ''))) %}
{% set selected_payload = namespace(items=[]) %}
{% if multiple and selected_options.items %}
{% for opt in selected_options.items %}
{% set selected_payload.items = selected_payload.items + [{'value': opt.value, 'label': opt.label}] %}
{% endfor %}
{% elif default_option %}
{% set selected_payload.items = [{'value': default_option.value, 'label': default_option.label}] %}
{% endif %}
<div
id="{{ id }}"
class="select {{ main_attrs.class }}"
{% if format == "object" %}data-format="object"{% endif %}
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% for key, value in main_attrs.items() %}
@@ -121,7 +131,7 @@
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{% if multiple %}{{ selectedArray | tojson }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
value="{% if format == 'object' %}{{ (selected_payload.items if multiple else (selected_payload.items[0] if selected_payload.items else none)) | tojson }}{% elif multiple %}{{ selectedArray | tojson }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
+43 -21
View File
@@ -2,8 +2,6 @@
const componentRegistry = {};
let observer = null;
const getInitFlag = (name) => `data-${name}-initialized`;
const registerComponent = (name, selectorOrOptions, initFunction) => {
const options = typeof selectorOrOptions === 'object'
? selectorOrOptions
@@ -22,12 +20,49 @@
try {
component.init(element);
element.dataset.basecoatComponent = componentName;
if (element.hasAttribute(`data-${componentName}-initialized`)) {
element.dataset.basecoatComponent = componentName;
}
} catch (error) {
console.error(`Failed to initialize ${componentName}:`, error);
if (typeof element._destroy === 'function') {
try {
element._destroy();
} catch (destroyError) {
console.error(`Failed to clean up ${componentName} after initialization error:`, destroyError);
}
}
delete element._destroy;
element.removeAttribute(`data-${componentName}-initialized`);
delete element.dataset.basecoatComponent;
}
};
const destroyComponent = (element) => {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return;
const componentName = element.dataset?.basecoatComponent;
if (typeof element._destroy === 'function') {
try {
element._destroy();
} catch (error) {
console.error('Failed to destroy Basecoat component:', error);
}
}
delete element._destroy;
if (componentName) element.removeAttribute(`data-${componentName}-initialized`);
delete element.dataset.basecoatComponent;
};
const destroyRemovedComponents = (node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.isConnected) return;
if (node.dataset?.basecoatComponent) destroyComponent(node);
node.querySelectorAll('[data-basecoat-component]').forEach(destroyComponent);
};
const initAllComponents = () => {
Object.entries(componentRegistry).forEach(([name, { selector }]) => {
document.querySelectorAll(selector).forEach(element => initComponent(element, name));
@@ -63,6 +98,7 @@
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach(initNewComponents);
mutation.removedNodes.forEach(destroyRemovedComponents);
});
});
@@ -75,31 +111,17 @@
observer = null;
};
const reinitComponent = (componentName) => {
const initRegisteredComponent = (componentName) => {
const component = componentRegistry[componentName];
if (!component) {
console.warn(`Component '${componentName}' not found in registry`);
return;
}
const flag = getInitFlag(componentName);
document.querySelectorAll(`[${flag}]`).forEach(element => {
element.removeAttribute(flag);
delete element.dataset.basecoatComponent;
});
document.querySelectorAll(component.selector).forEach(element => initComponent(element, componentName));
};
const reinitAll = () => {
Object.entries(componentRegistry).forEach(([name]) => {
const flag = getInitFlag(name);
document.querySelectorAll(`[${flag}]`).forEach(element => {
element.removeAttribute(flag);
delete element.dataset.basecoatComponent;
});
});
const initAllRegisteredComponents = () => {
initAllComponents();
};
@@ -113,8 +135,8 @@
window.basecoat = {
register: registerComponent,
init: reinitComponent,
initAll: reinitAll,
init: initRegisteredComponent,
initAll: initAllRegisteredComponents,
refresh: refreshComponent,
start: startObserver,
stop: stopObserver,
+205 -100
View File
@@ -12,7 +12,17 @@
const getValue = option => option.dataset.value ?? option.textContent.trim();
const getLabel = option => option.dataset.label || option.textContent.trim();
const getFormat = root => root.dataset.format === 'object' ? 'object' : 'value';
const isDisabled = option => option.getAttribute('aria-disabled') === 'true';
const toSelected = option => ({ value: getValue(option), label: getLabel(option) });
const normalizeEntry = entry => {
if (entry && typeof entry === 'object') {
const value = entry.value == null ? '' : String(entry.value);
return value ? { value, label: String(entry.label ?? entry.value) } : null;
}
const value = entry == null ? '' : String(entry);
return value ? { value, label: value } : null;
};
const getOptions = (listbox) => {
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
@@ -22,6 +32,53 @@
};
};
const getSelection = (state) => Array.from(state.selected.values());
const getCanonicalValue = (state) => state.isMultiple ? getSelection(state).map(item => item.value) : (getSelection(state)[0]?.value || '');
const getSelectedDetail = (state) => state.isMultiple ? getSelection(state) : (getSelection(state)[0] || null);
const serializeSelection = (state) => {
const selected = getSelection(state);
if (state.format === 'object') {
return JSON.stringify(state.isMultiple ? selected : (selected[0] || null));
}
const value = selected.map(item => item.value);
return state.isMultiple ? JSON.stringify(value) : (value[0] || '');
};
const parseStoredSelection = (storedValue, inputValue, state) => {
if (state.isMultiple) {
let parsed = [];
try {
parsed = JSON.parse(storedValue || '[]');
} catch (_) {
parsed = [];
}
if (!Array.isArray(parsed)) return [];
return parsed.map(item => {
const entry = normalizeEntry(state.format === 'object' ? item : { value: item, label: state.selected.get(String(item))?.label ?? item });
if (!entry) return null;
const option = state.options.find(opt => getValue(opt) === entry.value);
return option ? toSelected(option) : entry;
}).filter(Boolean);
}
if (state.format === 'object') {
try {
const entry = normalizeEntry(JSON.parse(storedValue || 'null'));
if (!entry) return [];
const option = state.options.find(opt => getValue(opt) === entry.value);
return [option ? toSelected(option) : entry];
} catch (_) {
return [];
}
}
const value = storedValue || '';
if (!value) return [];
const option = state.options.find(opt => getValue(opt) === value);
return [option ? toSelected(option) : { value, label: state.selected.get(value)?.label || inputValue || value }];
};
const scrollOptionIntoListbox = (state, option) => {
const optionRect = option.getBoundingClientRect();
const listboxRect = state.listbox.getBoundingClientRect();
@@ -56,9 +113,22 @@
const filterOptions = (state, { preserveActive = false } = {}) => {
const previousActive = state.activeIndex > -1 ? state.options[state.activeIndex] : null;
const search = state.input.value.trim().toLowerCase();
state.visibleOptions = [];
if (state.manualFilter) {
state.visibleOptions = state.options.filter(option => option.getAttribute('aria-hidden') !== 'true');
if (preserveActive && previousActive && state.visibleOptions.includes(previousActive)) {
setActiveOption(state, state.options.indexOf(previousActive));
} else {
setActiveOption(state, state.autoHighlight && state.visibleOptions.length > 0 ? state.options.indexOf(state.visibleOptions[0]) : -1);
}
syncEmptyState(state);
return;
}
const search = state.input.value.trim().toLowerCase();
state.allOptions.forEach(option => {
if (option.hasAttribute('data-force')) {
option.setAttribute('aria-hidden', 'false');
@@ -86,64 +156,60 @@
if (!state.chips) return;
state.chips.querySelectorAll('.combobox-chip').forEach(chip => chip.remove());
const selected = state.options.filter(option => state.selectedOptions.has(option));
selected.forEach(option => {
getSelection(state).forEach(entry => {
const chip = document.createElement('span');
chip.className = 'combobox-chip';
chip.dataset.value = getValue(option);
chip.textContent = getLabel(option);
chip.dataset.value = entry.value;
chip.textContent = entry.label;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'combobox-chip-remove';
remove.setAttribute('aria-label', `Remove ${getLabel(option)}`);
remove.setAttribute('aria-label', `Remove ${entry.label}`);
remove.innerHTML = '<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"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>';
remove.addEventListener('click', () => root.deselect(getValue(option)));
remove.addEventListener('click', () => root.deselect(entry.value));
chip.appendChild(remove);
state.chips.insertBefore(chip, state.input);
});
};
const updateValue = (root, optionOrOptions, triggerEvent = true) => {
const state = states.get(root);
let value;
const syncSelectedOptions = (state) => {
state.options.forEach(option => {
if (state.selected.has(getValue(option))) {
option.setAttribute('aria-selected', 'true');
} else {
option.removeAttribute('aria-selected');
}
});
};
const setSelected = (root, entries, triggerEvent = true) => {
const state = states.get(root);
const normalized = (Array.isArray(entries) ? entries : [entries]).map(normalizeEntry).filter(Boolean);
state.selected.clear();
if (state.isMultiple) {
const selected = Array.isArray(optionOrOptions) ? optionOrOptions : [];
state.selectedOptions.clear();
selected.forEach(option => state.selectedOptions.add(option));
state.options.forEach(option => {
if (state.selectedOptions.has(option)) {
option.setAttribute('aria-selected', 'true');
} else {
option.removeAttribute('aria-selected');
}
});
value = state.options.filter(option => state.selectedOptions.has(option)).map(getValue);
state.hiddenInput.value = JSON.stringify(value);
normalized.forEach(entry => state.selected.set(entry.value, entry));
state.input.value = '';
} else if (normalized[0]) {
state.selected.set(normalized[0].value, normalized[0]);
state.input.value = normalized[0].label;
} else {
state.input.value = '';
}
state.hiddenInput.value = serializeSelection(state);
syncSelectedOptions(state);
if (state.isMultiple) {
renderChips(root);
filterOptions(state, { preserveActive: true });
} else {
const option = optionOrOptions;
if (!option) return;
state.options.forEach(opt => {
if (opt === option) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
value = getValue(option);
state.hiddenInput.value = value;
state.input.value = getLabel(option);
}
if (triggerEvent) {
root.dispatchEvent(new CustomEvent('change', {
detail: { value },
detail: { value: getCanonicalValue(state), selected: getSelectedDetail(state) },
bubbles: true,
}));
}
@@ -175,47 +241,55 @@
const previousValue = elements.hiddenInput.value;
const previousInputValue = elements.input.value;
Object.assign(state, elements, getOptions(elements.listbox));
state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true' || root.dataset.multiple === 'true';
state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true';
state.closeOnSelect = root.dataset.closeOnSelect === 'true';
state.autoHighlight = root.dataset.autoHighlight === 'true';
if (state.isMultiple && !state.selectedOptions) state.selectedOptions = new Set();
state.manualFilter = root.dataset.filter === 'manual';
state.format = getFormat(root);
if (state.isMultiple) {
let values = [];
try {
const parsed = JSON.parse(previousValue || '[]');
values = Array.isArray(parsed) ? parsed : [];
} catch (_) {
values = [];
}
const ariaSelected = state.options.filter(option => option.getAttribute('aria-selected') === 'true').map(getValue);
const selectedValues = values.length ? values : ariaSelected;
updateValue(root, state.options.filter(option => selectedValues.includes(getValue(option))), false);
const stored = parseStoredSelection(previousValue, previousInputValue, state);
if (stored.length > 0) {
setSelected(root, stored, false);
} else {
const selected = state.options.find(option => getValue(option) === previousValue)
|| state.options.find(option => option.getAttribute('aria-selected') === 'true');
if (selected) {
updateValue(root, selected, false);
const selectedOptions = state.options.filter(option => option.getAttribute('aria-selected') === 'true').map(toSelected);
if (selectedOptions.length > 0) {
setSelected(root, state.isMultiple ? selectedOptions : selectedOptions[0], false);
} else if (state.isMultiple) {
setSelected(root, [], false);
} else {
state.input.value = previousInputValue;
state.options.forEach(option => option.removeAttribute('aria-selected'));
state.selected.clear();
state.hiddenInput.value = '';
syncSelectedOptions(state);
filterOptions(state, { preserveActive: true });
}
}
};
const resolveEntry = (state, value) => {
if (value && typeof value === 'object') {
const entry = normalizeEntry(value);
if (!entry) return null;
const option = state.options.find(opt => getValue(opt) === entry.value);
return option ? toSelected(option) : entry;
}
const option = state.options.find(opt => getValue(opt) === value);
if (option) return toSelected(option);
const existing = state.selected.get(String(value));
return existing || null;
};
const selectValue = (root, value) => {
const state = states.get(root);
const option = state.options.find(opt => getValue(opt) === value);
if (!option) return;
const entry = resolveEntry(state, value);
if (!entry) return;
if (state.isMultiple) {
const selected = new Set(state.selectedOptions);
selected.add(option);
updateValue(root, state.options.filter(opt => selected.has(opt)));
setSelected(root, [...getSelection(state).filter(item => item.value !== entry.value), entry]);
if (state.closeOnSelect) root.close(true);
} else {
updateValue(root, option);
setSelected(root, entry);
root.close(true);
}
};
@@ -223,11 +297,9 @@
const deselectValue = (root, value) => {
const state = states.get(root);
if (!state.isMultiple) return;
const option = state.options.find(opt => getValue(opt) === value);
if (!option || !state.selectedOptions.has(option)) return;
const selected = new Set(state.selectedOptions);
selected.delete(option);
updateValue(root, state.options.filter(opt => selected.has(opt)));
const normalized = String(value);
if (!state.selected.has(normalized)) return;
setSelected(root, getSelection(state).filter(item => item.value !== normalized));
};
const handleKeydown = (event, root) => {
@@ -237,9 +309,9 @@
const isOpen = state.popover.getAttribute('aria-hidden') === 'false';
if (event.key === 'Backspace' && state.isMultiple && state.input.value === '') {
const selected = state.options.filter(option => state.selectedOptions.has(option));
const selected = getSelection(state);
const last = selected[selected.length - 1];
if (last) root.deselect(getValue(last));
if (last) root.deselect(last.value);
return;
}
@@ -283,13 +355,14 @@
const initCombobox = (root) => {
if (root.dataset.comboboxInitialized) return;
const state = { root, activeIndex: -1, allOptions: [], options: [], visibleOptions: [], selectedOptions: null };
const state = { root, activeIndex: -1, allOptions: [], options: [], visibleOptions: [], selected: new Map(), format: 'value', manualFilter: false };
states.set(root, state);
root.refresh = () => refreshCombobox(root);
refreshCombobox(root);
if (!state.input || !state.popover || !state.listbox || !state.hiddenInput) {
states.delete(root);
delete root.refresh;
return;
}
@@ -305,63 +378,95 @@
root.select = (value) => selectValue(root, value);
root.selectByValue = root.select;
root.setValue = (value) => {
const entries = state.isMultiple ? (Array.isArray(value) ? value : (value == null ? [] : [value])) : [value];
const resolved = entries.map(entry => resolveEntry(state, entry) || normalizeEntry(entry)).filter(Boolean);
setSelected(root, state.isMultiple ? resolved : resolved[0]);
};
if (state.isMultiple) {
root.deselect = (value) => deselectValue(root, value);
root.toggle = (value) => {
const option = state.options.find(opt => getValue(opt) === value);
if (!option) return;
state.selectedOptions.has(option) ? root.deselect(value) : root.select(value);
const entry = resolveEntry(state, value);
if (!entry) return;
state.selected.has(entry.value) ? root.deselect(entry.value) : root.select(entry);
};
root.selectAll = () => updateValue(root, state.options);
root.selectNone = () => updateValue(root, []);
root.selectAll = () => setSelected(root, state.options.map(toSelected));
root.selectNone = () => setSelected(root, []);
}
state.input.addEventListener('focus', root.open);
state.input.addEventListener('click', root.open);
state.input.addEventListener('input', () => {
const handleInputFocus = root.open;
const handleInputClick = root.open;
const handleInput = () => {
root.open();
filterOptions(state);
if (!state.isMultiple) {
state.hiddenInput.value = '';
state.options.forEach(option => option.removeAttribute('aria-selected'));
state.selected.clear();
syncSelectedOptions(state);
}
});
state.input.addEventListener('keydown', (event) => handleKeydown(event, root));
state.listbox.addEventListener('mousemove', (event) => {
};
const handleInputKeydown = (event) => handleKeydown(event, root);
const handleListboxMousemove = (event) => {
const option = event.target.closest('[role="option"]');
if (option && state.visibleOptions.includes(option)) setActiveOption(state, state.options.indexOf(option));
});
state.listbox.addEventListener('click', (event) => {
};
const handleListboxClick = (event) => {
const option = event.target.closest('[role="option"]');
if (!option || !state.options.includes(option)) return;
state.isMultiple ? root.toggle(getValue(option)) : root.select(getValue(option));
if (state.isMultiple && !state.closeOnSelect) state.input.focus();
});
document.addEventListener('click', (event) => {
};
const handleDocumentClick = (event) => {
if (!root.contains(event.target)) root.close(false);
});
document.addEventListener('basecoat:popover', (event) => {
};
const handleDocumentPopover = (event) => {
if (event.detail.source !== root) root.close(false);
});
};
state.input.addEventListener('focus', handleInputFocus);
state.input.addEventListener('click', handleInputClick);
state.input.addEventListener('input', handleInput);
state.input.addEventListener('keydown', handleInputKeydown);
state.listbox.addEventListener('mousemove', handleListboxMousemove);
state.listbox.addEventListener('click', handleListboxClick);
document.addEventListener('click', handleDocumentClick);
document.addEventListener('basecoat:popover', handleDocumentPopover);
root._destroy = () => {
state.input.removeEventListener('focus', handleInputFocus);
state.input.removeEventListener('click', handleInputClick);
state.input.removeEventListener('input', handleInput);
state.input.removeEventListener('keydown', handleInputKeydown);
state.listbox.removeEventListener('mousemove', handleListboxMousemove);
state.listbox.removeEventListener('click', handleListboxClick);
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('basecoat:popover', handleDocumentPopover);
state.chips?.querySelectorAll('.combobox-chip').forEach(chip => chip.remove());
states.delete(root);
delete root.refresh;
delete root.open;
delete root.close;
delete root.select;
delete root.selectByValue;
delete root.setValue;
delete root.deselect;
delete root.toggle;
delete root.selectAll;
delete root.selectNone;
};
state.popover.setAttribute('aria-hidden', 'true');
state.input.setAttribute('aria-expanded', 'false');
Object.defineProperty(root, 'value', {
configurable: true,
get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : state.hiddenInput.value,
set: (value) => {
if (state.isMultiple) {
const values = Array.isArray(value) ? value : (value == null ? [] : [value]);
updateValue(root, state.options.filter(option => values.includes(getValue(option))));
} else {
root.select(value);
}
},
get: () => getCanonicalValue(state),
set: (value) => root.setValue(value),
});
Object.defineProperty(root, 'selected', {
configurable: true,
get: () => getSelectedDetail(state),
});
root.dataset.comboboxInitialized = 'true';
+32 -9
View File
@@ -49,6 +49,15 @@
};
const filterItems = (state) => {
if (state.manualFilter) {
setActiveItem(state, -1);
state.visibleItems = state.items.filter(item => item.getAttribute('aria-hidden') !== 'true');
if (state.visibleItems.length > 0) {
setActiveItem(state, state.items.indexOf(state.visibleItems[0]));
}
return;
}
const searchTerm = state.input.value.trim().toLowerCase();
setActiveItem(state, -1);
@@ -92,6 +101,7 @@
}
Object.assign(state, elements, getItems(elements.menu));
state.manualFilter = container.dataset.filter === 'manual';
filterItems(state);
};
@@ -126,7 +136,7 @@
const initCommand = (container) => {
if (container.dataset.commandInitialized) return;
const state = { activeIndex: -1, allItems: [], items: [], visibleItems: [] };
const state = { activeIndex: -1, allItems: [], items: [], visibleItems: [], manualFilter: false };
states.set(container, state);
container.refresh = () => refreshCommand(container);
@@ -138,28 +148,41 @@
if (!elements.menu) missing.push('menu');
console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container);
states.delete(container);
delete container.refresh;
return;
}
Object.assign(state, elements);
state.input.addEventListener('input', () => filterItems(state));
state.input.addEventListener('keydown', (event) => handleKeyNavigation(event, state));
state.menu.addEventListener('mousemove', (event) => {
const handleInput = () => filterItems(state);
const handleInputKeydown = (event) => handleKeyNavigation(event, state);
const handleMenuMousemove = (event) => {
const menuItem = event.target.closest('[role="menuitem"]');
if (menuItem && state.visibleItems.includes(menuItem)) {
const index = state.items.indexOf(menuItem);
if (index !== state.activeIndex) setActiveItem(state, index);
}
});
state.menu.addEventListener('click', (event) => {
};
const handleMenuClick = (event) => {
const clickedItem = event.target.closest('[role="menuitem"]');
if (clickedItem && state.visibleItems.includes(clickedItem)) {
const dialog = container.closest('dialog.command-dialog');
if (dialog && !clickedItem.hasAttribute('data-keep-command-open')) dialog.close();
}
});
};
state.input.addEventListener('input', handleInput);
state.input.addEventListener('keydown', handleInputKeydown);
state.menu.addEventListener('mousemove', handleMenuMousemove);
state.menu.addEventListener('click', handleMenuClick);
container._destroy = () => {
state.input.removeEventListener('input', handleInput);
state.input.removeEventListener('keydown', handleInputKeydown);
state.menu.removeEventListener('mousemove', handleMenuMousemove);
state.menu.removeEventListener('click', handleMenuClick);
states.delete(container);
delete container.refresh;
};
refreshCommand(container);
container.dataset.commandInitialized = 'true';
+36 -12
View File
@@ -76,6 +76,7 @@
refreshDropdownMenu(root);
if (!state.trigger || !state.popover || !state.menu) {
states.delete(root);
delete root.refresh;
return;
}
@@ -83,9 +84,9 @@
root.close = (focusOnTrigger = true) => closePopover(state, focusOnTrigger);
root.toggle = () => state.trigger.getAttribute('aria-expanded') === 'true' ? root.close() : root.open(false);
state.trigger.addEventListener('click', root.toggle);
const handleTriggerClick = root.toggle;
root.addEventListener('keydown', (event) => {
const handleKeydown = (event) => {
const isExpanded = state.trigger.getAttribute('aria-expanded') === 'true';
if (event.key === 'Escape') {
@@ -132,29 +133,52 @@
}
if (nextIndex !== state.activeIndex) setActiveItem(state, nextIndex);
});
};
state.menu.addEventListener('mousemove', (event) => {
const handleMenuMousemove = (event) => {
const menuItem = event.target.closest('[role^="menuitem"]');
if (menuItem && !isDisabled(menuItem) && state.items.includes(menuItem)) {
const index = state.items.indexOf(menuItem);
if (index !== state.activeIndex) setActiveItem(state, index);
}
});
};
state.menu.addEventListener('mouseleave', () => setActiveItem(state, -1));
state.menu.addEventListener('click', (event) => {
const handleMenuMouseleave = () => setActiveItem(state, -1);
const handleMenuClick = (event) => {
const menuItem = event.target.closest('[role^="menuitem"]');
if (menuItem && !isDisabled(menuItem)) root.close();
});
};
document.addEventListener('click', (event) => {
const handleDocumentClick = (event) => {
if (!root.contains(event.target)) root.close(false);
});
};
document.addEventListener('basecoat:popover', (event) => {
const handleDocumentPopover = (event) => {
if (event.detail.source !== root) root.close(false);
});
};
state.trigger.addEventListener('click', handleTriggerClick);
root.addEventListener('keydown', handleKeydown);
state.menu.addEventListener('mousemove', handleMenuMousemove);
state.menu.addEventListener('mouseleave', handleMenuMouseleave);
state.menu.addEventListener('click', handleMenuClick);
document.addEventListener('click', handleDocumentClick);
document.addEventListener('basecoat:popover', handleDocumentPopover);
root._destroy = () => {
state.trigger.removeEventListener('click', handleTriggerClick);
root.removeEventListener('keydown', handleKeydown);
state.menu.removeEventListener('mousemove', handleMenuMousemove);
state.menu.removeEventListener('mouseleave', handleMenuMouseleave);
state.menu.removeEventListener('click', handleMenuClick);
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('basecoat:popover', handleDocumentPopover);
states.delete(root);
delete root.refresh;
delete root.open;
delete root.close;
delete root.toggle;
};
state.trigger.setAttribute('aria-expanded', 'false');
state.popover.setAttribute('aria-hidden', 'true');
+22 -8
View File
@@ -1,5 +1,7 @@
(() => {
const initPopover = (popoverComponent) => {
if (popoverComponent.dataset.popoverInitialized) return;
const trigger = popoverComponent.querySelector(':scope > button');
const content = popoverComponent.querySelector(':scope > [data-popover]');
@@ -36,32 +38,44 @@
content.setAttribute('aria-hidden', 'false');
};
trigger.addEventListener('click', () => {
const handleTriggerClick = () => {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
closePopover();
} else {
openPopover();
}
});
};
popoverComponent.addEventListener('keydown', (event) => {
const handleKeydown = (event) => {
if (event.key === 'Escape') {
closePopover();
}
});
};
document.addEventListener('click', (event) => {
const handleDocumentClick = (event) => {
if (!popoverComponent.contains(event.target)) {
closePopover();
}
});
};
document.addEventListener('basecoat:popover', (event) => {
const handleDocumentPopover = (event) => {
if (event.detail.source !== popoverComponent) {
closePopover(false);
}
});
};
trigger.addEventListener('click', handleTriggerClick);
popoverComponent.addEventListener('keydown', handleKeydown);
document.addEventListener('click', handleDocumentClick);
document.addEventListener('basecoat:popover', handleDocumentPopover);
popoverComponent._destroy = () => {
trigger.removeEventListener('click', handleTriggerClick);
popoverComponent.removeEventListener('keydown', handleKeydown);
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('basecoat:popover', handleDocumentPopover);
};
popoverComponent.dataset.popoverInitialized = true;
popoverComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
+5 -1
View File
@@ -11,7 +11,11 @@
if (element.dataset.rangeInitialized) return;
updateRange(element);
element.addEventListener('input', () => updateRange(element));
const handleInput = () => updateRange(element);
element.addEventListener('input', handleInput);
element._destroy = () => {
element.removeEventListener('input', handleInput);
};
element.dataset.rangeInitialized = 'true';
}
+108 -36
View File
@@ -11,7 +11,10 @@
};
const getValue = (option) => option.dataset.value ?? option.textContent.trim();
const getLabel = (option) => option.dataset.label || option.textContent.trim();
const getFormat = (root) => root.dataset.format === 'object' ? 'object' : 'value';
const isDisabled = (option) => option.getAttribute('aria-disabled') === 'true';
const toSelected = (option) => ({ value: getValue(option), label: getLabel(option) });
const getOptions = (listbox) => {
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
@@ -21,6 +24,41 @@
};
};
const parseStoredValues = (storedValue, { isMultiple, format }) => {
if (isMultiple) {
try {
const parsed = JSON.parse(storedValue || '[]');
if (!Array.isArray(parsed)) return [];
return parsed
.map(item => format === 'object' && item && typeof item === 'object' ? item.value : item)
.filter(value => value != null)
.map(String);
} catch (_) {
return [];
}
}
if (format === 'object') {
try {
const parsed = JSON.parse(storedValue || 'null');
return parsed && typeof parsed === 'object' && parsed.value != null ? String(parsed.value) : '';
} catch (_) {
return '';
}
}
return storedValue || '';
};
const serializeSelection = (state, selected) => {
if (state.format === 'object') {
return JSON.stringify(state.isMultiple ? selected : (selected[0] || null));
}
const value = selected.map(item => item.value);
return state.isMultiple ? JSON.stringify(value) : (value[0] || '');
};
const scrollOptionIntoListbox = (state, option) => {
const optionRect = option.getBoundingClientRect();
const listboxRect = state.listbox.getBoundingClientRect();
@@ -55,6 +93,7 @@
const updateValue = (root, optionOrOptions, triggerEvent = true) => {
const state = states.get(root);
let value;
let selectedDetail;
if (state.isMultiple) {
const selected = Array.isArray(optionOrOptions) ? optionOrOptions : [];
@@ -62,22 +101,24 @@
selected.forEach(option => state.selectedOptions.add(option));
const selectedInOrder = state.options.filter(option => state.selectedOptions.has(option));
selectedDetail = selectedInOrder.map(toSelected);
if (selectedInOrder.length === 0) {
state.selectedLabel.textContent = state.placeholder;
state.selectedLabel.classList.add('text-muted-foreground');
} else {
state.selectedLabel.textContent = selectedInOrder.map(option => option.dataset.label || option.textContent.trim()).join(', ');
state.selectedLabel.textContent = selectedDetail.map(item => item.label).join(', ');
state.selectedLabel.classList.remove('text-muted-foreground');
}
value = selectedInOrder.map(getValue);
state.input.value = JSON.stringify(value);
value = selectedDetail.map(item => item.value);
state.input.value = serializeSelection(state, selectedDetail);
} else {
const option = optionOrOptions;
if (!option) return;
state.selectedLabel.innerHTML = option.innerHTML;
value = getValue(option);
state.input.value = value;
selectedDetail = toSelected(option);
value = selectedDetail.value;
state.input.value = serializeSelection(state, [selectedDetail]);
}
state.options.forEach(option => {
@@ -91,7 +132,7 @@
if (triggerEvent) {
root.dispatchEvent(new CustomEvent('change', {
detail: { value },
detail: { value, selected: selectedDetail },
bubbles: true,
}));
}
@@ -125,24 +166,20 @@
Object.assign(state, elements, getOptions(elements.listbox));
state.visibleOptions = [...state.options];
state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true';
state.format = getFormat(root);
state.placeholder = state.isMultiple ? (root.dataset.placeholder || '') : null;
state.closeOnSelect = root.dataset.closeOnSelect === 'true';
if (state.isMultiple) {
if (!state.selectedOptions) state.selectedOptions = new Set();
let values = [];
try {
const parsed = JSON.parse(previousValue || '[]');
values = Array.isArray(parsed) ? parsed : [];
} catch (_) {
values = [];
}
const values = parseStoredValues(previousValue, state);
const selected = values.length
? values.map(value => state.options.find(option => getValue(option) === value)).filter(Boolean)
: state.options.filter(option => option.getAttribute('aria-selected') === 'true');
updateValue(root, selected, false);
} else {
const selected = state.options.find(option => getValue(option) === previousValue)
const value = parseStoredValues(previousValue, state);
const selected = state.options.find(option => getValue(option) === value)
|| state.options.find(option => option.getAttribute('aria-selected') === 'true');
state.options.forEach(option => option.removeAttribute('aria-selected'));
if (selected) updateValue(root, selected, false);
@@ -172,7 +209,7 @@
} else {
const option = state.options.find(opt => getValue(opt) === value);
if (!option) return;
if (state.input.value !== value) updateValue(root, option);
if (root.value !== value) updateValue(root, option);
closePopover(state);
}
};
@@ -214,7 +251,7 @@
toggleMultipleValue(root, option);
if (state.closeOnSelect) root.close();
} else {
if (state.input.value !== getValue(option)) updateValue(root, option);
if (root.value !== getValue(option)) updateValue(root, option);
root.close();
}
}
@@ -241,13 +278,14 @@
const initSelect = (root) => {
if (root.dataset.selectInitialized) return;
const state = { activeIndex: -1, selectedOptions: null, options: [], allOptions: [], visibleOptions: [] };
const state = { activeIndex: -1, selectedOptions: null, options: [], allOptions: [], visibleOptions: [], format: 'value' };
states.set(root, state);
root.refresh = () => refreshSelect(root);
refreshSelect(root);
if (!state.trigger || !state.selectedLabel || !state.popover || !state.listbox || !state.input) {
states.delete(root);
delete root.refresh;
return;
}
@@ -266,23 +304,20 @@
root.close = (focusOnTrigger = true) => closePopover(state, focusOnTrigger);
root.togglePopover = () => state.trigger.getAttribute('aria-expanded') === 'true' ? root.close() : root.open();
state.trigger.addEventListener('keydown', (event) => handleKeyNavigation(event, root));
state.trigger.addEventListener('click', root.togglePopover);
state.listbox.addEventListener('mousemove', (event) => {
const handleTriggerKeydown = (event) => handleKeyNavigation(event, root);
const handleTriggerClick = root.togglePopover;
const handleListboxMousemove = (event) => {
const option = event.target.closest('[role="option"]');
if (option && state.visibleOptions.includes(option)) {
const index = state.options.indexOf(option);
if (index !== state.activeIndex) setActiveOption(state, index);
}
});
state.listbox.addEventListener('mouseleave', () => {
};
const handleListboxMouseleave = () => {
const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]');
setActiveOption(state, selectedOption ? state.options.indexOf(selectedOption) : -1);
});
state.listbox.addEventListener('click', (event) => {
};
const handleListboxClick = (event) => {
const clickedOption = event.target.closest('[role="option"]');
if (!clickedOption) return;
@@ -298,22 +333,49 @@
state.trigger.focus();
}
} else {
if (state.input.value !== getValue(option)) updateValue(root, option);
if (root.value !== getValue(option)) updateValue(root, option);
root.close();
}
});
document.addEventListener('click', (event) => {
};
const handleDocumentClick = (event) => {
if (!root.contains(event.target)) root.close(false);
});
document.addEventListener('basecoat:popover', (event) => {
};
const handleDocumentPopover = (event) => {
if (event.detail.source !== root) root.close(false);
});
};
state.trigger.addEventListener('keydown', handleTriggerKeydown);
state.trigger.addEventListener('click', handleTriggerClick);
state.listbox.addEventListener('mousemove', handleListboxMousemove);
state.listbox.addEventListener('mouseleave', handleListboxMouseleave);
state.listbox.addEventListener('click', handleListboxClick);
document.addEventListener('click', handleDocumentClick);
document.addEventListener('basecoat:popover', handleDocumentPopover);
root._destroy = () => {
state.trigger.removeEventListener('keydown', handleTriggerKeydown);
state.trigger.removeEventListener('click', handleTriggerClick);
state.listbox.removeEventListener('mousemove', handleListboxMousemove);
state.listbox.removeEventListener('mouseleave', handleListboxMouseleave);
state.listbox.removeEventListener('click', handleListboxClick);
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('basecoat:popover', handleDocumentPopover);
states.delete(root);
delete root.refresh;
delete root.open;
delete root.close;
delete root.togglePopover;
delete root.select;
delete root.selectByValue;
delete root.deselect;
delete root.toggle;
delete root.selectAll;
delete root.selectNone;
};
Object.defineProperty(root, 'value', {
configurable: true,
get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : state.input.value,
get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : parseStoredValues(state.input.value, state),
set: (value) => {
if (state.isMultiple) {
const values = Array.isArray(value) ? value : (value != null ? [value] : []);
@@ -328,6 +390,16 @@
},
});
Object.defineProperty(root, 'selected', {
configurable: true,
get: () => {
if (state.isMultiple) return state.options.filter(option => state.selectedOptions.has(option)).map(toSelected);
const value = root.value;
const option = state.options.find(opt => getValue(opt) === value);
return option ? toSelected(option) : null;
},
});
root.select = (value) => selectValue(root, value);
root.selectByValue = root.select;
if (state.isMultiple) {
+11 -2
View File
@@ -28,7 +28,7 @@
sidebarComponent.close = () => setState(false);
sidebarComponent.toggle = () => setState(!open);
sidebarComponent.addEventListener('click', (event) => {
const handleClick = (event) => {
const target = event.target;
const nav = sidebarComponent.querySelector('nav');
const isMobile = window.innerWidth < breakpoint;
@@ -43,7 +43,16 @@
if (document.activeElement) document.activeElement.blur();
sidebarComponent.close();
}
});
};
sidebarComponent.addEventListener('click', handleClick);
sidebarComponent._destroy = () => {
sidebarComponent.removeEventListener('click', handleClick);
delete sidebarComponent.open;
delete sidebarComponent.close;
delete sidebarComponent.toggle;
};
updateState();
sidebarComponent.dataset.sidebarInitialized = 'true';
+21 -5
View File
@@ -48,14 +48,19 @@
root.select = selectTab;
refreshTabs(root);
if (!state.tablist) return;
if (!state.tablist) {
states.delete(root);
delete root.refresh;
delete root.select;
return;
}
state.tablist.addEventListener('click', (event) => {
const handleClick = (event) => {
const clickedTab = event.target.closest('[role="tab"]');
if (clickedTab) root.select(clickedTab);
});
};
state.tablist.addEventListener('keydown', (event) => {
const handleKeydown = (event) => {
const currentTab = event.target;
if (!state.tabs.includes(currentTab)) return;
@@ -75,7 +80,18 @@
event.preventDefault();
root.select(nextTab, true);
});
};
state.tablist.addEventListener('click', handleClick);
state.tablist.addEventListener('keydown', handleKeydown);
root._destroy = () => {
state.tablist.removeEventListener('click', handleClick);
state.tablist.removeEventListener('keydown', handleKeydown);
states.delete(root);
delete root.refresh;
delete root.select;
};
root.dataset.tabsInitialized = 'true';
root.dispatchEvent(new CustomEvent('basecoat:initialized'));
+20 -4
View File
@@ -13,15 +13,17 @@
if (toasterElement.dataset.toasterInitialized) return;
toaster = toasterElement;
toaster.addEventListener('mouseenter', pauseAllTimeouts);
toaster.addEventListener('mouseleave', resumeAllTimeouts);
toaster.addEventListener('click', (event) => {
const handleClick = (event) => {
const actionLink = event.target.closest('.toast footer a');
const actionButton = event.target.closest('.toast footer button');
if (actionLink || actionButton) {
closeToast(event.target.closest('.toast'));
}
});
};
toaster.addEventListener('mouseenter', pauseAllTimeouts);
toaster.addEventListener('mouseleave', resumeAllTimeouts);
toaster.addEventListener('click', handleClick);
toasterElement.toast = (config = {}) => {
const toastElement = createToast(config);
@@ -34,6 +36,15 @@
};
toaster.querySelectorAll('.toast:not([data-toast-initialized])').forEach(initToast);
toaster._destroy = () => {
toaster.removeEventListener('mouseenter', pauseAllTimeouts);
toaster.removeEventListener('mouseleave', resumeAllTimeouts);
toaster.removeEventListener('click', handleClick);
toaster.querySelectorAll('.toast[data-toast-initialized]').forEach(toast => toast._destroy?.());
delete toaster.toast;
delete toaster.closeAll;
if (toaster === toasterElement) toaster = null;
};
toaster.dataset.toasterInitialized = 'true';
toaster.dispatchEvent(new CustomEvent('basecoat:initialized'));
}
@@ -63,6 +74,11 @@
toasts.set(element, state);
element.close = () => closeToast(element);
element._destroy = () => {
clearTimeout(state.timeoutId);
toasts.delete(element);
delete element.close;
};
element.dataset.toastInitialized = 'true';
}
+21 -4
View File
@@ -8,6 +8,7 @@
@param placeholder {string} [optional] - Placeholder text for the visible input.
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
@param auto_highlight {boolean} [optional] [default=false] - Highlights the first matching option while filtering.
@param format {string} [optional] [default="value"] - Hidden input serialization format: "value" or "object".
@param main_attrs {object} [optional] - Additional HTML attributes for the root div.
@param input_attrs {object} [optional] - Additional HTML attributes for the visible input.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover div.
@@ -22,6 +23,7 @@
placeholder=None,
close_on_select=false,
auto_highlight=false,
format="value",
main_attrs={},
input_attrs={},
popover_attrs={},
@@ -30,17 +32,32 @@
) %}
{% set id = id or ("combobox-" + (range(100000, 999999) | random | string)) %}
{% set selected_values = [] %}
{% set selected_entries = [] %}
{% if selected is defined and selected is iterable and selected is not string %}
{% set selected_values = selected %}
{% for item in selected %}
{% if item.value is defined %}
{% set selected_values = (selected_values.push(item.value), selected_values) %}
{% set selected_entries = (selected_entries.push({ value: item.value, label: (item.label if item.label is defined else item.value) }), selected_entries) %}
{% else %}
{% set selected_values = (selected_values.push(item), selected_values) %}
{% set selected_entries = (selected_entries.push({ value: item, label: item }), selected_entries) %}
{% endif %}
{% endfor %}
{% elif selected is defined and selected %}
{% set selected_values = [selected] %}
{% if selected.value is defined %}
{% set selected_values = [selected.value] %}
{% set selected_entries = [{ value: selected.value, label: (selected.label if selected.label is defined else selected.value) }] %}
{% else %}
{% set selected_values = [selected] %}
{% set selected_entries = [{ value: selected, label: selected }] %}
{% endif %}
{% endif %}
{% set hidden_value = (selected_values | dump if multiple else (selected_values[0] if selected_values[0] else '')) %}
{% set hidden_value = ((selected_entries | dump if multiple else ((selected_entries[0] if selected_entries[0] else None) | dump)) if format == "object" else (selected_values | dump if multiple else (selected_values[0] if selected_values[0] else ''))) %}
<div
id="{{ id }}"
class="combobox {{ main_attrs.class }}"
{% if multiple %}data-multiple="true"{% endif %}
{% if format == "object" %}data-format="object"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% if auto_highlight %}data-auto-highlight="true"{% endif %}
{% for key, value in main_attrs %}
+12 -1
View File
@@ -7,6 +7,7 @@
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
@param placeholder {string} [optional] - Placeholder text shown when no options are selected (multiple mode only).
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
@param format {string} [optional] [default="value"] - Hidden input serialization format: "value" or "object".
@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 popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@@ -21,6 +22,7 @@
multiple=false,
placeholder=None,
close_on_select=false,
format="value",
main_attrs={},
trigger_attrs={},
popover_attrs={},
@@ -69,10 +71,19 @@
{% endfor %}
{% endif %}
{% set default_label = (labels | join(', ') if (multiple and selected_options) else ((placeholder or '') if (multiple and not selected_options) else (default_option.label if default_option else ''))) %}
{% set selected_payload = [] %}
{% if multiple and selected_options %}
{% for opt in selected_options %}
{% set selected_payload = (selected_payload.push({ value: opt.value, label: opt.label }), selected_payload) %}
{% endfor %}
{% elif default_option %}
{% set selected_payload = [{ value: default_option.value, label: default_option.label }] %}
{% endif %}
<div
id="{{ id }}"
class="select {{ main_attrs.class }}"
{% if format == "object" %}data-format="object"{% endif %}
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% for key, value in main_attrs %}
@@ -121,7 +132,7 @@
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{% if multiple %}{{ selectedArray | dump }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
value="{% if format == 'object' %}{{ (selected_payload if multiple else (selected_payload[0] if selected_payload[0] else None)) | dump }}{% elif multiple %}{{ selectedArray | dump }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}