Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ContextMenu component #1213

Merged
merged 9 commits into from
Mar 25, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ export default function clickOffBounds(
}
}

document.body.addEventListener('click', outOfBoundsListener, true);
document.body.addEventListener('pointerdown', outOfBoundsListener, true);

function update(opts: Options) {
callback = opts.callback;
references = opts.references;
}

function destroy() {
document.body.removeEventListener('click', outOfBoundsListener, true);
document.body.removeEventListener('pointerdown', outOfBoundsListener, true);
}

return {
Expand Down
70 changes: 36 additions & 34 deletions mathesar_ui/src/component-library/common/actions/popper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,57 @@ import type {
ModifierArguments,
Options,
Instance,
VirtualElement,
} from '@popperjs/core/lib/types';
import type { Action } from './types';

export default function popper(
node: HTMLElement,
actionOpts: {
reference: HTMLElement;
reference: VirtualElement;
options?: Partial<Options>;
},
): Action {
let popperInstance: Instance;
let prevReference: HTMLElement | undefined;

function create(reference: HTMLElement, options?: Partial<Options>) {
if (reference) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
popperInstance = createPopper(reference, node, {
placement: options?.placement || 'bottom-start',
modifiers: [
{
name: 'setMinWidth',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'],
// @ts-ignore: https://github.com/centerofci/mathesar/issues/1055
fn: (obj: ModifierArguments<unknown>): void => {
// eslint-disable-next-line no-param-reassign
obj.state.styles.popper.minWidth = `${obj.state.rects.reference.width}px`;
},
// @ts-ignore: https://github.com/centerofci/mathesar/issues/1055
effect: (obj: ModifierArguments<unknown>): void => {
const width = (obj.state.elements.reference as HTMLElement)
.offsetWidth;
// eslint-disable-next-line no-param-reassign
obj.state.elements.popper.style.minWidth = `${width}px`;
},
function create(reference?: VirtualElement, options?: Partial<Options>) {
if (!reference) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
popperInstance = createPopper(reference, node, {
placement: options?.placement || 'bottom-start',
modifiers: [
{
name: 'setMinWidth',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'],
// @ts-ignore: https://github.com/centerofci/mathesar/issues/1055
fn: (obj: ModifierArguments<unknown>): void => {
// eslint-disable-next-line no-param-reassign
obj.state.styles.popper.minWidth = `${obj.state.rects.reference.width}px`;
},
{
name: 'flip',
// @ts-ignore: https://github.com/centerofci/mathesar/issues/1055
effect: (obj: ModifierArguments<unknown>): void => {
const width = (obj.state.elements.reference as HTMLElement)
.offsetWidth;
// eslint-disable-next-line no-param-reassign
obj.state.elements.popper.style.minWidth = `${width}px`;
},
{
name: 'offset',
options: {
offset: [0, 0],
},
},
{
name: 'flip',
},
{
name: 'offset',
options: {
offset: [0, 0],
},
],
}) as Instance;
}
},
],
}) as Instance;
}

function destroy() {
Expand Down
96 changes: 96 additions & 0 deletions mathesar_ui/src/component-library/context-menu/ContextMenu.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script lang="ts">
import Menu from '@mathesar-component-library-dir/menu/Menu.svelte';
import popper from '@mathesar-component-library-dir/common/actions/popper';
import portal from '@mathesar-component-library-dir/common/actions/portal';
import clickOffBounds from '@mathesar-component-library-dir/common/actions/clickOffBounds';
import { onMount } from 'svelte';

/**
* A reference to the DOM node where we'll attach the contextmenu event
* listener. Right-clicking anywhere inside this element will open the context
* menu. If you don't pass an element, we'll use the parent element wherever
* this component is mounted.
*/
let customTriggerElement: HTMLElement | undefined = undefined;
export { customTriggerElement as triggerElement };
export let closeOnInnerClick = true;

interface MousePosition {
x: number;
y: number;
}

let isVisible = false;
let element: HTMLDivElement | undefined;
let mousePosition: MousePosition = { x: 0, y: 0 };

function getVirtualReferenceElement(_mousePosition: MousePosition) {
const { x, y } = _mousePosition;
return {
getBoundingClientRect: () => ({
width: 0,
height: 0,
x,
y,
top: y,
right: x,
bottom: y,
left: x,
toJSON: () => ({ x, y }),
}),
};
}

function handleContextMenu(event: MouseEvent) {
event.preventDefault();
mousePosition = {
x: event.clientX,
y: event.clientY,
};
isVisible = !isVisible;
}

onMount(() => {
const triggerElement =
customTriggerElement ?? element?.parentElement ?? undefined;
if (!triggerElement) {
return () => {};
}
triggerElement.addEventListener('contextmenu', handleContextMenu);
return () => {
triggerElement.removeEventListener('contextmenu', handleContextMenu);
};
});

function close() {
isVisible = false;
}

function checkAndCloseOnInnerClick() {
if (closeOnInnerClick) {
close();
}
}
</script>

<div bind:this={element} class="context-menu-wrapper">
{#if isVisible}
<div
class="context-menu dropdown content"
use:clickOffBounds={{ callback: close }}
use:popper={{ reference: getVirtualReferenceElement(mousePosition) }}
use:portal
on:click={checkAndCloseOnInnerClick}
>
<Menu>
<slot />
</Menu>
</div>
{/if}
</div>

<style>
.context-menu {
position: fixed;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script>
import {
faFillDrip,
faPlus,
faTimes,
} from '@fortawesome/free-solid-svg-icons';
import { Meta, Story } from '@storybook/addon-svelte-csf';
import MenuItem from '@mathesar-component-library-dir/menu/MenuItem.svelte';
import Checkbox from '@mathesar-component-library-dir/checkbox/Checkbox.svelte';
import ContextMenu from '../ContextMenu.svelte';

const meta = {
title: 'Components/ContextMenu',
};

let count = 0;
let hasBackground = true;

function incrementCount() {
count += 1;
}

function toggleBackground() {
hasBackground = !hasBackground;
}
</script>

<Meta {...meta} />

<Story name="Basic">
<div class="box without-context">
<p>This box does <em>not</em> have a context menu.</p>
</div>

<div class="box with-context" class:has-background={hasBackground}>
<p>This box has a context menu.</p>
<p>The count is: <strong>{count}</strong>.</p>
<ContextMenu>
<MenuItem icon={{ data: faFillDrip }} on:click={toggleBackground}>
<Checkbox slot="control" checked={hasBackground} />
Use Background
</MenuItem>
<MenuItem icon={{ data: faPlus }} on:click={incrementCount}>
Increment Counter
</MenuItem>
</ContextMenu>
</div>

<div class="box with-context" has-background>
<p>This box <em>also</em> has a context menu.</p>
<ContextMenu>
<MenuItem icon={{ data: faTimes }}>I don't do anything</MenuItem>
</ContextMenu>
</div>

<div class="box without-context">
<p>This box does <em>not</em> have a context menu.</p>
</div>
</Story>

<style>
.box {
padding: 0 1em;
border: solid 0.1em #ccc;
border-radius: 0.5em;
margin: 1em 0;
}
.with-context.has-background {
background: rgb(220, 255, 199);
}

.without-context {
background: rgb(255, 220, 199);
}
</style>
1 change: 1 addition & 0 deletions mathesar_ui/src/component-library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { default as Button } from './button/Button.svelte';
export { default as CancelOrProceedButtonPair } from './cancel-or-proceed-button-pair/CancelOrProceedButtonPair.svelte';
export { default as Checkbox } from './checkbox/Checkbox.svelte';
export { default as CheckboxGroup } from './checkbox-group/CheckboxGroup.svelte';
export { default as ContextMenu } from './context-menu/ContextMenu.svelte';
export { default as Help } from './help/Help.svelte';
export { default as Icon } from './icon/Icon.svelte';
export { InputGroup, InputGroupText } from './input-group';
Expand Down