mirror of
https://github.com/fly-apps/live_beats.git
synced 2024-11-21 15:41:00 +00:00
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:
parent
b120cf69ad
commit
7461256268
8 changed files with 230 additions and 90 deletions
113
assets/js/app.js
113
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,13 +194,9 @@ Hooks.Ping = {
|
|||
}
|
||||
}
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
hooks: Hooks,
|
||||
params: {_csrf_token: csrfToken}
|
||||
})
|
||||
|
||||
let routeUpdated = () => {
|
||||
// Accessible focus handling
|
||||
let Focus = {
|
||||
focusMain(){
|
||||
let target = document.querySelector("main h1") || document.querySelector("main")
|
||||
if(target){
|
||||
let origTabIndex = target.tabIndex
|
||||
|
@ -208,6 +204,87 @@ let routeUpdated = () => {
|
|||
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},
|
||||
dom: {
|
||||
onNodeAdded(node){
|
||||
if(node instanceof HTMLElement && node.autofocus){
|
||||
node.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,8 +348,9 @@ 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 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">​</span>
|
||||
<div
|
||||
|
@ -387,7 +388,6 @@ defmodule LiveBeatsWeb.LiveHelpers do
|
|||
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) %>
|
||||
|
@ -397,7 +397,6 @@ defmodule LiveBeatsWeb.LiveHelpers 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) %>
|
||||
|
@ -406,6 +405,17 @@ defmodule LiveBeatsWeb.LiveHelpers do
|
|||
</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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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]}"}
|
||||
|
|
|
@ -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 -->
|
||||
|
@ -50,7 +48,7 @@
|
|||
<div class="flex text-sm text-gray-600">
|
||||
<label for="file-upload" 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">
|
||||
<span phx-click={js_exec("##{@uploads.mp3.ref}", "click", [])}>Upload files</span>
|
||||
<%= live_file_input @uploads.mp3, class: "sr-only" %>
|
||||
<%= live_file_input @uploads.mp3, class: "sr-only", tabindex: "0" %>
|
||||
</label>
|
||||
<p class="pl-1">or drag and drop</p>
|
||||
</div>
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in a new issue