mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-01-06 15:07:52 +08:00
579 lines
16 KiB
Elixir
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
|