Implement menu with JS commands and extract into component (#698)

This commit is contained in:
Jonatan Kłosko 2021-11-10 19:28:09 +01:00 committed by GitHub
parent a29abbf87c
commit 3ed5da0106
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 169 additions and 190 deletions

View file

@ -196,17 +196,20 @@
/* Toggleable menu */
.menu {
@apply absolute right-0 z-30 rounded-lg bg-white flex flex-col py-2 mt-1;
@apply absolute z-30 rounded-lg bg-white flex flex-col py-2 mt-1;
box-shadow: 0px 15px 99px rgba(13, 24, 41, 0.15);
}
.menu.right {
@apply right-0;
}
.menu.left {
right: auto;
@apply left-0;
}
.menu__item {
@apply flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 whitespace-nowrap;
.menu-item {
@apply w-full flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 whitespace-nowrap;
}
/* Boxes */

View file

@ -9,10 +9,6 @@ solely client-side operations.
/* === Global === */
[data-element="menu"]:not([data-js-open]) > [data-content] {
@apply hidden;
}
[phx-hook="Highlight"][data-highlighted] > [data-source] {
@apply hidden;
}

View file

@ -17,7 +17,6 @@ import Session from "./session";
import FocusOnUpdate from "./focus_on_update";
import ScrollOnUpdate from "./scroll_on_update";
import VirtualizedLines from "./virtualized_lines";
import Menu from "./menu";
import UserForm from "./user_form";
import VegaLite from "./vega_lite";
import Timer from "./timer";
@ -36,7 +35,6 @@ const hooks = {
FocusOnUpdate,
ScrollOnUpdate,
VirtualizedLines,
Menu,
UserForm,
VegaLite,
Timer,
@ -89,3 +87,14 @@ window.addEventListener("lb:focus", (event) => {
window.addEventListener("lb:set_value", (event) => {
event.target.value = event.detail.value;
});
// Other global handlers
window.addEventListener("contextmenu", (event) => {
const target = event.target.closest("[data-contextmenu-trigger-click]");
if (target) {
event.preventDefault();
target.dispatchEvent(new Event("click", { bubbles: true }));
}
});

View file

@ -1,87 +0,0 @@
import { getAttributeOrDefault, parseBoolean } from "../lib/attribute";
/**
* A hook controlling a toggleable menu.
*
* Configuration:
*
* * `data-primary` - a boolean indicating whether to open on the primary
* click or the secondary click (like right mouse button click). Defaults to `true`.
*
* The element should have two children:
*
* * one annotated with `data-toggle` being a clickable element
*
* * one annotated with `data-content` with menu content
*/
const Menu = {
mounted() {
this.props = getProps(this);
const toggleElement = this.el.querySelector("[data-toggle]");
if (!toggleElement) {
throw new Error("Menu must have a child with data-toggle attribute");
}
const contentElement = this.el.querySelector("[data-content]");
if (!contentElement) {
throw new Error("Menu must have a child with data-content attribute");
}
if (this.props.primary) {
toggleElement.addEventListener("click", (event) => {
if (this.el.hasAttribute("data-js-open")) {
this.el.removeAttribute("data-js-open");
} else {
this.el.setAttribute("data-js-open", "true");
// Postpone callback registration until the current click finishes bubbling.
setTimeout(() => {
document.addEventListener(
"click",
(event) => {
this.el.removeAttribute("data-js-open");
},
{ once: true }
);
}, 0);
}
});
} else {
toggleElement.addEventListener("contextmenu", (event) => {
event.preventDefault();
if (this.el.hasAttribute("data-js-open")) {
this.el.removeAttribute("data-js-open");
} else {
this.el.setAttribute("data-js-open", "true");
// Postpone callback registration until the current click finishes bubbling.
setTimeout(() => {
const handler = (event) => {
this.el.removeAttribute("data-js-open");
document.removeEventListener("click", handler);
document.removeEventListener("contextmenu", handler);
};
document.addEventListener("click", handler);
document.addEventListener("contextmenu", handler);
}, 0);
}
});
}
},
};
function getProps(hook) {
return {
primary: getAttributeOrDefault(
hook.el,
"data-primary",
"true",
parseBoolean
),
};
}
export default Menu;

View file

@ -273,9 +273,9 @@ defmodule LivebookWeb.Helpers do
## Examples
<.with_password_toggle id="input-id">
<input type="password" ...>
</.with_password_toggle>
<.with_password_toggle id="input-id">
<input type="password" ...>
</.with_password_toggle>
"""
def with_password_toggle(assigns) do
~H"""
@ -293,6 +293,55 @@ defmodule LivebookWeb.Helpers do
"""
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">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}>
<%= render_slot(@toggle) %>
</div>
<div id={"#{@id}-content"} class={"hidden menu #{@position}"}>
<%= render_slot(@content) %>
</div>
</div>
"""
end
defdelegate ansi_string_to_html(string), to: LivebookWeb.Helpers.ANSI
defdelegate ansi_string_to_html_lines(string), to: LivebookWeb.Helpers.ANSI

View file

@ -84,17 +84,19 @@ defmodule LivebookWeb.FileSelectComponent do
autocomplete="off" />
</form>
</div>
<div class="relative" id="path-selector-menu" phx-hook="Menu" data-element="menu">
<button class="icon-button" data-toggle tabindex="-1">
<.remix_icon icon="add-line" class="text-xl" />
</button>
<div class="menu" data-content>
<button class="menu__item text-gray-500" phx-click={js_show_new_dir_section()}>
<.menu id="path-selector-menu">
<:toggle>
<button class="icon-button" tabindex="-1">
<.remix_icon icon="add-line" class="text-xl" />
</button>
</:toggle>
<:content>
<button class="menu-item text-gray-500" phx-click={js_show_new_dir_section()}>
<.remix_icon icon="folder-add-fill" class="text-gray-400" />
<span class="font-medium">New directory</span>
</button>
</div>
</div>
</:content>
</.menu>
<%= if @inner_block do %>
<div>
<%= render_slot(@inner_block) %>
@ -189,19 +191,21 @@ defmodule LivebookWeb.FileSelectComponent do
defp file_system_menu_button(assigns) do
~H"""
<div class="relative" id="file-system-menu" phx-hook="Menu" data-element="menu">
<button type="button" class="button button-gray button-square-icon" data-toggle disabled={@file_system_select_disabled}>
<.file_system_icon file_system={@file.file_system} />
</button>
<div class="menu left" data-content>
<.menu id="file-system-menu" disabled={@file_system_select_disabled} position="left">
<:toggle>
<button type="button" class="button button-gray button-square-icon" disabled={@file_system_select_disabled}>
<.file_system_icon file_system={@file.file_system} />
</button>
</:toggle>
<:content>
<%= for {file_system, index} <- @file_systems |> Enum.with_index() do %>
<%= if file_system == @file.file_system do %>
<button class="menu__item text-gray-900">
<button class="menu-item text-gray-900">
<.file_system_icon file_system={file_system} />
<span class="font-medium"><%= file_system_label(file_system) %></span>
</button>
<% else %>
<button class="menu__item text-gray-500"
<button class="menu-item text-gray-500"
phx-target={@myself}
phx-click="set_file_system"
phx-value-index={index}>
@ -211,12 +215,12 @@ defmodule LivebookWeb.FileSelectComponent do
<% end %>
<% end %>
<%= live_patch to: Routes.settings_path(@socket, :page),
class: "menu__item text-gray-500 border-t border-gray-200" do %>
class: "menu-item text-gray-500 border-t border-gray-200" do %>
<.remix_icon icon="settings-3-line" />
<span class="font-medium">Configure</span>
<% end %>
</div>
</div>
</:content>
</.menu>
"""
end
@ -258,40 +262,38 @@ defmodule LivebookWeb.FileSelectComponent do
assigns = assign(assigns, :icon, icon)
~H"""
<div class="relative"
id={"file-menu-#{@file_info.file.path}"}
phx-hook="Menu"
data-primary="false"
data-element="menu">
<button class="w-full flex space-x-2 items-center p-2 rounded-lg hover:bg-gray-100 focus:ring-1 focus:ring-gray-400"
data-toggle
phx-click="set_path"
phx-value-path={@file_info.file.path}
phx-target={@myself}>
<span class="block">
<.remix_icon icon={@icon} class={"text-xl align-middle #{if(@file_info.is_running, do: "text-green-300", else: "text-gray-400")}"} />
</span>
<span class={"flex font-medium overflow-hidden whitespace-nowrap #{if(@file_info.is_running, do: "text-green-300", else: "text-gray-500")}"}>
<%= if @file_info.highlighted != "" do %>
<span class={"font-medium #{if(@file_info.is_running, do: "text-green-400", else: "text-gray-900")}"}>
<%= @file_info.highlighted %>
</span>
<% end %>
<span class="overflow-hidden overflow-ellipsis">
<%= @file_info.unhighlighted %>
<.menu id={"file-#{Base.encode16 @file_info.file.path}"} secondary_click>
<:toggle>
<button class="w-full flex space-x-2 items-center p-2 rounded-lg hover:bg-gray-100 focus:ring-1 focus:ring-gray-400"
data-toggle
phx-click="set_path"
phx-value-path={@file_info.file.path}
phx-target={@myself}>
<span class="block">
<.remix_icon icon={@icon} class={"text-xl align-middle #{if(@file_info.is_running, do: "text-green-300", else: "text-gray-400")}"} />
</span>
</span>
</button>
<div class="menu" data-content>
<span class={"flex font-medium overflow-hidden whitespace-nowrap #{if(@file_info.is_running, do: "text-green-300", else: "text-gray-500")}"}>
<%= if @file_info.highlighted != "" do %>
<span class={"font-medium #{if(@file_info.is_running, do: "text-green-400", else: "text-gray-900")}"}>
<%= @file_info.highlighted %>
</span>
<% end %>
<span class="overflow-hidden overflow-ellipsis">
<%= @file_info.unhighlighted %>
</span>
</span>
</button>
</:toggle>
<:content>
<%= if @file_info.editable do %>
<button class="menu__item text-gray-500"
<button class="menu-item text-gray-500"
phx-click="rename_file"
phx-target={@myself}
phx-value-path={@file_info.file.path}>
<.remix_icon icon="edit-line" />
<span class="font-medium">Rename</span>
</button>
<button class="menu__item text-red-600"
<button class="menu-item text-red-600"
phx-click="delete_file"
phx-target={@myself}
phx-value-path={@file_info.file.path}>
@ -299,8 +301,8 @@ defmodule LivebookWeb.FileSelectComponent do
<span class="font-medium">Delete</span>
</button>
<% end %>
</div>
</div>
</:content>
</.menu>
"""
end

View file

@ -28,21 +28,23 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
<h2 class="mb-4 uppercase font-semibold text-gray-500">
Running sessions (<%= length(@sessions) %>)
</h2>
<div class="relative" id={"sessions-order-menu"} phx-hook="Menu" data-element="menu">
<button class="button button-outlined-gray px-4 py-1" data-toggle>
<span><%= order_by_label(@order_by) %></span>
<.remix_icon icon="arrow-down-s-line" class="text-lg leading-none align-middle ml-1" />
</button>
<div class="menu" data-content>
<.menu id="sessions-order-menu">
<:toggle>
<button class="button button-outlined-gray px-4 py-1">
<span><%= order_by_label(@order_by) %></span>
<.remix_icon icon="arrow-down-s-line" class="text-lg leading-none align-middle ml-1" />
</button>
</:toggle>
<:content>
<%= for order_by <- ["date", "title"] do %>
<button class={"menu__item #{if order_by == @order_by, do: "text-gray-900", else: "text-gray-500"}"}
<button class={"menu-item #{if order_by == @order_by, do: "text-gray-900", else: "text-gray-500"}"}
phx-click={JS.push("set_order", value: %{order_by: order_by}, target: @myself)}>
<.remix_icon icon={order_by_icon(order_by)} />
<span class="font-medium"><%= order_by_label(order_by) %></span>
</button>
<% end %>
</div>
</div>
</:content>
</.menu>
</div>
<.session_list sessions={@sessions} socket={@socket} />
</div>
@ -81,30 +83,32 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
Created <%= format_creation_date(session.created_at) %>
</div>
</div>
<div class="relative" id={"session-#{session.id}-menu"} phx-hook="Menu" data-element="menu">
<button class="icon-button" data-toggle>
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
<div class="menu" data-content>
<button class="menu__item text-gray-500"
<.menu id={"session-#{session.id}-menu"}>
<:toggle>
<button class="icon-button">
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
</:toggle>
<:content>
<button class="menu-item text-gray-500"
phx-click="fork_session"
phx-value-id={session.id}>
<.remix_icon icon="git-branch-line" />
<span class="font-medium">Fork</span>
</button>
<a class="menu__item text-gray-500"
<a class="menu-item text-gray-500"
href={live_dashboard_process_path(@socket, session.pid)}
target="_blank">
<.remix_icon icon="dashboard-2-line" />
<span class="font-medium">See on Dashboard</span>
</a>
<%= live_patch to: Routes.home_path(@socket, :close_session, session.id),
class: "menu__item text-red-600" do %>
class: "menu-item text-red-600" do %>
<.remix_icon icon="close-circle-line" />
<span class="font-medium">Close</span>
<% end %>
</div>
</div>
</:content>
</.menu>
</div>
<% end %>
</div>

View file

@ -131,39 +131,41 @@ defmodule LivebookWeb.SessionLive do
phx-blur="set_notebook_name"
phx-hook="ContentEditable"
data-update-attribute="phx-value-name"><%= @data_view.notebook_name %></h1>
<div class="relative" id="session-menu" phx-hook="Menu" data-element="menu">
<button class="icon-button" data-toggle>
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
<div class="menu" data-content>
<.menu id="session-menu">
<:toggle>
<button class="icon-button">
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
</:toggle>
<:content>
<%= live_patch to: Routes.session_path(@socket, :export, @session.id, "livemd"),
class: "menu__item text-gray-500" do %>
class: "menu-item text-gray-500" do %>
<.remix_icon icon="download-2-line" />
<span class="font-medium">Export</span>
<% end %>
<button class="text-gray-500 menu__item"
<button class="menu-item text-gray-500"
phx-click="erase_outputs">
<.remix_icon icon="eraser-fill" />
<span class="font-medium">Erase outputs</span>
</button>
<button class="text-gray-500 menu__item"
<button class="menu-item text-gray-500"
phx-click="fork_session">
<.remix_icon icon="git-branch-line" />
<span class="font-medium">Fork</span>
</button>
<a class="text-gray-500 menu__item"
<a class="menu-item text-gray-500"
href={live_dashboard_process_path(@socket, @session.pid)}
target="_blank">
<.remix_icon icon="dashboard-2-line" />
<span class="font-medium">See on Dashboard</span>
</a>
<%= live_patch to: Routes.home_path(@socket, :close_session, @session.id),
class: "menu__item text-red-600" do %>
class: "menu-item text-red-600" do %>
<.remix_icon icon="close-circle-line" />
<span class="font-medium">Close</span>
<% end %>
</div>
</div>
</:content>
</.menu>
</div>
<div class="flex flex-col w-full space-y-16">
<%= if @data_view.section_views == [] do %>

View file

@ -25,25 +25,26 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
</a>
</span>
<%= if @section_view.valid_parents != [] and not @section_view.has_children? do %>
<div class="relative" id={"section-#{@section_view.id}-branch-menu"} phx-hook="Menu" data-element="menu">
<span class="tooltip top" data-tooltip="Branch out from">
<button class="icon-button"
aria-label="branch out from other section"
data-toggle>
<.remix_icon icon="git-branch-line" class="text-xl flip-horizontally" />
</button>
</span>
<div class="menu" data-content>
<.menu id={"section-#{@section_view.id}-branch-menu"}>
<:toggle>
<span class="tooltip top" data-tooltip="Branch out from">
<button class="icon-button"
aria-label="branch out from other section">
<.remix_icon icon="git-branch-line" class="text-xl flip-horizontally" />
</button>
</span>
</:toggle>
<:content>
<%= for parent <- @section_view.valid_parents do %>
<%= if @section_view.parent && @section_view.parent.id == parent.id do %>
<button class="menu__item text-gray-900"
<button class="menu-item text-gray-900"
phx-click="unset_section_parent"
phx-value-section_id={@section_view.id}>
<.remix_icon icon="arrow-right-s-line" />
<span class="font-medium"><%= parent.name %></span>
</button>
<% else %>
<button class="menu__item text-gray-500"
<button class="menu-item text-gray-500"
phx-click="set_section_parent"
phx-value-section_id={@section_view.id}
phx-value-parent_id={parent.id}>
@ -52,8 +53,8 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
</button>
<% end %>
<% end %>
</div>
</div>
</:content>
</.menu>
<% end %>
<span class="tooltip top" data-tooltip="Move up">
<button class="icon-button"