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 focus logic
This commit is contained in:
parent
2ff4d43292
commit
7f8f910b1c
3 changed files with 119 additions and 42 deletions
|
@ -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 />
|
||||||
|
|
|
@ -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}"
|
||||||
>
|
>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue