feat(context-menu): add initial focus logic

This commit is contained in:
Eric Y Liu 2021-03-19 16:26:50 -07:00
commit 7f8f910b1c
3 changed files with 119 additions and 42 deletions

View file

@ -24,40 +24,61 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const position = writable([x, y]); const position = writable([x, y]);
const currentIndex = writable(-1);
const hasPopup = writable(false);
const ctx = getContext("ContextMenu"); const ctx = getContext("ContextMenu");
let options = [];
let direction = 1; let direction = 1;
let prevX = 0; let prevX = 0;
let prevY = 0; let prevY = 0;
let focusIndex = -1;
function close() { function close() {
open = false; open = false;
x = 0; x = 0;
y = 0; y = 0;
prevX = 0;
prevY = 0;
focusIndex = -1;
} }
setContext("ContextMenu", { position, close }); setContext("ContextMenu", {
currentIndex,
position,
close,
setPopup: (popup) => {
hasPopup.set(popup);
},
});
afterUpdate(() => { afterUpdate(() => {
if (open && level === 1) { if (open) {
if (prevX !== x || prevY !== y) ref.focus(); options = [...ref.querySelectorAll("li[data-nested='false']")];
prevX = x;
prevY = y; if (level === 1) {
if (prevX !== x || prevY !== y) ref.focus();
prevX = x;
prevY = y;
}
dispatch("open");
} else { } else {
dispatch("close"); dispatch("close");
} }
if (!$hasPopup && options[focusIndex]) options[focusIndex].focus();
}); });
$: level = !ctx ? 1 : 2; $: level = !ctx ? 1 : 2;
$: currentIndex.set(focusIndex);
</script> </script>
<svelte:window <svelte:window
on:contextmenu|preventDefault="{(e) => { on:contextmenu|preventDefault="{(e) => {
if (level > 1) return; if (level > 1) return;
if (open || x === 0) x = e.x; if (open || x === 0) x = e.x;
if (open || y === 0) y = e.y; if (open || y === 0) y = e.y;
position.set([x, y]); position.set([x, y]);
open = true; open = true;
}}" }}"
@ -65,6 +86,9 @@
if (!open) return; if (!open) return;
if (e.target.contains(ref)) close(); if (e.target.contains(ref)) close();
}}" }}"
on:keydown="{(e) => {
if (open && e.key === 'Escape') close();
}}"
/> />
<ul <ul
@ -81,18 +105,23 @@
style="left: {x}px; top: {y}px; {$$restProps.style}" style="left: {x}px; top: {y}px; {$$restProps.style}"
on:click on:click
on:click="{({ target }) => { on:click="{({ target }) => {
if ( const closestOption = target.closest('[tabindex]');
target &&
target.closest('[tabindex]') && if (closestOption && closestOption.getAttribute('role') !== 'menuitem') {
target.closest('[tabindex]').getAttribute('role') === 'menuitem' close();
) {
return;
} }
close();
}}" }}"
on:keydown on:keydown
on:keydown="{(e) => { on:keydown="{(e) => {
// TODO: add focus logic if (e.key === 'ArrowDown') {
if (focusIndex < options.length - 1) focusIndex++;
} else if (e.key === 'ArrowUp') {
if (focusIndex === -1) {
focusIndex = options.length - 1;
} else {
if (focusIndex > 0) focusIndex--;
}
}
}}" }}"
> >
<slot /> <slot />

View file

