mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-11 04:38:18 +00:00
Prepare 1.0.0 beta release
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
- Removed the `.form` convenience selector for Basecoat 1.0. Use explicit component classes (`label`, `input`, `textarea`, `select`) or compose fields with `.field` / `.fieldset`.
|
||||
- Changed Combobox markup and behavior to an input-first structure. The visible input now filters options, the hidden input stores the submitted value, single select stores the selected value, and multiple select stores a JSON array.
|
||||
- Removed Combobox `data-multiple`; use `aria-multiselectable="true"` on the Combobox listbox, matching Select.
|
||||
- Changed Command markup to the migrated Basecoat structure: `.command-dialog` wraps `.command`, the search input lives in the command header, and items use role-based menu markup with `role="menuitem"`.
|
||||
- Removed Combobox-specific search-header behavior from Select. Use the dedicated Combobox component for editable/filterable selection.
|
||||
- Removed built-in document command events for Toast, Sidebar, and Theme. Use element methods instead: `toaster.toast(config)`, `sidebar.open()`, `sidebar.close()`, `sidebar.toggle()`, and `window.basecoat.theme.*`.
|
||||
@@ -22,9 +23,14 @@
|
||||
- Added `window.basecoat.refresh(element)` as a generic dispatcher for components that expose `refresh()`.
|
||||
- Added `refresh()` methods to Command, Select, Combobox, Dropdown Menu, and Tabs for dynamic child lists.
|
||||
- Added method APIs for Sidebar, Toast, and Theme.
|
||||
- Added `data-format="object"` to Select and Combobox for opt-in hidden input serialization as `{ value, label }` objects.
|
||||
- Added `selected` details to Select and Combobox change events and JavaScript properties.
|
||||
- Added `data-filter="manual"` to Command and Combobox for app-owned remote or local-search result filtering.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `window.basecoat.init(name)` and `window.basecoat.initAll()` to initialize uninitialized components only, instead of forcing global reinitialization.
|
||||
- Added internal destroy hooks for JavaScript components so removed component roots clean up event listeners and runtime state.
|
||||
- Reworked component CSS so shared component files own structure and behavior hooks while style-pack files own visual treatment.
|
||||
- Updated Button, Button Group, Input, Textarea, Select, Combobox, Command, Dialog, Dropdown Menu, Popover, Field, Tabs, Table, Card, Alert, Badge, Kbd, Label, Skeleton, Radio, Switch, Empty, Item, and Input Group toward current shadcn/ui styles.
|
||||
- Split Native Select documentation from custom Select documentation.
|
||||
|
||||
@@ -98,6 +98,13 @@ Generated source entrypoints are committed for transparency and package imports:
|
||||
|
||||
These entrypoints are generated by `scripts/generate-css-entrypoints.js` and by the build scripts.
|
||||
|
||||
## JavaScript Lifecycle
|
||||
|
||||
- `window.basecoat.init(name)` initializes uninitialized components for one registered component.
|
||||
- `window.basecoat.initAll()` initializes all uninitialized registered components.
|
||||
- `window.basecoat.refresh(element)` asks an initialized component to rescan dynamic children when it supports refresh.
|
||||
- Components use internal destroy hooks to clean up listeners when initialized roots are removed from the DOM.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
+1
-9088
File diff suppressed because one or more lines are too long
+1
-9029
File diff suppressed because one or more lines are too long
+1
-9025
File diff suppressed because one or more lines are too long
+1
-9083
File diff suppressed because one or more lines are too long
+1
-9084
File diff suppressed because one or more lines are too long
+1
-9037
File diff suppressed because one or more lines are too long
@@ -106,7 +106,7 @@ toc:
|
||||
<section class="prose">
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div class="combobox"></code></dt>
|
||||
<dd>Root element. Add <code>data-multiple="true"</code> for multiple selection and <code>data-auto-highlight="true"</code> to highlight the first match while filtering.</dd>
|
||||
<dd>Root element. Add <code>data-auto-highlight="true"</code> to highlight the first match while filtering. Add <code>data-format="object"</code> to serialize selected values as <code>{ value, label }</code> objects in the hidden input. Add <code>data-filter="manual"</code> when your app owns filtering, such as remote autocomplete or a local Lunr index.</dd>
|
||||
<dt><code class="highlight language-html"><input type="text" role="combobox"></code></dt>
|
||||
<dd>The editable control. It should include <code>aria-expanded</code>, <code>aria-controls</code>, and <code>aria-autocomplete="list"</code>.</dd>
|
||||
<dt><code class="highlight language-html"><div class="combobox-chips"></code> <span class="badge-secondary">Multiple only</span></dt>
|
||||
@@ -118,7 +118,7 @@ toc:
|
||||
<dt><code class="highlight language-html"><div role="option" data-value="..."></code></dt>
|
||||
<dd>Selectable option. Options can contain custom HTML; use <code>data-label</code> when the visible input should use different text than the rendered content.</dd>
|
||||
<dt><code class="highlight language-html"><input type="hidden"></code></dt>
|
||||
<dd>Submitted value. Single mode stores a string; multiple mode stores a JSON array.</dd>
|
||||
<dd>Submitted value. Single mode stores a string; multiple mode stores a JSON array. With <code>data-format="object"</code>, single mode stores a JSON object and multiple mode stores a JSON array of objects.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -131,7 +131,7 @@ toc:
|
||||
<dt><code>basecoat:popover</code></dt>
|
||||
<dd>Dispatched on <code>document</code> when the popup opens so other popup components can close.</dd>
|
||||
<dt><code>change</code></dt>
|
||||
<dd>Dispatched on selection changes with <code>event.detail.value</code>.</dd>
|
||||
<dd>Dispatched on selection changes with <code>event.detail.value</code> and <code>event.detail.selected</code>. <code>value</code> is the canonical value or array of values; <code>selected</code> is the matching <code>{ value, label }</code> object or array.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -141,6 +141,10 @@ toc:
|
||||
<dl>
|
||||
<dt><code>value</code></dt>
|
||||
<dd>Gets or sets the selected value. Multiple mode uses an array.</dd>
|
||||
<dt><code>selected</code></dt>
|
||||
<dd>Gets the selected <code>{ value, label }</code> object. Multiple mode returns an array.</dd>
|
||||
<dt><code>setValue(value)</code></dt>
|
||||
<dd>Sets the selected value. Accepts plain values or <code>{ value, label }</code> objects, which is useful when hydrating remote autocomplete selections before the option exists in the list.</dd>
|
||||
<dt><code>select(value)</code></dt>
|
||||
<dd>Selects an option by value.</dd>
|
||||
<dt><code>deselect(value)</code> <span class="badge-secondary">Multiple only</span></dt>
|
||||
@@ -149,6 +153,8 @@ toc:
|
||||
<dd>Toggles a selected value.</dd>
|
||||
<dt><code>refresh()</code></dt>
|
||||
<dd>Rescans options after changing children inside the existing <code>role="listbox"</code> element.</dd>
|
||||
<dt><code>data-filter="manual"</code></dt>
|
||||
<dd>Disables Basecoat's built-in text filtering. Update the listbox options yourself, then call <code>combobox.refresh()</code>.</dd>
|
||||
<dt><code>window.basecoat.refresh(combobox)</code></dt>
|
||||
<dd>Calls the component refresh method through the global dispatcher.</dd>
|
||||
</dl>
|
||||
|
||||
@@ -150,7 +150,7 @@ toc:
|
||||
<dd>Dialog variant. Wraps one <code><div class="command"></code>. Add <code>aria-label</code> or <code>aria-labelledby</code> for an accessible name.
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div class="command"></code></dt>
|
||||
<dd>Root command menu. Can also be used standalone without the dialog wrapper.
|
||||
<dd>Root command menu. Can also be used standalone without the dialog wrapper. Add <code>data-filter="manual"</code> when your app owns filtering, such as remote search or a local Lunr index.
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><header></code></dt>
|
||||
<dd>Search input wrapper. The macro includes a search icon and input.
|
||||
@@ -191,6 +191,8 @@ toc:
|
||||
<dd>Once the component is initialized, it dispatches a custom non-bubbling <code>basecoat:initialized</code> event on itself.</dd>
|
||||
<dt><code>command.refresh()</code></dt>
|
||||
<dd>Rescans command items after changing children inside the existing <code>role="menu"</code> list.</dd>
|
||||
<dt><code>data-filter="manual"</code></dt>
|
||||
<dd>Disables Basecoat's built-in text filtering. Update the menu items yourself, then call <code>command.refresh()</code>.</dd>
|
||||
<dt><code>window.basecoat.refresh(command)</code></dt>
|
||||
<dd>Calls the component refresh method through the global dispatcher.</dd>
|
||||
</dl>
|
||||
|
||||
@@ -114,6 +114,7 @@ toc:
|
||||
<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>
|
||||
<li><code>data-format="object"</code> <span class="badge-secondary">Optional</span>: serializes selected values as <code>{ value, label }</code> objects in the hidden input.</li>
|
||||
</ul>
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><button type="button"></code></dt>
|
||||
@@ -165,6 +166,7 @@ toc:
|
||||
<p>The hidden input that holds the selected value.</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>With <code>data-format="object"</code>: single-select contains a JSON object (e.g., <code>{"value":"apple","label":"Apple"}</code>) and multiselect contains a JSON array of objects.</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>
|
||||
@@ -186,10 +188,10 @@ toc:
|
||||
<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>
|
||||
<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.value</code>:</p>
|
||||
<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.value</code> and the selected object payload in <code>event.detail.selected</code>:</p>
|
||||
<ul>
|
||||
<li>Single select: <code>{ detail: { value: "something" }}</code> (string)</li>
|
||||
<li>Multiple select: <code>{ detail: { value: ["item1", "item2"] }}</code> (array)</li>
|
||||
<li>Single select: <code>{ detail: { value: "apple", selected: { value: "apple", label: "Apple" } }}</code></li>
|
||||
<li>Multiple select: <code>{ detail: { value: ["apple", "banana"], selected: [{ value: "apple", label: "Apple" }, { value: "banana", label: "Banana" }] }}</code></li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -219,6 +221,10 @@ toc:
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_value | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>selected</code> (property)</dt>
|
||||
<dd>
|
||||
<p>Gets the selected <code>{ value, label }</code> object. For multiselect, returns an array.</p>
|
||||
</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>
|
||||
|
||||
Generated
+4
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
placeholder=None,
|
||||
close_on_select=false,
|
||||
auto_highlight=false,
|
||||
format="value",
|
||||
main_attrs={},
|
||||
input_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -16,19 +17,33 @@
|
||||
hidden_input_attrs={}
|
||||
) %}
|
||||
{% set id = id or ("combobox-" + (range(100000, 999999) | random | string)) %}
|
||||
{% set selected_entries = namespace(items=[]) %}
|
||||
{% if selected is defined and selected is iterable and selected is not string %}
|
||||
{% set selected_values = selected %}
|
||||
{% for item in selected %}
|
||||
{% if item.value is defined %}
|
||||
{% set selected_entries.items = selected_entries.items + [{'value': item.value, 'label': item.label if item.label is defined else item.value}] %}
|
||||
{% else %}
|
||||
{% set selected_entries.items = selected_entries.items + [{'value': item, 'label': item}] %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% elif selected is defined and selected %}
|
||||
{% set selected_values = [selected] %}
|
||||
{% if selected.value is defined %}
|
||||
{% set selected_values = [selected.value] %}
|
||||
{% set selected_entries.items = [{'value': selected.value, 'label': selected.label if selected.label is defined else selected.value}] %}
|
||||
{% else %}
|
||||
{% set selected_values = [selected] %}
|
||||
{% set selected_entries.items = [{'value': selected, 'label': selected}] %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% set selected_values = [] %}
|
||||
{% endif %}
|
||||
{% set hidden_value = (selected_values | tojson if multiple else (selected_values[0] if selected_values else '')) %}
|
||||
{% set hidden_value = ((selected_entries.items | tojson if multiple else ((selected_entries.items[0] if selected_entries.items else none) | tojson)) if format == "object" else (selected_values | tojson if multiple else (selected_values[0] if selected_values else ''))) %}
|
||||
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="combobox {{ main_attrs.class }}"
|
||||
{% if multiple %}data-multiple="true"{% endif %}
|
||||
{% if format == "object" %}data-format="object"{% endif %}
|
||||
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
|
||||
{% if auto_highlight %}data-auto-highlight="true"{% endif %}
|
||||
{% for key, value in main_attrs.items() %}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
multiple=false,
|
||||
placeholder=None,
|
||||
close_on_select=false,
|
||||
format="value",
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -69,10 +70,19 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set default_label = (', '.join(labels) if (multiple and selected_options.items) else ((placeholder or '') if (multiple and not selected_options.items) else (default_option.label if default_option else ''))) %}
|
||||
{% set selected_payload = namespace(items=[]) %}
|
||||
{% if multiple and selected_options.items %}
|
||||
{% for opt in selected_options.items %}
|
||||
{% set selected_payload.items = selected_payload.items + [{'value': opt.value, 'label': opt.label}] %}
|
||||
{% endfor %}
|
||||
{% elif default_option %}
|
||||
{% set selected_payload.items = [{'value': default_option.value, 'label': default_option.label}] %}
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% if format == "object" %}data-format="object"{% endif %}
|
||||
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
|
||||
{% for key, value in main_attrs.items() %}
|
||||
@@ -121,7 +131,7 @@
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ name or id ~ '-value' }}"
|
||||
value="{% if multiple %}{{ selectedArray | tojson }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
|
||||
value="{% if format == 'object' %}{{ (selected_payload.items if multiple else (selected_payload.items[0] if selected_payload.items else none)) | tojson }}{% elif multiple %}{{ selectedArray | tojson }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
|
||||
{% for key, value in input_attrs.items() %}
|
||||
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
+43
-21
@@ -2,8 +2,6 @@
|
||||
const componentRegistry = {};
|
||||
let observer = null;
|
||||
|
||||
const getInitFlag = (name) => `data-${name}-initialized`;
|
||||
|
||||
const registerComponent = (name, selectorOrOptions, initFunction) => {
|
||||
const options = typeof selectorOrOptions === 'object'
|
||||
? selectorOrOptions
|
||||
@@ -22,12 +20,49 @@
|
||||
|
||||
try {
|
||||
component.init(element);
|
||||
element.dataset.basecoatComponent = componentName;
|
||||
if (element.hasAttribute(`data-${componentName}-initialized`)) {
|
||||
element.dataset.basecoatComponent = componentName;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize ${componentName}:`, error);
|
||||
if (typeof element._destroy === 'function') {
|
||||
try {
|
||||
element._destroy();
|
||||
} catch (destroyError) {
|
||||
console.error(`Failed to clean up ${componentName} after initialization error:`, destroyError);
|
||||
}
|
||||
}
|
||||
delete element._destroy;
|
||||
element.removeAttribute(`data-${componentName}-initialized`);
|
||||
delete element.dataset.basecoatComponent;
|
||||
}
|
||||
};
|
||||
|
||||
const destroyComponent = (element) => {
|
||||
if (!element || element.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const componentName = element.dataset?.basecoatComponent;
|
||||
|
||||
if (typeof element._destroy === 'function') {
|
||||
try {
|
||||
element._destroy();
|
||||
} catch (error) {
|
||||
console.error('Failed to destroy Basecoat component:', error);
|
||||
}
|
||||
}
|
||||
|
||||
delete element._destroy;
|
||||
if (componentName) element.removeAttribute(`data-${componentName}-initialized`);
|
||||
delete element.dataset.basecoatComponent;
|
||||
};
|
||||
|
||||
const destroyRemovedComponents = (node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (node.isConnected) return;
|
||||
|
||||
if (node.dataset?.basecoatComponent) destroyComponent(node);
|
||||
node.querySelectorAll('[data-basecoat-component]').forEach(destroyComponent);
|
||||
};
|
||||
|
||||
const initAllComponents = () => {
|
||||
Object.entries(componentRegistry).forEach(([name, { selector }]) => {
|
||||
document.querySelectorAll(selector).forEach(element => initComponent(element, name));
|
||||
@@ -63,6 +98,7 @@
|
||||
observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach(initNewComponents);
|
||||
mutation.removedNodes.forEach(destroyRemovedComponents);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,31 +111,17 @@
|
||||
observer = null;
|
||||
};
|
||||
|
||||
const reinitComponent = (componentName) => {
|
||||
const initRegisteredComponent = (componentName) => {
|
||||
const component = componentRegistry[componentName];
|
||||
if (!component) {
|
||||
console.warn(`Component '${componentName}' not found in registry`);
|
||||
return;
|
||||
}
|
||||
|
||||
const flag = getInitFlag(componentName);
|
||||
document.querySelectorAll(`[${flag}]`).forEach(element => {
|
||||
element.removeAttribute(flag);
|
||||
delete element.dataset.basecoatComponent;
|
||||
});
|
||||
|
||||
document.querySelectorAll(component.selector).forEach(element => initComponent(element, componentName));
|
||||
};
|
||||
|
||||
const reinitAll = () => {
|
||||
Object.entries(componentRegistry).forEach(([name]) => {
|
||||
const flag = getInitFlag(name);
|
||||
document.querySelectorAll(`[${flag}]`).forEach(element => {
|
||||
element.removeAttribute(flag);
|
||||
delete element.dataset.basecoatComponent;
|
||||
});
|
||||
});
|
||||
|
||||
const initAllRegisteredComponents = () => {
|
||||
initAllComponents();
|
||||
};
|
||||
|
||||
@@ -113,8 +135,8 @@
|
||||
|
||||
window.basecoat = {
|
||||
register: registerComponent,
|
||||
init: reinitComponent,
|
||||
initAll: reinitAll,
|
||||
init: initRegisteredComponent,
|
||||
initAll: initAllRegisteredComponents,
|
||||
refresh: refreshComponent,
|
||||
start: startObserver,
|
||||
stop: stopObserver,
|
||||
|
||||
+205
-100
@@ -12,7 +12,17 @@
|
||||
|
||||
const getValue = option => option.dataset.value ?? option.textContent.trim();
|
||||
const getLabel = option => option.dataset.label || option.textContent.trim();
|
||||
const getFormat = root => root.dataset.format === 'object' ? 'object' : 'value';
|
||||
const isDisabled = option => option.getAttribute('aria-disabled') === 'true';
|
||||
const toSelected = option => ({ value: getValue(option), label: getLabel(option) });
|
||||
const normalizeEntry = entry => {
|
||||
if (entry && typeof entry === 'object') {
|
||||
const value = entry.value == null ? '' : String(entry.value);
|
||||
return value ? { value, label: String(entry.label ?? entry.value) } : null;
|
||||
}
|
||||
const value = entry == null ? '' : String(entry);
|
||||
return value ? { value, label: value } : null;
|
||||
};
|
||||
|
||||
const getOptions = (listbox) => {
|
||||
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
|
||||
@@ -22,6 +32,53 @@
|
||||
};
|
||||
};
|
||||
|
||||
const getSelection = (state) => Array.from(state.selected.values());
|
||||
const getCanonicalValue = (state) => state.isMultiple ? getSelection(state).map(item => item.value) : (getSelection(state)[0]?.value || '');
|
||||
const getSelectedDetail = (state) => state.isMultiple ? getSelection(state) : (getSelection(state)[0] || null);
|
||||
|
||||
const serializeSelection = (state) => {
|
||||
const selected = getSelection(state);
|
||||
if (state.format === 'object') {
|
||||
return JSON.stringify(state.isMultiple ? selected : (selected[0] || null));
|
||||
}
|
||||
const value = selected.map(item => item.value);
|
||||
return state.isMultiple ? JSON.stringify(value) : (value[0] || '');
|
||||
};
|
||||
|
||||
const parseStoredSelection = (storedValue, inputValue, state) => {
|
||||
if (state.isMultiple) {
|
||||
let parsed = [];
|
||||
try {
|
||||
parsed = JSON.parse(storedValue || '[]');
|
||||
} catch (_) {
|
||||
parsed = [];
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map(item => {
|
||||
const entry = normalizeEntry(state.format === 'object' ? item : { value: item, label: state.selected.get(String(item))?.label ?? item });
|
||||
if (!entry) return null;
|
||||
const option = state.options.find(opt => getValue(opt) === entry.value);
|
||||
return option ? toSelected(option) : entry;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
if (state.format === 'object') {
|
||||
try {
|
||||
const entry = normalizeEntry(JSON.parse(storedValue || 'null'));
|
||||
if (!entry) return [];
|
||||
const option = state.options.find(opt => getValue(opt) === entry.value);
|
||||
return [option ? toSelected(option) : entry];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const value = storedValue || '';
|
||||
if (!value) return [];
|
||||
const option = state.options.find(opt => getValue(opt) === value);
|
||||
return [option ? toSelected(option) : { value, label: state.selected.get(value)?.label || inputValue || value }];
|
||||
};
|
||||
|
||||
const scrollOptionIntoListbox = (state, option) => {
|
||||
const optionRect = option.getBoundingClientRect();
|
||||
const listboxRect = state.listbox.getBoundingClientRect();
|
||||
@@ -56,9 +113,22 @@
|
||||
|
||||
const filterOptions = (state, { preserveActive = false } = {}) => {
|
||||
const previousActive = state.activeIndex > -1 ? state.options[state.activeIndex] : null;
|
||||
const search = state.input.value.trim().toLowerCase();
|
||||
state.visibleOptions = [];
|
||||
|
||||
if (state.manualFilter) {
|
||||
state.visibleOptions = state.options.filter(option => option.getAttribute('aria-hidden') !== 'true');
|
||||
|
||||
if (preserveActive && previousActive && state.visibleOptions.includes(previousActive)) {
|
||||
setActiveOption(state, state.options.indexOf(previousActive));
|
||||
} else {
|
||||
setActiveOption(state, state.autoHighlight && state.visibleOptions.length > 0 ? state.options.indexOf(state.visibleOptions[0]) : -1);
|
||||
}
|
||||
syncEmptyState(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const search = state.input.value.trim().toLowerCase();
|
||||
|
||||
state.allOptions.forEach(option => {
|
||||
if (option.hasAttribute('data-force')) {
|
||||
option.setAttribute('aria-hidden', 'false');
|
||||
@@ -86,64 +156,60 @@
|
||||
if (!state.chips) return;
|
||||
|
||||
state.chips.querySelectorAll('.combobox-chip').forEach(chip => chip.remove());
|
||||
const selected = state.options.filter(option => state.selectedOptions.has(option));
|
||||
|
||||
selected.forEach(option => {
|
||||
getSelection(state).forEach(entry => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'combobox-chip';
|
||||
chip.dataset.value = getValue(option);
|
||||
chip.textContent = getLabel(option);
|
||||
chip.dataset.value = entry.value;
|
||||
chip.textContent = entry.label;
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'combobox-chip-remove';
|
||||
remove.setAttribute('aria-label', `Remove ${getLabel(option)}`);
|
||||
remove.setAttribute('aria-label', `Remove ${entry.label}`);
|
||||
remove.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>';
|
||||
remove.addEventListener('click', () => root.deselect(getValue(option)));
|
||||
remove.addEventListener('click', () => root.deselect(entry.value));
|
||||
|
||||
chip.appendChild(remove);
|
||||
state.chips.insertBefore(chip, state.input);
|
||||
});
|
||||
};
|
||||
|
||||
const updateValue = (root, optionOrOptions, triggerEvent = true) => {
|
||||
const state = states.get(root);
|
||||
let value;
|
||||
const syncSelectedOptions = (state) => {
|
||||
state.options.forEach(option => {
|
||||
if (state.selected.has(getValue(option))) {
|
||||
option.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
option.removeAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setSelected = (root, entries, triggerEvent = true) => {
|
||||
const state = states.get(root);
|
||||
const normalized = (Array.isArray(entries) ? entries : [entries]).map(normalizeEntry).filter(Boolean);
|
||||
|
||||
state.selected.clear();
|
||||
if (state.isMultiple) {
|
||||
const selected = Array.isArray(optionOrOptions) ? optionOrOptions : [];
|
||||
state.selectedOptions.clear();
|
||||
selected.forEach(option => state.selectedOptions.add(option));
|
||||
state.options.forEach(option => {
|
||||
if (state.selectedOptions.has(option)) {
|
||||
option.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
option.removeAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
value = state.options.filter(option => state.selectedOptions.has(option)).map(getValue);
|
||||
state.hiddenInput.value = JSON.stringify(value);
|
||||
normalized.forEach(entry => state.selected.set(entry.value, entry));
|
||||
state.input.value = '';
|
||||
} else if (normalized[0]) {
|
||||
state.selected.set(normalized[0].value, normalized[0]);
|
||||
state.input.value = normalized[0].label;
|
||||
} else {
|
||||
state.input.value = '';
|
||||
}
|
||||
|
||||
state.hiddenInput.value = serializeSelection(state);
|
||||
syncSelectedOptions(state);
|
||||
if (state.isMultiple) {
|
||||
renderChips(root);
|
||||
filterOptions(state, { preserveActive: true });
|
||||
} else {
|
||||
const option = optionOrOptions;
|
||||
if (!option) return;
|
||||
state.options.forEach(opt => {
|
||||
if (opt === option) {
|
||||
opt.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
opt.removeAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
value = getValue(option);
|
||||
state.hiddenInput.value = value;
|
||||
state.input.value = getLabel(option);
|
||||
}
|
||||
|
||||
if (triggerEvent) {
|
||||
root.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value },
|
||||
detail: { value: getCanonicalValue(state), selected: getSelectedDetail(state) },
|
||||
bubbles: true,
|
||||
}));
|
||||
}
|
||||
@@ -175,47 +241,55 @@
|
||||
const previousValue = elements.hiddenInput.value;
|
||||
const previousInputValue = elements.input.value;
|
||||
Object.assign(state, elements, getOptions(elements.listbox));
|
||||
state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true' || root.dataset.multiple === 'true';
|
||||
state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true';
|
||||
state.closeOnSelect = root.dataset.closeOnSelect === 'true';
|
||||
state.autoHighlight = root.dataset.autoHighlight === 'true';
|
||||
if (state.isMultiple && !state.selectedOptions) state.selectedOptions = new Set();
|
||||
state.manualFilter = root.dataset.filter === 'manual';
|
||||
state.format = getFormat(root);
|
||||
|
||||
if (state.isMultiple) {
|
||||
let values = [];
|
||||
try {
|
||||
const parsed = JSON.parse(previousValue || '[]');
|
||||
values = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
values = [];
|
||||
}
|
||||
const ariaSelected = state.options.filter(option => option.getAttribute('aria-selected') === 'true').map(getValue);
|
||||
const selectedValues = values.length ? values : ariaSelected;
|
||||
updateValue(root, state.options.filter(option => selectedValues.includes(getValue(option))), false);
|
||||
const stored = parseStoredSelection(previousValue, previousInputValue, state);
|
||||
if (stored.length > 0) {
|
||||
setSelected(root, stored, false);
|
||||
} else {
|
||||
const selected = state.options.find(option => getValue(option) === previousValue)
|
||||
|| state.options.find(option => option.getAttribute('aria-selected') === 'true');
|
||||
if (selected) {
|
||||
updateValue(root, selected, false);
|
||||
const selectedOptions = state.options.filter(option => option.getAttribute('aria-selected') === 'true').map(toSelected);
|
||||
if (selectedOptions.length > 0) {
|
||||
setSelected(root, state.isMultiple ? selectedOptions : selectedOptions[0], false);
|
||||
} else if (state.isMultiple) {
|
||||
setSelected(root, [], false);
|
||||
} else {
|
||||
state.input.value = previousInputValue;
|
||||
state.options.forEach(option => option.removeAttribute('aria-selected'));
|
||||
state.selected.clear();
|
||||
state.hiddenInput.value = '';
|
||||
syncSelectedOptions(state);
|
||||
filterOptions(state, { preserveActive: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resolveEntry = (state, value) => {
|
||||
if (value && typeof value === 'object') {
|
||||
const entry = normalizeEntry(value);
|
||||
if (!entry) return null;
|
||||
const option = state.options.find(opt => getValue(opt) === entry.value);
|
||||
return option ? toSelected(option) : entry;
|
||||
}
|
||||
|
||||
const option = state.options.find(opt => getValue(opt) === value);
|
||||
if (option) return toSelected(option);
|
||||
const existing = state.selected.get(String(value));
|
||||
return existing || null;
|
||||
};
|
||||
|
||||
const selectValue = (root, value) => {
|
||||
const state = states.get(root);
|
||||
const option = state.options.find(opt => getValue(opt) === value);
|
||||
if (!option) return;
|
||||
const entry = resolveEntry(state, value);
|
||||
if (!entry) return;
|
||||
|
||||
if (state.isMultiple) {
|
||||
const selected = new Set(state.selectedOptions);
|
||||
selected.add(option);
|
||||
updateValue(root, state.options.filter(opt => selected.has(opt)));
|
||||
setSelected(root, [...getSelection(state).filter(item => item.value !== entry.value), entry]);
|
||||
if (state.closeOnSelect) root.close(true);
|
||||
} else {
|
||||
updateValue(root, option);
|
||||
setSelected(root, entry);
|
||||
root.close(true);
|
||||
}
|
||||
};
|
||||
@@ -223,11 +297,9 @@
|
||||
const deselectValue = (root, value) => {
|
||||
const state = states.get(root);
|
||||
if (!state.isMultiple) return;
|
||||
const option = state.options.find(opt => getValue(opt) === value);
|
||||
if (!option || !state.selectedOptions.has(option)) return;
|
||||
const selected = new Set(state.selectedOptions);
|
||||
selected.delete(option);
|
||||
updateValue(root, state.options.filter(opt => selected.has(opt)));
|
||||
const normalized = String(value);
|
||||
if (!state.selected.has(normalized)) return;
|
||||
setSelected(root, getSelection(state).filter(item => item.value !== normalized));
|
||||
};
|
||||
|
||||
const handleKeydown = (event, root) => {
|
||||
@@ -237,9 +309,9 @@
|
||||
const isOpen = state.popover.getAttribute('aria-hidden') === 'false';
|
||||
|
||||
if (event.key === 'Backspace' && state.isMultiple && state.input.value === '') {
|
||||
const selected = state.options.filter(option => state.selectedOptions.has(option));
|
||||
const selected = getSelection(state);
|
||||
const last = selected[selected.length - 1];
|
||||
if (last) root.deselect(getValue(last));
|
||||
if (last) root.deselect(last.value);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -283,13 +355,14 @@
|
||||
const initCombobox = (root) => {
|
||||
if (root.dataset.comboboxInitialized) return;
|
||||
|
||||
const state = { root, activeIndex: -1, allOptions: [], options: [], visibleOptions: [], selectedOptions: null };
|
||||
const state = { root, activeIndex: -1, allOptions: [], options: [], visibleOptions: [], selected: new Map(), format: 'value', manualFilter: false };
|
||||
states.set(root, state);
|
||||
root.refresh = () => refreshCombobox(root);
|
||||
|
||||
refreshCombobox(root);
|
||||
if (!state.input || !state.popover || !state.listbox || !state.hiddenInput) {
|
||||
states.delete(root);
|
||||
delete root.refresh;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,63 +378,95 @@
|
||||
|
||||
root.select = (value) => selectValue(root, value);
|
||||
root.selectByValue = root.select;
|
||||
root.setValue = (value) => {
|
||||
const entries = state.isMultiple ? (Array.isArray(value) ? value : (value == null ? [] : [value])) : [value];
|
||||
const resolved = entries.map(entry => resolveEntry(state, entry) || normalizeEntry(entry)).filter(Boolean);
|
||||
setSelected(root, state.isMultiple ? resolved : resolved[0]);
|
||||
};
|
||||
if (state.isMultiple) {
|
||||
root.deselect = (value) => deselectValue(root, value);
|
||||
root.toggle = (value) => {
|
||||
const option = state.options.find(opt => getValue(opt) === value);
|
||||
if (!option) return;
|
||||
state.selectedOptions.has(option) ? root.deselect(value) : root.select(value);
|
||||
const entry = resolveEntry(state, value);
|
||||
if (!entry) return;
|
||||
state.selected.has(entry.value) ? root.deselect(entry.value) : root.select(entry);
|
||||
};
|
||||
root.selectAll = () => updateValue(root, state.options);
|
||||
root.selectNone = () => updateValue(root, []);
|
||||
root.selectAll = () => setSelected(root, state.options.map(toSelected));
|
||||
root.selectNone = () => setSelected(root, []);
|
||||
}
|
||||
|
||||
state.input.addEventListener('focus', root.open);
|
||||
state.input.addEventListener('click', root.open);
|
||||
state.input.addEventListener('input', () => {
|
||||
const handleInputFocus = root.open;
|
||||
const handleInputClick = root.open;
|
||||
const handleInput = () => {
|
||||
root.open();
|
||||
filterOptions(state);
|
||||
if (!state.isMultiple) {
|
||||
state.hiddenInput.value = '';
|
||||
state.options.forEach(option => option.removeAttribute('aria-selected'));
|
||||
state.selected.clear();
|
||||
syncSelectedOptions(state);
|
||||
}
|
||||
});
|
||||
state.input.addEventListener('keydown', (event) => handleKeydown(event, root));
|
||||
|
||||
state.listbox.addEventListener('mousemove', (event) => {
|
||||
};
|
||||
const handleInputKeydown = (event) => handleKeydown(event, root);
|
||||
const handleListboxMousemove = (event) => {
|
||||
const option = event.target.closest('[role="option"]');
|
||||
if (option && state.visibleOptions.includes(option)) setActiveOption(state, state.options.indexOf(option));
|
||||
});
|
||||
|
||||
state.listbox.addEventListener('click', (event) => {
|
||||
};
|
||||
const handleListboxClick = (event) => {
|
||||
const option = event.target.closest('[role="option"]');
|
||||
if (!option || !state.options.includes(option)) return;
|
||||
state.isMultiple ? root.toggle(getValue(option)) : root.select(getValue(option));
|
||||
if (state.isMultiple && !state.closeOnSelect) state.input.focus();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
};
|
||||
const handleDocumentClick = (event) => {
|
||||
if (!root.contains(event.target)) root.close(false);
|
||||
});
|
||||
|
||||
document.addEventListener('basecoat:popover', (event) => {
|
||||
};
|
||||
const handleDocumentPopover = (event) => {
|
||||
if (event.detail.source !== root) root.close(false);
|
||||
});
|
||||
};
|
||||
|
||||
state.input.addEventListener('focus', handleInputFocus);
|
||||
state.input.addEventListener('click', handleInputClick);
|
||||
state.input.addEventListener('input', handleInput);
|
||||
state.input.addEventListener('keydown', handleInputKeydown);
|
||||
state.listbox.addEventListener('mousemove', handleListboxMousemove);
|
||||
state.listbox.addEventListener('click', handleListboxClick);
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener('basecoat:popover', handleDocumentPopover);
|
||||
|
||||
root._destroy = () => {
|
||||
state.input.removeEventListener('focus', handleInputFocus);
|
||||
state.input.removeEventListener('click', handleInputClick);
|
||||
state.input.removeEventListener('input', handleInput);
|
||||
state.input.removeEventListener('keydown', handleInputKeydown);
|
||||
state.listbox.removeEventListener('mousemove', handleListboxMousemove);
|
||||
state.listbox.removeEventListener('click', handleListboxClick);
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('basecoat:popover', handleDocumentPopover);
|
||||
state.chips?.querySelectorAll('.combobox-chip').forEach(chip => chip.remove());
|
||||
states.delete(root);
|
||||
delete root.refresh;
|
||||
delete root.open;
|
||||
delete root.close;
|
||||
delete root.select;
|
||||
delete root.selectByValue;
|
||||
delete root.setValue;
|
||||
delete root.deselect;
|
||||
delete root.toggle;
|
||||
delete root.selectAll;
|
||||
delete root.selectNone;
|
||||
};
|
||||
|
||||
state.popover.setAttribute('aria-hidden', 'true');
|
||||
state.input.setAttribute('aria-expanded', 'false');
|
||||
|
||||
Object.defineProperty(root, 'value', {
|
||||
configurable: true,
|
||||
get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : state.hiddenInput.value,
|
||||
set: (value) => {
|
||||
if (state.isMultiple) {
|
||||
const values = Array.isArray(value) ? value : (value == null ? [] : [value]);
|
||||
updateValue(root, state.options.filter(option => values.includes(getValue(option))));
|
||||
} else {
|
||||
root.select(value);
|
||||
}
|
||||
},
|
||||
get: () => getCanonicalValue(state),
|
||||
set: (value) => root.setValue(value),
|
||||
});
|
||||
|
||||
Object.defineProperty(root, 'selected', {
|
||||
configurable: true,
|
||||
get: () => getSelectedDetail(state),
|
||||
});
|
||||
|
||||
root.dataset.comboboxInitialized = 'true';
|
||||
|
||||
+32
-9
@@ -49,6 +49,15 @@
|
||||
};
|
||||
|
||||
const filterItems = (state) => {
|
||||
if (state.manualFilter) {
|
||||
setActiveItem(state, -1);
|
||||
state.visibleItems = state.items.filter(item => item.getAttribute('aria-hidden') !== 'true');
|
||||
if (state.visibleItems.length > 0) {
|
||||
setActiveItem(state, state.items.indexOf(state.visibleItems[0]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const searchTerm = state.input.value.trim().toLowerCase();
|
||||
|
||||
setActiveItem(state, -1);
|
||||
@@ -92,6 +101,7 @@
|
||||
}
|
||||
|
||||
Object.assign(state, elements, getItems(elements.menu));
|
||||
state.manualFilter = container.dataset.filter === 'manual';
|
||||
filterItems(state);
|
||||
};
|
||||
|
||||
@@ -126,7 +136,7 @@
|
||||
const initCommand = (container) => {
|
||||
if (container.dataset.commandInitialized) return;
|
||||
|
||||
const state = { activeIndex: -1, allItems: [], items: [], visibleItems: [] };
|
||||
const state = { activeIndex: -1, allItems: [], items: [], visibleItems: [], manualFilter: false };
|
||||
states.set(container, state);
|
||||
|
||||
container.refresh = () => refreshCommand(container);
|
||||
@@ -138,28 +148,41 @@
|
||||
if (!elements.menu) missing.push('menu');
|
||||
console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container);
|
||||
states.delete(container);
|
||||
delete container.refresh;
|
||||
return;
|
||||
}
|
||||
Object.assign(state, elements);
|
||||
|
||||
state.input.addEventListener('input', () => filterItems(state));
|
||||
state.input.addEventListener('keydown', (event) => handleKeyNavigation(event, state));
|
||||
|
||||
state.menu.addEventListener('mousemove', (event) => {
|
||||
const handleInput = () => filterItems(state);
|
||||
const handleInputKeydown = (event) => handleKeyNavigation(event, state);
|
||||
const handleMenuMousemove = (event) => {
|
||||
const menuItem = event.target.closest('[role="menuitem"]');
|
||||
if (menuItem && state.visibleItems.includes(menuItem)) {
|
||||
const index = state.items.indexOf(menuItem);
|
||||
if (index !== state.activeIndex) setActiveItem(state, index);
|
||||
}
|
||||
});
|
||||
|
||||
state.menu.addEventListener('click', (event) => {
|
||||
};
|
||||
const handleMenuClick = (event) => {
|
||||
const clickedItem = event.target.closest('[role="menuitem"]');
|
||||
if (clickedItem && state.visibleItems.includes(clickedItem)) {
|
||||
const dialog = container.closest('dialog.command-dialog');
|
||||
if (dialog && !clickedItem.hasAttribute('data-keep-command-open')) dialog.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
state.input.addEventListener('input', handleInput);
|
||||
state.input.addEventListener('keydown', handleInputKeydown);
|
||||
state.menu.addEventListener('mousemove', handleMenuMousemove);
|
||||
state.menu.addEventListener('click', handleMenuClick);
|
||||
|
||||
container._destroy = () => {
|
||||
state.input.removeEventListener('input', handleInput);
|
||||
state.input.removeEventListener('keydown', handleInputKeydown);
|
||||
state.menu.removeEventListener('mousemove', handleMenuMousemove);
|
||||
state.menu.removeEventListener('click', handleMenuClick);
|
||||
states.delete(container);
|
||||
delete container.refresh;
|
||||
};
|
||||
|
||||
refreshCommand(container);
|
||||
container.dataset.commandInitialized = 'true';
|
||||
|
||||
+36
-12
@@ -76,6 +76,7 @@
|
||||
refreshDropdownMenu(root);
|
||||
if (!state.trigger || !state.popover || !state.menu) {
|
||||
states.delete(root);
|
||||
delete root.refresh;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,9 +84,9 @@
|
||||
root.close = (focusOnTrigger = true) => closePopover(state, focusOnTrigger);
|
||||
root.toggle = () => state.trigger.getAttribute('aria-expanded') === 'true' ? root.close() : root.open(false);
|
||||
|
||||
state.trigger.addEventListener('click', root.toggle);
|
||||
const handleTriggerClick = root.toggle;
|
||||
|
||||
root.addEventListener('keydown', (event) => {
|
||||
const handleKeydown = (event) => {
|
||||
const isExpanded = state.trigger.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
@@ -132,29 +133,52 @@
|
||||
}
|
||||
|
||||
if (nextIndex !== state.activeIndex) setActiveItem(state, nextIndex);
|
||||
});
|
||||
};
|
||||
|
||||
state.menu.addEventListener('mousemove', (event) => {
|
||||
const handleMenuMousemove = (event) => {
|
||||
const menuItem = event.target.closest('[role^="menuitem"]');
|
||||
if (menuItem && !isDisabled(menuItem) && state.items.includes(menuItem)) {
|
||||
const index = state.items.indexOf(menuItem);
|
||||
if (index !== state.activeIndex) setActiveItem(state, index);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
state.menu.addEventListener('mouseleave', () => setActiveItem(state, -1));
|
||||
state.menu.addEventListener('click', (event) => {
|
||||
const handleMenuMouseleave = () => setActiveItem(state, -1);
|
||||
const handleMenuClick = (event) => {
|
||||
const menuItem = event.target.closest('[role^="menuitem"]');
|
||||
if (menuItem && !isDisabled(menuItem)) root.close();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const handleDocumentClick = (event) => {
|
||||
if (!root.contains(event.target)) root.close(false);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('basecoat:popover', (event) => {
|
||||
const handleDocumentPopover = (event) => {
|
||||
if (event.detail.source !== root) root.close(false);
|
||||
});
|
||||
};
|
||||
|
||||
state.trigger.addEventListener('click', handleTriggerClick);
|
||||
root.addEventListener('keydown', handleKeydown);
|
||||
state.menu.addEventListener('mousemove', handleMenuMousemove);
|
||||
state.menu.addEventListener('mouseleave', handleMenuMouseleave);
|
||||
state.menu.addEventListener('click', handleMenuClick);
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener('basecoat:popover', handleDocumentPopover);
|
||||
|
||||
root._destroy = () => {
|
||||
state.trigger.removeEventListener('click', handleTriggerClick);
|
||||
root.removeEventListener('keydown', handleKeydown);
|
||||
state.menu.removeEventListener('mousemove', handleMenuMousemove);
|
||||
state.menu.removeEventListener('mouseleave', handleMenuMouseleave);
|
||||
state.menu.removeEventListener('click', handleMenuClick);
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('basecoat:popover', handleDocumentPopover);
|
||||
states.delete(root);
|
||||
delete root.refresh;
|
||||
delete root.open;
|
||||
delete root.close;
|
||||
delete root.toggle;
|
||||
};
|
||||
|
||||
state.trigger.setAttribute('aria-expanded', 'false');
|
||||
state.popover.setAttribute('aria-hidden', 'true');
|
||||
|
||||
+22
-8
@@ -1,5 +1,7 @@
|
||||
(() => {
|
||||
const initPopover = (popoverComponent) => {
|
||||
if (popoverComponent.dataset.popoverInitialized) return;
|
||||
|
||||
const trigger = popoverComponent.querySelector(':scope > button');
|
||||
const content = popoverComponent.querySelector(':scope > [data-popover]');
|
||||
|
||||
@@ -36,32 +38,44 @@
|
||||
content.setAttribute('aria-hidden', 'false');
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', () => {
|
||||
const handleTriggerClick = () => {
|
||||
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
|
||||
if (isExpanded) {
|
||||
closePopover();
|
||||
} else {
|
||||
openPopover();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
popoverComponent.addEventListener('keydown', (event) => {
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const handleDocumentClick = (event) => {
|
||||
if (!popoverComponent.contains(event.target)) {
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('basecoat:popover', (event) => {
|
||||
const handleDocumentPopover = (event) => {
|
||||
if (event.detail.source !== popoverComponent) {
|
||||
closePopover(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', handleTriggerClick);
|
||||
popoverComponent.addEventListener('keydown', handleKeydown);
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener('basecoat:popover', handleDocumentPopover);
|
||||
|
||||
popoverComponent._destroy = () => {
|
||||
trigger.removeEventListener('click', handleTriggerClick);
|
||||
popoverComponent.removeEventListener('keydown', handleKeydown);
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('basecoat:popover', handleDocumentPopover);
|
||||
};
|
||||
|
||||
popoverComponent.dataset.popoverInitialized = true;
|
||||
popoverComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
|
||||
|
||||
+5
-1
@@ -11,7 +11,11 @@
|
||||
if (element.dataset.rangeInitialized) return;
|
||||
|
||||
updateRange(element);
|
||||
element.addEventListener('input', () => updateRange(element));
|
||||
const handleInput = () => updateRange(element);
|
||||
element.addEventListener('input', handleInput);
|
||||
element._destroy = () => {
|
||||
element.removeEventListener('input', handleInput);
|
||||
};
|
||||
element.dataset.rangeInitialized = 'true';
|
||||
}
|
||||
|
||||
|
||||
+108
-36
@@ -11,7 +11,10 @@
|
||||
};
|
||||
|
||||
const getValue = (option) => option.dataset.value ?? option.textContent.trim();
|
||||
const getLabel = (option) => option.dataset.label || option.textContent.trim();
|
||||
const getFormat = (root) => root.dataset.format === 'object' ? 'object' : 'value';
|
||||
const isDisabled = (option) => option.getAttribute('aria-disabled') === 'true';
|
||||
const toSelected = (option) => ({ value: getValue(option), label: getLabel(option) });
|
||||
|
||||
const getOptions = (listbox) => {
|
||||
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
|
||||
@@ -21,6 +24,41 @@
|
||||
};
|
||||
};
|
||||
|
||||
const parseStoredValues = (storedValue, { isMultiple, format }) => {
|
||||
if (isMultiple) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedValue || '[]');
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map(item => format === 'object' && item && typeof item === 'object' ? item.value : item)
|
||||
.filter(value => value != null)
|
||||
.map(String);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (format === 'object') {
|
||||
try {
|
||||
const parsed = JSON.parse(storedValue || 'null');
|
||||
return parsed && typeof parsed === 'object' && parsed.value != null ? String(parsed.value) : '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return storedValue || '';
|
||||
};
|
||||
|
||||
const serializeSelection = (state, selected) => {
|
||||
if (state.format === 'object') {
|
||||
return JSON.stringify(state.isMultiple ? selected : (selected[0] || null));
|
||||
}
|
||||
|
||||
const value = selected.map(item => item.value);
|
||||
return state.isMultiple ? JSON.stringify(value) : (value[0] || '');
|
||||
};
|
||||
|
||||
const scrollOptionIntoListbox = (state, option) => {
|
||||
const optionRect = option.getBoundingClientRect();
|
||||
const listboxRect = state.listbox.getBoundingClientRect();
|
||||
@@ -55,6 +93,7 @@
|
||||
const updateValue = (root, optionOrOptions, triggerEvent = true) => {
|
||||
const state = states.get(root);
|
||||
let value;
|
||||
let selectedDetail;
|
||||
|
||||
if (state.isMultiple) {
|
||||
const selected = Array.isArray(optionOrOptions) ? optionOrOptions : [];
|
||||
@@ -62,22 +101,24 @@
|
||||
selected.forEach(option => state.selectedOptions.add(option));
|
||||
|
||||
const selectedInOrder = state.options.filter(option => state.selectedOptions.has(option));
|
||||
selectedDetail = selectedInOrder.map(toSelected);
|
||||
if (selectedInOrder.length === 0) {
|
||||
state.selectedLabel.textContent = state.placeholder;
|
||||
state.selectedLabel.classList.add('text-muted-foreground');
|
||||
} else {
|
||||
state.selectedLabel.textContent = selectedInOrder.map(option => option.dataset.label || option.textContent.trim()).join(', ');
|
||||
state.selectedLabel.textContent = selectedDetail.map(item => item.label).join(', ');
|
||||
state.selectedLabel.classList.remove('text-muted-foreground');
|
||||
}
|
||||
|
||||
value = selectedInOrder.map(getValue);
|
||||
state.input.value = JSON.stringify(value);
|
||||
value = selectedDetail.map(item => item.value);
|
||||
state.input.value = serializeSelection(state, selectedDetail);
|
||||
} else {
|
||||
const option = optionOrOptions;
|
||||
if (!option) return;
|
||||
state.selectedLabel.innerHTML = option.innerHTML;
|
||||
value = getValue(option);
|
||||
state.input.value = value;
|
||||
selectedDetail = toSelected(option);
|
||||
value = selectedDetail.value;
|
||||
state.input.value = serializeSelection(state, [selectedDetail]);
|
||||
}
|
||||
|
||||
state.options.forEach(option => {
|
||||
@@ -91,7 +132,7 @@
|
||||
|
||||
if (triggerEvent) {
|
||||
root.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value },
|
||||
detail: { value, selected: selectedDetail },
|
||||
bubbles: true,
|
||||
}));
|
||||
}
|
||||
@@ -125,24 +166,20 @@
|
||||
Object.assign(state, elements, getOptions(elements.listbox));
|
||||
state.visibleOptions = [...state.options];
|
||||
state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true';
|
||||
state.format = getFormat(root);
|
||||
state.placeholder = state.isMultiple ? (root.dataset.placeholder || '') : null;
|
||||
state.closeOnSelect = root.dataset.closeOnSelect === 'true';
|
||||
|
||||
if (state.isMultiple) {
|
||||
if (!state.selectedOptions) state.selectedOptions = new Set();
|
||||
let values = [];
|
||||
try {
|
||||
const parsed = JSON.parse(previousValue || '[]');
|
||||
values = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
values = [];
|
||||
}
|
||||
const values = parseStoredValues(previousValue, state);
|
||||
const selected = values.length
|
||||
? values.map(value => state.options.find(option => getValue(option) === value)).filter(Boolean)
|
||||
: state.options.filter(option => option.getAttribute('aria-selected') === 'true');
|
||||
updateValue(root, selected, false);
|
||||
} else {
|
||||
const selected = state.options.find(option => getValue(option) === previousValue)
|
||||
const value = parseStoredValues(previousValue, state);
|
||||
const selected = state.options.find(option => getValue(option) === value)
|
||||
|| state.options.find(option => option.getAttribute('aria-selected') === 'true');
|
||||
state.options.forEach(option => option.removeAttribute('aria-selected'));
|
||||
if (selected) updateValue(root, selected, false);
|
||||
@@ -172,7 +209,7 @@
|
||||
} else {
|
||||
const option = state.options.find(opt => getValue(opt) === value);
|
||||
if (!option) return;
|
||||
if (state.input.value !== value) updateValue(root, option);
|
||||
if (root.value !== value) updateValue(root, option);
|
||||
closePopover(state);
|
||||
}
|
||||
};
|
||||
@@ -214,7 +251,7 @@
|
||||
toggleMultipleValue(root, option);
|
||||
if (state.closeOnSelect) root.close();
|
||||
} else {
|
||||
if (state.input.value !== getValue(option)) updateValue(root, option);
|
||||
if (root.value !== getValue(option)) updateValue(root, option);
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
@@ -241,13 +278,14 @@
|
||||
const initSelect = (root) => {
|
||||
if (root.dataset.selectInitialized) return;
|
||||
|
||||
const state = { activeIndex: -1, selectedOptions: null, options: [], allOptions: [], visibleOptions: [] };
|
||||
const state = { activeIndex: -1, selectedOptions: null, options: [], allOptions: [], visibleOptions: [], format: 'value' };
|
||||
states.set(root, state);
|
||||
root.refresh = () => refreshSelect(root);
|
||||
|
||||
refreshSelect(root);
|
||||
if (!state.trigger || !state.selectedLabel || !state.popover || !state.listbox || !state.input) {
|
||||
states.delete(root);
|
||||
delete root.refresh;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,23 +304,20 @@
|
||||
root.close = (focusOnTrigger = true) => closePopover(state, focusOnTrigger);
|
||||
root.togglePopover = () => state.trigger.getAttribute('aria-expanded') === 'true' ? root.close() : root.open();
|
||||
|
||||
state.trigger.addEventListener('keydown', (event) => handleKeyNavigation(event, root));
|
||||
state.trigger.addEventListener('click', root.togglePopover);
|
||||
|
||||
state.listbox.addEventListener('mousemove', (event) => {
|
||||
const handleTriggerKeydown = (event) => handleKeyNavigation(event, root);
|
||||
const handleTriggerClick = root.togglePopover;
|
||||
const handleListboxMousemove = (event) => {
|
||||
const option = event.target.closest('[role="option"]');
|
||||
if (option && state.visibleOptions.includes(option)) {
|
||||
const index = state.options.indexOf(option);
|
||||
if (index !== state.activeIndex) setActiveOption(state, index);
|
||||
}
|
||||
});
|
||||
|
||||
state.listbox.addEventListener('mouseleave', () => {
|
||||
};
|
||||
const handleListboxMouseleave = () => {
|
||||
const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]');
|
||||
setActiveOption(state, selectedOption ? state.options.indexOf(selectedOption) : -1);
|
||||
});
|
||||
|
||||
state.listbox.addEventListener('click', (event) => {
|
||||
};
|
||||
const handleListboxClick = (event) => {
|
||||
const clickedOption = event.target.closest('[role="option"]');
|
||||
if (!clickedOption) return;
|
||||
|
||||
@@ -298,22 +333,49 @@
|
||||
state.trigger.focus();
|
||||
}
|
||||
} else {
|
||||
if (state.input.value !== getValue(option)) updateValue(root, option);
|
||||
if (root.value !== getValue(option)) updateValue(root, option);
|
||||
root.close();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
};
|
||||
const handleDocumentClick = (event) => {
|
||||
if (!root.contains(event.target)) root.close(false);
|
||||
});
|
||||
|
||||
document.addEventListener('basecoat:popover', (event) => {
|
||||
};
|
||||
const handleDocumentPopover = (event) => {
|
||||
if (event.detail.source !== root) root.close(false);
|
||||
});
|
||||
};
|
||||
|
||||
state.trigger.addEventListener('keydown', handleTriggerKeydown);
|
||||
state.trigger.addEventListener('click', handleTriggerClick);
|
||||
state.listbox.addEventListener('mousemove', handleListboxMousemove);
|
||||
state.listbox.addEventListener('mouseleave', handleListboxMouseleave);
|
||||
state.listbox.addEventListener('click', handleListboxClick);
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener('basecoat:popover', handleDocumentPopover);
|
||||
|
||||
root._destroy = () => {
|
||||
state.trigger.removeEventListener('keydown', handleTriggerKeydown);
|
||||
state.trigger.removeEventListener('click', handleTriggerClick);
|
||||
state.listbox.removeEventListener('mousemove', handleListboxMousemove);
|
||||
state.listbox.removeEventListener('mouseleave', handleListboxMouseleave);
|
||||
state.listbox.removeEventListener('click', handleListboxClick);
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('basecoat:popover', handleDocumentPopover);
|
||||
states.delete(root);
|
||||
delete root.refresh;
|
||||
delete root.open;
|
||||
delete root.close;
|
||||
delete root.togglePopover;
|
||||
delete root.select;
|
||||
delete root.selectByValue;
|
||||
delete root.deselect;
|
||||
delete root.toggle;
|
||||
delete root.selectAll;
|
||||
delete root.selectNone;
|
||||
};
|
||||
|
||||
Object.defineProperty(root, 'value', {
|
||||
configurable: true,
|
||||
get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : state.input.value,
|
||||
get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : parseStoredValues(state.input.value, state),
|
||||
set: (value) => {
|
||||
if (state.isMultiple) {
|
||||
const values = Array.isArray(value) ? value : (value != null ? [value] : []);
|
||||
@@ -328,6 +390,16 @@
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(root, 'selected', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
if (state.isMultiple) return state.options.filter(option => state.selectedOptions.has(option)).map(toSelected);
|
||||
const value = root.value;
|
||||
const option = state.options.find(opt => getValue(opt) === value);
|
||||
return option ? toSelected(option) : null;
|
||||
},
|
||||
});
|
||||
|
||||
root.select = (value) => selectValue(root, value);
|
||||
root.selectByValue = root.select;
|
||||
if (state.isMultiple) {
|
||||
|
||||
+11
-2
@@ -28,7 +28,7 @@
|
||||
sidebarComponent.close = () => setState(false);
|
||||
sidebarComponent.toggle = () => setState(!open);
|
||||
|
||||
sidebarComponent.addEventListener('click', (event) => {
|
||||
const handleClick = (event) => {
|
||||
const target = event.target;
|
||||
const nav = sidebarComponent.querySelector('nav');
|
||||
const isMobile = window.innerWidth < breakpoint;
|
||||
@@ -43,7 +43,16 @@
|
||||
if (document.activeElement) document.activeElement.blur();
|
||||
sidebarComponent.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
sidebarComponent.addEventListener('click', handleClick);
|
||||
|
||||
sidebarComponent._destroy = () => {
|
||||
sidebarComponent.removeEventListener('click', handleClick);
|
||||
delete sidebarComponent.open;
|
||||
delete sidebarComponent.close;
|
||||
delete sidebarComponent.toggle;
|
||||
};
|
||||
|
||||
updateState();
|
||||
sidebarComponent.dataset.sidebarInitialized = 'true';
|
||||
|
||||
+21
-5
@@ -48,14 +48,19 @@
|
||||
|
||||
root.select = selectTab;
|
||||
refreshTabs(root);
|
||||
if (!state.tablist) return;
|
||||
if (!state.tablist) {
|
||||
states.delete(root);
|
||||
delete root.refresh;
|
||||
delete root.select;
|
||||
return;
|
||||
}
|
||||
|
||||
state.tablist.addEventListener('click', (event) => {
|
||||
const handleClick = (event) => {
|
||||
const clickedTab = event.target.closest('[role="tab"]');
|
||||
if (clickedTab) root.select(clickedTab);
|
||||
});
|
||||
};
|
||||
|
||||
state.tablist.addEventListener('keydown', (event) => {
|
||||
const handleKeydown = (event) => {
|
||||
const currentTab = event.target;
|
||||
if (!state.tabs.includes(currentTab)) return;
|
||||
|
||||
@@ -75,7 +80,18 @@
|
||||
|
||||
event.preventDefault();
|
||||
root.select(nextTab, true);
|
||||
});
|
||||
};
|
||||
|
||||
state.tablist.addEventListener('click', handleClick);
|
||||
state.tablist.addEventListener('keydown', handleKeydown);
|
||||
|
||||
root._destroy = () => {
|
||||
state.tablist.removeEventListener('click', handleClick);
|
||||
state.tablist.removeEventListener('keydown', handleKeydown);
|
||||
states.delete(root);
|
||||
delete root.refresh;
|
||||
delete root.select;
|
||||
};
|
||||
|
||||
root.dataset.tabsInitialized = 'true';
|
||||
root.dispatchEvent(new CustomEvent('basecoat:initialized'));
|
||||
|
||||
+20
-4
@@ -13,15 +13,17 @@
|
||||
if (toasterElement.dataset.toasterInitialized) return;
|
||||
toaster = toasterElement;
|
||||
|
||||
toaster.addEventListener('mouseenter', pauseAllTimeouts);
|
||||
toaster.addEventListener('mouseleave', resumeAllTimeouts);
|
||||
toaster.addEventListener('click', (event) => {
|
||||
const handleClick = (event) => {
|
||||
const actionLink = event.target.closest('.toast footer a');
|
||||
const actionButton = event.target.closest('.toast footer button');
|
||||
if (actionLink || actionButton) {
|
||||
closeToast(event.target.closest('.toast'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
toaster.addEventListener('mouseenter', pauseAllTimeouts);
|
||||
toaster.addEventListener('mouseleave', resumeAllTimeouts);
|
||||
toaster.addEventListener('click', handleClick);
|
||||
|
||||
toasterElement.toast = (config = {}) => {
|
||||
const toastElement = createToast(config);
|
||||
@@ -34,6 +36,15 @@
|
||||
};
|
||||
|
||||
toaster.querySelectorAll('.toast:not([data-toast-initialized])').forEach(initToast);
|
||||
toaster._destroy = () => {
|
||||
toaster.removeEventListener('mouseenter', pauseAllTimeouts);
|
||||
toaster.removeEventListener('mouseleave', resumeAllTimeouts);
|
||||
toaster.removeEventListener('click', handleClick);
|
||||
toaster.querySelectorAll('.toast[data-toast-initialized]').forEach(toast => toast._destroy?.());
|
||||
delete toaster.toast;
|
||||
delete toaster.closeAll;
|
||||
if (toaster === toasterElement) toaster = null;
|
||||
};
|
||||
toaster.dataset.toasterInitialized = 'true';
|
||||
toaster.dispatchEvent(new CustomEvent('basecoat:initialized'));
|
||||
}
|
||||
@@ -63,6 +74,11 @@
|
||||
toasts.set(element, state);
|
||||
|
||||
element.close = () => closeToast(element);
|
||||
element._destroy = () => {
|
||||
clearTimeout(state.timeoutId);
|
||||
toasts.delete(element);
|
||||
delete element.close;
|
||||
};
|
||||
element.dataset.toastInitialized = 'true';
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@param placeholder {string} [optional] - Placeholder text for the visible input.
|
||||
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
|
||||
@param auto_highlight {boolean} [optional] [default=false] - Highlights the first matching option while filtering.
|
||||
@param format {string} [optional] [default="value"] - Hidden input serialization format: "value" or "object".
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the root div.
|
||||
@param input_attrs {object} [optional] - Additional HTML attributes for the visible input.
|
||||
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover div.
|
||||
@@ -22,6 +23,7 @@
|
||||
placeholder=None,
|
||||
close_on_select=false,
|
||||
auto_highlight=false,
|
||||
format="value",
|
||||
main_attrs={},
|
||||
input_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -30,17 +32,32 @@
|
||||
) %}
|
||||
{% set id = id or ("combobox-" + (range(100000, 999999) | random | string)) %}
|
||||
{% set selected_values = [] %}
|
||||
{% set selected_entries = [] %}
|
||||
{% if selected is defined and selected is iterable and selected is not string %}
|
||||
{% set selected_values = selected %}
|
||||
{% for item in selected %}
|
||||
{% if item.value is defined %}
|
||||
{% set selected_values = (selected_values.push(item.value), selected_values) %}
|
||||
{% set selected_entries = (selected_entries.push({ value: item.value, label: (item.label if item.label is defined else item.value) }), selected_entries) %}
|
||||
{% else %}
|
||||
{% set selected_values = (selected_values.push(item), selected_values) %}
|
||||
{% set selected_entries = (selected_entries.push({ value: item, label: item }), selected_entries) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% elif selected is defined and selected %}
|
||||
{% set selected_values = [selected] %}
|
||||
{% if selected.value is defined %}
|
||||
{% set selected_values = [selected.value] %}
|
||||
{% set selected_entries = [{ value: selected.value, label: (selected.label if selected.label is defined else selected.value) }] %}
|
||||
{% else %}
|
||||
{% set selected_values = [selected] %}
|
||||
{% set selected_entries = [{ value: selected, label: selected }] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set hidden_value = (selected_values | dump if multiple else (selected_values[0] if selected_values[0] else '')) %}
|
||||
{% set hidden_value = ((selected_entries | dump if multiple else ((selected_entries[0] if selected_entries[0] else None) | dump)) if format == "object" else (selected_values | dump if multiple else (selected_values[0] if selected_values[0] else ''))) %}
|
||||
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="combobox {{ main_attrs.class }}"
|
||||
{% if multiple %}data-multiple="true"{% endif %}
|
||||
{% if format == "object" %}data-format="object"{% endif %}
|
||||
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
|
||||
{% if auto_highlight %}data-auto-highlight="true"{% endif %}
|
||||
{% for key, value in main_attrs %}
|
||||
|
||||
+12
-1
@@ -7,6 +7,7 @@
|
||||
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
|
||||
@param placeholder {string} [optional] - Placeholder text shown when no options are selected (multiple mode only).
|
||||
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
|
||||
@param format {string} [optional] [default="value"] - Hidden input serialization format: "value" or "object".
|
||||
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
|
||||
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
|
||||
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@@ -21,6 +22,7 @@
|
||||
multiple=false,
|
||||
placeholder=None,
|
||||
close_on_select=false,
|
||||
format="value",
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -69,10 +71,19 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set default_label = (labels | join(', ') if (multiple and selected_options) else ((placeholder or '') if (multiple and not selected_options) else (default_option.label if default_option else ''))) %}
|
||||
{% set selected_payload = [] %}
|
||||
{% if multiple and selected_options %}
|
||||
{% for opt in selected_options %}
|
||||
{% set selected_payload = (selected_payload.push({ value: opt.value, label: opt.label }), selected_payload) %}
|
||||
{% endfor %}
|
||||
{% elif default_option %}
|
||||
{% set selected_payload = [{ value: default_option.value, label: default_option.label }] %}
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% if format == "object" %}data-format="object"{% endif %}
|
||||
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
|
||||
{% for key, value in main_attrs %}
|
||||
@@ -121,7 +132,7 @@
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ name or id ~ '-value' }}"
|
||||
value="{% if multiple %}{{ selectedArray | dump }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
|
||||
value="{% if format == 'object' %}{{ (selected_payload if multiple else (selected_payload[0] if selected_payload[0] else None)) | dump }}{% elif multiple %}{{ selectedArray | dump }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
|
||||
{% for key, value in input_attrs %}
|
||||
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user