Selects an option by value (i.e. the option with the matching data-value attribute). For single-select, this will close the popover. For multiselect, this adds the value to the selection if not already selected.
diff --git a/package-lock.json b/package-lock.json
index e47e0c6..f3050c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "basecoat",
- "version": "1.0.0",
+ "version": "1.0.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "basecoat",
- "version": "1.0.0",
+ "version": "1.0.0-beta.0",
"license": "MIT",
"workspaces": [
"packages/*"
@@ -6918,7 +6918,7 @@
},
"packages/cli": {
"name": "basecoat-cli",
- "version": "1.0.0",
+ "version": "1.0.0-beta.0",
"license": "MIT",
"dependencies": {
"commander": "^13.1.0",
@@ -6931,7 +6931,7 @@
},
"packages/css": {
"name": "basecoat-css",
- "version": "1.0.0",
+ "version": "1.0.0-beta.0",
"license": "MIT"
}
}
diff --git a/package.json b/package.json
index 3cc3b34..131be48 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "basecoat",
- "version": "1.0.0",
+ "version": "1.0.0-beta.0",
"description": "shadcn/ui for the rest of us",
"author": "hunvreus",
"type": "module",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 9caedb4..f82494f 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "basecoat-cli",
- "version": "1.0.0",
+ "version": "1.0.0-beta.0",
"description": "Add Basecoat components to your project",
"author": "hunvreus",
"license": "MIT",
diff --git a/packages/css/package.json b/packages/css/package.json
index 0a390ac..9c9a1d3 100644
--- a/packages/css/package.json
+++ b/packages/css/package.json
@@ -1,6 +1,6 @@
{
"name": "basecoat-css",
- "version": "1.0.0",
+ "version": "1.0.0-beta.0",
"description": "Tailwind CSS for Basecoat components",
"author": {
"name": "hunvreus",
diff --git a/src/jinja/combobox.html.jinja b/src/jinja/combobox.html.jinja
index 57b657b..8f4c93a 100644
--- a/src/jinja/combobox.html.jinja
+++ b/src/jinja/combobox.html.jinja
@@ -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 ''))) %}
`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,
diff --git a/src/js/combobox.js b/src/js/combobox.js
index 40942d3..36bb5b4 100644
--- a/src/js/combobox.js
+++ b/src/js/combobox.js
@@ -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 = '
';
- 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';
diff --git a/src/js/command.js b/src/js/command.js
index 658b2a8..ccc450e 100644
--- a/src/js/command.js
+++ b/src/js/command.js
@@ -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';
diff --git a/src/js/dropdown-menu.js b/src/js/dropdown-menu.js
index 66ef1d5..f6da941 100644
--- a/src/js/dropdown-menu.js
+++ b/src/js/dropdown-menu.js
@@ -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');
diff --git a/src/js/popover.js b/src/js/popover.js
index 3786f47..69dd4ff 100644
--- a/src/js/popover.js
+++ b/src/js/popover.js
@@ -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'));
diff --git a/src/js/range.js b/src/js/range.js
index b5abc65..7e63129 100644
--- a/src/js/range.js
+++ b/src/js/range.js
@@ -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';
}
diff --git a/src/js/select.js b/src/js/select.js
index d95c960..ac1fdb6 100644
--- a/src/js/select.js
+++ b/src/js/select.js
@@ -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) {
diff --git a/src/js/sidebar.js b/src/js/sidebar.js
index 7284c5a..d70ec0c 100644
--- a/src/js/sidebar.js
+++ b/src/js/sidebar.js
@@ -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';
diff --git a/src/js/tabs.js b/src/js/tabs.js
index ead80dd..1baf0e9 100644
--- a/src/js/tabs.js
+++ b/src/js/tabs.js
@@ -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'));
diff --git a/src/js/toast.js b/src/js/toast.js
index a3264da..700e393 100644
--- a/src/js/toast.js
+++ b/src/js/toast.js
@@ -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';
}
diff --git a/src/nunjucks/combobox.njk b/src/nunjucks/combobox.njk
index b8d7c6b..642f570 100644
--- a/src/nunjucks/combobox.njk
+++ b/src/nunjucks/combobox.njk
@@ -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 ''))) %}