@ -35,14 +35,14 @@
/** /**
* Specify the id * Specify the id
* It's highly recommended to provide an id when using in a selectable/radio menu group * It's recommended to provide an id as a value to bind to within a selectable/radio menu group
*/ */
export let id = "ccs-" + Math.random().toString(36); export let id = "ccs-" + Math.random().toString(36);
/** Obtain a reference to the list item HTML element */ /** Obtain a reference to the list item HTML element */
export let ref = null; export let ref = null;
import { onMount, getContext, createEventDispatcher } from "svelte"; import { onMount, getContext, createEventDispatcher, tick } from "svelte";
import ContextMenu from "./ContextMenu.svelte"; import ContextMenu from "./ContextMenu.svelte";
import Checkmark16 from "carbon-icons-svelte/lib/Checkmark16/Checkmark16.svelte"; import Checkmark16 from "carbon-icons-svelte/lib/Checkmark16/Checkmark16.svelte";
import CaretRight16 from "carbon-icons-svelte/lib/CaretRight16/CaretRight16.svelte"; import CaretRight16 from "carbon-icons-svelte/lib/CaretRight16/CaretRight16.svelte";
@ -54,13 +54,41 @@
let unsubCurrentIds = undefined; let unsubCurrentIds = undefined;
let unsubCurrentId = undefined; let unsubCurrentId = undefined;
let unsubRadioIds = undefined;
let radioIds = [];
let timeoutHover = undefined; let timeoutHover = undefined;
let rootMenuPosition = [0, 0]; let rootMenuPosition = [0, 0];
let currentIndex = -1;
let submenuOpen = false;
const unsubCurrentIndex = ctx.currentIndex.subscribe((index) => {
currentIndex = index;
});
const unsubPosition = ctx.position.subscribe((position) => { const unsubPosition = ctx.position.subscribe((position) => {
rootMenuPosition = position; rootMenuPosition = position;
}); });
function handleClick(opts = {}) {
if (disabled) return ctx.close();
if (subOptions) return;
if (!!ctxGroup) {
ctxGroup.toggleOption({ id });
} else if (!!ctxRadioGroup) {
if (opts.fromKeyboard) {
ctxRadioGroup.setOption({ id: radioIds[currentIndex] });
} else {
ctxRadioGroup.setOption({ id });
}
} else {
selected = !selected;
}
ctx.close();
dispatch("click");
}
onMount(() => { onMount(() => {
selectable = selected === true; selectable = selected === true;
@ -74,12 +102,17 @@
unsubCurrentId = ctxRadioGroup.currentId.subscribe((_id) => { unsubCurrentId = ctxRadioGroup.currentId.subscribe((_id) => {
selected = id === _id; selected = id === _id;
}); });
unsubRadioIds = ctxRadioGroup.radioIds.subscribe((ids) => {
radioIds = ids;
});
} }
return () => { return () => {
unsubCurrentIndex();
unsubPosition(); unsubPosition();
if (unsubCurrentIds) unsubCurrentIds(); if (unsubCurrentIds) unsubCurrentIds();
if (unsubCurrentId) unsubCurrentId(); if (unsubCurrentId) unsubCurrentId();
if (unsubRadioIds) unsubRadioIds();
if (typeof timeoutHover === "number") clearTimeout(timeoutHover); if (typeof timeoutHover === "number") clearTimeout(timeoutHover);
}; };
}); });
@ -98,7 +131,6 @@
} }
} }
let submenuOpen = false;
let submenuPosition = [0, 0]; let submenuPosition = [0, 0];
$: subOptions = $$slots.default; $: subOptions = $$slots.default;
@ -107,12 +139,16 @@
submenuPosition = [rootMenuPosition[0] + width, y]; submenuPosition = [rootMenuPosition[0] + width, y];
} }
$: ctx.setPopup(submenuOpen);
let role = "menuitem"; let role = "menuitem";
$: indented = isSelectable || isRadio;
$: { $: {
if (isSelectable) role = "menuitemcheckbox"; if (isSelectable) role = "menuitemcheckbox";
if (isRadio) { if (isRadio) {
role = "menuitemradio"; role = "menuitemradio";
ctxRadioGroup.addOption({ id });
if (selected) { if (selected) {
if (ctxRadioGroup) ctxRadioGroup.setOption({ id }); if (ctxRadioGroup) ctxRadioGroup.setOption({ id });
@ -134,12 +170,32 @@
class:bx--context-menu-option="{true}" class:bx--context-menu-option="{true}"
class:bx--context-menu-option--disabled="{true}" class:bx--context-menu-option--disabled="{true}"
class:bx--context-menu-option--active="{subOptions && submenuOpen}" class:bx--context-menu-option--active="{subOptions && submenuOpen}"
indented="{isSelectable || isRadio}" indented="{indented}"
aria-checked="{isSelectable || isRadio ? selected : undefined}" aria-checked="{isSelectable || isRadio ? selected : undefined}"
data-nested="{ref &&
ref.closest('.bx--context-menu').getAttribute('data-level') === '2'}"
data-sub="{subOptions}"
{...$$restProps} {...$$restProps}
on:keydown on:keydown
on:keydown="{(e) => { on:keydown="{async ({ key }) => {
// TODO: handle focus if (
subOptions &&
(key === 'ArrowRight' || key === ' ' || key === 'Enter')
) {
submenuOpen = true;
await tick();
const options = ref.querySelectorAll('li[tabindex]');
if (options[0]) options[0].focus();
return;
}
if (key === ' ' || key === 'Enter') {
handleClick({ fromKeyboard: true });
}
}}" }}"
on:mouseenter on:mouseenter
on:mouseenter="{() => { on:mouseenter="{() => {
@ -156,26 +212,10 @@
submenuOpen = false; submenuOpen = false;
} }
}}" }}"
on:click="{() => { on:click="{handleClick}"
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} {#if subOptions}
<div <div
data-nested="{true}"
class:bx--context-menu-option__content="{true}" class:bx--context-menu-option__content="{true}"
class:bx--context-menu-option__content--disabled="{disabled}" class:bx--context-menu-option__content--disabled="{disabled}"
> >

View file

@ -7,12 +7,18 @@
import { setContext } from "svelte"; import { setContext } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import ContextMenuGroup from "./ContextMenuGroup.svelte";
const currentId = writable(""); const currentId = writable("");
const radioIds = writable([]);
setContext("ContextMenuRadioGroup", { setContext("ContextMenuRadioGroup", {
currentId, currentId,
radioIds,
addOption: ({ id }) => {
if (!$radioIds.includes(id)) {
radioIds.update((_) => [..._, id]);
}
},
setOption: ({ id }) => { setOption: ({ id }) => {
selectedId = id; selectedId = id;
}, },
@ -21,6 +27,8 @@
$: currentId.set(selectedId); $: currentId.set(selectedId);
</script> </script>
<ContextMenuGroup labelText="{labelText}"> <li role="none">
<slot /> <ul role="group" aria-label="{labelText}">
</ContextMenuGroup> <slot />
</ul>
</li>