mirror of
https://github.com/carbon-design-system/carbon-components-svelte.git
synced 2025-09-18 11:36:36 +00:00
feat(context-menu): add initial ContextMenu
This commit is contained in:
parent
cd350a814f
commit
3a1e561a27
8 changed files with 364 additions and 3 deletions
|
@ -20,7 +20,7 @@
|
||||||
import Footer from "../components/Footer.svelte";
|
import Footer from "../components/Footer.svelte";
|
||||||
|
|
||||||
const deprecated = ["ToggleSmall", "Icon"];
|
const deprecated = ["ToggleSmall", "Icon"];
|
||||||
const new_components = ["Popover"];
|
const new_components = ["Popover", "ContextMenu"];
|
||||||
|
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
let isSideNavOpen = true;
|
let isSideNavOpen = true;
|
||||||
|
@ -105,10 +105,18 @@
|
||||||
>
|
>
|
||||||
{child.title}
|
{child.title}
|
||||||
{#if deprecated.includes(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}
|
||||||
{#if new_components.includes(child.title)}
|
{#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}
|
{/if}
|
||||||
</SideNavMenuItem>
|
</SideNavMenuItem>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
99
src/ContextMenu/ContextMenu.svelte
Normal file
99
src/ContextMenu/ContextMenu.svelte
Normal 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>
|
1
src/ContextMenu/ContextMenuDivider.svelte
Normal file
1
src/ContextMenu/ContextMenuDivider.svelte
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<li role="separator" class:bx--context-menu-divider="{true}"></li>
|
35
src/ContextMenu/ContextMenuGroup.svelte
Normal file
35
src/ContextMenu/ContextMenuGroup.svelte
Normal 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>
|
182
src/ContextMenu/ContextMenuOption.svelte
Normal file
182
src/ContextMenu/ContextMenuOption.svelte
Normal 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>
|
24
src/ContextMenu/ContextMenuRadioGroup.svelte
Normal file
24
src/ContextMenu/ContextMenuRadioGroup.svelte
Normal 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
5
src/ContextMenu/index.js
Normal 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";
|
|
@ -4,6 +4,13 @@ export { Breadcrumb, BreadcrumbItem, BreadcrumbSkeleton } from "./Breadcrumb";
|
||||||
export { Button, ButtonSkeleton, ButtonSet } from "./Button";
|
export { Button, ButtonSkeleton, ButtonSet } from "./Button";
|
||||||
export { Checkbox, CheckboxSkeleton } from "./Checkbox";
|
export { Checkbox, CheckboxSkeleton } from "./Checkbox";
|
||||||
export { ContentSwitcher, Switch } from "./ContentSwitcher";
|
export { ContentSwitcher, Switch } from "./ContentSwitcher";
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuDivider,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuOption,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
} from "./ContextMenu";
|
||||||
export { Copy } from "./Copy";
|
export { Copy } from "./Copy";
|
||||||
export { CopyButton } from "./CopyButton";
|
export { CopyButton } from "./CopyButton";
|
||||||
export { ComboBox } from "./ComboBox";
|
export { ComboBox } from "./ComboBox";
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue