UNPKG

@github/details-menu-element

Version:
320 lines (319 loc) 10.8 kB
class DetailsMenuElement extends HTMLElement { get preload() { return this.hasAttribute('preload'); } set preload(value) { if (value) { this.setAttribute('preload', ''); } else { this.removeAttribute('preload'); } } get src() { return this.getAttribute('src') || ''; } set src(value) { this.setAttribute('src', value); } connectedCallback() { if (!this.hasAttribute('role')) this.setAttribute('role', 'menu'); const details = this.parentElement; if (!details) return; const summary = details.querySelector('summary'); if (summary) { summary.setAttribute('aria-haspopup', 'menu'); if (!summary.hasAttribute('role')) summary.setAttribute('role', 'button'); } const subscriptions = [ fromEvent(details, 'compositionstart', e => trackComposition(this, e)), fromEvent(details, 'compositionend', e => trackComposition(this, e)), fromEvent(details, 'click', e => shouldCommit(details, e)), fromEvent(details, 'change', e => shouldCommit(details, e)), fromEvent(details, 'keydown', e => keydown(details, this, e)), fromEvent(details, 'toggle', () => loadFragment(details, this), { once: true }), fromEvent(details, 'toggle', () => closeCurrentMenu(details)), this.preload ? fromEvent(details, 'mouseover', () => loadFragment(details, this), { once: true }) : NullSubscription, ...focusOnOpen(details) ]; states.set(this, { subscriptions, loaded: false, isComposing: false }); } disconnectedCallback() { const state = states.get(this); if (!state) return; states.delete(this); for (const sub of state.subscriptions) { sub.unsubscribe(); } } } const states = new WeakMap(); const NullSubscription = { unsubscribe() { } }; function fromEvent(target, eventName, onNext, options = false) { target.addEventListener(eventName, onNext, options); return { unsubscribe: () => { target.removeEventListener(eventName, onNext, options); } }; } function loadFragment(details, menu) { const src = menu.getAttribute('src'); if (!src) return; const state = states.get(menu); if (!state) return; if (state.loaded) return; state.loaded = true; const loader = menu.querySelector('include-fragment'); if (loader && !loader.hasAttribute('src')) { loader.addEventListener('loadend', () => autofocus(details)); loader.setAttribute('src', src); } } function focusOnOpen(details) { let isMouse = false; const onmousedown = () => (isMouse = true); const onkeydown = () => (isMouse = false); const ontoggle = () => { if (!details.hasAttribute('open')) return; if (autofocus(details)) return; if (!isMouse) focusFirstItem(details); }; return [ fromEvent(details, 'mousedown', onmousedown), fromEvent(details, 'keydown', onkeydown), fromEvent(details, 'toggle', ontoggle) ]; } function closeCurrentMenu(details) { if (!details.hasAttribute('open')) return; for (const menu of document.querySelectorAll('details[open] > details-menu')) { const opened = menu.closest('details'); if (opened && opened !== details && !opened.contains(details)) { opened.removeAttribute('open'); } } } function autofocus(details) { if (!details.hasAttribute('open')) return false; const input = details.querySelector('details-menu [autofocus]'); if (input) { input.focus(); return true; } else { return false; } } function focusFirstItem(details) { const selected = document.activeElement; if (selected && isMenuItem(selected) && details.contains(selected)) return; const target = sibling(details, true); if (target) target.focus(); } function sibling(details, next) { const options = Array.from(details.querySelectorAll('[role^="menuitem"]:not([hidden]):not([disabled])')); const selected = document.activeElement; const index = selected instanceof HTMLElement ? options.indexOf(selected) : -1; const found = next ? options[index + 1] : options[index - 1]; const def = next ? options[0] : options[options.length - 1]; return found || def; } const ctrlBindings = navigator.userAgent.match(/Macintosh/); function shouldCommit(details, event) { const target = event.target; if (!(target instanceof Element)) return; if (target.closest('details') !== details) return; if (event.type === 'click') { const menuitem = target.closest('[role="menuitem"], [role="menuitemradio"]'); if (!menuitem) return; const input = menuitem.querySelector('input'); if (menuitem.tagName === 'LABEL' && target === input) return; const onlyCommitOnChangeEvent = menuitem.tagName === 'LABEL' && input && !input.checked; if (!onlyCommitOnChangeEvent) { commit(menuitem, details); } } else if (event.type === 'change') { const menuitem = target.closest('[role="menuitemradio"], [role="menuitemcheckbox"]'); if (menuitem) commit(menuitem, details); } } function updateChecked(selected, details) { for (const el of details.querySelectorAll('[role="menuitemradio"], [role="menuitemcheckbox"]')) { const input = el.querySelector('input[type="radio"], input[type="checkbox"]'); let checkState = (el === selected).toString(); if (input instanceof HTMLInputElement) { checkState = input.indeterminate ? 'mixed' : input.checked.toString(); } el.setAttribute('aria-checked', checkState); } } function commit(selected, details) { if (selected.hasAttribute('disabled') || selected.getAttribute('aria-disabled') === 'true') return; const menu = selected.closest('details-menu'); if (!menu) return; const dispatched = menu.dispatchEvent(new CustomEvent('details-menu-select', { cancelable: true, detail: { relatedTarget: selected } })); if (!dispatched) return; updateLabel(selected, details); updateChecked(selected, details); if (selected.getAttribute('role') !== 'menuitemcheckbox') close(details); menu.dispatchEvent(new CustomEvent('details-menu-selected', { detail: { relatedTarget: selected } })); } function keydown(details, menu, event) { if (!(event instanceof KeyboardEvent)) return; if (details.querySelector('details[open]')) return; const state = states.get(menu); if (!state || state.isComposing) return; const isSummaryFocused = event.target instanceof Element && event.target.tagName === 'SUMMARY'; switch (event.key) { case 'Escape': if (details.hasAttribute('open')) { close(details); event.preventDefault(); event.stopPropagation(); } break; case 'ArrowDown': { if (isSummaryFocused && !details.hasAttribute('open')) { details.setAttribute('open', ''); } const target = sibling(details, true); if (target) target.focus(); event.preventDefault(); } break; case 'ArrowUp': { if (isSummaryFocused && !details.hasAttribute('open')) { details.setAttribute('open', ''); } const target = sibling(details, false); if (target) target.focus(); event.preventDefault(); } break; case 'n': { if (ctrlBindings && event.ctrlKey) { const target = sibling(details, true); if (target) target.focus(); event.preventDefault(); } } break; case 'p': { if (ctrlBindings && event.ctrlKey) { const target = sibling(details, false); if (target) target.focus(); event.preventDefault(); } } break; case ' ': case 'Enter': { const selected = document.activeElement; if (selected instanceof HTMLElement && isMenuItem(selected) && selected.closest('details') === details) { event.preventDefault(); event.stopPropagation(); selected.click(); } } break; } } function isMenuItem(el) { const role = el.getAttribute('role'); return role === 'menuitem' || role === 'menuitemcheckbox' || role === 'menuitemradio'; } function close(details) { const wasOpen = details.hasAttribute('open'); if (!wasOpen) return; details.removeAttribute('open'); const summary = details.querySelector('summary'); if (summary) summary.focus(); } function updateLabel(item, details) { const button = details.querySelector('[data-menu-button]'); if (!button) return; const text = labelText(item); if (text) { button.textContent = text; } else { const html = labelHTML(item); if (html) button.innerHTML = html; } } function labelText(el) { if (!el) return null; const textEl = el.hasAttribute('data-menu-button-text') ? el : el.querySelector('[data-menu-button-text]'); if (!textEl) return null; return textEl.getAttribute('data-menu-button-text') || textEl.textContent; } function labelHTML(el) { if (!el) return null; const contentsEl = el.hasAttribute('data-menu-button-contents') ? el : el.querySelector('[data-menu-button-contents]'); return contentsEl ? contentsEl.innerHTML : null; } function trackComposition(menu, event) { const state = states.get(menu); if (!state) return; state.isComposing = event.type === 'compositionstart'; } export default DetailsMenuElement; if (!window.customElements.get('details-menu')) { window.DetailsMenuElement = DetailsMenuElement; window.customElements.define('details-menu', DetailsMenuElement); }