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 %>