mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-11 12:48:19 +00:00
Compare commits
6
Commits
0.3.10-beta.1
...
0.3.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713c163fb8 | ||
|
|
a7f686e71b | ||
|
|
128f79c2e4 | ||
|
|
ee63071f87 | ||
|
|
def943d74e | ||
|
|
f43c20948b |
@@ -69,14 +69,14 @@
|
||||
{# CSS #}
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
{# JS #}
|
||||
<script src="/assets/js/basecoat.js" defer></script>
|
||||
<script src="/assets/js/command.js" defer></script>
|
||||
<script src="/assets/js/dropdown-menu.js" defer></script>
|
||||
<script src="/assets/js/popover.js" defer></script>
|
||||
<script src="/assets/js/select.js" defer></script>
|
||||
<script src="/assets/js/sidebar.js" defer></script>
|
||||
<script src="/assets/js/tabs.js" defer></script>
|
||||
<script src="/assets/js/toast.js" defer></script>
|
||||
<script src="/assets/js/basecoat.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/command.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/dropdown-menu.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/popover.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/select.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/sidebar.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/tabs.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/toast.js?v={{ pkg.version }}" defer></script>
|
||||
{# HTMX #}
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
{# Highlight.js #}
|
||||
|
||||
@@ -2794,7 +2794,6 @@
|
||||
}
|
||||
@layer components {
|
||||
.button-group {
|
||||
isolation: isolate;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -60,10 +60,10 @@ toc:
|
||||
type: "group",
|
||||
label: "Fruits",
|
||||
items: [
|
||||
{ type: "item", value: "apple", label: "Apple", attrs: {"data-force": "true"} },
|
||||
{ type: "item", value: "banana", label: "Banana", attrs: {"data-keywords": "bread"} },
|
||||
{ type: "item", value: "apple", label: "Apple" },
|
||||
{ type: "item", value: "banana", label: "Banana" },
|
||||
{ type: "item", value: "blueberry", label: "Blueberry" },
|
||||
{ type: "item", value: "pineapple", label: "Grapes" },
|
||||
{ type: "item", value: "grapes", label: "Grapes" },
|
||||
{ type: "item", value: "pineapple", label: "Pineapple" }
|
||||
]
|
||||
}
|
||||
@@ -126,6 +126,7 @@ toc:
|
||||
<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>
|
||||
<li><code>data-close-on-select="true"</code> <span class="badge-secondary">Optional</span>: closes the popover when selecting an option in multiselect mode.</li>
|
||||
</ul>
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><button type="button"></code></dt>
|
||||
@@ -175,8 +176,15 @@ toc:
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -331,7 +387,6 @@ 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">
|
||||
@@ -360,7 +415,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 %}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "basecoat",
|
||||
"version": "0.3.10-beta.1",
|
||||
"version": "0.3.10",
|
||||
"description": "shadcn/ui for the rest of us",
|
||||
"author": "hunvreus",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "basecoat-cli",
|
||||
"version": "0.3.10-beta.1",
|
||||
"version": "0.3.10",
|
||||
"description": "Add Basecoat components to your project",
|
||||
"author": "hunvreus",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "basecoat-css",
|
||||
"version": "0.3.10-beta.1",
|
||||
"version": "0.3.10",
|
||||
"description": "Tailwind CSS for Basecoat components",
|
||||
"author": {
|
||||
"name": "hunvreus",
|
||||
|
||||
@@ -388,7 +388,8 @@
|
||||
/* Button Group */
|
||||
@layer components {
|
||||
.button-group {
|
||||
@apply inline-flex w-fit items-stretch isolate;
|
||||
@apply inline-flex w-fit items-stretch;
|
||||
/* isolate */
|
||||
|
||||
> *:focus-visible,
|
||||
> :is(.dropdown-menu, .popover, .select) > button:focus-visible {
|
||||
|
||||
@@ -83,12 +83,10 @@
|
||||
</footer>
|
||||
{% endif %}
|
||||
{% if close_button %}
|
||||
<form method="dialog">
|
||||
<button aria-label="Close dialog">
|
||||
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
<button type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
|
||||
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</dialog>
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
|
||||
+18
-23
@@ -6,6 +6,7 @@
|
||||
@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 close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
|
||||
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@@ -21,6 +22,7 @@
|
||||
items=[],
|
||||
multiple=false,
|
||||
placeholder=None,
|
||||
close_on_select=false,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -31,7 +33,13 @@
|
||||
) %}
|
||||
{% 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 []) %}
|
||||
{% if selected is defined and selected is iterable and selected is not string %}
|
||||
{% set selectedArray = selected %}
|
||||
{% elif selected is defined %}
|
||||
{% set selectedArray = [selected] %}
|
||||
{% else %}
|
||||
{% set selectedArray = [] %}
|
||||
{% endif %}
|
||||
{% set first_option = namespace(item=None) %}
|
||||
{% set selected_options = namespace(items=[]) %}
|
||||
|
||||
@@ -70,6 +78,7 @@
|
||||
id="{{ id }}"
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
|
||||
{% for key, value in main_attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
@@ -135,28 +144,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 %}
|
||||
|
||||
@@ -189,7 +184,7 @@
|
||||
<div
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
{% if item.value is defined and item.value is not none %}data-value="{{ item.value }}"{% endif %}
|
||||
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
|
||||
{% if item.attrs %}
|
||||
{% for key, value in item.attrs.items() %}
|
||||
|
||||
+152
-143
@@ -22,20 +22,19 @@
|
||||
let visibleOptions = [...options];
|
||||
let activeIndex = -1;
|
||||
const isMultiple = listbox.getAttribute('aria-multiselectable') === 'true';
|
||||
let selectedValues = isMultiple ? new Set() : null;
|
||||
let placeholder = null;
|
||||
const selectedOptions = isMultiple ? new Set() : null;
|
||||
const placeholder = isMultiple ? (selectComponent.dataset.placeholder || '') : null;
|
||||
const closeOnSelect = selectComponent.dataset.closeOnSelect === 'true';
|
||||
|
||||
if (isMultiple) {
|
||||
placeholder = selectComponent.dataset.placeholder || '';
|
||||
}
|
||||
const getValue = (opt) => opt.dataset.value ?? opt.textContent.trim();
|
||||
|
||||
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 +53,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(getValue);
|
||||
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;
|
||||
value = getValue(option);
|
||||
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 +98,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 => getValue(opt) === value && !selectedOptions.has(opt));
|
||||
if (!option) return;
|
||||
selectedOptions.add(option);
|
||||
updateValue(options.filter(opt => selectedOptions.has(opt)));
|
||||
} else {
|
||||
const option = options.find(opt => getValue(opt) === 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 => getValue(opt) === 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 => getValue(opt) === value);
|
||||
if (!option) return;
|
||||
toggleMultipleValue(option);
|
||||
};
|
||||
|
||||
if (filter) {
|
||||
const filterOptions = () => {
|
||||
const searchTerm = filter.value.trim().toLowerCase();
|
||||
|
||||
|
||||
setActiveOption(-1);
|
||||
|
||||
visibleOptions = [];
|
||||
@@ -230,35 +190,36 @@
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
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(getValue));
|
||||
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 => getValue(o) === 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 => getValue(opt) === input.value) || options[0];
|
||||
if (initialOption) updateValue(initialOption, false);
|
||||
}
|
||||
|
||||
const handleKeyNavigation = (event) => {
|
||||
@@ -275,20 +236,27 @@
|
||||
}
|
||||
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);
|
||||
if (closeOnSelect) {
|
||||
closePopover();
|
||||
}
|
||||
} else {
|
||||
selectOption(options[activeIndex]);
|
||||
if (input.value !== getValue(option)) {
|
||||
updateValue(option);
|
||||
}
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -355,7 +323,7 @@
|
||||
document.dispatchEvent(new CustomEvent('basecoat:popover', {
|
||||
detail: { source: selectComponent }
|
||||
}));
|
||||
|
||||
|
||||
if (filter) {
|
||||
if (hasTransition()) {
|
||||
popover.addEventListener('transitionend', () => {
|
||||
@@ -368,7 +336,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 +355,28 @@
|
||||
|
||||
listbox.addEventListener('click', (event) => {
|
||||
const clickedOption = event.target.closest('[role="option"]');
|
||||
if (clickedOption) {
|
||||
if (isMultiple) {
|
||||
toggleMultipleValue(clickedOption.dataset.value);
|
||||
setActiveOption(options.indexOf(clickedOption));
|
||||
if (!clickedOption) return;
|
||||
|
||||
const option = options.find(opt => opt === clickedOption);
|
||||
if (!option) return;
|
||||
|
||||
if (isMultiple) {
|
||||
toggleMultipleValue(option);
|
||||
if (closeOnSelect) {
|
||||
closePopover();
|
||||
} else {
|
||||
setActiveOption(options.indexOf(option));
|
||||
if (filter) {
|
||||
filter.focus();
|
||||
} else {
|
||||
trigger.focus();
|
||||
}
|
||||
} else {
|
||||
selectOption(clickedOption);
|
||||
}
|
||||
} else {
|
||||
if (input.value !== getValue(option)) {
|
||||
updateValue(option);
|
||||
}
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -416,10 +394,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(getValue);
|
||||
} 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 => getValue(o) === v && !opts.includes(o));
|
||||
if (opt) opts.push(opt);
|
||||
});
|
||||
updateValue(opts);
|
||||
} else {
|
||||
const option = options.find(opt => getValue(opt) === 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);
|
||||
selectComponent.selectNone = () => updateValue([]);
|
||||
}
|
||||
selectComponent.dataset.selectInitialized = true;
|
||||
selectComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
|
||||
|
||||
+19
-24
@@ -6,6 +6,7 @@
|
||||
@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 close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
|
||||
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@@ -21,6 +22,7 @@
|
||||
items=None,
|
||||
multiple=false,
|
||||
placeholder=None,
|
||||
close_on_select=false,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -31,7 +33,13 @@
|
||||
) %}
|
||||
{% 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 []) %}
|
||||
{% if selected is defined and selected is iterable and selected is not string %}
|
||||
{% set selectedArray = selected %}
|
||||
{% elif selected is defined %}
|
||||
{% set selectedArray = [selected] %}
|
||||
{% else %}
|
||||
{% set selectedArray = [] %}
|
||||
{% endif %}
|
||||
{% set first_option = [] %}
|
||||
{% set selected_options = [] %}
|
||||
|
||||
@@ -70,6 +78,7 @@
|
||||
id="{{ id }}"
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
|
||||
{% for key, value in main_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
@@ -135,28 +144,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 %}
|
||||
|
||||
@@ -189,7 +184,7 @@
|
||||
<div
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
{% if item.value is defined and item.value is not none %}data-value="{{ item.value }}"{% endif %}
|
||||
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
|
||||
{% if item.attrs %}
|
||||
{% for key, value in item.attrs %}
|
||||
@@ -201,4 +196,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
|
||||
Reference in New Issue
Block a user