Add double layer context menu for overflow views
This commit is contained in:
parent
40e7f58131
commit
d377186f0d
|
@ -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;
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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("!")}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue