Add double layer context menu for overflow views

This commit is contained in:
Andrew Kingston 2024-08-16 18:43:48 +01:00
parent 40e7f58131
commit d377186f0d
No known key found for this signature in database
6 changed files with 156 additions and 42 deletions

View File

@ -115,6 +115,7 @@
} }
.spectrum-ActionButton--quiet.is-selected { .spectrum-ActionButton--quiet.is-selected {
color: var(--spectrum-global-color-gray-900); color: var(--spectrum-global-color-gray-900);
background: var(--spectrum-global-color-gray-300);
} }
.noPadding { .noPadding {
padding: 0; padding: 0;

View File

@ -1,14 +1,20 @@
<script> <script>
import { setContext } from "svelte" import { setContext, getContext } from "svelte"
import Popover from "../Popover/Popover.svelte" import Popover from "../Popover/Popover.svelte"
import Menu from "../Menu/Menu.svelte" import Menu from "../Menu/Menu.svelte"
export let disabled = false export let disabled = false
export let align = "left" export let align = "left"
export let portalTarget export let portalTarget
export let openOnHover = false
export let animate
export let offset
const actionMenuContext = getContext("actionMenu")
let anchor let anchor
let dropdown let dropdown
let timeout
// This is needed because display: contents is considered "invisible". // This is needed because display: contents is considered "invisible".
// It should only ever be an action button, so should be fine. // It should only ever be an action button, so should be fine.
@ -16,11 +22,19 @@
anchor = node.firstChild anchor = node.firstChild
} }
export const show = e => {
cancelHide()
dropdown.show()
}
export const hide = () => { export const hide = () => {
dropdown.hide() dropdown.hide()
} }
export const show = () => {
dropdown.show() // Hides this menu and all parent menus
const hideAll = () => {
hide()
actionMenuContext?.hide()
} }
const openMenu = event => { const openMenu = event => {
@ -30,12 +44,25 @@
} }
} }
setContext("actionMenu", { show, hide }) const queueHide = () => {
timeout = setTimeout(hide, 10)
}
const cancelHide = () => {
clearTimeout(timeout)
}
setContext("actionMenu", { show, hide, hideAll })
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div use:getAnchor on:click={openMenu}> <div
use:getAnchor
on:click={openOnHover ? null : openMenu}
on:mouseenter={openOnHover ? show : null}
on:mouseleave={openOnHover ? queueHide : null}
>
<slot name="control" /> <slot name="control" />
</div> </div>
<Popover <Popover
@ -43,9 +70,13 @@
{anchor} {anchor}
{align} {align}
{portalTarget} {portalTarget}
{animate}
{offset}
resizable={false} resizable={false}
on:open on:open
on:close on:close
on:mouseenter={openOnHover ? cancelHide : null}
on:mouseleave={openOnHover ? queueHide : null}
> >
<Menu> <Menu>
<slot /> <slot />

View File

