Skip to content

feat: improve sidebar #572

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/atomic/organizations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ defmodule Atomic.Organizations do
[%Organization{}, ...]

"""
def list_organizations(params \\ %{})
def list_organizations do
Organization |> Repo.all()
end

def list_organizations(opts) when is_list(opts) do
Organization
Expand Down
86 changes: 86 additions & 0 deletions lib/atomic_web/components/accordion.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule AtomicWeb.Components.Accordion do
@moduledoc """
Provides accordion-related components and helper functions.
"""
use AtomicWeb, :component

alias Phoenix.LiveView.JS
import AtomicWeb.Components.Icon

@doc """
Accordion components allows users to show and hide sections of related panel on a page.

## Examples

```heex
<.accordion>
<:trigger>Accordion</:trigger>
<:panel>Content</:panel>
</.accordion>
```
"""

attr :class, :any, doc: "Extend existing component styles"
attr :controlled, :boolean, default: false
attr :id, :string, required: true
attr :rest, :global

slot :trigger, validate_attrs: false
slot :panel, validate_attrs: false

@spec accordion(Socket.assigns()) :: Rendered.t()
def accordion(assigns) do
~H"""
<div class={["accordion", assigns[:class]]} id={@id} {@rest}>
<%= for {{trigger, panel}, idx} <- @trigger |> Enum.zip(@panel) |> Enum.with_index() do %>
<h3>
<button
aria-controls={panel_id(@id, idx)}
aria-expanded={to_string(panel[:default_expanded] == true)}
class={[
"accordion-trigger relative w-full [&_.accordion-trigger-icon]:aria-expanded:rotate-180",
trigger[:class]
]}
id={trigger_id(@id, idx)}
phx-click={handle_click(assigns, idx)}
type="button"
{assigns_to_attributes(trigger, [:class, :icon_name])}
>
{render_slot(trigger)}
<.icon class="accordion-trigger-icon size-5 absolute top-1/2 right-4 -translate-y-1/2 transition-all duration-300 ease-in-out" name={trigger[:icon_name] || "hero-chevron-down"} />
</button>
</h3>
<div class="accordion-panel grid-rows-[0fr] grid transform transition-all duration-200 ease-in data-[expanded]:grid-rows-[1fr]" data-expanded={panel[:default_expanded]} id={panel_id(@id, idx)} role="region">
<div class="overflow-hidden">
<div class={["accordion-panel-content", panel[:class]]} {assigns_to_attributes(panel, [:class, :default_expanded ])}>
{render_slot(panel)}
</div>
</div>
</div>
<% end %>
</div>
"""
end

defp trigger_id(id, idx), do: "#{id}_trigger#{idx}"
defp panel_id(id, idx), do: "#{id}_panel#{idx}"

defp handle_click(%{controlled: controlled, id: id}, idx) do
op =
{"aria-expanded", "true", "false"}
|> JS.toggle_attribute(to: "##{trigger_id(id, idx)}")
|> JS.toggle_attribute({"data-expanded", ""}, to: "##{panel_id(id, idx)}")

if controlled do
op
|> JS.set_attribute({"aria-expanded", "false"},
to: "##{id} .accordion-trigger:not(##{trigger_id(id, idx)})"
)
|> JS.remove_attribute("data-expanded",
to: "##{id} .accordion-panel:not(##{panel_id(id, idx)})"
)
else
op
end
end
end
8 changes: 4 additions & 4 deletions lib/atomic_web/components/dropdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ defmodule AtomicWeb.Components.Dropdown do
<div phx-click={JS.toggle(to: "##{@id}", in: {"ease-out duration-100", "transform opacity-0 scale-95", "transform opacity-100 scale-100"}, out: {"ease-in duration-75", "transform opacity-100 scale-100", "transform opacity-0 scale-95"}, display: "block")}>
{render_slot(@wrapper)}
</div>
<div id={@id} class={"#{if @orientation == :down, do: "top-full mt-3 origin-top-right", else: "bottom-full mb-3 origin-bottom-right"} absolute right-0 z-10 hidden w-56 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5"}>
<div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
<div id={@id} class={"#{if @orientation == :down, do: "top-full mt-2 origin-top", else: "bottom-full mb-2 origin-bottom"} shadow-zinc-400/50 absolute right-0 z-10 hidden w-full min-w-fit rounded-md bg-white shadow-lg ring-1 ring-zinc-300"}>
<div class="p-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
<%= for item <- @items do %>
<%= if item[:patch] || item[:navigate] || item[:href] || item[:phx_click] do %>
<.link
Expand All @@ -41,12 +41,12 @@ defmodule AtomicWeb.Components.Dropdown do
JS.hide(to: "##{@id}", transition: {"ease-in duration-75", "transform opacity-100 scale-100", "transform opacity-0 scale-95"})
end
}
class={"#{item[:class]} flex items-center gap-x-2 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 hover:text-zinc-900"}
class={"#{item[:class]} flex items-center gap-x-2 rounded-sm p-2 text-sm text-zinc-700 hover:bg-zinc-100 hover:text-zinc-900"}
role="menuitem"
method={Map.get(item, :method, "get")}
>
<%= if item[:icon] do %>
<.icon name={item.icon} class="size-5 ml-2 inline-block" />
<.icon name={item.icon} class={"#{item[:icon_class]} size-5 inline-block"} />
<% end %>
{item.name}
</.link>
Expand Down
74 changes: 50 additions & 24 deletions lib/atomic_web/components/organizations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,69 @@ defmodule AtomicWeb.Components.Organizations do
@moduledoc false
use AtomicWeb, :live_component

import AtomicWeb.Components.Avatar
import AtomicWeb.Components.{Avatar, Accordion}

alias Atomic.Accounts
alias Atomic.Organizations

@impl true
def render(assigns) do
~H"""
<ul role="list" class="-mx-2 mt-2 max-h-72 max-h-72 space-y-1 overflow-y-auto">
<%= for organization <- @organizations do %>
<li>
<div
phx-target={@myself}
phx-click="select-organization"
phx-value-organization_id={organization.id}
class={
<div id={@id}>
<.accordion id={"#{@id}-accordion"} class="flex-grow rounded-md border" controlled={true}>
<:trigger>
<%= if @current_organization do %>
<div class="group flex cursor-pointer gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-zinc-700 hover:text-primary-500">
<.avatar
class={"#{(@current_organization && @current_organization.id == @current_organization.id) && "text-primary-600"} border border-zinc-200 group-hover:text-primary-500"}
src={Uploaders.Logo.url({@current_organization.logo, @current_organization}, :original)}
name={@current_organization.name}
size={:xs}
type={:organization}
color={:white}
/>
<span class="mt-1 truncate">{@current_organization.name}</span>
</div>
<% else %>
<div class="group cursor-pointer gap-x-3 rounded-md p-2 text-left text-sm leading-6 text-zinc-600 hover:text-primary-500">
<.icon name="hero-pencil-solid" class="size-5 shrink-0 text-zinc-400 group-hover:text-primary-500" />
<span class="mt-1 truncate">{gettext("Pick an organization")}</span>
</div>
<% end %>
</:trigger>
<:panel>
<ul role="list" class="mt-2 max-h-72 space-y-0.5 overflow-y-auto overscroll-contain p-1">
<%= for organization <- @organizations do %>
<li>
<div
phx-target={@myself}
phx-click="select-organization"
phx-value-organization_id={organization.id}
class={
"#{if @current_organization && organization.id == @current_organization.id do
"bg-zinc-50 text-primary-500"
else
"text-zinc-700 hover:text-primary-500 hover:bg-zinc-50"
end} group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold cursor-pointer"
}
type="button"
>
<.avatar
class={"#{if @current_organization && organization.id == @current_organization.id do "border-primary-600" else "border-zinc-200" end} #{(@current_organization && organization.id == @current_organization.id) && "text-primary-600"} border group-hover:border-primary-600 group-hover:text-primary-500"}
src={Uploaders.Logo.url({organization.logo, organization}, :original)}
name={organization.name}
size={:xs}
type={:organization}
color={:white}
/>
<span class="mt-1 truncate">{organization.name}</span>
</div>
</li>
<% end %>
</ul>
type="button"
>
<.avatar
class={"#{(@current_organization && organization.id == @current_organization.id) && "text-primary-600"} border border-zinc-200 group-hover:text-primary-500"}
src={Uploaders.Logo.url({organization.logo, organization}, :original)}
name={organization.name}
size={:xs}
type={:organization}
color={:white}
/>
<span class="mt-1 truncate">{organization.name}</span>
</div>
</li>
<% end %>
</ul>
</:panel>
</.accordion>
</div>
"""
end

Expand Down
70 changes: 40 additions & 30 deletions lib/atomic_web/components/sidebar.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ defmodule AtomicWeb.Components.Sidebar do

~H"""
<div class="relative z-50 hidden" role="dialog">
<.sidebar_header />
<.sidebar_list current_user={@current_user} current_organization={@current_organization} current_page={@current_page} />
</div>
<!-- Navigation -->
Expand All @@ -44,14 +43,12 @@ defmodule AtomicWeb.Components.Sidebar do
<.sidebar_dropdown current_user={@current_user} orientation={:down} />
</div>
</div>
<div id="sidebar-overlay" class="fixed inset-0 z-40 hidden cursor-pointer bg-black bg-opacity-50" phx-click={hide_mobile_sidebar()}></div>
<div id="sidebar-overlay" class="fixed inset-0 z-40 hidden cursor-pointer bg-black bg-opacity-50 backdrop-blur-sm" phx-click={hide_mobile_sidebar()}></div>
<!-- Sidebar Panel -->
<div id="mobile-sidebar" class="fixed inset-0 z-50 hidden w-64" role="dialog" aria-modal="true">
<div class="fixed inset-0 flex w-fit">
<div class="relative flex w-64 max-w-xs flex-col border-r bg-white">
<div class="relative flex w-72 max-w-xs flex-col rounded-r-md border-r bg-white">
<div class="flex justify-between p-4">
<.sidebar_header />

