feat(context-menu): add initial ContextMenu

This commit is contained in:
Eric Y Liu 2021-03-19 13:25:45 -07:00
commit 3a1e561a27
8 changed files with 364 additions and 3 deletions

View file

@ -20,7 +20,7 @@
import Footer from "../components/Footer.svelte";
const deprecated = ["ToggleSmall", "Icon"];
const new_components = ["Popover"];
const new_components = ["Popover", "ContextMenu"];
let isOpen = false;
let isSideNavOpen = true;
@ -105,10 +105,18 @@
>
{child.title}
{#if deprecated.includes(child.title)}
<Tag size="sm" type="red">Deprecated</Tag>
<Tag size="sm" type="red" style="margin-top: 0; margin-bottom: 0">
Deprecated
</Tag>
{/if}
{#if new_components.includes(child.title)}
<Tag size="sm" type="green">New</Tag>
<Tag
size="sm"
type="green"
style="margin-top: 0; margin-bottom: 0"
>
New
</Tag>
{/if}
</SideNavMenuItem>
{/each}

View file

@ -0,0 +1,99 @@
<script>
/**
* Set to `true` to open the menu
* Either `x` and `y` must be greater than zero
*/
export let open = false;
/** Specify the horizontal offset of the menu position */
export let x = 0;
/** Specify the vertical offset of the menu position */
export let y = 0;
/** Obtain a reference to the unordered list HTML element */
export let ref = null;
import {
setContext,
getContext,
afterUpdate,
createEventDispatcher,
} from "svelte";
import { writable } from "svelte/store";
const dispatch = createEventDispatcher();
const position = writable([x, y]);
const ctx = getContext("ContextMenu");
let direction = 1;
let prevX = 0;
let prevY = 0;
function close() {
open = false;
x = 0;
y = 0;
}
setContext("ContextMenu", { position, close });
afterUpdate(() => {
if (open && level === 1) {
if (prevX !== x || prevY !== y) ref.focus();
prevX = x;
prevY = y;
} else {
dispatch("close");
}
});
$: level = !ctx ? 1 : 2;
</script>
<svelte:window
on:contextmenu|preventDefault="{(e) => {
if (level > 1) return;
if (open || x === 0) x = e.x;
if (open || y === 0) y = e.y;
position.set([x, y]);
open = true;
}}"
on:click="{(e) => {
if (!open) return;
if (e.target.contains(ref)) close();
}}"
/>
<ul
bind:this="{ref}"
role="menu"
tabindex="-1"
data-direction="{direction}"
data-level="{level}"
class:bx--context-menu="{true}"
class:bx--context-menu--open="{open}"
class:bx--context-menu--invisible="{open && x === 0 && y === 0}"
class:bx--context-menu--root="{level === 1}"
{...$$restProps}
style="left: {x}px; top: {y}px; {$$restProps.style}"
on:click
on:click="{({ target }) => {
if (
target &&
target.closest('[tabindex]') &&
target.closest('[tabindex]').getAttribute('role') === 'menuitem'
) {
return;
}
close();
}}"
on:keydown
on:keydown="{(e) => {
// TODO: add focus logic
}}"
>
<slot />
</ul>

View file

@ -0,0 +1 @@
<li role="separator" class:bx--context-menu-divider="{true}"></li>

View file

@ -0,0 +1,35 @@
<script>
/** @type {string[]} */
export let selectedIds = [];
export let label = "";
import { setContext } from "svelte";
import { writable } from "svelte/store";
const currentIds = writable([]);
setContext("ContextMenuGroup", {
currentIds,
addOption: ({ id }) => {
if (!selectedIds.includes(id)) {
selectedIds = [...selectedIds, id];
}
},
toggleOption: ({ id }) => {
if (!selectedIds.includes(id)) {
selectedIds = [...selectedIds, id];
} else {
selectedIds = selectedIds.filter((_) => _ !== id);
}
},
});
$: currentIds.set(selectedIds);
</script>
<li role="none">
<ul role="group" aria-label="{label}">
<slot />
</ul>
</li>

View file

@ -0,0 +1,182 @@
<script>
export let disabled = false;
export let icon = undefined;
export let indented = false;
export let label = "";
export let selected = false;
export let selectable = false;
export let shortcut = "";
export let id = "ccs-" + Math.random().toString(36);
export let ref = null;
import { onMount, getContext, createEventDispatcher } from "svelte";
import ContextMenu from "./ContextMenu.svelte";
import Checkmark16 from "carbon-icons-svelte/lib/Checkmark16/Checkmark16.svelte";
import CaretRight16 from "carbon-icons-svelte/lib/CaretRight16/CaretRight16.svelte";
const dispatch = createEventDispatcher();
const ctx = getContext("ContextMenu");
const ctxGroup = getContext("ContextMenuGroup");
const ctxRadioGroup = getContext("ContextMenuRadioGroup");
let unsubCurrentIds = undefined;
let unsubCurrentId = undefined;
let initialSelected = false;
let timeoutHover = undefined;
let rootMenuPosition = [0, 0];
const unsubPosition = ctx.position.subscribe((position) => {
rootMenuPosition = position;
});
onMount(() => {
initialSelected = selected;
if (ctxGroup) {
unsubCurrentIds = ctxGroup.currentIds.subscribe((ids) => {
selected = ids.includes(id);
});
}
if (ctxRadioGroup) {
unsubCurrentId = ctxRadioGroup.currentId.subscribe((_id) => {
selected = id === _id;
});
}
return () => {
unsubPosition();
if (unsubCurrentIds) unsubCurrentIds();
if (unsubCurrentId) unsubCurrentId();
if (typeof timeoutHover === "number") clearTimeout(timeoutHover);
};
});
$: isSelectable = !!ctxGroup || initialSelected || selectable;
$: isRadio = !!ctxRadioGroup;
$: if (isSelectable) {
indented = true;
if (selected) {
if (ctxGroup) ctxGroup.addOption({ id });
icon = Checkmark16;
} else {
icon = undefined;
}
}
let submenuOpen = false;
let submenuPosition = [0, 0];
$: subOptions = $$slots.default;
$: if (submenuOpen) {
const { width, y } = ref.getBoundingClientRect();
submenuPosition = [rootMenuPosition[0] + width, y];
}
let role = "menuitem";
$: {
if (isSelectable) role = "menuitemcheckbox";
if (isRadio) {
role = "menuitemradio";
if (selected) {
if (ctxRadioGroup) ctxRadioGroup.setOption({ id });
icon = Checkmark16;
} else {
icon = undefined;
}
}
}
</script>
<li
bind:this="{ref}"
role="{role}"
tabindex="-1"
aria-disabled="{!subOptions && disabled}"
aria-haspopup="{subOptions ? true : undefined}"
aria-expanded="{subOptions ? submenuOpen : undefined}"
class:bx--context-menu-option="{true}"
class:bx--context-menu-option--disabled="{true}"
class:bx--context-menu-option--active="{subOptions && submenuOpen}"
indented="{isSelectable || isRadio}"
aria-checked="{isSelectable || isRadio ? selected : undefined}"
{...$$restProps}
on:keydown
on:keydown="{(e) => {}}"
on:mouseenter
on:mouseenter="{(e) => {
if (subOptions) {
timeoutHover = setTimeout(() => {
submenuOpen = true;
}, 150);
}
}}"
on:mouseleave
on:mouseleave="{(e) => {
if (subOptions) {
if (typeof timeoutHover === 'number') clearTimeout(timeoutHover);
submenuOpen = false;
}
}}"
on:click="{() => {
if (disabled) return ctx.close();
if (subOptions) return;
if (!!ctxGroup) {
ctxGroup.toggleOption({ id });
} else if (!!ctxRadioGroup) {
ctxRadioGroup.setOption({ id });
} else {
console.log('toggle');
selected = !selected;
}
ctx.close();
dispatch('click');
}}"
>
{#if subOptions}
<div
data-nested="{true}"
class:bx--context-menu-option__content="{true}"
class:bx--context-menu-option__content--disabled="{disabled}"
>
{#if indented}
<div class:bx--context-menu-option__icon="{true}">
<svelte:component this="{icon}" />
</div>
{/if}
<span class:bx--context-menu-option__label="{true}" title="{label}">
{label}
</span>
<div class:bx--context-menu-option__info="{true}"><CaretRight16 /></div>
</div>
<ContextMenu
open="{submenuOpen}"
x="{submenuPosition[0]}"
y="{submenuPosition[1]}"
>
<slot />
</ContextMenu>
{:else}
<div
class:bx--context-menu-option__content="{true}"
class:bx--context-menu-option__content--disabled="{disabled}"
>
{#if indented}
<div class:bx--context-menu-option__icon="{true}">
<svelte:component this="{icon}" />
</div>
{/if}
<span class:bx--context-menu-option__label="{true}" title="{label}">
{label}
</span>
<div class:bx--context-menu-option__info="{true}">{shortcut}</div>
</div>
{/if}
</li>

View file

@ -0,0 +1,24 @@
<script>
export let selectedId = "";
export let label = "";
import { setContext } from "svelte";
import { writable } from "svelte/store";
import ContextMenuGroup from "./ContextMenuGroup.svelte";
const currentId = writable("");
setContext("ContextMenuRadioGroup", {
currentId,
setOption: ({ id }) => {
selectedId = id;
},
});
$: currentId.set(selectedId);
</script>
<ContextMenuGroup label="{label}">
<slot />
</ContextMenuGroup>

5
src/ContextMenu/index.js Normal file
View file

@ -0,0 +1,5 @@
export { default as ContextMenu } from "./ContextMenu.svelte";
export { default as ContextMenuDivider } from "./ContextMenuDivider.svelte";
export { default as ContextMenuGroup } from "./ContextMenuGroup.svelte";
export { default as ContextMenuOption } from "./ContextMenuOption.svelte";
export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.svelte";

View file

@ -4,6 +4,13 @@ export { Breadcrumb, BreadcrumbItem, BreadcrumbSkeleton } from "./Breadcrumb";
export { Button, ButtonSkeleton, ButtonSet } from "./Button";
export { Checkbox, CheckboxSkeleton } from "./Checkbox";
export { ContentSwitcher, Switch } from "./ContentSwitcher";
export {
ContextMenu,
ContextMenuDivider,
ContextMenuGroup,
ContextMenuOption,
ContextMenuRadioGroup,
} from "./ContextMenu";
export { Copy } from "./Copy";
export { CopyButton } from "./CopyButton";
export { ComboBox } from "./ComboBox";