diff --git a/assets/js/app.js b/assets/js/app.js index 476cdc1..c13b1a8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -5,6 +5,7 @@ import topbar from "../vendor/topbar" let nowSeconds = () => Math.round(Date.now() / 1000) let rand = (min, max) => Math.floor(Math.random() * (max - min) + min) +let isVisible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0) let execJS = (selector, attr) => { document.querySelectorAll(selector).forEach(el => liveSocket.execJS(el, el.getAttribute(attr))) @@ -177,7 +178,6 @@ Hooks.AudioPlayer = { formatTime(seconds){ return new Date(1000 * seconds).toISOString().substr(14, 5) } } - Hooks.Ping = { mounted(){ this.handleEvent("pong", () => { @@ -194,20 +194,97 @@ Hooks.Ping = { } } +// Accessible focus handling +let Focus = { + focusMain(){ + let target = document.querySelector("main h1") || document.querySelector("main") + if(target){ + let origTabIndex = target.tabIndex + target.tabIndex = -1 + target.focus() + target.tabIndex = origTabIndex + } + }, + // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + isFocusable(el){ + if(el.tabIndex > 0 || (el.tabIndex === 0 && el.getAttribute("tabIndex") !== null)){ return true } + if(el.disabled){ return false } + + switch(el.nodeName) { + case "A": + return !!el.href && el.rel !== "ignore" + case "INPUT": + return el.type != "hidden" && el.type !== "file" + case "BUTTON": + case "SELECT": + case "TEXTAREA": + return true + default: + return false + } + }, + // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + attemptFocus(el){ + if(!el){ return } + if(!this.isFocusable(el)){ return false } + try { + el.focus() + } catch(e){} + + return document.activeElement === el + }, + // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + focusFirstDescendant(el){ + for(let i = 0; i < el.childNodes.length; i++){ + let child = el.childNodes[i] + if(this.attemptFocus(child) || this.focusFirstDescendant(child)){ + return true + } + } + return false + }, + // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + focusLastDescendant(element){ + for(let i = element.childNodes.length - 1; i >= 0; i--){ + let child = element.childNodes[i] + if(this.attemptFocus(child) || this.focusLastDescendant(child)){ + return true + } + } + return false + }, +} + +// Accessible focus wrapping +Hooks.FocusWrap = { + mounted(){ + this.content = document.querySelector(this.el.getAttribute("data-content")) + this.focusStart = this.el.querySelector(`#${this.el.id}-start`) + this.focusEnd = this.el.querySelector(`#${this.el.id}-end`) + this.focusStart.addEventListener("focus", () => Focus.focusLastDescendant(this.content)) + this.focusEnd.addEventListener("focus", () => Focus.focusFirstDescendant(this.content)) + this.content.addEventListener("phx:show-end", () => this.content.focus()) + if(window.getComputedStyle(this.content).display !== "none"){ + Focus.focusFirstDescendant(this.content) + } + }, +} + let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, - params: {_csrf_token: csrfToken} + params: {_csrf_token: csrfToken}, + dom: { + onNodeAdded(node){ + if(node instanceof HTMLElement && node.autofocus){ + node.focus() + } + } + } }) -let routeUpdated = () => { - let target = document.querySelector("main h1") || document.querySelector("main") - if (target) { - let origTabIndex = target.tabIndex - target.tabIndex = -1 - target.focus() - target.tabIndex = origTabIndex - } +let routeUpdated = ({kind}) => { + Focus.focusMain() } // Show progress bar on live navigation and form submits @@ -216,9 +293,27 @@ window.addEventListener("phx:page-loading-start", info => topbar.show()) window.addEventListener("phx:page-loading-stop", info => topbar.hide()) // Accessible routing -window.addEventListener("phx:page-loading-stop", routeUpdated) +window.addEventListener("phx:page-loading-stop", e => routeUpdated(e.detail)) window.addEventListener("js:exec", e => e.target[e.detail.call](...e.detail.args)) +window.addEventListener("js:focus", e => { + let parent = document.querySelector(e.detail.parent) + if(parent && isVisible(parent)){ e.target.focus() } +}) +window.addEventListener("js:focus-closest", e => { + let el = e.target + let sibling = el.nextElementSibling + while(sibling){ + if(isVisible(sibling) && Focus.attemptFocus(sibling)){ return } + sibling = sibling.nextElementSibling + } + sibling = el.previousElementSibling + while(sibling){ + if(isVisible(sibling) && Focus.attemptFocus(sibling)){ return } + sibling = sibling.previousElementSibling + } + Focus.attemptFocus(el.parent) || Focus.focusMain() +}) window.addEventListener("phx:remove-el", e => document.getElementById(e.detail.id).remove()) // connect if there are any LiveViews on the page diff --git a/lib/live_beats_web/live/layout_component.ex b/lib/live_beats_web/live/layout_component.ex index 2b4b216..05a3e0f 100644 --- a/lib/live_beats_web/live/layout_component.ex +++ b/lib/live_beats_web/live/layout_component.ex @@ -17,6 +17,9 @@ defmodule LiveBeatsWeb.LayoutComponent do case assigns[:show] do %{module: _module, confirm: {text, attrs}} = show -> show + |> Map.put_new(:title, show[:title]) + |> Map.put_new(:on_cancel, show[:on_cancel] || %JS{}) + |> Map.put_new(:on_confirm, show[:on_confirm] || %JS{}) |> Map.put_new(:patch, nil) |> Map.put_new(:navigate, nil) |> Map.merge(%{confirm_text: text, confirm_attrs: attrs}) @@ -32,7 +35,15 @@ defmodule LiveBeatsWeb.LayoutComponent do ~H"""
+
<%= live_flash(@flash, @kind) %>