Graphite/frontend-svelte/src/components/floating-menus/MenuList.svelte

352 lines
11 KiB
Svelte

<svelte:options accessors={true} />
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { type MenuListEntry } from "@/wasm-communication/messages";
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.svelte";
import LayoutCol from "@/components/layout/LayoutCol.svelte";
import LayoutRow from "@/components/layout/LayoutRow.svelte";
import IconLabel from "@/components/widgets/labels/IconLabel.svelte";
import Separator from "@/components/widgets/labels/Separator.svelte";
import TextLabel from "@/components/widgets/labels/TextLabel.svelte";
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.svelte";
let self: FloatingMenu;
let scroller: LayoutCol;
// emits: ["update:open", "update:activeEntry", "naturalWidth"],
const dispatch = createEventDispatcher<{ open: boolean; activeEntry: MenuListEntry }>();
export let entries: MenuListEntry[][];
export let activeEntry: MenuListEntry | undefined = undefined;
export let open: boolean;
export let direction: MenuDirection = "Bottom";
export let minWidth = 0;
export let drawIcon = false;
export let interactive = false;
export let scrollableY = false;
export let virtualScrollingEntryHeight = 0;
export let tooltip: string | undefined = undefined;
let highlighted = activeEntry as MenuListEntry | undefined;
let virtualScrollingEntriesStart = 0;
// Called only when `open` is changed from outside this component (with v-model)
$: watchOpen(open);
$: watchRemeasureWidth(entries, drawIcon);
$: virtualScrollingTotalHeight = entries.length === 0 ? 0 : entries[0].length * virtualScrollingEntryHeight;
$: virtualScrollingStartIndex = Math.floor(virtualScrollingEntriesStart / virtualScrollingEntryHeight) || 0;
$: virtualScrollingEndIndex = entries.length === 0 ? 0 : Math.min(entries[0].length, virtualScrollingStartIndex + 1 + 400 / virtualScrollingEntryHeight);
function watchOpen(open: boolean) {
highlighted = activeEntry;
dispatch("open", open);
}
function watchRemeasureWidth(_: MenuListEntry[][], __: boolean) {
self?.measureAndEmitNaturalWidth();
}
function onScroll(e: Event) {
if (!virtualScrollingEntryHeight) return;
virtualScrollingEntriesStart = (e.target as HTMLElement)?.scrollTop || 0;
}
function onEntryClick(menuListEntry: MenuListEntry): void {
// Call the action if available
if (menuListEntry.action) menuListEntry.action();
// Emit the clicked entry as the new active entry
dispatch("activeEntry", menuListEntry);
// Close the containing menu
if (menuListEntry.ref) menuListEntry.ref.open = false;
dispatch("open", false);
open = false;
}
function onEntryPointerEnter(menuListEntry: MenuListEntry): void {
if (!menuListEntry.children?.length) return;
if (menuListEntry.ref) menuListEntry.ref.open = true;
else dispatch("open", true);
}
function onEntryPointerLeave(menuListEntry: MenuListEntry): void {
if (!menuListEntry.children?.length) return;
if (menuListEntry.ref) menuListEntry.ref.open = false;
else dispatch("open", false);
}
function isEntryOpen(menuListEntry: MenuListEntry): boolean {
if (!menuListEntry.children?.length) return false;
return menuListEntry.ref?.open || false;
}
/// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
export function keydown(e: KeyboardEvent, submenu: boolean): boolean {
// Interactive menus should keep the active entry the same as the highlighted one
if (interactive) highlighted = activeEntry;
const menuOpen = open;
const flatEntries = entries.flat().filter((entry) => !entry.disabled);
const openChild = flatEntries.findIndex((entry) => entry.children?.length && entry.ref?.open);
const openSubmenu = (highlighted: MenuListEntry): void => {
if (highlighted.ref && highlighted.children?.length) {
highlighted.ref.open = true;
// Highlight first item
highlighted.ref.setHighlighted(highlighted.children[0][0]);
}
};
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
// Allow opening menu with space or enter
open = true;
highlighted = activeEntry;
} else if (menuOpen && openChild >= 0) {
// Redirect the keyboard navigation to a submenu if one is open
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
// Highlight the menu item in the parent list that corresponds with the open submenu
if (e.key !== "Escape" && highlighted) setHighlighted(flatEntries[openChild]);
// Handle the child closing the entire menu stack
if (shouldCloseStack) {
open = false;
return true;
}
} else if ((menuOpen || interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
// Navigate to the next and previous entries with arrow keys
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
if (highlighted) {
const index = highlighted ? flatEntries.map((entry) => entry.label).indexOf(highlighted.label) : 0;
newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
// Interactive dropdowns should lock at the end whereas other dropdowns should loop
if (interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex));
else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
}
const newEntry = flatEntries[newIndex];
setHighlighted(newEntry);
} else if (menuOpen && e.key === "Escape") {
// Close menu with escape key
open = false;
// Reset active to before open
setHighlighted(activeEntry);
} else if (menuOpen && highlighted && e.key === "Enter") {
// Handle clicking on an option if enter is pressed
if (!highlighted.children?.length) onEntryClick(highlighted);
else openSubmenu(highlighted);
// Stop the event from triggering a press on a new dialog
e.preventDefault();
// Enter should close the entire menu stack
return true;
} else if (menuOpen && highlighted && e.key === "ArrowRight") {
// Right arrow opens a submenu
openSubmenu(highlighted);
} else if (menuOpen && e.key === "ArrowLeft") {
// Left arrow closes a submenu
if (submenu) open = false;
}
// By default, keep the menu stack open
return false;
}
export function setHighlighted(newHighlight: MenuListEntry | undefined) {
highlighted = newHighlight;
// Interactive menus should keep the active entry the same as the highlighted one
if (interactive && newHighlight?.value !== activeEntry?.value && newHighlight) dispatch("activeEntry", newHighlight);
}
export function scrollViewTo(distanceDown: number): void {
scroller.div().scrollTo(0, distanceDown);
}
</script>
<FloatingMenu
class="menu-list"
{open}
on:open={({ detail }) => (open = detail)}
on:naturalWidth
type="Dropdown"
windowEdgeMargin={0}
escapeCloses={false}
{direction}
{minWidth}
scrollableY={scrollableY && virtualScrollingEntryHeight === 0}
bind:this={self}
>
<!-- If we put the scrollableY on the layoutcol for non-font dropdowns then for some reason it always creates a tiny scrollbar.
However when we are using the virtual scrolling then we need the layoutcol to be scrolling so we can bind the events without using $refs. -->
<LayoutCol
bind:this={scroller}
scrollableY={scrollableY && virtualScrollingEntryHeight !== 0}
on:scroll={onScroll}
styles={{ "min-width": virtualScrollingEntryHeight ? `${minWidth}px` : `inherit` }}
>
{#if virtualScrollingEntryHeight}
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingStartIndex * virtualScrollingEntryHeight}px` }} />
{/if}
{#each entries as section, sectionIndex (sectionIndex)}
{#if sectionIndex > 0}
<Separator type="List" direction="Vertical" />
{/if}
{#each virtualScrollingEntryHeight ? section.slice(virtualScrollingStartIndex, virtualScrollingEndIndex) : section as entry, entryIndex (entryIndex + (virtualScrollingEntryHeight ? virtualScrollingStartIndex : 0))}
<LayoutRow
class="row"
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
styles={{ height: virtualScrollingEntryHeight || "20px" }}
{tooltip}
on:click={() => !entry.disabled && onEntryClick(entry)}
on:pointerenter={() => !entry.disabled && onEntryPointerEnter(entry)}
on:pointerleave={() => !entry.disabled && onEntryPointerLeave(entry)}
>
{#if entry.icon && drawIcon}
<IconLabel icon={entry.icon} class="entry-icon" />
{:else if drawIcon}
<div class="no-icon" />
{/if}
{#if entry.font}
<link rel="stylesheet" href={entry.font?.toString()} />
{/if}
<TextLabel class="entry-label" styles={{ "font-family": `${!entry.font ? "inherit" : entry.value}` }}>{entry.label}</TextLabel>
{#if entry.shortcut?.keys.length}
<UserInputLabel keysWithLabelsGroups={[entry.shortcut.keys]} requiresLock={entry.shortcutRequiresLock} />
{/if}
{#if entry.children?.length}
<div class="submenu-arrow" />
{:else}
<div class="no-submenu-arrow" />
{/if}
{#if entry.children}
<svelte:self on:naturalWidth open={entry.ref?.open || false} direction="TopRight" entries={entry.children} {minWidth} {drawIcon} {scrollableY} bind:this={entry.ref} />
{/if}
</LayoutRow>
{/each}
{/each}
{#if virtualScrollingEntryHeight}
<LayoutRow class="scroll-spacer" styles={{ height: `${virtualScrollingTotalHeight - virtualScrollingEndIndex * virtualScrollingEntryHeight}px` }} />
{/if}
</LayoutCol>
</FloatingMenu>
<style lang="scss" global>
.menu-list {
.floating-menu-container .floating-menu-content {
padding: 4px 0;
.separator div {
background: var(--color-4-dimgray);
}
.scroll-spacer {
flex: 0 0 auto;
}
.row {
height: 20px;
align-items: center;
white-space: nowrap;
position: relative;
flex: 0 0 auto;
& > * {
flex: 0 0 auto;
}
.entry-icon svg {
fill: var(--color-e-nearwhite);
}
.no-icon {
width: 16px;
}
.entry-label {
flex: 1 1 100%;
margin-left: 8px;
}
.entry-icon,
.no-icon {
margin: 0 4px;
& + .entry-label {
margin-left: 0;
}
}
.user-input-label {
margin-left: 16px;
}
.submenu-arrow {
width: 0;
height: 0;
border-style: solid;
border-width: 3px 0 3px 6px;
border-color: transparent transparent transparent var(--color-e-nearwhite);
}
.no-submenu-arrow {
width: 6px;
}
.submenu-arrow,
.no-submenu-arrow {
margin-left: 6px;
margin-right: 4px;
}
&:hover,
&.open {
background: var(--color-6-lowergray);
color: var(--color-f-white);
.entry-icon svg {
fill: var(--color-f-white);
}
}
&.active {
background: var(--color-e-nearwhite);
color: var(--color-2-mildblack);
.entry-icon svg {
fill: var(--color-2-mildblack);
}
}
&.disabled {
color: var(--color-8-uppergray);
&:hover {
background: none;
}
svg {
fill: var(--color-8-uppergray);
}
}
}
}
}
</style>