<button type="button" phx-click={hide_mobile_sidebar()} class="absolute top-0 right-0 p-4">
<span class="sr-only">Close sidebar</span>
<.icon name="hero-x-mark" class="size-6 text-zinc-700" />
Expand Down Expand Up @@ -81,36 +78,37 @@ defmodule AtomicWeb.Components.Sidebar do

~H"""
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-zinc-200 bg-white px-6 pb-4">
<.sidebar_header />
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-zinc-200 bg-white p-6">
<.sidebar_dropdown current_user={@current_user} orientation={:down} />
<.sidebar_list current_user={@current_user} current_organization={@current_organization} current_page={@current_page} />
<!-- Organizations listing -->
<%= if Enum.count(@organizations) > 0 do %>
<div class="text-xs font-semibold leading-6 text-zinc-400">{gettext("Your organizations")}</div>
<div class="text-sm font-semibold leading-6 text-zinc-500">{gettext("Your organizations")}</div>
<.live_component id="desktop-organizations" module={AtomicWeb.Components.Organizations} current_user={@current_user} current_organization={@current_organization} organizations={@organizations} />
<% end %>
<!-- Sidebar -->
<div class="absolute bottom-0 w-full">
<.sidebar_dropdown current_user={@current_user} orientation={:up} />
</div>
<div class="absolute bottom-0 w-full"></div>
</div>
</div>
"""
end

