mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-13 13:48:28 +00:00
Prepare 1.0.0 beta release
This commit is contained in:
@@ -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() %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user