diff --git a/assets/js/hooks/combobox.js b/assets/js/hooks/combobox.js index db6e48a..14c436e 100644 --- a/assets/js/hooks/combobox.js +++ b/assets/js/hooks/combobox.js @@ -40,7 +40,7 @@ export default { this.syncSelection() this.close() this.sendQuery('') - this.el.setAttribute('data-prima-ready', 'true') + this.js().setAttribute(this.el, 'data-prima-ready', 'true') }, reconnected() { @@ -112,7 +112,7 @@ export default { if (this.refs.optionsContainer && this.refs.searchInput) { const optionsId = this.refs.optionsContainer.getAttribute('id') if (optionsId) { - this.refs.searchInput.setAttribute('aria-controls', optionsId) + this.js().setAttribute(this.refs.searchInput, 'aria-controls', optionsId) } } @@ -153,13 +153,23 @@ export default { this.positionOptions() if (this.isOpen) this.restoreOptionFocus() this.syncSelectedAttributes() - this.el.setAttribute('data-prima-ready', 'true') + this.js().setAttribute(this.el, 'data-prima-ready', 'true') }, restoreOptionFocus() { const options = this.getVisibleOptions() + const focused = this.getCurrentFocusedOption() + if (options.includes(focused)) { + this.js().setAttribute(this.refs.searchInput, 'aria-activedescendant', focused.id) + return + } + const focusedOption = options.find(option => option.dataset.value === this.focusedOptionBeforeUpdate) || options[0] - if (focusedOption) this.setFocus(focusedOption) + if (focusedOption) { + this.setFocus(focusedOption) + } else { + this.js().removeAttribute(this.refs.searchInput, 'aria-activedescendant') + } }, sendQuery(query) { @@ -254,12 +264,13 @@ export default { }, setFocus(el) { - this.refs.optionsContainer?.querySelector(SELECTORS.FOCUSED_OPTION)?.removeAttribute('data-focus') - el.setAttribute('data-focus', 'true') + const focused = this.getCurrentFocusedOption() + if (focused) this.js().removeAttribute(focused, 'data-focus') + this.js().setAttribute(el, 'data-focus', 'true') // Update aria-activedescendant to point to the focused option if (el.id) { - this.refs.searchInput.setAttribute('aria-activedescendant', el.id) + this.js().setAttribute(this.refs.searchInput, 'aria-activedescendant', el.id) } el.scrollIntoView({ block: 'nearest', inline: 'nearest' }) @@ -269,6 +280,8 @@ export default { const firstOption = this.refs.optionsContainer?.querySelector(SELECTORS.VISIBLE_OPTION) if (firstOption) { this.setFocus(firstOption) + } else { + this.js().removeAttribute(this.refs.searchInput, 'aria-activedescendant') } }, @@ -566,7 +579,7 @@ export default { if (!this.refs.optionsContainer || this.isOpen) return this.isOpen = true - this.refs.searchInput.setAttribute('aria-expanded', 'true') + this.js().setAttribute(this.refs.searchInput, 'aria-expanded', 'true') // Reset local filtering for a fresh opening. for (const option of this.getRegularOptions()) { this.showOption(option) @@ -598,17 +611,17 @@ export default { close() { clearTimeout(this.searchTimer) - this.refs.searchInput.removeAttribute('aria-activedescendant') + this.js().removeAttribute(this.refs.searchInput, 'aria-activedescendant') if (this.refs.createOption) { this.hideOption(this.refs.createOption) this.refs.createOption.textContent = '' - this.refs.createOption.removeAttribute('data-focus') + this.js().removeAttribute(this.refs.createOption, 'data-focus') } if (!this.refs.optionsContainer || !this.isOpen) return this.isOpen = false - this.refs.searchInput.setAttribute('aria-expanded', 'false') + this.js().setAttribute(this.refs.searchInput, 'aria-expanded', 'false') this.liveSocket.execJS(this.refs.optionsContainer, this.refs.optionsContainer.getAttribute('js-hide')) }, diff --git a/assets/js/hooks/dropdown.js b/assets/js/hooks/dropdown.js index d960209..540bbab 100644 --- a/assets/js/hooks/dropdown.js +++ b/assets/js/hooks/dropdown.js @@ -24,10 +24,12 @@ const SELECTORS = { export default { mounted() { this.initialize() + this.js().setAttribute(this.el, 'data-prima-ready', 'true') }, updated() { this.initialize() + this.setFocus(this.el.querySelector(SELECTORS.FOCUSED_MENUITEM)) }, reconnected() { @@ -42,20 +44,19 @@ export default { this.cleanup() this.setupElements() this.setupEventListeners() - this.el.setAttribute('data-prima-ready', 'true') + if (this.isMenuVisible()) this.startAutoUpdate() }, setupElements() { const button = this.el.querySelector(SELECTORS.BUTTON) const menuWrapper = this.el.querySelector(SELECTORS.MENU_WRAPPER) const menu = this.el.querySelector(SELECTORS.MENU) - const items = this.el.querySelectorAll(SELECTORS.MENUITEM) const referenceSelector = menuWrapper?.getAttribute('data-reference') const referenceElement = referenceSelector ? document.querySelector(referenceSelector) : button this.setupAriaRelationships(button, menu) - this.refs = { button, menuWrapper, menu, items, referenceElement } + this.refs = { button, menuWrapper, menu, referenceElement } }, setupEventListeners() { @@ -85,6 +86,14 @@ export default { } }, + startAutoUpdate() { + // Setup autoUpdate to reposition on scroll/resize + this.cleanupAutoUpdate() + this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.menuWrapper, () => { + this.positionMenu() + }) + }, + cleanupAutoUpdate() { if (this.autoUpdateCleanup) { this.autoUpdateCleanup() @@ -242,13 +251,9 @@ export default { this.refs.menu.style.display = shouldBeOpen ? '' : 'none' if (!shouldBeOpen) return - this.refs.button.setAttribute('aria-expanded', 'true') + this.js().setAttribute(this.refs.button, 'aria-expanded', 'true') - // Setup autoUpdate to reposition on scroll/resize - this.cleanupAutoUpdate() - this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.menuWrapper, () => { - this.positionMenu() - }) + this.startAutoUpdate() }, handleHideEnd() { @@ -257,8 +262,8 @@ export default { if (shouldBeOpen) return this.clearFocus() - this.refs.menu.removeAttribute('aria-activedescendant') - this.refs.button.setAttribute('aria-expanded', 'false') + this.js().removeAttribute(this.refs.menu, 'aria-activedescendant') + this.js().setAttribute(this.refs.button, 'aria-expanded', 'false') this.refs.menuWrapper.style.display = 'none' this.cleanupAutoUpdate() }, @@ -283,15 +288,16 @@ export default { setFocus(el) { this.clearFocus() if (el && el.getAttribute('aria-disabled') !== 'true') { - el.setAttribute('data-focus', '') - this.refs.menu.setAttribute('aria-activedescendant', el.id) + this.js().setAttribute(el, 'data-focus', '') + this.js().setAttribute(this.refs.menu, 'aria-activedescendant', el.id) } else { - this.refs.menu.removeAttribute('aria-activedescendant') + this.js().removeAttribute(this.refs.menu, 'aria-activedescendant') } }, clearFocus() { - this.el.querySelector(SELECTORS.FOCUSED_MENUITEM)?.removeAttribute('data-focus') + const focused = this.el.querySelector(SELECTORS.FOCUSED_MENUITEM) + if (focused) this.js().removeAttribute(focused, 'data-focus') }, hideMenu() { @@ -343,8 +349,8 @@ export default { }, setupAriaRelationships(button, menu) { - button.setAttribute('aria-controls', menu.id) - menu.setAttribute('aria-labelledby', button.id) + this.js().setAttribute(button, 'aria-controls', menu.id) + this.js().setAttribute(menu, 'aria-labelledby', button.id) this.setupSectionLabels() }, @@ -357,7 +363,7 @@ export default { const firstChild = section.firstElementChild if (firstChild && firstChild.getAttribute('role') === 'presentation') { // Link the section to the heading - section.setAttribute('aria-labelledby', firstChild.id) + this.js().setAttribute(section, 'aria-labelledby', firstChild.id) } }) }, diff --git a/assets/js/hooks/listbox.js b/assets/js/hooks/listbox.js index 80d6603..36e880a 100644 --- a/assets/js/hooks/listbox.js +++ b/assets/js/hooks/listbox.js @@ -27,10 +27,12 @@ const SELECTORS = { export default { mounted() { this.initialize() + this.js().setAttribute(this.el, 'data-prima-ready', 'true') }, updated() { this.initialize() + this.setFocus(this.el.querySelector(SELECTORS.FOCUSED_OPTION)) }, reconnected() { @@ -47,7 +49,7 @@ export default { this.setupEventListeners() this.syncDisabledState() this.syncSelectionFromInput() - this.el.setAttribute('data-prima-ready', 'true') + if (this.isListboxVisible()) this.startAutoUpdate() }, setupElements() { @@ -65,8 +67,8 @@ export default { }, setupAriaRelationships(button, listbox) { - button.setAttribute('aria-controls', listbox.id) - listbox.setAttribute('aria-labelledby', button.id) + this.js().setAttribute(button, 'aria-controls', listbox.id) + this.js().setAttribute(listbox, 'aria-labelledby', button.id) }, syncSelectionFromInput() { @@ -107,6 +109,14 @@ export default { } }, + startAutoUpdate() { + // Setup autoUpdate to reposition on scroll/resize + this.cleanupAutoUpdate() + this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.optionsWrapper, () => { + this.positionListbox() + }) + }, + cleanupAutoUpdate() { if (this.autoUpdateCleanup) { this.autoUpdateCleanup() @@ -327,15 +337,16 @@ export default { setFocus(el) { this.clearFocus() if (el && el.getAttribute('aria-disabled') !== 'true') { - el.setAttribute('data-focus', '') - this.refs.listbox.setAttribute('aria-activedescendant', el.id) + this.js().setAttribute(el, 'data-focus', '') + this.js().setAttribute(this.refs.listbox, 'aria-activedescendant', el.id) } else { - this.refs.listbox.removeAttribute('aria-activedescendant') + this.js().removeAttribute(this.refs.listbox, 'aria-activedescendant') } }, clearFocus() { - this.el.querySelector(SELECTORS.FOCUSED_OPTION)?.removeAttribute('data-focus') + const focused = this.el.querySelector(SELECTORS.FOCUSED_OPTION) + if (focused) this.js().removeAttribute(focused, 'data-focus') }, hideListbox() { @@ -381,14 +392,10 @@ export default { this.refs.listbox.style.display = shouldBeOpen ? '' : 'none' if (!shouldBeOpen) return - this.refs.button.setAttribute('aria-expanded', 'true') + this.js().setAttribute(this.refs.button, 'aria-expanded', 'true') this.refs.listbox.focus({ preventScroll: true }) - // Setup autoUpdate to reposition on scroll/resize - this.cleanupAutoUpdate() - this.autoUpdateCleanup = autoUpdate(this.refs.referenceElement, this.refs.optionsWrapper, () => { - this.positionListbox() - }) + this.startAutoUpdate() }, handleHideEnd() { @@ -397,8 +404,8 @@ export default { if (shouldBeOpen) return this.clearFocus() - this.refs.listbox.removeAttribute('aria-activedescendant') - this.refs.button.setAttribute('aria-expanded', 'false') + this.js().removeAttribute(this.refs.listbox, 'aria-activedescendant') + this.js().setAttribute(this.refs.button, 'aria-expanded', 'false') this.refs.optionsWrapper.style.display = 'none' this.cleanupAutoUpdate() }, diff --git a/assets/js/hooks/modal.js b/assets/js/hooks/modal.js index 878f798..79708ed 100644 --- a/assets/js/hooks/modal.js +++ b/assets/js/hooks/modal.js @@ -1,5 +1,6 @@ export default { mounted() { + this.setupPushEventListeners() this.initialize() }, @@ -19,24 +20,21 @@ export default { this.cleanup() this.setupElements() this.setupDOMEventListeners() - this.setupPushEventListeners() this.checkInitialShow() - this.el.setAttribute('data-prima-ready', 'true') + this.js().setAttribute(this.el, 'data-prima-ready', 'true') }, setupPushEventListeners() { - this.pushEventRefs = [ - this.handleEvent("prima:modal:open", (payload) => { - if (!payload.id || payload.id === this.el.id) { - this.handleModalOpen() - } - }), - this.handleEvent("prima:modal:close", (payload) => { - if (!payload.id || payload.id === this.el.id) { - this.handleModalClose() - } - }) - ] + this.handleEvent("prima:modal:open", (payload) => { + if (!payload.id || payload.id === this.el.id) { + this.handleModalOpen() + } + }) + this.handleEvent("prima:modal:close", (payload) => { + if (!payload.id || payload.id === this.el.id) { + this.handleModalClose() + } + }) }, setupElements() { @@ -81,13 +79,6 @@ export default { }) this.listeners = [] } - - if (this.pushEventRefs) { - this.pushEventRefs.forEach(ref => { - if (ref) this.removeHandleEvent(ref) - }) - this.pushEventRefs = [] - } }, checkInitialShow() { @@ -99,7 +90,7 @@ export default { handleModalOpen() { this.storeFocusedElement() this.preventBodyScroll() - this.el.removeAttribute('aria-hidden') + this.js().removeAttribute(this.el, 'aria-hidden') this.maybeExecJS(this.el, "js-show"); this.maybeExecJS(this.ref("modal-overlay"), "js-show"); if (this.async) { @@ -113,7 +104,7 @@ export default { this.maybeExecJS(this.ref("modal-loader"), "js-hide"); this.maybeExecJS(this.ref("modal-panel"), "js-show"); this.setupAriaRelationships() - this.el.removeAttribute('aria-hidden') + this.js().removeAttribute(this.el, 'aria-hidden') const panelShowEndHandler = this.handlePanelShowEnd.bind(this) this.ref("modal-panel").addEventListener("phx:show-end", panelShowEndHandler); @@ -138,7 +129,7 @@ export default { handleOverlayHideEnd() { this.maybeExecJS(this.el, "js-hide"); - this.el.setAttribute('aria-hidden', 'true') + this.js().setAttribute(this.el, 'aria-hidden', 'true') this.restoreFocusedElement() }, @@ -185,7 +176,7 @@ export default { } // Set aria-labelledby on the modal container - this.el.setAttribute('aria-labelledby', titleElement.id) + this.js().setAttribute(this.el, 'aria-labelledby', titleElement.id) } }, diff --git a/lib/prima/combobox.ex b/lib/prima/combobox.ex index b75d82f..71c28bc 100644 --- a/lib/prima/combobox.ex +++ b/lib/prima/combobox.ex @@ -464,6 +464,7 @@ defmodule Prima.Combobox do ~H"""