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 <nolan@thewordnerd.info>
This commit is contained in:
Chris McCord 2022-02-04 11:41:05 -05:00
parent d25554632f
commit 819d5ecc98
8 changed files with 230 additions and 90 deletions

View file

@ -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)))
@ -181,7 +182,6 @@ Hooks.AudioPlayer = {
formatTime(seconds){ return new Date(1000 * seconds).toISOString().substr(14, 5) }
}
Hooks.Ping = {
mounted(){
this.handleEvent("pong", () => {
@ -198,20 +198,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
@ -220,9 +297,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

View file

@ -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"""
<div class={unless @show, do: "hidden"}>
<%= 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 %></:title>
<.live_component module={@show.module} {@show} />
<:cancel>Cancel</:cancel>
<:confirm {@show.confirm_attrs}><%= @show.confirm_text %></:confirm>

View file

@ -29,7 +29,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
~H"""
<div
id="connection-status"
class="hidden rounded-md bg-red-50 p-4 fixed top-1 right-1 w-96 fade-in-scale"
class="hidden rounded-md bg-red-50 p-4 fixed top-1 right-1 w-96 fade-in-scale z-50"
js-show={show("#connection-status")}
js-hide={hide("#connection-status")}
>
@ -55,13 +55,13 @@ defmodule LiveBeatsWeb.LiveHelpers do
<%= if live_flash(@flash, @kind) do %>
<div
id="flash"
class="rounded-md bg-red-50 p-4 fixed top-1 right-1 w-96 fade-in-scale"
class="rounded-md bg-red-50 p-4 fixed top-1 right-1 w-96 fade-in-scale z-50"
phx-click={JS.push("lv:clear-flash") |> JS.remove_class("fade-in-scale", to: "#flash") |> hide("#flash")}
phx-hook="Flash"
>
<div class="flex justify-between items-center space-x-3 text-red-700">
<.icon name={:exclamation_circle} class="w-5 w-5"/>
<p class="flex-1 text-sm font-medium">
<p class="flex-1 text-sm font-medium" role="alert">
<%= live_flash(@flash, @kind) %>
</p>
<button type="button" class="inline-flex bg-red-50 rounded-md p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600">
@ -78,14 +78,14 @@ defmodule LiveBeatsWeb.LiveHelpers do
<%= if live_flash(@flash, @kind) do %>
<div
id="flash"
class="rounded-md bg-green-50 p-4 fixed top-1 right-1 w-96 fade-in-scale"
class="rounded-md bg-green-50 p-4 fixed top-1 right-1 w-96 fade-in-scale z-50"
phx-click={JS.push("lv:clear-flash") |> JS.remove_class("fade-in-scale") |> hide("#flash")}
phx-value-key="info"
phx-hook="Flash"
>
<div class="flex justify-between items-center space-x-3 text-green-700">
<.icon name={:check_circle} class="w-5 h-5"/>
<p class="flex-1 text-sm font-medium">
<p class="flex-1 text-sm font-medium" role="alert">
<%= live_flash(@flash, @kind) %>
</p>
<button type="button" class="inline-flex bg-green-50 rounded-md p-1.5 text-green-500 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-green-50 focus:ring-green-600">
@ -348,64 +348,74 @@ defmodule LiveBeatsWeb.LiveHelpers do
|> assign_rest(~w(id show patch navigate on_cancel on_confirm title confirm cancel)a)
~H"""
<div id={@id} class={"fixed z-10 inset-0 overflow-y-auto #{if @show, do: "fade-in", else: "hidden"}"} aria-labelledby="modal-title" role="dialog" aria-modal="true" {@rest}>
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div
id={"#{@id}-container"}
class={"#{if @show, do: "fade-in-scale", else: "hidden"} sticky inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform sm:my-8 sm:align-middle sm:max-w-xl sm:w-full sm:p-6"}
phx-window-keydown={hide_modal(@on_cancel, @id)} phx-key="escape"
phx-click-away={hide_modal(@on_cancel, @id)}
>
<%= if @patch do %>
<.link patch={@patch} data-modal-return class="hidden"></.link>
<% end %>
<%= if @navigate do %>
<.link navigate={@navigate} data-modal-return class="hidden"></.link>
<% end %>
<div class="sm:flex sm:items-start">
<div class={"mx-auto flex-shrink-0 flex items-center justify-center h-8 w-8 rounded-full bg-purple-100 sm:mx-0"}>
<!-- Heroicon name: outline/plus -->
<.icon name={:information_circle} outlined class="h-6 w-6 text-purple-600"/>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full mr-12">
<h3 class="text-lg leading-6 font-medium text-gray-900" id={"#{@id}-title"}>
<%= render_slot(@title) %>
</h3>
<div class="mt-2">
<p id={"#{@id}-content"} class={"text-sm text-gray-500"}>
<%= render_slot(@inner_block) %>
</p>
<div id={@id} class={"fixed z-10 inset-0 overflow-y-auto #{if @show, do: "fade-in", else: "hidden"}"} {@rest}>
<.focus_wrap id={"#{@id}-focus-wrap"} content={"##{@id}-container"}>
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0" aria-labelledby={"#{@id}-title"} aria-describedby={"#{@id}-description"} role="dialog" aria-modal="true" tabindex="0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div
id={"#{@id}-container"}
class={"#{if @show, do: "fade-in-scale", else: "hidden"} sticky inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform sm:my-8 sm:align-middle sm:max-w-xl sm:w-full sm:p-6"}
phx-window-keydown={hide_modal(@on_cancel, @id)} phx-key="escape"
phx-click-away={hide_modal(@on_cancel, @id)}
>
<%= if @patch do %>
<.link patch={@patch} data-modal-return class="hidden"></.link>
<% end %>
<%= if @navigate do %>
<.link navigate={@navigate} data-modal-return class="hidden"></.link>
<% end %>
<div class="sm:flex sm:items-start">
<div class={"mx-auto flex-shrink-0 flex items-center justify-center h-8 w-8 rounded-full bg-purple-100 sm:mx-0"}>
<!-- Heroicon name: outline/plus -->
<.icon name={:information_circle} outlined class="h-6 w-6 text-purple-600"/>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full mr-12">
<h3 class="text-lg leading-6 font-medium text-gray-900" id={"#{@id}-title"}>
<%= render_slot(@title) %>
</h3>
<div class="mt-2">
<p id={"#{@id}-content"} class={"text-sm text-gray-500"}>
<%= render_slot(@inner_block) %>
</p>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<%= for confirm <- @confirm do %>
<button
id={"#{@id}-confirm"}
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
phx-click={@on_confirm}
phx-disable-with
tabindex="1"
{assigns_to_attributes(confirm)}
>
<%= render_slot(confirm) %>
</button>
<% end %>
<%= for cancel <- @cancel do %>
<button
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
phx-click={hide_modal(@on_cancel, @id)}
tabindex="2"
{assigns_to_attributes(cancel)}
>
<%= render_slot(cancel) %>
</button>
<% end %>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<%= for confirm <- @confirm do %>
<button
id={"#{@id}-confirm"}
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
phx-click={@on_confirm}
phx-disable-with
{assigns_to_attributes(confirm)}
>
<%= render_slot(confirm) %>
</button>
<% end %>
<%= for cancel <- @cancel do %>
<button
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
phx-click={hide_modal(@on_cancel, @id)}
{assigns_to_attributes(cancel)}
>
<%= render_slot(cancel) %>
</button>
<% end %>
</div>
</div>
</div>
</div>
</.focus_wrap>
</div>
"""
end
def focus_wrap(assigns) do
~H"""
<div id={@id} phx-hook="FocusWrap" data-content={@content}>
<span id={"#{@id}-start"} tabindex="0" aria-hidden="true"></span>
<%= render_slot(@inner_block) %>
<span id={"#{@id}-end"} tabindex="0" aria-hidden="true"></span>
</div>
"""
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

View file

@ -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</: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>
<:col let={%{song: song}} label="Duration"><%= MP3Stat.to_mmss(song.duration) %></:col>
<: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
</.link>

View file

@ -22,7 +22,7 @@ defmodule LiveBeatsWeb.ProfileLive.SongEntryComponent do
<% end %>
</label>
<input type="text" name={"songs[#{@ref}][title]"} value={@title}
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/>
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm" {%{autofocus: @index == 0}}/>
</div>
<div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600">
<label for="name" class="block text-xs font-medium text-gray-900">Artist</label>
@ -45,8 +45,14 @@ defmodule LiveBeatsWeb.ProfileLive.SongEntryComponent do
<div class="col-span-full sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start">
<.error input_name={"songs[#{@ref}][attribution]"} field={:attribution} errors={@errors} class="-mt-1"/>
</div>
<div style={"transition: width 0.5s ease-in-out; width: #{@progress}%; min-width: 1px;"} class="col-span-full bg-purple-500 dark:bg-purple-400 h-1.5 w-0 p-0">
</div>
<div
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={@progress}
style={"transition: width 0.5s ease-in-out; width: #{@progress}%; min-width: 1px;"}
class="col-span-full bg-purple-500 dark:bg-purple-400 h-1.5 w-0 p-0"
></div>
</div>
"""
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))

View file

@ -7,7 +7,7 @@ defmodule LiveBeatsWeb.ProfileLive.SongRowComponent do
def render(assigns) do
~H"""
<tr id={@id} class={@class}}>
<tr id={@id} class={@class} tabindex="0">
<%= for {col, i} <- Enum.with_index(@col) do %>
<td
class={"px-6 py-3 text-sm font-medium text-gray-900 #{if i == 0, do: "w-80 cursor-pointer"} #{col[:class]}"}

View file

@ -1,7 +1,5 @@
<div>
<h2><%= @title %>
<p class="inline text-gray-500 text-sm ml-2">(songs expire every six hours)</p>
</h2>
<p class="inline text-gray-500 text-sm">(songs expire every six hours)</p>
<.form
for={:songs}
@ -13,8 +11,8 @@
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
<div class="space-y-2 sm:space-y-2">
<%= 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 %>
<!-- upload -->
@ -51,7 +49,7 @@
<label for={@uploads.mp3.ref} class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
Upload files
</label>
<%= live_file_input @uploads.mp3, class: "sr-only" %>
<%= live_file_input @uploads.mp3, class: "sr-only", tabindex: "0" %>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">

View file

@ -149,6 +149,12 @@
</div>
</div>
<.flash flash={@flash} kind={:info}/>
<.flash flash={@flash} kind={:error}/>
<.connection_status>
Re-establishing connection...
</.connection_status>
<.live_component module={LiveBeatsWeb.LayoutComponent} id="layout" />
<%= if @current_user do %>
@ -156,12 +162,6 @@
<% end %>
<main class="flex-1 relative z-0 overflow-y-auto focus:outline-none">
<.flash flash={@flash} kind={:info}/>
<.flash flash={@flash} kind={:error}/>
<.connection_status>
Re-establishing connection...
</.connection_status>
<%= @inner_content %>
</main>
<div class="relative">