Moving to serialized array

This commit is contained in:
Ronan Berder
2026-01-05 11:28:05 +08:00
parent f43c20948b
commit def943d74e
4 changed files with 227 additions and 198 deletions
+65 -9
View File
@@ -55,6 +55,7 @@ toc:
{{ select(
trigger_attrs={"class": "w-[180px]"},
is_combobox=true,
multiple=true,
items=[
{
type: "group",
@@ -175,8 +176,15 @@ toc:
<dt><code class="highlight language-html">&lt;input type="hidden" name="{ NAME }" value="{ VALUE }"&gt;</code></dt>
<dd>
<p>The hidden input that holds the selected value.</p>
<p>For multiselect: multiple hidden inputs with the same name will be created (one for each selected value). When no options are selected, a single input with an empty value is submitted.</p>
<p><strong>Note:</strong> This differs slightly from native <code>&lt;select multiple&gt;</code> behavior, which submits nothing when empty. This allows the component to work without JavaScript for initial rendering while maintaining a simpler architecture.</p>
<p>For single-select: contains the selected option's value as a string.</p>
<p>For multiselect: contains a <strong>JSON array</strong> of selected values (e.g., <code>["apple","banana"]</code>). When no options are selected, contains an empty array (<code>[]</code>).</p>
<p><strong>Backend handling:</strong> Parse the JSON value on the server side. For example:</p>
<ul>
<li><strong>Python/Flask:</strong> <code>values = json.loads(request.form.get('field', '[]'))</code></li>
<li><strong>PHP:</strong> <code>$values = json_decode($_POST['field'] ?? '[]', true);</code></li>
<li><strong>Node.js:</strong> <code>const values = JSON.parse(req.body.field || '[]');</code></li>
<li><strong>Ruby/Rails:</strong> <code>values = JSON.parse(params[:field] || '[]')</code></li>
</ul>
</dd>
</dl>
</section>
@@ -200,21 +208,65 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript methods</a></h4>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript methods and properties</a></h4>
<section class="prose">
<dl>
<dt><code>selectByValue(value)</code></dt>
<dt><code>value</code> (property)</dt>
<dd>
<p>Selects an option by value (i.e. the option with the matching <code>data-value</code> attribute). For single-select, this will close the popover. For multiselect, this adds the value to the selection without removing existing selections.</p>
<p>Get or set the current value. For single-select, this is a string. For multiselect, this is an array. The multiselect setter accepts both strings and arrays.</p>
{% set code_script %}<script>
{% set code_value %}<script>
const selectComponent = document.querySelector('#my-select');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.selectByValue('apple');
// Get value
console.log(selectComponent.value); // 'apple'
// Set value (single-select)
selectComponent.value = 'banana';
// Set value (multiselect - accepts string or array)
selectComponent.value = ['apple', 'banana'];
selectComponent.value = 'apple'; // normalized to ['apple']
});
</script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
{{ code_block(code_value | prettyHtml, "html") }}
</dd>
<dt><code>select(value)</code></dt>
<dd>
<p>Selects an option by value (i.e. the option with the matching <code>data-value</code> attribute). For single-select, this will close the popover. For multiselect, this adds the value to the selection if not already selected.</p>
{% set code_select %}<script>
const selectComponent = document.querySelector('#my-select');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.select('apple');
});
</script>{% endset %}
{{ code_block(code_select | prettyHtml, "html") }}
</dd>
<dt><code>deselect(value)</code> <span class="badge-secondary">Multiselect only</span></dt>
<dd>
<p>Removes a specific value from the selection. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
{% set code_deselect %}<script>
const selectComponent = document.querySelector('#my-multiselect');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.deselect('apple');
});
</script>{% endset %}
{{ code_block(code_deselect | prettyHtml, "html") }}
</dd>
<dt><code>toggle(value)</code> <span class="badge-secondary">Multiselect only</span></dt>
<dd>
<p>Toggles a specific value in the selection (adds if not present, removes if present). Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
{% set code_toggle %}<script>
const selectComponent = document.querySelector('#my-multiselect');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.toggle('apple'); // adds if not selected, removes if selected
});
</script>{% endset %}
{{ code_block(code_toggle | prettyHtml, "html") }}
</dd>
<dt><code>selectAll()</code> <span class="badge-secondary">Multiselect only</span></dt>
<dd>
@@ -240,6 +292,10 @@ toc:
</script>{% endset %}
{{ code_block(code_script_none | prettyHtml, "html") }}
</dd>
<dt><code>selectByValue(value)</code></dt>
<dd>
<p><strong>Deprecated:</strong> Alias for <code>select(value)</code>. Kept for backward compatibility.</p>
</dd>
</dl>
</section>
@@ -360,7 +416,7 @@ toc:
<h3 id="example-multiple"><a href="#example-multiple">Multiple</a></h3>
<section class="prose">
<p>Enable multiple selection by setting <code>multiple=true</code>. The selected options are displayed as a comma-separated list in the trigger button. Use <code>data-label</code> to specify clean text labels for options that contain HTML (like icons), otherwise the text content will be extracted automatically.</p>
<p>Enable multiple selection by setting adding <code>aria-multiselectable="true"</code> to the <code>listbox</code>. The selected options are displayed as a comma-separated list in the trigger button. Use <code>data-label</code> to specify clean text labels for options that contain HTML (like icons), otherwise the text content will be extracted automatically.</p>
</section>
{% set code %}
+7 -21
View File
@@ -135,28 +135,14 @@
{% endif %}
</div>
</div>
{% if multiple and selectedArray %}
{% for val in selectedArray %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ val }}"
{% if loop.index0 > 0 %}id="{{ id }}-value-{{ loop.index0 }}"{% endif %}
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{% if 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 %}
{% else %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% endif %}
>
</div>
{% endmacro %}
+148 -147
View File
@@ -22,20 +22,16 @@
let visibleOptions = [...options];
let activeIndex = -1;
const isMultiple = listbox.getAttribute('aria-multiselectable') === 'true';
let selectedValues = isMultiple ? new Set() : null;
let placeholder = null;
if (isMultiple) {
placeholder = selectComponent.dataset.placeholder || '';
}
const selectedOptions = isMultiple ? new Set() : null;
const placeholder = isMultiple ? (selectComponent.dataset.placeholder || '') : null;
const setActiveOption = (index) => {
if (activeIndex > -1 && options[activeIndex]) {
options[activeIndex].classList.remove('active');
}
activeIndex = index;
if (activeIndex > -1) {
const activeOption = options[activeIndex];
activeOption.classList.add('active');
@@ -54,70 +50,43 @@
return parseFloat(style.transitionDuration) > 0 || parseFloat(style.transitionDelay) > 0;
};
const syncMultipleInputs = () => {
if (!isMultiple) return;
const values = Array.from(selectedValues);
const inputs = Array.from(selectComponent.querySelectorAll(':scope > input[type="hidden"]'));
inputs.slice(1).forEach(inp => inp.remove());
if (values.length === 0) {
input.value = '';
} else {
input.value = values[0];
let insertAfter = input;
for (let i = 1; i < values.length; i++) {
const clone = input.cloneNode(true);
clone.removeAttribute('id');
clone.value = values[i];
insertAfter.after(clone);
insertAfter = clone;
}
}
};
const updateMultipleLabel = () => {
if (!isMultiple) return;
const selected = options.filter(opt => selectedValues.has(opt.dataset.value));
if (selected.length === 0) {
selectedLabel.textContent = placeholder;
selectedLabel.classList.add('text-muted-foreground');
} else {
selectedLabel.textContent = selected.map(opt => opt.dataset.label || opt.textContent.trim()).join(', ');
selectedLabel.classList.remove('text-muted-foreground');
}
};
const updateValue = (optionOrOptions, triggerEvent = true) => {
let value;
if (isMultiple) {
const opts = Array.isArray(optionOrOptions) ? optionOrOptions : [];
selectedValues = new Set(opts.map(opt => opt.dataset.value));
options.forEach(opt => {
if (selectedValues.has(opt.dataset.value)) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
updateMultipleLabel();
syncMultipleInputs();
value = Array.from(selectedValues);
selectedOptions.clear();
opts.forEach(opt => selectedOptions.add(opt));
// Get selected options in DOM order
const selected = options.filter(opt => selectedOptions.has(opt));
if (selected.length === 0) {
selectedLabel.textContent = placeholder;
selectedLabel.classList.add('text-muted-foreground');
} else {
selectedLabel.textContent = selected.map(opt => opt.dataset.label || opt.textContent.trim()).join(', ');
selectedLabel.classList.remove('text-muted-foreground');
}
value = selected.map(opt => opt.dataset.value);
input.value = JSON.stringify(value);
} else {
const option = optionOrOptions;
if (!option) return;
selectedLabel.innerHTML = option.innerHTML;
input.value = option.dataset.value;
options.forEach(opt => {
if (opt === option) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
value = option.dataset.value;
input.value = value;
}
options.forEach(opt => {
const isSelected = isMultiple ? selectedOptions.has(opt) : opt === optionOrOptions;
if (isSelected) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
if (triggerEvent) {
selectComponent.dispatchEvent(new CustomEvent('change', {
detail: { value },
@@ -126,85 +95,73 @@
}
};
const toggleMultipleValue = (value, triggerEvent = true) => {
if (!isMultiple || value == null) return;
const newValues = new Set(selectedValues);
if (newValues.has(value)) {
newValues.delete(value);
} else {
newValues.add(value);
}
const selectedOptions = options.filter(opt => newValues.has(opt.dataset.value));
updateValue(selectedOptions, triggerEvent);
};
const closePopover = (focusOnTrigger = true) => {
if (popover.getAttribute('aria-hidden') === 'true') return;
if (filter) {
const resetFilter = () => {
filter.value = '';
visibleOptions = [...options];
allOptions.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
};
if (hasTransition()) {
popover.addEventListener('transitionend', resetFilter, { once: true });
} else {
resetFilter();
}
}
if (focusOnTrigger) trigger.focus();
popover.setAttribute('aria-hidden', 'true');
trigger.setAttribute('aria-expanded', 'false');
setActiveOption(-1);
}
const selectOption = (option) => {
if (!option) return;
const oldValue = input.value;
const newValue = option.dataset.value;
if (newValue != null && newValue !== oldValue) {
updateValue(option);
}
closePopover();
};
const selectByValue = (value) => {
const option = options.find(opt => opt.dataset.value === value);
if (isMultiple) {
if (value != null && selectedValues.has(value)) return;
if (option && value != null) {
const newValues = new Set(selectedValues);
newValues.add(value);
const selectedOptions = options.filter(opt => newValues.has(opt.dataset.value));
updateValue(selectedOptions);
}
const toggleMultipleValue = (option) => {
if (selectedOptions.has(option)) {
selectedOptions.delete(option);
} else {
selectOption(option);
selectedOptions.add(option);
}
updateValue(options.filter(opt => selectedOptions.has(opt)));
};
const select = (value) => {
if (isMultiple) {
const option = options.find(opt => opt.dataset.value === value && !selectedOptions.has(opt));
if (!option) return;
selectedOptions.add(option);
updateValue(options.filter(opt => selectedOptions.has(opt)));
} else {
const option = options.find(opt => opt.dataset.value === value);
if (!option) return;
if (input.value !== value) {
updateValue(option);
}
closePopover();
}
};
const selectAll = () => {
const deselect = (value) => {
if (!isMultiple) return;
updateValue(options.filter(opt => opt.dataset.value != null));
const option = options.find(opt => opt.dataset.value === value && selectedOptions.has(opt));
if (!option) return;
selectedOptions.delete(option);
updateValue(options.filter(opt => selectedOptions.has(opt)));
};
const selectNone = () => {
const toggle = (value) => {
if (!isMultiple) return;
updateValue([]);
const option = options.find(opt => opt.dataset.value === value);
if (!option) return;
toggleMultipleValue(option);
};
if (filter) {
const filterOptions = () => {
const searchTerm = filter.value.trim().toLowerCase();
setActiveOption(-1);
visibleOptions = [];
@@ -230,35 +187,38 @@
}
});
};
filter.addEventListener('input', filterOptions);
}
// Initialization
if (isMultiple) {
const validValues = new Set(options.map(opt => opt.dataset.value).filter(v => v != null));
const inputs = Array.from(selectComponent.querySelectorAll(':scope > input[type="hidden"]'));
const initialValues = inputs
.map(inp => inp.value)
.filter(v => v != null && validValues.has(v));
const ariaSelected = options.filter(opt => opt.getAttribute('aria-selected') === 'true');
try {
const parsed = JSON.parse(input.value || '[]');
const validValues = new Set(options.map(opt => opt.dataset.value).filter(v => v != null));
const initialValues = Array.isArray(parsed) ? parsed.filter(v => validValues.has(v)) : [];
let initialOptions;
if (initialValues.length > 0) {
initialOptions = options.filter(opt => initialValues.includes(opt.dataset.value));
} else {
initialOptions = options.filter(opt => opt.getAttribute('aria-selected') === 'true');
const initialOptions = [];
if (initialValues.length > 0) {
// Match values to options in order, allowing duplicates
initialValues.forEach(val => {
const opt = options.find(o => o.dataset.value === val && !initialOptions.includes(o));
if (opt) initialOptions.push(opt);
});
} else {
initialOptions.push(...ariaSelected);
}
updateValue(initialOptions, false);
} catch (e) {
updateValue(ariaSelected, false);
}
updateValue(initialOptions, false);
} else {
let initialOption = options.find(opt => opt.dataset.value === input.value);
if (!initialOption) {
initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0];
}
if (initialOption) {
updateValue(initialOption, false);
}
const initialOption = options.find(opt => opt.dataset.value === input.value)
|| options.find(opt => opt.dataset.value !== undefined)
|| options[0];
if (initialOption) updateValue(initialOption, false);
}
const handleKeyNavigation = (event) => {
@@ -275,20 +235,24 @@
}
return;
}
event.preventDefault();
if (event.key === 'Escape') {
closePopover();
return;
}
if (event.key === 'Enter') {
if (activeIndex > -1) {
const option = options[activeIndex];
if (isMultiple) {
toggleMultipleValue(options[activeIndex].dataset.value);
toggleMultipleValue(option);
} else {
selectOption(options[activeIndex]);
if (input.value !== option.dataset.value) {
updateValue(option);
}
closePopover();
}
}
return;
@@ -355,7 +319,7 @@
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: selectComponent }
}));
if (filter) {
if (hasTransition()) {
popover.addEventListener('transitionend', () => {
@@ -368,7 +332,7 @@
popover.setAttribute('aria-hidden', 'false');
trigger.setAttribute('aria-expanded', 'true');
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
if (selectedOption) {
setActiveOption(options.indexOf(selectedOption));
@@ -387,18 +351,24 @@
listbox.addEventListener('click', (event) => {
const clickedOption = event.target.closest('[role="option"]');
if (clickedOption) {
if (isMultiple) {
toggleMultipleValue(clickedOption.dataset.value);
setActiveOption(options.indexOf(clickedOption));
if (filter) {
filter.focus();
} else {
trigger.focus();
}
if (!clickedOption) return;
const option = options.find(opt => opt === clickedOption);
if (!option) return;
if (isMultiple) {
toggleMultipleValue(option);
setActiveOption(options.indexOf(option));
if (filter) {
filter.focus();
} else {
selectOption(clickedOption);
trigger.focus();
}
} else {
if (input.value !== option.dataset.value) {
updateValue(option);
}
closePopover();
}
});
@@ -416,10 +386,41 @@
popover.setAttribute('aria-hidden', 'true');
selectComponent.selectByValue = selectByValue;
// Public API
Object.defineProperty(selectComponent, 'value', {
get: () => {
if (isMultiple) {
return options.filter(opt => selectedOptions.has(opt)).map(opt => opt.dataset.value);
} else {
return input.value;
}
},
set: (val) => {
if (isMultiple) {
const values = Array.isArray(val) ? val : (val != null ? [val] : []);
const opts = [];
values.forEach(v => {
const opt = options.find(o => o.dataset.value === v && !opts.includes(o));
if (opt) opts.push(opt);
});
updateValue(opts);
} else {
const option = options.find(opt => opt.dataset.value === val);
if (option) {
updateValue(option);
closePopover();
}
}
}
});
selectComponent.select = select;
selectComponent.selectByValue = select; // Backward compatibility alias
if (isMultiple) {
selectComponent.selectAll = selectAll;
selectComponent.selectNone = selectNone;
selectComponent.deselect = deselect;
selectComponent.toggle = toggle;
selectComponent.selectAll = () => updateValue(options.filter(opt => opt.dataset.value != null));
selectComponent.selectNone = () => updateValue([]);
}
selectComponent.dataset.selectInitialized = true;
selectComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
+7 -21
View File
@@ -135,28 +135,14 @@
{% endif %}
</div>
</div>
{% if multiple and selectedArray %}
{% for val in selectedArray %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ val }}"
{% if loop.index0 > 0 %}id="{{ id }}-value-{{ loop.index0 }}"{% endif %}
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{% if 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 %}
{% else %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% endif %}
>
</div>
{% endmacro %}