Migrate empty and item docs to Basecoat APIs

This commit is contained in:
Ronan Berder
2026-06-03 18:59:21 +08:00
parent 317c490e0c
commit 7e7d509e90
164 changed files with 15779 additions and 2940 deletions
+323
View File
@@ -0,0 +1,323 @@
(() => {
const initCombobox = (combobox) => {
const input = combobox.querySelector(':scope > input[role="combobox"], :scope > .combobox-chips input[role="combobox"]');
const chips = combobox.querySelector(':scope > .combobox-chips');
const popover = combobox.querySelector(':scope > [data-popover]');
const listbox = popover ? popover.querySelector('[role="listbox"]') : null;
const hiddenInput = combobox.querySelector(':scope > input[type="hidden"]');
if (!input || !popover || !listbox || !hiddenInput) {
const missing = [];
if (!input) missing.push('input');
if (!popover) missing.push('popover');
if (!listbox) missing.push('listbox');
if (!hiddenInput) missing.push('hidden input');
console.error(`Combobox initialisation failed. Missing element(s): ${missing.join(', ')}`, combobox);
return;
}
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
const options = allOptions.filter(option => option.getAttribute('aria-disabled') !== 'true');
const isMultiple = listbox.getAttribute('aria-multiselectable') === 'true' || combobox.dataset.multiple === 'true';
const closeOnSelect = combobox.dataset.closeOnSelect === 'true';
const autoHighlight = combobox.dataset.autoHighlight === 'true';
const selectedOptions = isMultiple ? new Set() : null;
let visibleOptions = [...options];
let activeIndex = -1;
const getValue = option => option.dataset.value ?? option.textContent.trim();
const getLabel = option => option.dataset.label || option.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');
if (!activeOption.id) activeOption.id = `${listbox.id || combobox.id || 'combobox'}-option-${activeIndex}`;
input.setAttribute('aria-activedescendant', activeOption.id);
} else {
input.removeAttribute('aria-activedescendant');
}
};
const syncEmptyState = () => {
popover.dataset.empty = String(visibleOptions.length === 0);
};
const filterOptions = ({ preserveActive = false } = {}) => {
const previousActive = activeIndex > -1 ? options[activeIndex] : null;
const search = input.value.trim().toLowerCase();
visibleOptions = [];
allOptions.forEach(option => {
if (option.hasAttribute('data-force')) {
option.setAttribute('aria-hidden', 'false');
if (options.includes(option)) visibleOptions.push(option);
return;
}
const optionText = (option.dataset.filter || option.dataset.label || option.textContent).trim().toLowerCase();
const keywords = (option.dataset.keywords || '').toLowerCase().split(/[\s,]+/).filter(Boolean);
const matches = !search || optionText.includes(search) || keywords.some(keyword => keyword.includes(search));
option.setAttribute('aria-hidden', String(!matches));
if (matches && options.includes(option)) visibleOptions.push(option);
});
if (preserveActive && previousActive && visibleOptions.includes(previousActive)) {
setActiveOption(options.indexOf(previousActive));
} else {
setActiveOption(autoHighlight && visibleOptions.length > 0 ? options.indexOf(visibleOptions[0]) : -1);
}
syncEmptyState();
};
const openPopover = () => {
if (popover.getAttribute('aria-hidden') === 'false') return;
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: combobox }
}));
filterOptions();
popover.setAttribute('aria-hidden', 'false');
input.setAttribute('aria-expanded', 'true');
};
const closePopover = (focusInput = false) => {
if (popover.getAttribute('aria-hidden') === 'true') return;
popover.setAttribute('aria-hidden', 'true');
input.setAttribute('aria-expanded', 'false');
setActiveOption(-1);
if (focusInput) input.focus();
};
const renderChips = () => {
if (!chips) return;
chips.querySelectorAll('.combobox-chip').forEach(chip => chip.remove());
const selected = options.filter(option => selectedOptions.has(option));
selected.forEach(option => {
const chip = document.createElement('span');
chip.className = 'combobox-chip';
chip.dataset.value = getValue(option);
chip.textContent = getLabel(option);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'combobox-chip-remove';
remove.setAttribute('aria-label', `Remove ${getLabel(option)}`);
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', () => deselect(getValue(option)));
chip.appendChild(remove);
chips.insertBefore(chip, input);
});
};
const updateValue = (optionOrOptions, triggerEvent = true) => {
let value;
if (isMultiple) {
const selected = Array.isArray(optionOrOptions) ? optionOrOptions : [];
selectedOptions.clear();
selected.forEach(option => selectedOptions.add(option));
options.forEach(option => {
if (selectedOptions.has(option)) {
option.setAttribute('aria-selected', 'true');
} else {
option.removeAttribute('aria-selected');
}
});
value = options.filter(option => selectedOptions.has(option)).map(getValue);
hiddenInput.value = JSON.stringify(value);
input.value = '';
renderChips();
filterOptions({ preserveActive: true });
} else {
const option = optionOrOptions;
if (!option) return;
options.forEach(opt => {
if (opt === option) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
value = getValue(option);
hiddenInput.value = value;
input.value = getLabel(option);
}
if (triggerEvent) {
combobox.dispatchEvent(new CustomEvent('change', {
detail: { value },
bubbles: true
}));
}
};
const select = (value) => {
const option = options.find(opt => getValue(opt) === value);
if (!option) return;
if (isMultiple) {
const selected = new Set(selectedOptions);
selected.add(option);
updateValue(options.filter(opt => selected.has(opt)));
if (closeOnSelect) closePopover(true);
} else {
updateValue(option);
closePopover(true);
}
};
const deselect = (value) => {
if (!isMultiple) return;
const option = options.find(opt => getValue(opt) === value);
if (!option || !selectedOptions.has(option)) return;
const selected = new Set(selectedOptions);
selected.delete(option);
updateValue(options.filter(opt => selected.has(opt)));
};
const toggle = (value) => {
if (!isMultiple) return;
const option = options.find(opt => getValue(opt) === value);
if (!option) return;
selectedOptions.has(option) ? deselect(value) : select(value);
};
const handleKeydown = (event) => {
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape', 'Backspace'].includes(event.key)) return;
const isOpen = popover.getAttribute('aria-hidden') === 'false';
if (event.key === 'Backspace' && isMultiple && input.value === '') {
const selected = options.filter(option => selectedOptions.has(option));
const last = selected[selected.length - 1];
if (last) deselect(getValue(last));
return;
}
if (event.key === 'Escape') {
closePopover(true);
return;
}
if (!isOpen && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
openPopover();
}
if (popover.getAttribute('aria-hidden') === 'true') return;
if (event.key === 'Enter') {
if (activeIndex > -1) {
event.preventDefault();
const option = options[activeIndex];
isMultiple ? toggle(getValue(option)) : select(getValue(option));
}
return;
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) || visibleOptions.length === 0) return;
event.preventDefault();
const currentVisibleIndex = activeIndex > -1 ? visibleOptions.indexOf(options[activeIndex]) : -1;
let nextVisibleIndex = currentVisibleIndex;
if (event.key === 'ArrowDown') nextVisibleIndex = Math.min(currentVisibleIndex + 1, visibleOptions.length - 1);
if (event.key === 'ArrowUp') nextVisibleIndex = currentVisibleIndex <= 0 ? 0 : currentVisibleIndex - 1;
if (event.key === 'Home') nextVisibleIndex = 0;
if (event.key === 'End') nextVisibleIndex = visibleOptions.length - 1;
const nextOption = visibleOptions[nextVisibleIndex];
setActiveOption(options.indexOf(nextOption));
nextOption.scrollIntoView({ block: 'nearest' });
};
input.addEventListener('focus', openPopover);
input.addEventListener('click', openPopover);
input.addEventListener('input', () => {
openPopover();
filterOptions();
if (!isMultiple) {
hiddenInput.value = '';
options.forEach(option => option.removeAttribute('aria-selected'));
}
});
input.addEventListener('keydown', handleKeydown);
listbox.addEventListener('mousemove', (event) => {
const option = event.target.closest('[role="option"]');
if (option && visibleOptions.includes(option)) setActiveOption(options.indexOf(option));
});
listbox.addEventListener('click', (event) => {
const option = event.target.closest('[role="option"]');
if (!option || !options.includes(option)) return;
isMultiple ? toggle(getValue(option)) : select(getValue(option));
if (isMultiple && !closeOnSelect) input.focus();
});
document.addEventListener('click', (event) => {
if (!combobox.contains(event.target)) closePopover(false);
});
document.addEventListener('basecoat:popover', (event) => {
if (event.detail.source !== combobox) closePopover(false);
});
if (isMultiple) {
let initialValues = [];
try {
initialValues = JSON.parse(hiddenInput.value || '[]');
} catch (_) {
initialValues = [];
}
if (!Array.isArray(initialValues)) initialValues = [];
const ariaSelected = options.filter(option => option.getAttribute('aria-selected') === 'true').map(getValue);
const values = initialValues.length ? initialValues : ariaSelected;
updateValue(options.filter(option => values.includes(getValue(option))), false);
} else {
const initialOption = options.find(option => getValue(option) === hiddenInput.value || option.getAttribute('aria-selected') === 'true');
if (initialOption) updateValue(initialOption, false);
}
popover.setAttribute('aria-hidden', 'true');
input.setAttribute('aria-expanded', 'false');
Object.defineProperty(combobox, 'value', {
get: () => isMultiple ? options.filter(option => selectedOptions.has(option)).map(getValue) : hiddenInput.value,
set: (value) => {
if (isMultiple) {
const values = Array.isArray(value) ? value : (value == null ? [] : [value]);
updateValue(options.filter(option => values.includes(getValue(option))));
} else {
select(value);
}
}
});
combobox.select = select;
combobox.selectByValue = select;
if (isMultiple) {
combobox.deselect = deselect;
combobox.toggle = toggle;
combobox.selectAll = () => updateValue(options);
combobox.selectNone = () => updateValue([]);
}
combobox.dataset.comboboxInitialized = true;
combobox.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
if (window.basecoat) {
window.basecoat.register('combobox', '.combobox:not([data-combobox-initialized])', initCombobox);
}
})();
+11 -8
View File
@@ -16,6 +16,9 @@
let menuItems = [];
let activeIndex = -1;
const isDisabled = (item) =>
item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true';
const closePopover = (focusOnTrigger = true) => {
if (trigger.getAttribute('aria-expanded') === 'false') return;
trigger.setAttribute('aria-expanded', 'false');
@@ -36,10 +39,7 @@
trigger.setAttribute('aria-expanded', 'true');
popover.setAttribute('aria-hidden', 'false');
menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]')).filter(item =>
!item.hasAttribute('disabled') &&
item.getAttribute('aria-disabled') !== 'true'
);
menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]')).filter(item => !isDisabled(item));
if (menuItems.length > 0 && initialSelection) {
if (initialSelection === 'first') {
@@ -58,7 +58,9 @@
if (activeIndex > -1 && menuItems[activeIndex]) {
const activeItem = menuItems[activeIndex];
activeItem.classList.add('active');
trigger.setAttribute('aria-activedescendant', activeItem.id);
if (activeItem.id) {
trigger.setAttribute('aria-activedescendant', activeItem.id);
}
} else {
trigger.removeAttribute('aria-activedescendant');
}
@@ -131,7 +133,7 @@
menu.addEventListener('mousemove', (event) => {
const menuItem = event.target.closest('[role^="menuitem"]');
if (menuItem && menuItems.includes(menuItem)) {
if (menuItem && !isDisabled(menuItem) && menuItems.includes(menuItem)) {
const index = menuItems.indexOf(menuItem);
if (index !== activeIndex) {
setActiveItem(index);
@@ -144,7 +146,8 @@
});
menu.addEventListener('click', (event) => {
if (event.target.closest('[role^="menuitem"]')) {
const menuItem = event.target.closest('[role^="menuitem"]');
if (menuItem && !isDisabled(menuItem)) {
closePopover();
}
});
@@ -168,4 +171,4 @@
if (window.basecoat) {
window.basecoat.register('dropdown-menu', '.dropdown-menu:not([data-dropdown-menu-initialized])', initDropdownMenu);
}
})();
})();
+21
View File
@@ -0,0 +1,21 @@
(() => {
function updateRange(element) {
const min = parseFloat(element.min || '0');
const max = parseFloat(element.max || '100');
const value = parseFloat(element.value || '0');
const percent = max === min ? 0 : ((value - min) / (max - min)) * 100;
element.style.setProperty('--slider-value', `${percent}%`);
}
function initRange(element) {
if (element.dataset.rangeInitialized) return;
updateRange(element);
element.addEventListener('input', () => updateRange(element));
element.dataset.rangeInitialized = 'true';
}
if (window.basecoat) {
window.basecoat.register('range', 'input[type="range"]:not([data-range-initialized])', initRange);
}
})();
+1 -72
View File
@@ -5,7 +5,6 @@
const popover = selectComponent.querySelector(':scope > [data-popover]');
const listbox = popover ? popover.querySelector('[role="listbox"]') : null;
const input = selectComponent.querySelector(':scope > input[type="hidden"]');
const filter = selectComponent.querySelector('header input[type="text"]');
if (!trigger || !popover || !listbox || !input) {
const missing = [];
@@ -48,11 +47,6 @@
}
};
const hasTransition = () => {
const style = getComputedStyle(popover);
return parseFloat(style.transitionDuration) > 0 || parseFloat(style.transitionDelay) > 0;
};
const updateValue = (optionOrOptions, triggerEvent = true) => {
let value;
@@ -101,20 +95,6 @@
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');
@@ -161,39 +141,6 @@
toggleMultipleValue(option);
};
if (filter) {
const filterOptions = () => {
const searchTerm = filter.value.trim().toLowerCase();
setActiveOption(-1);
visibleOptions = [];
allOptions.forEach(option => {
if (option.hasAttribute('data-force')) {
option.setAttribute('aria-hidden', 'false');
if (options.includes(option)) {
visibleOptions.push(option);
}
return;
}
const optionText = (option.dataset.filter || option.textContent).trim().toLowerCase();
const keywordList = (option.dataset.keywords || '')
.toLowerCase()
.split(/[\s,]+/)
.filter(Boolean);
const matchesKeyword = keywordList.some(keyword => keyword.includes(searchTerm));
const matches = optionText.includes(searchTerm) || matchesKeyword;
option.setAttribute('aria-hidden', String(!matches));
if (matches && options.includes(option)) {
visibleOptions.push(option);
}
});
};
filter.addEventListener('input', filterOptions);
}
// Initialization
if (isMultiple) {
const ariaSelected = options.filter(opt => opt.getAttribute('aria-selected') === 'true');
@@ -315,25 +262,11 @@
});
trigger.addEventListener('keydown', handleKeyNavigation);
if (filter) {
filter.addEventListener('keydown', handleKeyNavigation);
}
const openPopover = () => {
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: selectComponent }
}));
if (filter) {
if (hasTransition()) {
popover.addEventListener('transitionend', () => {
filter.focus();
}, { once: true });
} else {
filter.focus();
}
}
popover.setAttribute('aria-hidden', 'false');
trigger.setAttribute('aria-expanded', 'true');
@@ -366,11 +299,7 @@
closePopover();
} else {
setActiveOption(options.indexOf(option));
if (filter) {
filter.focus();
} else {
trigger.focus();
}
trigger.focus();
}
} else {
if (input.value !== getValue(option)) {
+24 -6
View File
@@ -6,7 +6,12 @@
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const panels = tabs.map(tab => document.getElementById(tab.getAttribute('aria-controls'))).filter(Boolean);
const isDisabled = (tab) => tab.disabled || tab.getAttribute('aria-disabled') === 'true';
const getEnabledTabs = () => tabs.filter(tab => !isDisabled(tab));
const selectTab = (tabToSelect) => {
if (isDisabled(tabToSelect)) return;
tabs.forEach((tab, index) => {
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
@@ -29,20 +34,33 @@
if (!tabs.includes(currentTab)) return;
let nextTab;
const currentIndex = tabs.indexOf(currentTab);
const enabledTabs = getEnabledTabs();
const currentIndex = enabledTabs.indexOf(currentTab);
const orientation = tablist.getAttribute('aria-orientation') || 'horizontal';
if (currentIndex === -1) return;
switch (event.key) {
case 'ArrowRight':
nextTab = tabs[(currentIndex + 1) % tabs.length];
if (orientation !== 'horizontal') return;
nextTab = enabledTabs[(currentIndex + 1) % enabledTabs.length];
break;
case 'ArrowLeft':
nextTab = tabs[(currentIndex - 1 + tabs.length) % tabs.length];
if (orientation !== 'horizontal') return;
nextTab = enabledTabs[(currentIndex - 1 + enabledTabs.length) % enabledTabs.length];
break;
case 'ArrowDown':
if (orientation !== 'vertical') return;
nextTab = enabledTabs[(currentIndex + 1) % enabledTabs.length];
break;
case 'ArrowUp':
if (orientation !== 'vertical') return;
nextTab = enabledTabs[(currentIndex - 1 + enabledTabs.length) % enabledTabs.length];
break;
case 'Home':
nextTab = tabs[0];
nextTab = enabledTabs[0];
break;
case 'End':
nextTab = tabs[tabs.length - 1];
nextTab = enabledTabs[enabledTabs.length - 1];
break;
default:
return;
@@ -60,4 +78,4 @@
if (window.basecoat) {
window.basecoat.register('tabs', '.tabs:not([data-tabs-initialized])', initTabs);
}
})();
})();