mirror of
				https://github.com/livebook-dev/livebook.git
				synced 2025-11-01 00:06:04 +08:00 
			
		
		
		
	* Accessibility fixes * Restructure j/k navigation to support headlines * Focus modal content when open * Further focus adjustments * Fix tests * Remove unused functions
		
			
				
	
	
		
			378 lines
		
	
	
	
		
			10 KiB
		
	
	
	
		
			Elixir
		
	
	
	
	
	
			
		
		
	
	
			378 lines
		
	
	
	
		
			10 KiB
		
	
	
	
		
			Elixir
		
	
	
	
	
	
| defmodule LivebookWeb.Helpers do
 | |
|   use Phoenix.Component
 | |
| 
 | |
|   alias Phoenix.LiveView.JS
 | |
| 
 | |
|   alias LivebookWeb.Router.Helpers, as: Routes
 | |
| 
 | |
|   alias Livebook.FileSystem
 | |
| 
 | |
|   @doc """
 | |
|   Wraps the given content in a modal dialog.
 | |
| 
 | |
|   When closed, the modal redirects to the given `:return_to` URL.
 | |
| 
 | |
|   ## Example
 | |
| 
 | |
|       <.live_modal return_to={...}>
 | |
|         <.live_component module={MyComponent}  />
 | |
|       </.live_modal>
 | |
|   """
 | |
|   def modal(assigns) do
 | |
|     assigns =
 | |
|       assigns
 | |
|       |> assign_new(:class, fn -> "" end)
 | |
| 
 | |
|     ~H"""
 | |
|     <div class="fixed z-[10000] inset-0 fade-in" phx-remove={JS.transition("fade-out")}>
 | |
|       <!-- Modal container -->
 | |
|       <div class="h-screen flex items-center justify-center p-4">
 | |
|         <!-- Overlay -->
 | |
|         <div class="absolute inset-0 bg-gray-500 opacity-75 z-0" aria-hidden="true"></div>
 | |
| 
 | |
|         <!-- Modal box -->
 | |
|         <div class={"relative max-h-full overflow-y-auto bg-white rounded-lg shadow-xl #{@class}"}
 | |
|           role="dialog"
 | |
|           aria-modal="true"
 | |
|           tabindex="0"
 | |
|           autofocus
 | |
|           phx-window-keydown={click_modal_close()}
 | |
|           phx-click-away={click_modal_close()}
 | |
|           phx-key="escape">
 | |
| 
 | |
|           <%= live_patch to: @return_to,
 | |
|                 class: "absolute top-6 right-6 text-gray-400 flex space-x-1 items-center",
 | |
|                 aria_label: "close modal",
 | |
|                 id: "close-modal-button" do %>
 | |
|             <span class="text-sm">(esc)</span>
 | |
|             <.remix_icon icon="close-line" class="text-2xl" />
 | |
|           <% end %>
 | |
| 
 | |
|           <%= render_slot(@inner_block) %>
 | |
|         </div>
 | |
|       </div>
 | |
|     </div>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defp click_modal_close(js \\ %JS{}) do
 | |
|     JS.dispatch(js, "click", to: "#close-modal-button")
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Determines user platform based on the given *User-Agent* header.
 | |
|   """
 | |
|   @spec platform_from_user_agent(String.t()) :: :linux | :mac | :windows | :other
 | |
|   def platform_from_user_agent(user_agent) when is_binary(user_agent) do
 | |
|     cond do
 | |
|       linux?(user_agent) -> :linux
 | |
|       mac?(user_agent) -> :mac
 | |
|       windows?(user_agent) -> :windows
 | |
|       true -> :other
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   defp linux?(user_agent), do: String.match?(user_agent, ~r/Linux/)
 | |
|   defp mac?(user_agent), do: String.match?(user_agent, ~r/Mac OS X/)
 | |
|   defp windows?(user_agent), do: String.match?(user_agent, ~r/Windows/)
 | |
| 
 | |
|   @doc """
 | |
|   Returns path to specific process dialog within LiveDashboard.
 | |
|   """
 | |
|   def live_dashboard_process_path(socket, pid) do
 | |
|     pid_str = Phoenix.LiveDashboard.PageBuilder.encode_pid(pid)
 | |
|     Routes.live_dashboard_path(socket, :page, node(), "processes", info: pid_str)
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Converts human-readable strings to strings which can be used
 | |
|   as HTML element IDs (compatible with HTML5)
 | |
| 
 | |
|   At the same time duplicate IDs are enumerated to avoid duplicates
 | |