defp sidebar_list(assigns) do
~H"""
<ul role="list" class="-mx-2 space-y-1">
<ul role="list" class="space-y-1">
<%= for page <- AtomicWeb.Config.pages(@current_user, @current_organization) do %>
<li class="select-none">
<.link navigate={page.url} class={"#{if @current_page == page.key do "bg-zinc-50 text-primary-500" else "text-zinc-700 hover:text-primary-500 hover:bg-zinc-50" end} group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6"}>
<.icon name={page.icon} class={
<.link navigate={page.url} class={"#{if @current_page == page.key do "font-extrabold text-primary-500" else "text-zinc-700 hover:text-primary-500 font-medium" end} group flex gap-x-3 rounded-md p-2 leading-6 hover:bg-zinc-50"}>
<.icon
name={if @current_page == page.key, do: page.icon_selected, else: page.icon}
class={
"#{if @current_page == page.key do
"text-primary-500"
else
"text-zinc-400 group-hover:text-primary-500"
end} size-6 shrink-0"
} />
end} size-7 shrink-0"
}
/>
{page.title}
</.link>
</li>
Expand All @@ -124,9 +122,14 @@ defmodule AtomicWeb.Components.Sidebar do
<%= if @current_user do %>
<AtomicWeb.Components.Dropdown.dropdown orientation={@orientation} items={dropdown_items(@current_user)} id="user-menu-button">
<:wrapper>
<button class="flex w-full select-none flex-row items-center gap-x-2 px-4 py-3 text-sm font-semibold leading-6 text-zinc-700 lg:px-0">
<AtomicWeb.Components.Avatar.avatar name={@current_user.name} src={user_image(@current_user)} size={:xs} color={:light_zinc} class="!text-sm" />
<span class="text-sm font-semibold leading-6">{@current_user.name}</span>
<button class="flex w-full select-none flex-row items-center justify-between gap-x-2 rounded-md p-2 leading-6 text-zinc-700 hover:bg-zinc-100">
<div class="flex flex-row items-center gap-x-2">
<AtomicWeb.Components.Avatar.avatar name={@current_user.name} src={user_image(@current_user)} size={:xs} color={:light_zinc} />
<div class="flex flex-col items-start">
<span class="text-xs font-semibold">{@current_user.name}</span>
<span class="text-xs text-slate-600">@{@current_user.slug}</span>
</div>
</div>
<.icon name="hero-chevron-right-solid" class="size-5" />
</button>
</:wrapper>
Expand All @@ -140,26 +143,23 @@ defmodule AtomicWeb.Components.Sidebar do
"""
end

