mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-14 22:18:19 +00:00
Prepare 1.0.0-beta.5
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
(() => {
|
||||
const states = new WeakMap();
|
||||
|
||||
const isDisabled = (details) => {
|
||||
const summary = details.querySelector(':scope > summary');
|
||||
return details.getAttribute('aria-disabled') === 'true'
|
||||
|| details.dataset.disabled === 'true'
|
||||
|| summary?.getAttribute('aria-disabled') === 'true';
|
||||
};
|
||||
|
||||
const isMultiple = (root) => root.hasAttribute('data-multiple');
|
||||
|
||||
const closeSiblings = (root, activeDetails) => {
|
||||
if (isMultiple(root) || !activeDetails.open) return;
|
||||
root.querySelectorAll(':scope > details[open]').forEach((details) => {
|
||||
if (details !== activeDetails) details.open = false;
|
||||
});
|
||||
};
|
||||
|
||||
const initAccordion = (root) => {
|
||||
if (root.dataset.accordionInitialized) return;
|
||||
|
||||
const handleClick = (event) => {
|
||||
const summary = event.target.closest('summary');
|
||||
const details = summary?.closest('details');
|
||||
if (!details || details.parentElement !== root || !isDisabled(details)) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
const summary = event.target.closest('summary');
|
||||
const details = summary?.closest('details');
|
||||
if (!details || details.parentElement !== root || !isDisabled(details)) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleToggle = (event) => {
|
||||
const details = event.target;
|
||||
if (details.parentElement !== root) return;
|
||||
if (isDisabled(details)) {
|
||||
details.open = false;
|
||||
return;
|
||||
}
|
||||
closeSiblings(root, details);
|
||||
};
|
||||
|
||||
root.addEventListener('click', handleClick);
|
||||
root.addEventListener('keydown', handleKeydown);
|
||||
root.addEventListener('toggle', handleToggle, true);
|
||||
root.querySelectorAll(':scope > details[open]').forEach((details) => closeSiblings(root, details));
|
||||
|
||||
states.set(root, { handleClick, handleToggle });
|
||||
root._destroy = () => {
|
||||
root.removeEventListener('click', handleClick);
|
||||
root.removeEventListener('keydown', handleKeydown);
|
||||
root.removeEventListener('toggle', handleToggle, true);
|
||||
states.delete(root);
|
||||
};
|
||||
|
||||
root.dataset.accordionInitialized = 'true';
|
||||
root.dispatchEvent(new CustomEvent('basecoat:initialized'));
|
||||
};
|
||||
|
||||
if (window.basecoat) {
|
||||
window.basecoat.register('accordion', '.accordion:not([data-accordion-initialized])', initAccordion);
|
||||
}
|
||||
})();
|
||||
+115
-26
@@ -2,12 +2,29 @@
|
||||
const states = new WeakMap();
|
||||
|
||||
const getElements = (root) => {
|
||||
const input = root.querySelector(':scope > input[role="combobox"], :scope > .combobox-chips input[role="combobox"]');
|
||||
const chips = root.querySelector(':scope > .combobox-chips');
|
||||
const popover = root.querySelector(':scope > [data-popover]');
|
||||
const input = root.querySelector(':scope > input[role="combobox"], :scope > .input-group input[role="combobox"], :scope > .combobox-chips input[role="combobox"]')
|
||||
|| popover?.querySelector('input[role="combobox"]');
|
||||
const chips = root.querySelector(':scope > .combobox-chips');
|
||||
const popupTrigger = root.querySelector(':scope > button[aria-haspopup="listbox"]');
|
||||
const trigger = popupTrigger || root.querySelector(':scope > .input-group button[aria-haspopup="listbox"]');
|
||||
const clearButton = root.querySelector('[data-clear]');
|
||||
const valueTarget = popupTrigger?.querySelector('[data-value]') || (popupTrigger?.matches('[data-value]') ? popupTrigger : null);
|
||||
const listbox = popover ? popover.querySelector('[role="listbox"]') : null;
|
||||
const hiddenInput = root.querySelector(':scope > input[type="hidden"]');
|
||||
return { input, chips, popover, listbox, hiddenInput };
|
||||
return { input, chips, trigger, clearButton, valueTarget, popover, listbox, hiddenInput };
|
||||
};
|
||||
|
||||
const ensureMultipleInputSurface = (root, elements) => {
|
||||
if (elements.listbox?.getAttribute('aria-multiselectable') !== 'true' || elements.chips || !elements.input) return elements;
|
||||
if (elements.input.parentElement !== root) return elements;
|
||||
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'combobox-chips';
|
||||
root.insertBefore(chips, elements.input);
|
||||
chips.appendChild(elements.input);
|
||||
|
||||
return getElements(root);
|
||||
};
|
||||
|
||||
const getValue = option => option.dataset.value ?? option.textContent.trim();
|
||||
@@ -111,7 +128,7 @@
|
||||
state.popover.dataset.empty = String(state.visibleOptions.length === 0);
|
||||
};
|
||||
|
||||
const filterOptions = (state, { preserveActive = false } = {}) => {
|
||||
const filterOptions = (state, { preserveActive = false, search: forcedSearch } = {}) => {
|
||||
const previousActive = state.activeIndex > -1 ? state.options[state.activeIndex] : null;
|
||||
state.visibleOptions = [];
|
||||
|
||||
@@ -127,7 +144,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const search = state.input.value.trim().toLowerCase();
|
||||
const search = (forcedSearch ?? state.input.value).trim().toLowerCase();
|
||||
|
||||
state.allOptions.forEach(option => {
|
||||
if (option.hasAttribute('data-force')) {
|
||||
@@ -161,20 +178,42 @@
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'combobox-chip';
|
||||
chip.dataset.value = entry.value;
|
||||
chip.textContent = entry.label;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = entry.label;
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'combobox-chip-remove';
|
||||
remove.className = 'combobox-chip-remove btn';
|
||||
remove.dataset.variant = 'ghost';
|
||||
remove.dataset.size = 'icon-xs';
|
||||
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(entry.value));
|
||||
remove.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
root.deselect(entry.value);
|
||||
state.input.focus();
|
||||
});
|
||||
|
||||
chip.appendChild(label);
|
||||
chip.appendChild(remove);
|
||||
state.chips.insertBefore(chip, state.input);
|
||||
});
|
||||
};
|
||||
|
||||
const syncTriggerValue = (state) => {
|
||||
if (!state.valueTarget) return;
|
||||
const selected = getSelection(state);
|
||||
state.valueTarget.textContent = state.isMultiple
|
||||
? selected.map(entry => entry.label).join(', ')
|
||||
: (selected[0]?.label || state.valueTarget.dataset.placeholder || '');
|
||||
};
|
||||
|
||||
const syncClearButton = (state) => {
|
||||
if (!state.clearButton) return;
|
||||
state.clearButton.hidden = getSelection(state).length === 0 && state.input.value === '';
|
||||
};
|
||||
|
||||
const syncSelectedOptions = (state) => {
|
||||
state.options.forEach(option => {
|
||||
if (state.selected.has(getValue(option))) {
|
||||
@@ -195,7 +234,7 @@
|
||||
state.input.value = '';
|
||||
} else if (normalized[0]) {
|
||||
state.selected.set(normalized[0].value, normalized[0]);
|
||||
state.input.value = normalized[0].label;
|
||||
state.input.value = state.valueTarget ? '' : normalized[0].label;
|
||||
} else {
|
||||
state.input.value = '';
|
||||
}
|
||||
@@ -206,6 +245,8 @@
|
||||
renderChips(root);
|
||||
filterOptions(state, { preserveActive: true });
|
||||
}
|
||||
syncTriggerValue(state);
|
||||
syncClearButton(state);
|
||||
|
||||
if (triggerEvent) {
|
||||
root.dispatchEvent(new CustomEvent('change', {
|
||||
@@ -219,15 +260,35 @@
|
||||
if (state.popover.getAttribute('aria-hidden') === 'true') return;
|
||||
state.popover.setAttribute('aria-hidden', 'true');
|
||||
state.input.setAttribute('aria-expanded', 'false');
|
||||
state.trigger?.setAttribute('aria-expanded', 'false');
|
||||
setActiveOption(state, -1);
|
||||
if (focusInput) state.input.focus();
|
||||
if (focusInput) {
|
||||
state.skipOpenOnFocus = true;
|
||||
state.input.focus();
|
||||
requestAnimationFrame(() => { state.skipOpenOnFocus = false; });
|
||||
}
|
||||
};
|
||||
|
||||
const openPopover = (state) => {
|
||||
const { root } = state;
|
||||
if (state.popover.getAttribute('aria-hidden') === 'false') return;
|
||||
document.dispatchEvent(new CustomEvent('basecoat:popover', { detail: { source: root } }));
|
||||
root.refresh();
|
||||
const selected = getSelection(state)[0];
|
||||
if (state.valueTarget) state.input.value = '';
|
||||
const search = !state.isMultiple && selected && state.input.value === selected.label ? '' : undefined;
|
||||
filterOptions(state, { search });
|
||||
state.popover.setAttribute('aria-hidden', 'false');
|
||||
state.input.setAttribute('aria-expanded', 'true');
|
||||
state.trigger?.setAttribute('aria-expanded', 'true');
|
||||
if (state.trigger) state.input.focus();
|
||||
};
|
||||
|
||||
const refreshCombobox = (root) => {
|
||||
const state = states.get(root);
|
||||
if (!state) return;
|
||||
|
||||
const elements = getElements(root);
|
||||
let elements = getElements(root);
|
||||
if (!elements.input || !elements.popover || !elements.listbox || !elements.hiddenInput) {
|
||||
const missing = [];
|
||||
if (!elements.input) missing.push('input');
|
||||
@@ -238,6 +299,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
elements = ensureMultipleInputSurface(root, elements);
|
||||
const previousValue = elements.hiddenInput.value;
|
||||
const previousInputValue = elements.input.value;
|
||||
Object.assign(state, elements, getOptions(elements.listbox));
|
||||
@@ -264,6 +326,8 @@
|
||||
filterOptions(state, { preserveActive: true });
|
||||
}
|
||||
}
|
||||
syncTriggerValue(state);
|
||||
syncClearButton(state);
|
||||
};
|
||||
|
||||
const resolveEntry = (state, value) => {
|
||||
@@ -290,7 +354,8 @@
|
||||
if (state.closeOnSelect) root.close(true);
|
||||
} else {
|
||||
setSelected(root, entry);
|
||||
root.close(true);
|
||||
root.close(state.trigger ? false : true);
|
||||
state.trigger?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -302,6 +367,15 @@
|
||||
setSelected(root, getSelection(state).filter(item => item.value !== normalized));
|
||||
};
|
||||
|
||||
const clearValue = (root) => {
|
||||
const state = states.get(root);
|
||||
setSelected(root, [], true);
|
||||
state.input.value = '';
|
||||
filterOptions(state);
|
||||
syncClearButton(state);
|
||||
if (state.popover.getAttribute('aria-hidden') === 'false') state.input.focus();
|
||||
};
|
||||
|
||||
const handleKeydown = (event, root) => {
|
||||
const state = states.get(root);
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape', 'Backspace'].includes(event.key)) return;
|
||||
@@ -322,7 +396,7 @@
|
||||
|
||||
if (!isOpen && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
root.open();
|
||||
openPopover(state);
|
||||
}
|
||||
|
||||
if (state.popover.getAttribute('aria-hidden') === 'true') return;
|
||||
@@ -355,7 +429,7 @@
|
||||
const initCombobox = (root) => {
|
||||
if (root.dataset.comboboxInitialized) return;
|
||||
|
||||
const state = { root, activeIndex: -1, allOptions: [], options: [], visibleOptions: [], selected: new Map(), format: 'value', manualFilter: false };
|
||||
const state = { root, activeIndex: -1, allOptions: [], options: [], visibleOptions: [], selected: new Map(), format: 'value', manualFilter: false, skipOpenOnFocus: false };
|
||||
states.set(root, state);
|
||||
root.refresh = () => refreshCombobox(root);
|
||||
|
||||
@@ -366,14 +440,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
root.open = () => {
|
||||
if (state.popover.getAttribute('aria-hidden') === 'false') return;
|
||||
document.dispatchEvent(new CustomEvent('basecoat:popover', { detail: { source: root } }));
|
||||
root.refresh();
|
||||
filterOptions(state);
|
||||
state.popover.setAttribute('aria-hidden', 'false');
|
||||
state.input.setAttribute('aria-expanded', 'true');
|
||||
};
|
||||
root.close = (focusInput = false) => closePopover(state, focusInput);
|
||||
|
||||
root.select = (value) => selectValue(root, value);
|
||||
@@ -394,17 +460,34 @@
|
||||
root.selectNone = () => setSelected(root, []);
|
||||
}
|
||||
|
||||
const handleInputFocus = root.open;
|
||||
const handleInputClick = root.open;
|
||||
const handleInputFocus = () => {
|
||||
if (state.skipOpenOnFocus) return;
|
||||
openPopover(state);
|
||||
};
|
||||
const handleInputClick = () => openPopover(state);
|
||||
const handleInput = () => {
|
||||
root.open();
|
||||
openPopover(state);
|
||||
filterOptions(state);
|
||||
if (!state.isMultiple) {
|
||||
state.hiddenInput.value = '';
|
||||
state.selected.clear();
|
||||
syncSelectedOptions(state);
|
||||
syncTriggerValue(state);
|
||||
syncClearButton(state);
|
||||
}
|
||||
};
|
||||
const handleTriggerClick = () => {
|
||||
if (state.popover.getAttribute('aria-hidden') === 'false') {
|
||||
root.close(false);
|
||||
} else {
|
||||
openPopover(state);
|
||||
}
|
||||
};
|
||||
const handleClearClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearValue(root);
|
||||
};
|
||||
const handleInputKeydown = (event) => handleKeydown(event, root);
|
||||
const handleListboxMousemove = (event) => {
|
||||
const option = event.target.closest('[role="option"]');
|
||||
@@ -427,6 +510,8 @@
|
||||
state.input.addEventListener('click', handleInputClick);
|
||||
state.input.addEventListener('input', handleInput);
|
||||
state.input.addEventListener('keydown', handleInputKeydown);
|
||||
state.trigger?.addEventListener('click', handleTriggerClick);
|
||||
state.clearButton?.addEventListener('click', handleClearClick);
|
||||
state.listbox.addEventListener('mousemove', handleListboxMousemove);
|
||||
state.listbox.addEventListener('click', handleListboxClick);
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
@@ -437,6 +522,8 @@
|
||||
state.input.removeEventListener('click', handleInputClick);
|
||||
state.input.removeEventListener('input', handleInput);
|
||||
state.input.removeEventListener('keydown', handleInputKeydown);
|
||||
state.trigger?.removeEventListener('click', handleTriggerClick);
|
||||
state.clearButton?.removeEventListener('click', handleClearClick);
|
||||
state.listbox.removeEventListener('mousemove', handleListboxMousemove);
|
||||
state.listbox.removeEventListener('click', handleListboxClick);
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
@@ -444,11 +531,11 @@
|
||||
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.clear;
|
||||
delete root.deselect;
|
||||
delete root.toggle;
|
||||
delete root.selectAll;
|
||||
@@ -457,6 +544,8 @@
|
||||
|
||||
state.popover.setAttribute('aria-hidden', 'true');
|
||||
state.input.setAttribute('aria-expanded', 'false');
|
||||
state.trigger?.setAttribute('aria-expanded', 'false');
|
||||
root.clear = () => clearValue(root);
|
||||
|
||||
Object.defineProperty(root, 'value', {
|
||||
configurable: true,
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
const states = new WeakMap();
|
||||
|
||||
const isDisabled = (item) =>
|
||||
item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true';
|
||||
item.hasAttribute('disabled') ||
|
||||
item.getAttribute('aria-disabled') === 'true' ||
|
||||
item.getAttribute('data-disabled') === 'true';
|
||||
|
||||
const getElements = (container) => ({
|
||||
input: container.querySelector('header input'),
|
||||
|
||||
+12
-1
@@ -146,7 +146,18 @@
|
||||
const handleMenuMouseleave = () => setActiveItem(state, -1);
|
||||
const handleMenuClick = (event) => {
|
||||
const menuItem = event.target.closest('[role^="menuitem"]');
|
||||
if (menuItem && !isDisabled(menuItem)) root.close();
|
||||
if (!menuItem || isDisabled(menuItem)) return;
|
||||
|
||||
if (menuItem.getAttribute('role') === 'menuitemcheckbox') {
|
||||
menuItem.setAttribute('aria-checked', menuItem.getAttribute('aria-checked') !== 'true');
|
||||
} else if (menuItem.getAttribute('role') === 'menuitemradio') {
|
||||
const group = menuItem.closest('[role="group"], [role="menu"]');
|
||||
group?.querySelectorAll('[role="menuitemradio"]').forEach((item) => {
|
||||
if (!isDisabled(item)) item.setAttribute('aria-checked', item === menuItem ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
root.close();
|
||||
};
|
||||
|
||||
const handleDocumentClick = (event) => {
|
||||
|
||||
Reference in New Issue
Block a user