livebook/lib/livebook_web/components/core_components.ex
2023-05-10 18:23:08 +02:00

579 lines
16 KiB
Elixir

defmodule LivebookWeb.CoreComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
Renders [Remix](https://remixicon.com) icon.
## Examples
<.remix_icon icon="cpu-line" />
<.remix_icon icon="cpu-line" class="align-middle mr-1" />
"""
attr :icon, :string, required: true
attr :class, :string, default: nil
attr :rest, :global
def remix_icon(assigns) do
~H"""
<i class={["ri-#{@icon}", @class]} aria-hidden="true" {@rest}></i>
"""
end
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
"""
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :kind, :atom,
values: [:info, :success, :warning, :error],
doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
def flash(assigns) do
~H"""
<div
:if={message = Phoenix.Flash.get(@flash, @kind)}
class={[
"shadow-custom-1 max-w-2xl flex items-center space-x-3 rounded-lg px-4 py-2 border-l-4 rounded-l-none bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-500 cursor-pointer",
@kind == :info && "border-blue-500",
@kind == :success && "border-blue-500",
@kind == :warning && "border-yellow-300",
@kind == :error && "border-red-500"
]}
role="alert"
phx-click="lv:clear-flash"
phx-value-key={@kind}
{@rest}
>
<.remix_icon :if={@kind == :info} icon="information-line" class="text-2xl text-blue-500" />
<.remix_icon :if={@kind == :success} icon="checkbox-circle-fill" class="text-2xl text-blue-500" />
<.remix_icon :if={@kind == :warning} icon="alert-line" class="text-2xl text-yellow-400" />
<.remix_icon :if={@kind == :error} icon="close-circle-line" class="text-2xl text-red-500" />
<span class="whitespace-pre-wrap pr-2 max-h-52 overflow-y-auto tiny-scrollbar" phx-no-format><%= message %></span>
</div>
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
def flash_group(assigns) do
~H"""
<div class="fixed right-8 top-5 z-[1000] flex flex-col space-y-3">
<.flash kind={:info} flash={@flash} />
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
@doc """
Creates a live region with the given role.
## Examples
<.live_region role="alert" />
<.live_region role="status" />
"""
def live_region(assigns) do
~H"""
<div class="sr-only" role={@role} id="live-region"></div>
"""
end
@doc """
Sends a message to be read by the screen reader by changing the text content of the live region
"""
def sr_message(js \\ %JS{}, message) do
JS.dispatch(js, "lb:set_text", to: "#live-region", detail: %{value: message})
end
@doc """
Wraps the given content in a modal dialog.
## Example
<.modal id="edit-modal" patch={...}>
<.live_component module={MyComponent} />
</.modal>
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :patch, :string, default: nil
attr :navigate, :string, default: nil
attr :class, :string, default: nil
attr :width, :atom, values: [:small, :medium, :big, :large], required: true
attr :rest, :global
slot :inner_block, required: true
def modal(assigns) do
~H"""
<div
id={@id}
class={["fixed z-[10000] inset-0", if(@show, do: "fade-in", else: "hidden")]}
phx-remove={JS.transition("fade-out")}
{@rest}
>
<!-- Modal container -->
<div class="h-screen flex items-center justify-center p-4">
<!-- Overlay -->
<div class="absolute z-0 inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<!-- Modal box -->
<.focus_wrap
id={"#{@id}-content"}
class={[
"relative max-h-full overflow-y-auto bg-white rounded-lg shadow-xl",
"w-full",
modal_width_class(@width)
]}
role="dialog"
aria-modal="true"
tabindex="0"
autofocus
phx-window-keydown={hide_modal(@id)}
phx-click-away={hide_modal(@id)}
phx-key="escape"
>
<.link :if={@patch} patch={@patch} class="hidden" id={"#{@id}-return"}></.link>
<.link :if={@navigate} patch={@navigate} class="hidden" id={"#{@id}-return"}></.link>
<button
type="button"
class="absolute top-6 right-6 text-gray-400 flex space-x-1 items-center"
aria_label="close modal"
phx-click={hide_modal(@id)}
>
<span class="text-sm">(esc)</span>
<.remix_icon icon="close-line" class="text-2xl" />
</button>
<%= render_slot(@inner_block) %>
</.focus_wrap>
</div>
</div>
"""
end
defp modal_width_class(:small), do: "max-w-sm"
defp modal_width_class(:medium), do: "max-w-xl"
defp modal_width_class(:big), do: "max-w-4xl"
defp modal_width_class(:large), do: "max-w-6xl"
@doc """
Shows a modal rendered with `modal/1`.
"""
def show_modal(js \\ %JS{}, id) do
js
|> JS.show(
to: "##{id}",
transition: {"ease-out duration-200", "opacity-0", "opacity-100"}
)
end
@doc """
Hides a modal rendered with `modal/1`.
"""
def hide_modal(js \\ %JS{}, id) do
js
|> JS.hide(
to: "##{id}",
transition: {"ease-in duration-200", "opacity-100", "opacity-0"}
)
|> JS.dispatch("click", to: "##{id}-return")
end
@doc """
Renders a popup menu that shows up on toggle click.
## Examples
<.menu id="my-menu">
<:toggle>
<button>Open</button>
</:toggle>
<.menu_item>
<button role="menuitem">Option 1</button>
</.menu_item>
<.menu_item>
<button role="menuitem">Option 2</button>
</.menu_item>
</.menu>
"""
attr :id, :string, required: true
attr :disabled, :boolean, default: false, doc: "whether the menu is active"
attr :position, :atom,
default: :bottom_right,
values: [:top_left, :top_right, :bottom_left, :bottom_right]
attr :distant, :boolean,
default: false,
doc: "whether the menu should be further from the anchor element"
attr :secondary_click, :boolean,
default: false,
doc: "whether secondary click (usually right mouse click) should open the menu"
slot :toggle, required: true
slot :inner_block, required: true
def menu(assigns) do
~H"""
<div class="relative" id={@id}>
<div
phx-click={not @disabled && show_menu(@id)}
data-contextmenu-trigger-click={@secondary_click}
phx-window-keydown={hide_menu(@id)}
phx-key="escape"
>
<%= render_slot(@toggle) %>
</div>
<div id={"#{@id}-overlay"} class="fixed z-[90] inset-0 hidden" phx-click-away={hide_menu(@id)}>
</div>
<menu
id={"#{@id}-content"}
class={[
"absolute z-[100] rounded-lg bg-white flex flex-col py-2 shadow-[0_15px_99px_-0px_rgba(12,24,41,0.15)] hidden",
menu_position_class(@position),
@distant && menu_distant_class(@position)
]}
role="menu"
phx-click-away={hide_menu(@id)}
>
<%= render_slot(@inner_block) %>
</menu>
</div>
"""
end
defp show_menu(id) do
JS.show(to: "##{id}-overlay")
|> JS.show(to: "##{id}-content", display: "flex")
end
defp hide_menu(id) do
JS.hide(to: "##{id}-overlay")
|> JS.hide(to: "##{id}-content")
end
defp menu_position_class(:top_left), do: "top-0 left-0 transform -translate-y-full -mt-1"
defp menu_position_class(:top_right), do: "top-0 right-0 transform -translate-y-full -mt-1"
defp menu_position_class(:bottom_left), do: "bottom-0 left-0 transform translate-y-full -mb-1"
defp menu_position_class(:bottom_right), do: "bottom-0 right-0 transform translate-y-full -mb-1"
defp menu_distant_class(position) when position in [:top_left, :top_right], do: "-mt-2"
defp menu_distant_class(position) when position in [:bottom_left, :bottom_right], do: "-mb-2"
@doc """
Wraps a menu item that shows a submenu on hover.
This component should be used within `menu/1` content.
## Example
<.submenu>
<:primary>
<button class"menu-item" role="menuitem">Submenu</button>
</:primary>
<.menu_item>
<button class"menu-item" role="menuitem">Option 1</button>
</.menu_item>
</.submenu>
"""
slot :primary, required: true
slot :inner_block, required: true
def submenu(assigns) do
~H"""
<div class="group relative">
<%= render_slot(@primary) %>
<div class="absolute -top-2 right-0 translate-x-full pl-2 hidden group-hover:flex group-focus-within:flex">
<menu class="relative mt-0 z-[100] rounded-lg bg-white flex flex-col py-2 shadow-[0_15px_99px_-0px_rgba(12,24,41,0.15)]">
<%= render_slot(@inner_block) %>
</menu>
</div>
</div>
"""
end
@doc """
Renders a menu item used in `menu/1` and `submenu/1`.
"""
attr :disabled, :boolean, default: false
attr :variant, :atom, default: :default, values: [:default, :selected, :danger]
slot :inner_block, required: true
def menu_item(assigns) do
~H"""
<li class={[
"w-full",
"[&>:first-child]:w-full [&>:first-child]:flex [&>:first-child]:space-x-3 [&>:first-child]:px-5 [&>:first-child]:py-2 [&>:first-child]:items-center [&>:first-child:hover]:bg-gray-100 [&>:first-child:focus]:bg-gray-100 [&>:first-child]:whitespace-nowrap font-medium",
menu_item_class(@variant),
@disabled && "pointer-events-none opacity-50"
]}>
<%= render_slot(@inner_block) %>
</li>
"""
end
defp menu_item_class(:default), do: "text-gray-500"
defp menu_item_class(:selected), do: "text-gray-900"
defp menu_item_class(:danger), do: "text-red-600"
@doc """
Renders a text content skeleton.
"""
attr :empty, :boolean, default: false, doc: "if the source is empty"
attr :bg_class, :string, default: "bg-gray-200", doc: "the skeleton background color"
def content_skeleton(assigns) do
~H"""
<%= if @empty do %>
<div class="h-4"></div>
<% else %>
<div class="max-w-2xl w-full animate-pulse">
<div class="flex-1 space-y-4">
<div class={[@bg_class, "h-4 rounded-lg w-3/4"]}></div>
<div class={[@bg_class, "h-4 rounded-lg"]}></div>
<div class={[@bg_class, "h-4 rounded-lg w-5/6"]}></div>
</div>
</div>
<% end %>
"""
end
@doc """
Renders a highlighted code snippet.
## Examples
<.code_preview
source_id="my-snippet"
language="elixir"
source="System.version()" />
"""
attr :source_id, :string, required: true
attr :language, :string, required: true
attr :source, :string, required: true
def code_preview(assigns) do
~H"""
<div class="markdown">
<pre><code
class="tiny-scrollbar"
id={"#{@source_id}-highlight"}
phx-hook="Highlight"
data-language={@language}
><div id={@source_id} data-source><%= @source %></div><div data-target></div></code></pre>
</div>
"""
end
@doc """
Renders text with a tiny label.
## Examples
<.labeled_text label="Name">Sherlock Holmes</.labeled_text>
"""
attr :label, :string, required: true
attr :one_line, :boolean,
default: false,
doc: "whether to force the text into a single scrollable line"
slot :inner_block, required: true
def labeled_text(assigns) do
~H"""
<div class="flex flex-col space-y-1">
<span class="text-sm text-gray-500">
<%= @label %>
</span>
<span class={
"text-gray-800 text-sm font-semibold #{if @one_line, do: "whitespace-nowrap overflow-auto tiny-scrollbar"}"
}>
<%= render_slot(@inner_block) %>
</span>
</div>
"""
end
@doc """
Renders a choice button that is either active or not.
## Examples
<.choice_button active={@tab == "my_tab"} phx-click="set_my_tab">
My tab
</.choice_button>
"""
attr :active, :boolean, required: true
attr :disabled, :boolean
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def choice_button(assigns) do
assigns =
assigns
|> assign_new(:disabled, fn -> assigns.active end)
~H"""
<button
class={[
"px-5 py-2 rounded-lg border text-gray-700 bg-white border-gray-200",
@active && "bg-blue-100 border-blue-600",
@class
]}
disabled={@disabled}
{@rest}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Renders an status indicator circle.
"""
attr :variant, :atom,
required: true,
values: [:success, :warning, :error, :inactive, :waiting, :progressing]
def status_indicator(assigns) do
~H"""
<span class="relative flex h-3 w-3">
<span
:if={animated_circle_class(@variant)}
class={[
animated_circle_class(@variant),
"animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
]}
>
</span>
<span class={[circle_class(@variant), "relative inline-flex rounded-full h-3 w-3"]}></span>
</span>
"""
end
defp circle_class(:success), do: "bg-green-bright-400"
defp circle_class(:warning), do: "bg-yellow-bright-200"
defp circle_class(:error), do: "bg-red-400"
defp circle_class(:inactive), do: "bg-gray-500"
defp circle_class(:waiting), do: "bg-gray-400"
defp circle_class(:progressing), do: "bg-blue-500"
defp animated_circle_class(:waiting), do: "bg-gray-300"
defp animated_circle_class(:progressing), do: "bg-blue-400"
defp animated_circle_class(_other), do: nil
@doc """
Renders an informative box as a placeholder for a list.
"""
slot :inner_block, required: true
slot :actions
def no_entries(assigns) do
~H"""
<div class="p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
<div>
<.remix_icon icon="windy-line" class="text-gray-400 text-xl" />
</div>
<div class="grow flex items-center justify-between">
<div class="text-gray-600">
<%= render_slot(@inner_block) %>
</div>
<%= render_slot(@actions) %>
</div>
</div>
"""
end
@doc """
Returns the text in singular or plural depending on the quantity.
## Examples
<.listing items={@packages}>
<:item :let={package}><code><%= package.name %></code></:item>
<:singular_suffix>package</:singular_suffix>
<:plural_suffix>packages</:plural_suffix>
</.listing>
"""
attr :items, :list, required: true
slot :item, required: true
slot :plural_suffix
slot :singular_suffix
def listing(%{items: [_]} = assigns) do
~H"""
<%= render_slot(@item, hd(@items)) %>
<%= render_slot(@singular_suffix) %>
"""
end
def listing(%{items: [_, _ | _]} = assigns) do
{items, assigns} = Map.pop!(assigns, :items)
{leading, [second_to_last, last]} = Enum.split(items, -2)
assigns = assign(assigns, leading: leading, second_to_last: second_to_last, last: last)
~H"""
<%= for item <- @leading do %>
<%= render_slot(@item, item) %>,
<% end %>
<%= render_slot(@item, @second_to_last) %> and <%= render_slot(@item, @last) %>
<%= render_slot(@plural_suffix) %>
"""
end
# JS commands
@doc """
Toggles classes on elements.
"""
def toggle_class(js \\ %JS{}, names, opts \\ []) do
opts = Keyword.validate!(opts, [:to])
to = Keyword.fetch!(opts, :to)
names
|> String.split()
|> Enum.reduce(js, fn name, js ->
js
|> JS.remove_class(name, to: "#{to}.#{name}")
|> JS.add_class(name, to: "#{to}:not(.#{name})")
end)
end
end