mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-12 13:18:22 +00:00
Stable multi-select implementation
This commit is contained in:
+1
-6282
File diff suppressed because one or more lines are too long
+110
-15
@@ -32,6 +32,8 @@ toc:
|
||||
id: example-disabled
|
||||
- label: With icon
|
||||
id: example-with-icon
|
||||
- label: Multiple
|
||||
id: example-multiple
|
||||
---
|
||||
|
||||
{% from "macros/code_preview.njk" import code_preview %}
|
||||
@@ -120,44 +122,62 @@ toc:
|
||||
<section class="prose">
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div class="select"></code></dt>
|
||||
<dd>Wraps around the entire component.
|
||||
<dd>
|
||||
<p>Wraps around the entire component. Can have the following attributes:</p>
|
||||
<ul>
|
||||
<li><code>data-placeholder="{ TEXT }"</code> <span class="badge-secondary">Optional</span>: placeholder text shown when no options are selected (multiselect only).</li>
|
||||
</ul>
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><button type="button" popovertarget="{ POPOVER_ID }"></code></dt>
|
||||
<dt><code class="highlight language-html"><button type="button"></code></dt>
|
||||
<dd>
|
||||
<p>The trigger to open the popover, can also have the following attributes:</p>
|
||||
<p>The trigger to open the popover. Should have the following attributes:</p>
|
||||
<ul>
|
||||
<li><code>id="{BUTTON_ID}"</code>: linked to by the <code>aria-labelledby</code> attribute of the listbox.</li>
|
||||
<li><code>aria-haspopup="listbox"</code>: indicates that the button opens a listbox.</li>
|
||||
<li><code>aria-controls="{ LISTBOX_ID }"</code>: points to the listbox's id.</li>
|
||||
<li><code>aria-expanded="false"</code>: tracks the popover's state.</li>
|
||||
<li><code>aria-activedescendant="{ OPTION_ID }"</code>: points to the active option's id.</li>
|
||||
<li><code>aria-activedescendant="{ OPTION_ID }"</code> <span class="badge-secondary">Optional</span>: points to the active option's id.</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt><code class="highlight language-html"><div popover class="popover" id="{ POPOVER_ID }"></code></dt>
|
||||
<dd>As with the <a href="/components/popover">Popover</a> component, you can set up the side and alignment of the popover using the <code>data-side</code> and <code>data-align</code> attributes.
|
||||
<dt><code class="highlight language-html"><div data-popover aria-hidden="true" id="{ POPOVER_ID }"></code></dt>
|
||||
<dd>The popover content. You can set up the side and alignment using the <code>data-side</code> and <code>data-align</code> attributes (see <a href="/components/popover">Popover</a> component).
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div role="listbox"></code></dt>
|
||||
<dd>The listbox containing the options. Should have the following attributes:
|
||||
<dd>
|
||||
<p>The listbox containing the options. Should have the following attributes:</p>
|
||||
<ul>
|
||||
<li><code>id="{ LISTBOX_ID }"</code>: refered to by the <code>aria-controls</code> attribute of the trigger.</li>
|
||||
<li><code>aria-labelledby="{ BUTTON_ID }"</code>: linked to by the button's <code>id</code> attribute.</li>
|
||||
<li><code>aria-multiselectable="true"</code> <span class="badge-secondary">Optional</span>: enables multiple selection mode.</li>
|
||||
</ul>
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div role="option" data-value="{ VALUE }"></code></dt>
|
||||
<dd>Option that can be selected.Should have a unique id if you use the <code>aria-activedescendant</code> attribute on the trigger.</dd>
|
||||
<dd>
|
||||
<p>Option that can be selected. Should have the following attributes:</p>
|
||||
<ul>
|
||||
<li><code>id="{ OPTION_ID }"</code> <span class="badge-secondary">Optional</span>: unique id for this option (needed if you use <code>aria-activedescendant</code> on the trigger).</li>
|
||||
<li><code>data-value="{ VALUE }"</code>: the value for this option.</li>
|
||||
<li><code>data-label="{ LABEL }"</code> <span class="badge-secondary">Optional</span>: the text label to use for this option when selected in multiple mode. If not provided, the option's text content will be used (HTML stripped).</li>
|
||||
<li><code>aria-selected="true"</code> <span class="badge-secondary">Optional</span>: indicates this option is selected.</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt><code class="highlight language-html"><hr role="separator"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>Separator between groups/options.</dd>
|
||||
<dt><code class="highlight language-html"><div role="group"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>Group of options, can have a <code>aria-labelledby</code> attribute to link to a heading.</dd>
|
||||
<dt><code class="highlight language-html"><span role="heading"></code></dt>
|
||||
<dt><code class="highlight language-html"><span role="heading"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>Group heading, must have an <code>id</code> attribute if you use the <code>aria-labelledby</code> attribute on the group.</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
</dl>
|
||||
</dd>
|
||||
<dt><code class="highlight language-html"><input type="hidden" name="{ NAME }" value="{ VALUE }"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>The hidden input that holds the value of the field (if needed).</dd>
|
||||
<dt><code class="highlight language-html"><input type="hidden" name="{ NAME }" value="{ VALUE }"></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><select multiple></code> behavior, which submits nothing when empty. This allows the component to work without JavaScript for initial rendering while maintaining a simpler architecture.</p>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -170,7 +190,13 @@ toc:
|
||||
<dt><code>basecoat:popover</code></dt>
|
||||
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
|
||||
<dt><code>change</code></dt>
|
||||
<dd>When the selected value changes, the component dispatches a custom (bubbling) <code>change</code> event on itself, with the selected value in <code>event.detail</code> (e.g. <code>{ detail: { value: "something" }}</code>).</dd>
|
||||
<dd>
|
||||
<p>When the selected value changes, the component dispatches a custom (bubbling) <code>change</code> event on itself, with the selected value in <code>event.detail</code>:</p>
|
||||
<ul>
|
||||
<li>Single select: <code>{ detail: { value: "something" }}</code></li>
|
||||
<li>Multiple select: <code>{ detail: { value: "last-changed", values: ["item1", "item2"] }}</code></li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -178,9 +204,9 @@ toc:
|
||||
|
||||
<section class="prose">
|
||||
<dl>
|
||||
<dt><code>selectByValue</code></dt>
|
||||
<dt><code>selectByValue(value)</code></dt>
|
||||
<dd>
|
||||
<p>You can call this method on the component after it is initialized to select an option by value (i.e. the option with the matching <code>data-value</code> attribute):</p>
|
||||
<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>
|
||||
|
||||
{% set code_script %}<script>
|
||||
const selectComponent = document.querySelector('#my-select');
|
||||
@@ -190,6 +216,30 @@ toc:
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_script | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>selectAll()</code> <span class="badge-secondary">Multiselect only</span></dt>
|
||||
<dd>
|
||||
<p>Selects all available options. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
|
||||
|
||||
{% set code_script_all %}<script>
|
||||
const selectComponent = document.querySelector('#my-multiselect');
|
||||
selectComponent.addEventListener('basecoat:initialized', () => {
|
||||
selectComponent.selectAll();
|
||||
});
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_script_all | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>selectNone()</code> <span class="badge-secondary">Multiselect only</span></dt>
|
||||
<dd>
|
||||
<p>Deselects all options. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
|
||||
|
||||
{% set code_script_none %}<script>
|
||||
const selectComponent = document.querySelector('#my-multiselect');
|
||||
selectComponent.addEventListener('basecoat:initialized', () => {
|
||||
selectComponent.selectNone();
|
||||
});
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_script_none | prettyHtml, "html") }}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -264,6 +314,7 @@ toc:
|
||||
{% set code %}
|
||||
{% call select(
|
||||
id="select-with-icons",
|
||||
placeholder="Select a chart...",
|
||||
trigger_attrs={'class': 'w-[180px]'}
|
||||
) %}
|
||||
<div type="button" role="option" data-value="bar">
|
||||
@@ -287,4 +338,48 @@ toc:
|
||||
{% endcall %}
|
||||
{% endset %}
|
||||
|
||||
{{ code_preview("select-scrollable", code | prettyHtml) }}
|
||||
{{ code_preview("select-with-icons", code | prettyHtml) }}
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{% set code %}
|
||||
{% call select(
|
||||
id="select-multiple",
|
||||
name="chart-types",
|
||||
multiple=true,
|
||||
placeholder="Select chart types...",
|
||||
selected=["bar", "line"],
|
||||
trigger_attrs={"class": "w-[220px]"}
|
||||
) %}
|
||||
<div role="option" data-value="bar" data-label="Bar">
|
||||
<span class="flex items-center gap-2">
|
||||
{% lucide "chart-bar", {"class": "text-muted-foreground"} %}
|
||||
Bar
|
||||
</span>
|
||||
</div>
|
||||
<div role="option" data-value="line" data-label="Line">
|
||||
<span class="flex items-center gap-2">
|
||||
{% lucide "chart-line", {"class": "text-muted-foreground"} %}
|
||||
Line
|
||||
</span>
|
||||
</div>
|
||||
<div role="option" data-value="pie" data-label="Pie">
|
||||
<span class="flex items-center gap-2">
|
||||
{% lucide "chart-pie", {"class": "text-muted-foreground"} %}
|
||||
Pie
|
||||
</span>
|
||||
</div>
|
||||
<div role="option" data-value="area" data-label="Area">
|
||||
<span class="flex items-center gap-2">
|
||||
{% lucide "chart-area", {"class": "text-muted-foreground"} %}
|
||||
Area
|
||||
</span>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endset %}
|
||||
|
||||
{{ code_preview("select-multiple", code | prettyHtml) }}
|
||||
|
||||
+47
-19
@@ -2,8 +2,10 @@
|
||||
Renders a select or combobox component.
|
||||
|
||||
@param id {string} [optional] - Unique identifier for the select component.
|
||||
@param selected {string} [optional] - The initially selected value.
|
||||
@param selected {string|array} [optional] - The initially selected value(s).
|
||||
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
|
||||
@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 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.
|
||||
@@ -17,6 +19,8 @@
|
||||
selected=None,
|
||||
name=None,
|
||||
items=[],
|
||||
multiple=false,
|
||||
placeholder=None,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -27,8 +31,9 @@
|
||||
) %}
|
||||
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
|
||||
|
||||
{% set selectedArray = ((selected if (selected is iterable and selected is not string) else [selected]) if selected else []) %}
|
||||
{% set first_option = namespace(item=None) %}
|
||||
{% set selected_option = namespace(item=None) %}
|
||||
{% set selected_options = namespace(items=[]) %}
|
||||
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
@@ -37,26 +42,34 @@
|
||||
{% if not first_option.item %}
|
||||
{% set first_option.item = sub_item %}
|
||||
{% endif %}
|
||||
{% if selected and sub_item.value == selected and not selected_option.item %}
|
||||
{% set selected_option.item = sub_item %}
|
||||
{% if sub_item.value in selectedArray %}
|
||||
{% set selected_options.items = selected_options.items + [sub_item] %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not first_option.item %}
|
||||
{% set first_option.item = item %}
|
||||
{% endif %}
|
||||
{% if selected and item.value == selected and not selected_option.item %}
|
||||
{% set selected_option.item = item %}
|
||||
{% if item.value in selectedArray %}
|
||||
{% set selected_options.items = selected_options.items + [item] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% set default_option = selected_option.item or first_option.item or None %}
|
||||
{% set default_option = (selected_options.items[0] if selected_options.items else first_option.item) %}
|
||||
{% set labels = [] %}
|
||||
{% if multiple and selected_options.items %}
|
||||
{% for opt in selected_options.items %}
|
||||
{% set labels = labels + [opt.label] %}
|
||||
{% 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 ''))) %}
|
||||
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
|
||||
{% for key, value in main_attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
@@ -72,7 +85,7 @@
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<span class="truncate">{{ default_option.label }}</span>
|
||||
<span class="truncate">{{ default_label }}</span>
|
||||
{% if is_combobox %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
{% else %}
|
||||
@@ -110,25 +123,40 @@
|
||||
id="{{ id }}-listbox"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="{{ id }}-trigger"
|
||||
{% if multiple %}aria-multiselectable="true"{% endif %}
|
||||
{% for key, value in listbox_attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items %}
|
||||
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
|
||||
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
>
|
||||
{% 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 %}
|
||||
|
||||
@@ -138,7 +166,7 @@
|
||||
@param items {array} - The array of items to render.
|
||||
@param parent_id_prefix {string} [optional] - The prefix for the item id.
|
||||
#}
|
||||
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
|
||||
{% macro render_select_items(items, selectedArray, parent_id_prefix="items") %}
|
||||
{% for item in items %}
|
||||
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
|
||||
{% if item.type == "group" %}
|
||||
@@ -153,7 +181,7 @@
|
||||
{% endif %}
|
||||
>
|
||||
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
{{ render_select_items(item['items'], selected, item_id) if item['items'] }}
|
||||
{{ render_select_items(item['items'], selectedArray, item_id) if item['items'] }}
|
||||
</div>
|
||||
{% elif item.type == "separator" %}
|
||||
<hr role="separator" />
|
||||
@@ -162,7 +190,7 @@
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
{% if selected == item.value %}aria-selected="true"{% endif %}
|
||||
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
|
||||
{% if item.attrs %}
|
||||
{% for key, value in item.attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
|
||||
+175
-14
@@ -3,23 +3,31 @@
|
||||
const trigger = selectComponent.querySelector(':scope > button');
|
||||
const selectedLabel = trigger.querySelector(':scope > span');
|
||||
const popover = selectComponent.querySelector(':scope > [data-popover]');
|
||||
const listbox = popover.querySelector('[role="listbox"]');
|
||||
const listbox = popover ? popover.querySelector('[role="listbox"]') : null;
|
||||
const input = selectComponent.querySelector(':scope > input[type="hidden"]');
|
||||
const filter = selectComponent.querySelector('header input[type="text"]');
|
||||
|
||||
if (!trigger || !popover || !listbox || !input) {
|
||||
const missing = [];
|
||||
if (!trigger) missing.push('trigger');
|
||||
if (!popover) missing.push('popover');
|
||||
if (!listbox) missing.push('listbox');
|
||||
if (!input) missing.push('input');
|
||||
if (!input) missing.push('input');
|
||||
console.error(`Select component initialisation failed. Missing element(s): ${missing.join(', ')}`, selectComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
|
||||
const options = allOptions.filter(opt => opt.getAttribute('aria-disabled') !== 'true');
|
||||
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 setActiveOption = (index) => {
|
||||
if (activeIndex > -1 && options[activeIndex]) {
|
||||
@@ -46,13 +54,46 @@
|
||||
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 = (option, triggerEvent = true) => {
|
||||
if (option) {
|
||||
selectedLabel.innerHTML = option.innerHTML;
|
||||
input.value = option.dataset.value;
|
||||
listbox.querySelector('[role="option"][aria-selected="true"]')?.removeAttribute('aria-selected');
|
||||
option.setAttribute('aria-selected', 'true');
|
||||
|
||||
|
||||
if (triggerEvent) {
|
||||
const event = new CustomEvent('change', {
|
||||
detail: { value: option.dataset.value },
|
||||
@@ -63,6 +104,35 @@
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMultipleValue = (value, triggerEvent = true) => {
|
||||
if (!isMultiple || value == null) return;
|
||||
|
||||
if (selectedValues.has(value)) {
|
||||
selectedValues.delete(value);
|
||||
} else {
|
||||
selectedValues.add(value);
|
||||
}
|
||||
|
||||
options.forEach(opt => {
|
||||
if (selectedValues.has(opt.dataset.value)) {
|
||||
opt.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
opt.removeAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
|
||||
updateMultipleLabel();
|
||||
syncMultipleInputs();
|
||||
|
||||
if (triggerEvent) {
|
||||
const event = new CustomEvent('change', {
|
||||
detail: { value, values: Array.from(selectedValues) },
|
||||
bubbles: true
|
||||
});
|
||||
selectComponent.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
const closePopover = (focusOnTrigger = true) => {
|
||||
if (popover.getAttribute('aria-hidden') === 'true') return;
|
||||
|
||||
@@ -101,7 +171,59 @@
|
||||
|
||||
const selectByValue = (value) => {
|
||||
const option = options.find(opt => opt.dataset.value === value);
|
||||
selectOption(option);
|
||||
if (isMultiple) {
|
||||
if (option && value != null) {
|
||||
selectedValues.add(value);
|
||||
options.forEach(opt => {
|
||||
if (selectedValues.has(opt.dataset.value)) {
|
||||
opt.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
opt.removeAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
updateMultipleLabel();
|
||||
syncMultipleInputs();
|
||||
const event = new CustomEvent('change', {
|
||||
detail: { value, values: Array.from(selectedValues) },
|
||||
bubbles: true
|
||||
});
|
||||
selectComponent.dispatchEvent(event);
|
||||
}
|
||||
} else {
|
||||
selectOption(option);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (!isMultiple) return;
|
||||
selectedValues = new Set(options.map(opt => opt.dataset.value).filter(v => v != null));
|
||||
options.forEach(opt => {
|
||||
if (selectedValues.has(opt.dataset.value)) {
|
||||
opt.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
opt.removeAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
updateMultipleLabel();
|
||||
syncMultipleInputs();
|
||||
const event = new CustomEvent('change', {
|
||||
detail: { values: Array.from(selectedValues) },
|
||||
bubbles: true
|
||||
});
|
||||
selectComponent.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
if (!isMultiple) return;
|
||||
selectedValues.clear();
|
||||
options.forEach(opt => opt.removeAttribute('aria-selected'));
|
||||
updateMultipleLabel();
|
||||
syncMultipleInputs();
|
||||
const event = new CustomEvent('change', {
|
||||
detail: { values: [] },
|
||||
bubbles: true
|
||||
});
|
||||
selectComponent.dispatchEvent(event);
|
||||
};
|
||||
|
||||
if (filter) {
|
||||
@@ -137,13 +259,40 @@
|
||||
filter.addEventListener('input', filterOptions);
|
||||
}
|
||||
|
||||
let initialOption = options.find(opt => opt.dataset.value === input.value);
|
||||
|
||||
if (!initialOption) {
|
||||
initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0];
|
||||
}
|
||||
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));
|
||||
if (initialValues.length > 0) {
|
||||
initialValues.forEach(val => selectedValues.add(val));
|
||||
} else {
|
||||
const preSelected = options.filter(opt => opt.getAttribute('aria-selected') === 'true');
|
||||
if (preSelected.length > 0) {
|
||||
preSelected.forEach(opt => opt.dataset.value != null && selectedValues.add(opt.dataset.value));
|
||||
}
|
||||
}
|
||||
options.forEach(opt => {
|
||||
if (selectedValues.has(opt.dataset.value)) {
|
||||
opt.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
opt.removeAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
updateMultipleLabel();
|
||||
syncMultipleInputs();
|
||||
} else {
|
||||
let initialOption = options.find(opt => opt.dataset.value === input.value);
|
||||
|
||||
updateValue(initialOption, false);
|
||||
if (!initialOption) {
|
||||
initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0];
|
||||
}
|
||||
|
||||
if (initialOption) {
|
||||
updateValue(initialOption, false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyNavigation = (event) => {
|
||||
const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';
|
||||
@@ -169,7 +318,11 @@
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (activeIndex > -1) {
|
||||
selectOption(options[activeIndex]);
|
||||
if (isMultiple) {
|
||||
toggleMultipleValue(options[activeIndex].dataset.value);
|
||||
} else {
|
||||
selectOption(options[activeIndex]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -268,7 +421,11 @@
|
||||
listbox.addEventListener('click', (event) => {
|
||||
const clickedOption = event.target.closest('[role="option"]');
|
||||
if (clickedOption) {
|
||||
selectOption(clickedOption);
|
||||
if (isMultiple) {
|
||||
toggleMultipleValue(clickedOption.dataset.value);
|
||||
} else {
|
||||
selectOption(clickedOption);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -285,8 +442,12 @@
|
||||
});
|
||||
|
||||
popover.setAttribute('aria-hidden', 'true');
|
||||
|
||||
|
||||
selectComponent.selectByValue = selectByValue;
|
||||
if (isMultiple) {
|
||||
selectComponent.selectAll = selectAll;
|
||||
selectComponent.selectNone = selectNone;
|
||||
}
|
||||
selectComponent.dataset.selectInitialized = true;
|
||||
selectComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
|
||||
};
|
||||
|
||||
+47
-19
@@ -2,8 +2,10 @@
|
||||
Renders a select or combobox component.
|
||||
|
||||
@param id {string} [optional] - Unique identifier for the select component.
|
||||
@param selected {string} [optional] - The initially selected value.
|
||||
@param selected {string|array} [optional] - The initially selected value(s).
|
||||
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
|
||||
@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 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.
|
||||
@@ -17,6 +19,8 @@
|
||||
selected=None,
|
||||
name=None,
|
||||
items=None,
|
||||
multiple=false,
|
||||
placeholder=None,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -27,8 +31,9 @@
|
||||
) %}
|
||||
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
|
||||
|
||||
{% set selectedArray = ((selected if (selected is iterable and selected is not string) else [selected]) if selected else []) %}
|
||||
{% set first_option = [] %}
|
||||
{% set selected_option = [] %}
|
||||
{% set selected_options = [] %}
|
||||
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
@@ -37,26 +42,34 @@
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(sub_item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and sub_item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(sub_item), selected_option) %}
|
||||
{% if sub_item.value in selectedArray %}
|
||||
{% set selected_options = (selected_options.push(sub_item), selected_options) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(item), selected_option) %}
|
||||
{% if item.value in selectedArray %}
|
||||
{% set selected_options = (selected_options.push(item), selected_options) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% set default_option = selected_option[0] or first_option[0] or None %}
|
||||
{% set default_option = (selected_options[0] if selected_options[0] else (first_option[0] if first_option[0] else None)) %}
|
||||
{% set labels = [] %}
|
||||
{% if multiple and selected_options %}
|
||||
{% for opt in selected_options %}
|
||||
{% set labels = (labels.push(opt.label), labels) %}
|
||||
{% 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 ''))) %}
|
||||
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
|
||||
{% for key, value in main_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
@@ -72,7 +85,7 @@
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<span class="truncate">{{ default_option.label }}</span>
|
||||
<span class="truncate">{{ default_label }}</span>
|
||||
{% if is_combobox %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
{% else %}
|
||||
@@ -110,25 +123,40 @@
|
||||
id="{{ id }}-listbox"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="{{ id }}-trigger"
|
||||
{% if multiple %}aria-multiselectable="true"{% endif %}
|
||||
{% for key, value in listbox_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items and items.length > 0 %}
|
||||
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
|
||||
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
>
|
||||
{% 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 %}
|
||||
|
||||
@@ -138,7 +166,7 @@
|
||||
@param items {array} - The array of items to render.
|
||||
@param parent_id_prefix {string} [optional] - The prefix for the item id.
|
||||
#}
|
||||
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
|
||||
{% macro render_select_items(items, selectedArray, parent_id_prefix="items") %}
|
||||
{% for item in items %}
|
||||
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
|
||||
{% if item.type == "group" %}
|
||||
@@ -153,7 +181,7 @@
|
||||
{% endif %}
|
||||
>
|
||||
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
{{ render_select_items(item.items, selected, item_id) if item.items }}
|
||||
{{ render_select_items(item.items, selectedArray, item_id) if item.items }}
|
||||
</div>
|
||||
{% elif item.type == "separator" %}
|
||||
<hr role="separator" />
|
||||
@@ -162,7 +190,7 @@
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
{% if selected == item.value %}aria-selected="true"{% endif %}
|
||||
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
|
||||
{% if item.attrs %}
|
||||
{% for key, value in item.attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
|
||||
Reference in New Issue
Block a user