|   """
 | |
|   @spec names_to_html_ids(list(String.t())) :: list(String.t())
 | |
|   def names_to_html_ids(names) do
 | |
|     names
 | |
|     |> Enum.map(&name_to_html_id/1)
 | |
|     |> Enum.map_reduce(%{}, fn html_id, counts ->
 | |
|       counts = Map.update(counts, html_id, 1, &(&1 + 1))
 | |
| 
 | |
|       case counts[html_id] do
 | |
|         1 -> {html_id, counts}
 | |
|         count -> {"#{html_id}-#{count}", counts}
 | |
|       end
 | |
|     end)
 | |
|     |> elem(0)
 | |
|   end
 | |
| 
 | |
|   defp name_to_html_id(name) do
 | |
|     name
 | |
|     |> String.trim()
 | |
|     |> String.downcase()
 | |
|     |> String.replace(~r/\s+/u, "-")
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Renders [Remix](https://remixicon.com) icon.
 | |
| 
 | |
|   ## Examples
 | |
| 
 | |
|       <.remix_icon icon="cpu-line" />
 | |
| 
 | |
|       <.remix_icon icon="cpu-line" class="align-middle mr-1" />
 | |
|   """
 | |
|   def remix_icon(assigns) do
 | |
|     assigns =
 | |
|       assigns
 | |
|       |> assign_new(:class, fn -> "" end)
 | |
|       |> assign(:attrs, assigns_to_attributes(assigns, [:icon, :class]))
 | |
| 
 | |
|     ~H"""
 | |
|     <i class={"ri-#{@icon} #{@class}"} aria-hidden="true" {@attrs}></i>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Renders a list of select input options with the given one selected.
 | |
| 
 | |
|   ## Examples
 | |
| 
 | |
|       <.select
 | |
|         name="language"
 | |
|         selected={@language}
 | |
|         options={[en: "English", pl: "Polski", fr: "Français"]} />
 | |
|   """
 | |
|   def select(assigns) do
 | |
|     ~H"""
 | |
|     <select class="input" name={@name}>
 | |
|       <%= for {value, label} <- @options do %>
 | |
|         <option value={value} selected={value == @selected}>
 | |
|           <%= label %>
 | |
|         </option>
 | |
|       <% end %>
 | |
|     </select>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Renders a checkbox input styled as a switch.
 | |
| 
 | |
|   Also, a hidden input with the same name is rendered
 | |
|   alongside the checkbox, so the submitted value is
 | |
|   always either `"true"` or `"false"`.
 | |
| 
 | |
|   ## Examples
 | |
| 
 | |
|       <.switch_checkbox
 | |
|         name="likes_cats"
 | |
|         label="I very much like cats"
 | |
|         checked={@likes_cats} />
 | |
|   """
 | |
|   def switch_checkbox(assigns) do
 | |
|     assigns =
 | |
|       assigns
 | |
|       |> assign_new(:label, fn -> nil end)
 | |
|       |> assign_new(:disabled, fn -> false end)
 | |
|       |> assign_new(:class, fn -> "" end)
 | |
|       |> assign(
 | |
|         :attrs,
 | |
|         assigns_to_attributes(assigns, [:label, :name, :checked, :disabled, :class])
 | |
|       )
 | |
| 
 | |
|     ~H"""
 | |
|     <div class="flex space-x-3 items-center justify-between">
 | |
|       <%= if @label do %>
 | |
|         <span class="text-gray-700"><%= @label %></span>
 | |
|       <% end %>
 | |
|       <label class={"switch-button #{if(@disabled, do: "switch-button--disabled")}"}>
 | |
|         <input type="hidden" value="false" name={@name} />
 | |
|         <input
 | |
|           type="checkbox"
 | |
|           value="true"
 | |
|           class={"switch-button__checkbox #{@class}"}
 | |
|           name={@name}
 | |
|           checked={@checked}
 | |
|           {@attrs} />
 | |
|         <div class="switch-button__bg"></div>
 | |
|       </label>
 | |
|     </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>
 | |
|   """
 | |
|   def choice_button(assigns) do
 | |
|     assigns =
 | |
|       assigns
 | |
|       |> assign_new(:class, fn -> "" end)
 | |
|       |> assign_new(:disabled, fn -> assigns.active end)
 | |
|       |> assign(:attrs, assigns_to_attributes(assigns, [:active, :class, :disabled]))
 | |
| 
 | |
|     ~H"""
 | |
|     <button class={"choice-button #{if(@active, do: "active")} #{@class}"} disabled={@disabled} {@attrs}>
 | |
|       <%= render_slot(@inner_block) %>
 | |
|     </button>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Renders a highlighted code snippet.
 | |
| 
 | |
|   ## Examples
 | |
| 
 | |
|       <.code_preview
 | |
|         source_id="my-snippet"
 | |
|         language="elixir"
 | |
|         source="System.version()" />
 | |
|   """
 | |
|   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" text="Sherlock Holmes" />
 | |
|   """
 | |
|   def labeled_text(assigns) do
 | |
|     assigns = assign_new(assigns, :one_line, fn -> false end)
 | |
| 
 | |
|     ~H"""
 | |
