mirror of
https://github.com/fly-apps/live_beats.git
synced 2024-10-31 21:48:49 +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
117
assets/js/app.js
117
assets/js/app.js
|
@ -5,6 +5,7 @@ import topbar from "../vendor/topbar"
|
||||||
|
|
||||||
let nowSeconds = () => Math.round(Date.now() / 1000)
|
let nowSeconds = () => Math.round(Date.now() / 1000)
|
||||||
let rand = (min, max) => Math.floor(Math.random() * (max - min) + min)
|
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) => {
|
let execJS = (selector, attr) => {
|
||||||
document.querySelectorAll(selector).forEach(el => liveSocket.execJS(el, el.getAttribute(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) }
|
formatTime(seconds){ return new Date(1000 * seconds).toISOString().substr(14, 5) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Hooks.Ping = {
|
Hooks.Ping = {
|
||||||
mounted(){
|
mounted(){
|
||||||
this.handleEvent("pong", () => {
|
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 csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
hooks: Hooks,
|
hooks: Hooks,
|
||||||
params: {_csrf_token: csrfToken}
|
params: {_csrf_token: csrfToken},
|
||||||
|
dom: {
|
||||||
|
onNodeAdded(node){
|
||||||
|
if(node instanceof HTMLElement && node.autofocus){
|
||||||
|
node.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let routeUpdated = () => {
|
let routeUpdated = ({kind}) => {
|
||||||
let target = document.querySelector("main h1") || document.querySelector("main")
|
Focus.focusMain()
|
||||||
if (target) {
|
|
||||||
let origTabIndex = target.tabIndex
|
|
||||||
target.tabIndex = -1
|
|
||||||
target.focus()
|
|
||||||
target.tabIndex = origTabIndex
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show progress bar on live navigation and form submits
|
// 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())
|
window.addEventListener("phx:page-loading-stop", info => topbar.hide())
|
||||||
|
|
||||||
// Accessible routing
|
// 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: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())
|
window.addEventListener("phx:remove-el", e => document.getElementById(e.detail.id).remove())
|
||||||
|
|
||||||
// connect if there are any LiveViews on the page
|
// connect if there are any LiveViews on the page
|
||||||
|
|
|
@ -17,6 +17,9 @@ defmodule LiveBeatsWeb.LayoutComponent do
|
||||||
case assigns[:show] do
|
case assigns[:show] do
|
||||||
%{module: _module, confirm: {text, attrs}} = show ->
|
%{module: _module, confirm: {text, attrs}} = show ->
|
||||||
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(:patch, nil)
|
||||||
|> Map.put_new(:navigate, nil)
|
|> Map.put_new(:navigate, nil)
|
||||||
|> Map.merge(%{confirm_text: text, confirm_attrs: attrs})
|
|> Map.merge(%{confirm_text: text, confirm_attrs: attrs})
|
||||||
|
@ -32,7 +35,15 @@ defmodule LiveBeatsWeb.LayoutComponent do
|
||||||
~H"""
|
~H"""
|
||||||
<div class={unless @show, do: "hidden"}>
|
<div class={unless @show, do: "hidden"}>
|
||||||
<%= if @show do %>
|
<%= 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} />
|
<.live_component module={@show.module} {@show} />
|
||||||
<:cancel>Cancel</:cancel>
|
<:cancel>Cancel</:cancel>
|
||||||
<:confirm {@show.confirm_attrs}><%= @show.confirm_text %></:confirm>
|
<:confirm {@show.confirm_attrs}><%= @show.confirm_text %></:confirm>
|
||||||
|
|
|
@ -29,7 +29,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
id="connection-status"
|
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-show={show("#connection-status")}
|
||||||
js-hide={hide("#connection-status")}
|
js-hide={hide("#connection-status")}
|
||||||
>
|
>
|
||||||
|
@ -55,13 +55,13 @@ defmodule LiveBeatsWeb.LiveHelpers do
|
||||||
<%= if live_flash(@flash, @kind) do %>
|
<%= if live_flash(@flash, @kind) do %>
|
||||||
<div
|
<div
|
||||||
id="flash"
|
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-click={JS.push("lv:clear-flash") |> JS.remove_class("fade-in-scale", to: "#flash") |> hide("#flash")}
|
||||||
phx-hook="Flash"
|
phx-hook="Flash"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center space-x-3 text-red-700">
|
<div class="flex justify-between items-center space-x-3 text-red-700">
|
||||||
<.icon name={:exclamation_circle} class="w-5 w-5"/>
|
<.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) %>
|
<%= live_flash(@flash, @kind) %>
|
||||||
</p>
|
</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">
|
<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 %>
|
<%= if live_flash(@flash, @kind) do %>
|
||||||
<div
|
<div
|
||||||
id="flash"
|
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-click={JS.push("lv:clear-flash") |> JS.remove_class("fade-in-scale") |> hide("#flash")}
|
||||||
phx-value-key="info"
|
phx-value-key="info"
|
||||||
phx-hook="Flash"
|
phx-hook="Flash"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center space-x-3 text-green-700">
|
<div class="flex justify-between items-center space-x-3 text-green-700">
|
||||||
<.icon name={:check_circle} class="w-5 h-5"/>
|
<.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) %>
|
<%= live_flash(@flash, @kind) %>
|
||||||
</p>
|
</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">
|
<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)
|
|> assign_rest(~w(id show patch navigate on_cancel on_confirm title confirm cancel)a)
|
||||||
|
|
||||||
~H"""
|
~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 id={@id} class={"fixed z-10 inset-0 overflow-y-auto #{if @show, do: "fade-in", else: "hidden"}"} {@rest}>
|
||||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<.focus_wrap id={"#{@id}-focus-wrap"} content={"##{@id}-container"}>
|
||||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
<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">
|
||||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||||
<div
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
id={"#{@id}-container"}
|
<div
|
||||||
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"}
|
id={"#{@id}-container"}
|
||||||
phx-window-keydown={hide_modal(@on_cancel, @id)} phx-key="escape"
|
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-click-away={hide_modal(@on_cancel, @id)}
|
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>
|
<%= if @patch do %>
|
||||||
<% end %>
|
<.link patch={@patch} data-modal-return class="hidden"></.link>
|
||||||
<%= if @navigate do %>
|
<% end %>
|
||||||
<.link navigate={@navigate} data-modal-return class="hidden"></.link>
|
<%= if @navigate do %>
|
||||||
<% end %>
|
<.link navigate={@navigate} data-modal-return class="hidden"></.link>
|
||||||
<div class="sm:flex sm:items-start">
|
<% end %>
|
||||||
<div class={"mx-auto flex-shrink-0 flex items-center justify-center h-8 w-8 rounded-full bg-purple-100 sm:mx-0"}>
|
<div class="sm:flex sm:items-start">
|
||||||
<!-- Heroicon name: outline/plus -->
|
<div class={"mx-auto flex-shrink-0 flex items-center justify-center h-8 w-8 rounded-full bg-purple-100 sm:mx-0"}>
|
||||||
<.icon name={:information_circle} outlined class="h-6 w-6 text-purple-600"/>
|
<!-- Heroicon name: outline/plus -->
|
||||||
</div>
|
<.icon name={:information_circle} outlined class="h-6 w-6 text-purple-600"/>
|
||||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full mr-12">
|
</div>
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900" id={"#{@id}-title"}>
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full mr-12">
|
||||||
<%= render_slot(@title) %>
|
<h3 class="text-lg leading-6 font-medium text-gray-900" id={"#{@id}-title"}>
|
||||||
</h3>
|
<%= render_slot(@title) %>
|
||||||
<div class="mt-2">
|
</h3>
|
||||||
<p id={"#{@id}-content"} class={"text-sm text-gray-500"}>
|
<div class="mt-2">
|
||||||
<%= render_slot(@inner_block) %>
|
<p id={"#{@id}-content"} class={"text-sm text-gray-500"}>
|
||||||
</p>
|
<%= render_slot(@inner_block) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<%= for confirm <- @confirm do %>
|
||||||
<%= for confirm <- @confirm do %>
|
<button
|
||||||
<button
|
id={"#{@id}-confirm"}
|
||||||
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"
|
||||||
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-click={@on_confirm}
|
phx-disable-with
|
||||||
phx-disable-with
|
{assigns_to_attributes(confirm)}
|
||||||
tabindex="1"
|
>
|
||||||
{assigns_to_attributes(confirm)}
|
<%= render_slot(confirm) %>
|
||||||
>
|
</button>
|
||||||
<%= render_slot(confirm) %>
|
<% end %>
|
||||||
</button>
|
<%= for cancel <- @cancel do %>
|
||||||
<% end %>
|
<button
|
||||||
<%= for cancel <- @cancel do %>
|
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"
|
||||||
<button
|
phx-click={hide_modal(@on_cancel, @id)}
|
||||||
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"
|
{assigns_to_attributes(cancel)}
|
||||||
phx-click={hide_modal(@on_cancel, @id)}
|
>
|
||||||
tabindex="2"
|
<%= render_slot(cancel) %>
|
||||||
{assigns_to_attributes(cancel)}
|
</button>
|
||||||
>
|
<% end %>
|
||||||
<%= render_slot(cancel) %>
|
</div>
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
@ -576,6 +586,16 @@ defmodule LiveBeatsWeb.LiveHelpers do
|
||||||
JS.dispatch(js, "js:exec", to: to, detail: %{call: call, args: args})
|
JS.dispatch(js, "js:exec", to: to, detail: %{call: call, args: args})
|
||||||
end
|
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
|
defp assign_rest(assigns, exclude) do
|
||||||
assign(assigns, :rest, assigns_to_attributes(assigns, exclude))
|
assign(assigns, :rest, assigns_to_attributes(assigns, exclude))
|
||||||
end
|
end
|
||||||
|
|
|
@ -51,7 +51,12 @@ defmodule LiveBeatsWeb.ProfileLive do
|
||||||
<%= for song <- if(@owns_profile?, do: @songs, else: []), id = "delete-modal-#{song.id}" do %>
|
<%= for song <- if(@owns_profile?, do: @songs, else: []), id = "delete-modal-#{song.id}" do %>
|
||||||
<.modal
|
<.modal
|
||||||
id={id}
|
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 %>"?
|
Are you sure you want to delete "<%= song.title %>"?
|
||||||
<:cancel>Cancel</:cancel>
|
<: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="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="Duration"><%= MP3Stat.to_mmss(song.duration) %></:col>
|
||||||
<:col let={%{song: song}} label="" if={@owns_profile?}>
|
<: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"/>
|
<.icon name={:trash} class="-ml-0.5 mr-2 h-4 w-4"/>
|
||||||
Delete
|
Delete
|
||||||
</.link>
|
</.link>
|
||||||
|
|
|
@ -22,7 +22,7 @@ defmodule LiveBeatsWeb.ProfileLive.SongEntryComponent do
|
||||||
<% end %>
|
<% end %>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" name={"songs[#{@ref}][title]"} value={@title}
|
<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>
|
||||||
<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">
|
<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>
|
<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">
|
<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"/>
|
<.error input_name={"songs[#{@ref}][attribution]"} field={:attribution} errors={@errors} class="-mt-1"/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
@ -55,10 +61,11 @@ defmodule LiveBeatsWeb.ProfileLive.SongEntryComponent do
|
||||||
{:ok, assign(socket, progress: progress)}
|
{:ok, assign(socket, progress: progress)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def update(%{changeset: changeset, id: id}, socket) do
|
def update(%{changeset: changeset, id: id, index: index}, socket) do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(ref: id)
|
|> assign(ref: id)
|
||||||
|
|> assign(index: index)
|
||||||
|> assign(:errors, changeset.errors)
|
|> assign(:errors, changeset.errors)
|
||||||
|> assign(title: Ecto.Changeset.get_field(changeset, :title))
|
|> assign(title: Ecto.Changeset.get_field(changeset, :title))
|
||||||
|> assign(artist: Ecto.Changeset.get_field(changeset, :artist))
|
|> assign(artist: Ecto.Changeset.get_field(changeset, :artist))
|
||||||
|
|
|
@ -7,7 +7,7 @@ defmodule LiveBeatsWeb.ProfileLive.SongRowComponent do
|
||||||
|
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<tr id={@id} class={@class}}>
|
<tr id={@id} class={@class} tabindex="0">
|
||||||
<%= for {col, i} <- Enum.with_index(@col) do %>
|
<%= for {col, i} <- Enum.with_index(@col) do %>
|
||||||
<td
|
<td
|
||||||
class={"px-6 py-3 text-sm font-medium text-gray-900 #{if i == 0, do: "w-80 cursor-pointer"} #{col[:class]}"}
|
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>
|
<div>
|
||||||
<h2><%= @title %>
|
<p class="inline text-gray-500 text-sm">(songs expire every six hours)</p>
|
||||||
<p class="inline text-gray-500 text-sm ml-2">(songs expire every six hours)</p>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
for={:songs}
|
for={:songs}
|
||||||
|
@ -13,8 +11,8 @@
|
||||||
|
|
||||||
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
|
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
|
||||||
<div class="space-y-2 sm:space-y-2">
|
<div class="space-y-2 sm:space-y-2">
|
||||||
<%= for {ref, changeset} <- @changesets do %>
|
<%= for {{ref, changeset}, i} <- Enum.with_index(@changesets) do %>
|
||||||
<.live_component id={ref} module={SongEntryComponent} changeset={changeset} />
|
<.live_component id={ref} module={SongEntryComponent} changeset={changeset} index={i} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- upload -->
|
<!-- upload -->
|
||||||
|
@ -50,7 +48,7 @@
|
||||||
<div class="flex text-sm text-gray-600">
|
<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">
|
<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>
|
<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>
|
</label>
|
||||||
<p class="pl-1">or drag and drop</p>
|
<p class="pl-1">or drag and drop</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -149,6 +149,12 @@
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<.live_component module={LiveBeatsWeb.LayoutComponent} id="layout" />
|
||||||
|
|
||||||
<%= if @current_user do %>
|
<%= if @current_user do %>
|
||||||
|
@ -156,12 +162,6 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<main class="flex-1 relative z-0 overflow-y-auto focus:outline-none">
|
<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 %>
|
<%= @inner_content %>
|
||||||
</main>
|
</main>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|
Loading…
Reference in a new issue