From 7461256268a12c5e7923ece7011ba3791ee8272b Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 4 Feb 2022 11:41:05 -0500 Subject: [PATCH] Add accessible modal with focus_wrap component The focus_wrap function component (and hook) can be used to focus wrap any content. The focus and focus_closest JS functions were added to programmtaically focus an element on the client or find the next element or previous element sibling when an action is taken that requires moving focus to cloest item. Co-authored-by: Nolan Darilek --- assets/js/app.js | 117 +++++++++++++-- lib/live_beats_web/live/layout_component.ex | 13 +- lib/live_beats_web/live/live_helpers.ex | 138 ++++++++++-------- lib/live_beats_web/live/profile_live.ex | 13 +- .../live/profile_live/song_entry_component.ex | 15 +- .../live/profile_live/song_row_component.ex | 2 +- .../upload_form_component.html.heex | 10 +- .../templates/layout/live.html.heex | 12 +- 8 files changed, 230 insertions(+), 90 deletions(-) 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"""
<%= if @show do %> - <.modal show id={@id} navigate={@show.navigate} patch={@show.patch}> + <.modal + show + id={@id} + navigate={@show.navigate} + patch={@show.patch} + on_cancel={@show.on_cancel} + on_confirm={@show.on_confirm} + > + <:title><%= @show.title %> <.live_component module={@show.module} {@show} /> <:cancel>Cancel <:confirm {@show.confirm_attrs}><%= @show.confirm_text %> diff --git a/lib/live_beats_web/live/live_helpers.ex b/lib/live_beats_web/live/live_helpers.ex index f94d662..2dc8eb5 100644 --- a/lib/live_beats_web/live/live_helpers.ex +++ b/lib/live_beats_web/live/live_helpers.ex @@ -29,7 +29,7 @@ defmodule LiveBeatsWeb.LiveHelpers do ~H""" + +
+ """ + end + + def focus_wrap(assigns) do + ~H""" +
+ + <%= render_slot(@inner_block) %> +
""" end @@ -576,6 +586,16 @@ defmodule LiveBeatsWeb.LiveHelpers do JS.dispatch(js, "js:exec", to: to, detail: %{call: call, args: args}) end + def focus(js \\ %JS{}, parent, to) do + JS.dispatch(js, "js:focus", to: to, detail: %{parent: parent}) + end + + def focus_closest(js \\ %JS{}, to) do + js + |> JS.dispatch("js:focus-closest", to: to) + |> hide(to) + end + defp assign_rest(assigns, exclude) do assign(assigns, :rest, assigns_to_attributes(assigns, exclude)) end diff --git a/lib/live_beats_web/live/profile_live.ex b/lib/live_beats_web/live/profile_live.ex index 723e109..8a4ae88 100644 --- a/lib/live_beats_web/live/profile_live.ex +++ b/lib/live_beats_web/live/profile_live.ex @@ -51,7 +51,12 @@ defmodule LiveBeatsWeb.ProfileLive do <%= for song <- if(@owns_profile?, do: @songs, else: []), id = "delete-modal-#{song.id}" do %> <.modal id={id} - on_confirm={JS.push("delete", value: %{id: song.id}) |> hide_modal(id) |> hide("#song-#{song.id}")} + on_cancel={focus("##{id}", "#delete-song-#{song.id}")} + on_confirm={ + JS.push("delete", value: %{id: song.id}) + |> hide_modal(id) + |> focus_closest("#song-#{song.id}") + |> hide("#song-#{song.id}")} > Are you sure you want to delete "<%= song.title %>"? <:cancel>Cancel @@ -72,7 +77,11 @@ defmodule LiveBeatsWeb.ProfileLive do <:col let={%{song: song}} label="Attribution" class="max-w-5xl break-words text-gray-600 font-light"><%= song.attribution %> <:col let={%{song: song}} label="Duration"><%= MP3Stat.to_mmss(song.duration) %> <:col let={%{song: song}} label="" if={@owns_profile?}> - <.link phx-click={show_modal("delete-modal-#{song.id}")} class="inline-flex items-center px-3 py-2 text-sm leading-4 font-medium"> + <.link + id={"delete-song-#{song.id}"} + phx-click={show_modal("delete-modal-#{song.id}")} + class="inline-flex items-center px-3 py-2 text-sm leading-4 font-medium" + > <.icon name={:trash} class="-ml-0.5 mr-2 h-4 w-4"/> Delete diff --git a/lib/live_beats_web/live/profile_live/song_entry_component.ex b/lib/live_beats_web/live/profile_live/song_entry_component.ex index 014f0de..b20dfd8 100644 --- a/lib/live_beats_web/live/profile_live/song_entry_component.ex +++ b/lib/live_beats_web/live/profile_live/song_entry_component.ex @@ -22,7 +22,7 @@ defmodule LiveBeatsWeb.ProfileLive.SongEntryComponent do <% end %> + class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm" {%{autofocus: @index == 0}}/>
@@ -45,8 +45,14 @@ defmodule LiveBeatsWeb.ProfileLive.SongEntryComponent do
<.error input_name={"songs[#{@ref}][attribution]"} field={:attribution} errors={@errors} class="-mt-1"/>
-
-
+
""" end @@ -55,10 +61,11 @@ defmodule LiveBeatsWeb.ProfileLive.SongEntryComponent do {:ok, assign(socket, progress: progress)} end - def update(%{changeset: changeset, id: id}, socket) do + def update(%{changeset: changeset, id: id, index: index}, socket) do {:ok, socket |> assign(ref: id) + |> assign(index: index) |> assign(:errors, changeset.errors) |> assign(title: Ecto.Changeset.get_field(changeset, :title)) |> assign(artist: Ecto.Changeset.get_field(changeset, :artist)) diff --git a/lib/live_beats_web/live/profile_live/song_row_component.ex b/lib/live_beats_web/live/profile_live/song_row_component.ex index a9bc960..33d3884 100644 --- a/lib/live_beats_web/live/profile_live/song_row_component.ex +++ b/lib/live_beats_web/live/profile_live/song_row_component.ex @@ -7,7 +7,7 @@ defmodule LiveBeatsWeb.ProfileLive.SongRowComponent do def render(assigns) do ~H""" - + <%= for {col, i} <- Enum.with_index(@col) do %> -

<%= @title %> -

(songs expire every six hours)

-

+

(songs expire every six hours)

<.form for={:songs} @@ -13,8 +11,8 @@
- <%= for {ref, changeset} <- @changesets do %> - <.live_component id={ref} module={SongEntryComponent} changeset={changeset} /> + <%= for {{ref, changeset}, i} <- Enum.with_index(@changesets) do %> + <.live_component id={ref} module={SongEntryComponent} changeset={changeset} index={i} /> <% end %> @@ -50,7 +48,7 @@

or drag and drop

diff --git a/lib/live_beats_web/templates/layout/live.html.heex b/lib/live_beats_web/templates/layout/live.html.heex index eeca001..b45e9ae 100644 --- a/lib/live_beats_web/templates/layout/live.html.heex +++ b/lib/live_beats_web/templates/layout/live.html.heex @@ -149,6 +149,12 @@
+ <.flash flash={@flash} kind={:info}/> + <.flash flash={@flash} kind={:error}/> + <.connection_status> + Re-establishing connection... + + <.live_component module={LiveBeatsWeb.LayoutComponent} id="layout" /> <%= if @current_user do %> @@ -156,12 +162,6 @@ <% end %>
- <.flash flash={@flash} kind={:info}/> - <.flash flash={@flash} kind={:error}/> - <.connection_status> - Re-establishing connection... - - <%= @inner_content %>