|     <div class="flex flex-col space-y-1">
 | |
|       <span class="text-xs text-gray-500">
 | |
|         <%= @label %>
 | |
|       </span>
 | |
|       <span class={"text-gray-800 text-sm font-semibold #{if @one_line, do: "whitespace-nowrap overflow-auto tiny-scrollbar"}"}>
 | |
|         <%= @text %>
 | |
|       </span>
 | |
|     </div>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Renders a wrapper around password input
 | |
|   with an added visibility toggle button.
 | |
| 
 | |
|   The toggle switches the input's type between `password`
 | |
|   and `text`.
 | |
| 
 | |
|   ## Examples
 | |
| 
 | |
|       <.with_password_toggle id="input-id">
 | |
|         <input type="password" ...>
 | |
|       </.with_password_toggle>
 | |
|   """
 | |
|   def with_password_toggle(assigns) do
 | |
|     ~H"""
 | |
|     <div id={"password-toggle-#{@id}"} class="relative inline w-min" phx-hook="PasswordToggle">
 | |
|       <!-- render password input -->
 | |
|       <%= render_slot(@inner_block) %>
 | |
|       <button
 | |
|         class="bg-gray-50 p-1 icon-button absolute inset-y-0 right-1"
 | |
|         type="button"
 | |
|         aria-label="toggle password visibility"
 | |
|         phx-change="ignore">
 | |
|         <.remix_icon icon="eye-line" class="text-xl" />
 | |
|       </button>
 | |
|     </div>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Renders a popup menu that shows up on toggle click.
 | |
| 
 | |
|   ## Assigns
 | |
| 
 | |
|     * `:id` - unique HTML id
 | |
| 
 | |
|     * `:disabled` - whether the menu is active. Defaults to `false`
 | |
| 
 | |
|     * `:position` - which side of the clickable the menu menu should
 | |
|       be attached to, either `"left"` or `"right"`. Defaults to `"right"`
 | |
| 
 | |
|     * `:secondary_click` - whether secondary click (usually right mouse click)
 | |
|       should open the menu. Defaults to `false`
 | |
| 
 | |
|   ## Examples
 | |
| 
 | |
|       <.menu id="my-menu">
 | |
|         <:toggle>
 | |
|           <button>Open</button>
 | |
|         </:toggle>
 | |
|         <:content>
 | |
|           <button class"menu-item" role="menuitem">Option 1</button>
 | |
|         </:content>
 | |
|       </.menu>
 | |
|   """
 | |
|   def menu(assigns) do
 | |
|     assigns =
 | |
|       assigns
 | |
|       |> assign_new(:disabled, fn -> false end)
 | |
|       |> assign_new(:position, fn -> "right" end)
 | |
|       |> assign_new(:secondary_click, fn -> false end)
 | |
| 
 | |
|     ~H"""
 | |
|     <div class="relative"
 | |
|       id={@id}>
 | |
|       <div
 | |
|         phx-click={not @disabled && JS.toggle(to: "##{@id}-content")}
 | |
|         phx-click-away={JS.hide(to: "##{@id}-content")}
 | |
|         data-contextmenu-trigger-click={@secondary_click}
 | |
|         phx-window-keydown={JS.hide(to: "##{@id}-content")}
 | |
|         phx-key="escape">
 | |
|         <%= render_slot(@toggle) %>
 | |
|       </div>
 | |
|       <menu id={"#{@id}-content"} class={"hidden menu #{@position}"} role="menu">
 | |
|         <%= render_slot(@content) %>
 | |
|       </menu>
 | |
|     </div>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defdelegate ansi_string_to_html(string), to: LivebookWeb.Helpers.ANSI
 | |
|   defdelegate ansi_string_to_html_lines(string), to: LivebookWeb.Helpers.ANSI
 | |
| 
 | |
|   @doc """
 | |
|   Renders an icon representing the given file system.
 | |
|   """
 | |
|   def file_system_icon(assigns)
 | |
| 
 | |
|   def file_system_icon(%{file_system: %FileSystem.Local{}} = assigns) do
 | |
|     ~H"""
 | |
|     <.remix_icon icon="hard-drive-2-line leading-none" />
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   def file_system_icon(%{file_system: %FileSystem.S3{}} = assigns) do
 | |
|     ~H"""
 | |
|     <i class="not-italic">
 | |
|       <span class="text-[0.75em] font-semibold align-middle">S3</span>
 | |
|     </i>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   @doc """
 | |
|   Formats the given file system into a descriptive label.
 | |
|   """
 | |
|   def file_system_label(file_system)
 | |
| 
 | |
|   def file_system_label(%FileSystem.Local{}), do: "Local disk"
 | |
|   def file_system_label(%FileSystem.S3{} = fs), do: fs.bucket_url
 | |
| end
 |