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 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)))
@ -181,7 +182,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", () => {
@ -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 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
@ -220,9 +297,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

View file

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

View file

@ -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">&#8203;</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">&#8203;</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

View file

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

View file

@ -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))

View file

@ -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]}"}

View file

@ -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 -->
@ -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"> <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 Upload files
</label> </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> <p class="pl-1">or drag and drop</p>
</div> </div>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">

View file

@ -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">