defp sidebar_header(assigns) do
~H"""
<.link navigate={~p"/"} class="flex h-16 shrink-0 select-none items-center gap-x-4 pt-4">
<img src={~p"/images/atomic.svg"} class="h-14 w-auto" alt="Atomic" />
<p class="text-2xl font-semibold text-zinc-400">Atomic</p>
</.link>
"""
end

defp dropdown_items(nil), do: []
defp dropdown_items(current_user), do: authenticated_dropdown_items(current_user)

defp authenticated_dropdown_items(current_user) do
[
%{
icon: "hero-user-circle",
icon_class: "text-zinc-500",
name: gettext("Your profile"),
class: "font-semibold",
navigate: ~p"/profile/#{current_user}"
},
%{
icon: "hero-arrow-right-start-on-rectangle",
icon_class: "text-zinc-500",
name: gettext("Sign out"),
class: "font-semibold",
href: ~p"/users/log_out",
method: "delete"
}
Expand All @@ -173,7 +173,10 @@ defmodule AtomicWeb.Components.Sidebar do
transition:
{"transition ease-in-out duration-300 transform", "-translate-x-full", "translate-x-0"}
)
|> JS.show(to: "#sidebar-overlay")
|> JS.show(
to: "#sidebar-overlay",
transition: {"ease-in duration-300", "opacity-0", "opacity-100"}
)
|> JS.dispatch("focus", to: "#mobile-sidebar")
end

Expand All @@ -200,5 +203,12 @@ defmodule AtomicWeb.Components.Sidebar do
end

defp get_organizations(nil), do: []
defp get_organizations(user), do: Organizations.list_user_organizations(user.id)

defp get_organizations(user) do
if user.role == :master do
Organizations.list_organizations()
else
Organizations.list_user_organizations(user.id)
end
end
end
Loading
Loading