@ -151,9 +151,9 @@ export default function positionDropdown(element, opts) {
// Determine X strategy // Determine X strategy
if (align === "right") { if (align === "right") {
applyXStrategy(Strategies.EndToEnd) applyXStrategy(Strategies.EndToEnd)
} else if (align === "right-outside") { } else if (align === "right-outside" || align === "right-context-menu") {
applyXStrategy(Strategies.StartToEnd) applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside") { } else if (align === "left-outside" || align === "left-context-menu") {
applyXStrategy(Strategies.EndToStart) applyXStrategy(Strategies.EndToStart)
} else if (align === "center") { } else if (align === "center") {
applyXStrategy(Strategies.MidPoint) applyXStrategy(Strategies.MidPoint)
@ -164,6 +164,12 @@ export default function positionDropdown(element, opts) {
// Determine Y strategy // Determine Y strategy
if (align === "right-outside" || align === "left-outside") { if (align === "right-outside" || align === "left-outside") {
applyYStrategy(Strategies.MidPoint) applyYStrategy(Strategies.MidPoint)
} else if (
align === "right-context-menu" ||
align === "left-context-menu"
) {
applyYStrategy(Strategies.StartToStart)
styles.top -= 4 // Manual adjustment for action menu padding
} else { } else {
applyYStrategy(Strategies.StartToEnd) applyYStrategy(Strategies.StartToEnd)
} }

View File

@ -27,7 +27,7 @@
const onClick = () => { const onClick = () => {
if (actionMenu && !noClose) { if (actionMenu && !noClose) {
actionMenu.hide() actionMenu.hideAll()
} }
dispatch("click") dispatch("click")
} }
@ -47,8 +47,9 @@
</div> </div>
{/if} {/if}
<span class="spectrum-Menu-itemLabel"><slot /></span> <span class="spectrum-Menu-itemLabel"><slot /></span>
{#if keys?.length} {#if keys?.length || $$slots.right}
<div class="keys"> <div class="keys">
<slot name="right" />
{#each keys as key} {#each keys as key}
<div class="key"> <div class="key">
{#if key.startsWith("!")} {#if key.startsWith("!")}

View File

@ -1,7 +1,7 @@
<script> <script>
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import Portal from "svelte-portal" import Portal from "svelte-portal"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext, onDestroy } from "svelte"
import positionDropdown from "../Actions/position_dropdown" import positionDropdown from "../Actions/position_dropdown"
import clickOutside from "../Actions/click_outside" import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
@ -28,7 +28,21 @@
export let resizable = true export let resizable = true
export let wrap = false export let wrap = false
const animationDuration = 260
let timeout
let blockPointerEvents = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
$: {
if (open && animate) {
blockPointerEvents = true
clearTimeout(timeout)
timeout = setTimeout(() => {
blockPointerEvents = false
}, animationDuration / 2)
}
}
export const show = () => { export const show = () => {
dispatch("open") dispatch("open")
@ -77,6 +91,10 @@
hide() hide()
} }
} }
onDestroy(() => {
clearTimeout(timeout)
})
</script> </script>
{#if open} {#if open}
@ -104,9 +122,13 @@
class="spectrum-Popover is-open" class="spectrum-Popover is-open"
class:customZindex class:customZindex
class:hidden={!showPopover} class:hidden={!showPopover}
class:blockPointerEvents
role="presentation" role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};" style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 260 : 0 }} transition:fly|local={{
y: -20,
duration: animate ? animationDuration : 0,
}}
on:mouseenter on:mouseenter
on:mouseleave on:mouseleave
> >
@ -122,6 +144,9 @@
overflow: auto; overflow: auto;
transition: opacity 260ms ease-out; transition: opacity 260ms ease-out;
} }
.blockPointerEvents {
pointer-events: none;
}
.hidden { .hidden {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;

View File

@ -6,7 +6,13 @@
contextMenuStore, contextMenuStore,
} from "stores/builder" } from "stores/builder"
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte" import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import { Icon, Button, Popover, ActionMenu, MenuItem } from "@budibase/bbui" import {
Icon,
Button,
ActionButton,
ActionMenu,
MenuItem,
} from "@budibase/bbui"
import { params, url } from "@roxi/routify" import { params, url } from "@roxi/routify"
import EditViewModal from "./EditViewModal.svelte" import EditViewModal from "./EditViewModal.svelte"
import DeleteViewModal from "./DeleteViewModal.svelte" import DeleteViewModal from "./DeleteViewModal.svelte"
@ -19,12 +25,13 @@
import { alphabetical } from "components/backend/TableNavigator/utils" import { alphabetical } from "components/backend/TableNavigator/utils"
import CreateViewModal from "./CreateViewModal.svelte" import CreateViewModal from "./CreateViewModal.svelte"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import { derived } from "svelte/store"
let viewContainer // View overflow
let observer let observer
let viewContainer
let viewVisibiltyMap = {} let viewVisibiltyMap = {}
let overflowPopover let overflowMenu
let anchor
// Editing table // Editing table
let createViewModal let createViewModal
@ -46,8 +53,15 @@
.filter(x => x.version === 2) .filter(x => x.version === 2)
.slice() .slice()
.sort(alphabetical) .sort(alphabetical)
$: setUpObserver(viewContainer, views) $: setUpObserver(views)
$: overflowedViews = views.filter(view => !viewVisibiltyMap[view.id]) $: overflowedViews = views.filter(view => !viewVisibiltyMap[view.id])
$: viewHidden = viewVisibiltyMap[activeId] === false
const viewUrl = derived([url, params], ([$url, $params]) => viewId => {
return $url(`../${$params.tableId}/${encodeURIComponent(viewId)}`)
})
const tableUrl = derived(url, $url => tableId => $url(`../${tableId}`))
const openTableContextMenu = e => { const openTableContextMenu = e => {
if (!tableEditable) { if (!tableEditable) {
@ -114,11 +128,24 @@
) )
} }
const setUpObserver = (viewContainer, views) => { const editOverflowView = async view => {
if (!views.length || !viewContainer) { editableView = view
await tick()
editViewModal?.show()
}
const deleteOverflowView = async view => {
editableView = view
await tick()
deleteViewModal?.show()
}
const setUpObserver = async views => {
observer?.disconnect() observer?.disconnect()
if (!views.length) {
return return
} }
await tick()
observer = new IntersectionObserver( observer = new IntersectionObserver(
entries => { entries => {
let updates = {} let updates = {}
@ -136,9 +163,11 @@
} }
) )
for (let child of viewContainer.children) { for (let child of viewContainer.children) {
if (child.dataset.id) {
observer.observe(child) observer.observe(child)
} }
} }
}
onDestroy(() => { onDestroy(() => {
observer?.disconnect() observer?.disconnect()
@ -152,7 +181,7 @@
size="24" size="24"
/> />
<a <a
href={$url(`../${tableId}`)} href={$tableUrl(tableId)}
class="nav-item" class="nav-item"
class:active={tableId === activeId} class:active={tableId === activeId}
on:contextmenu={openTableContextMenu} on:contextmenu={openTableContextMenu}
@ -178,7 +207,7 @@
{#each views as view (view.id)} {#each views as view (view.id)}
{@const selectedBy = $userSelectedResourceMap[view.id]} {@const selectedBy = $userSelectedResourceMap[view.id]}
<a <a
href={$url(`../${tableId}/${encodeURIComponent(view.id)}`)} href={$viewUrl(view.id)}
class="nav-item" class="nav-item"
class:active={view.id === activeId} class:active={view.id === activeId}
class:hidden={!viewVisibiltyMap[view.id]} class:hidden={!viewVisibiltyMap[view.id]}
@ -209,27 +238,42 @@
</span> </span>
{/if} {/if}
{#if overflowedViews.length} {#if overflowedViews.length}
<ActionMenu align="right"> <ActionMenu align="right" bind:this={overflowMenu}>
<div slot="control"> <div slot="control">
<Icon <ActionButton icon="ChevronDown" quiet selected={viewHidden}>
name="ChevronDown" {overflowedViews.length} more
size="XL" </ActionButton>
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectrum-global-color-gray-900)"
on:click={overflowPopover?.show}
/>
</div> </div>
{#each overflowedViews as view} {#each overflowedViews as view}
<ActionMenu
align="left-context-menu"
openOnHover
animate={false}
offset={-4}
>
<div slot="control">
<a <a
href={$viewUrl(view.id)}
class="nav-overflow-item" class="nav-overflow-item"
class:active={view.id === activeId} class:active={view.id === activeId}
href={$url(`../${tableId}/${encodeURIComponent(view.id)}`)} on:click={overflowMenu?.hide}
> >
<MenuItem> <MenuItem icon={viewHidden ? "Checkmark" : null}>
{view.name} {view.name}
<Icon slot="right" name="ChevronRight" />
</MenuItem> </MenuItem>
</a> </a>
</div>
<a href={$viewUrl(view.id)}>
<MenuItem icon="Checkmark">Select</MenuItem>
</a>
<MenuItem icon="Edit" on:click={() => editOverflowView(view)}>
Edit
</MenuItem>
<MenuItem icon="Delete" on:click={() => deleteOverflowView(view)}>
Delete
</MenuItem>
</ActionMenu>
{/each} {/each}
</ActionMenu> </ActionMenu>
{/if} {/if}
@ -266,22 +310,23 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
padding: 0 var(--spacing-xl); padding: 0 var(--spacing-xl);
gap: 8px; gap: 12px;
} }
.nav__views { .nav__views {
width: 0; flex: 0 1 auto;
flex: 1 1 auto;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
gap: 8px; gap: 8px;
margin-left: -4px;
} }
/* Table and view items */ /* Table and view items */
.nav-item { .nav-item {
padding: 6px 8px; padding: 0 8px;
height: 32px;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -309,4 +354,9 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
/* OVerflow items */
.nav-overflow-item:not(.active) :global(> .spectrum-Menu-item > .icon) {
visibility: hidden;
}
</style> </style>