mirror of
https://github.com/carbon-design-system/carbon-components-svelte.git
synced 2025-09-15 10:21:05 +00:00
Alignment with Carbon version 10.31 (#571)
* chore(deps-dev): upgrade carbon-components to v10.31.0 * fix(slider): use CSS to hide input if hideTextInput is true * docs(slider): add hidden text input, invalid, disabled examples * feat(tabs): support "container" type for TabsSkeleton * chore(list-box): remove hotfix inline style to center dropdown chevron * fix(number-input): use add, subtract icons and update markup * feat(select): add warning state * docs(select): add invalid state example * docs(select): add helper text example * fix(structured-list): add "rowgroup" role to StructuredListBody * docs: release code snippet max-width * docs(select): add skeleton hidden label example * feat(popover): add Popover component * feat(pagination): dispatch button click events to be consistent with PaginationNav * fix(multi-select): type clear as a custom event * docs(radio-button): add disabled buttons example * chore(tabs): use absolute icon import * fix(link): remove line breaks within anchor link * docs(radio-button): adjust section copy verbiage * chore(deps-dev): upgrade carbon-icons-svelte to v10.27 v10.27 uses the SvelteComponentTyped interface * docs(accordion): adjust section title verbiage * test(types): fix warnings from svelte-check * fix(search): only set autofocus attribute if equals true * feat(popover): add closeOnOutsideClick prop * docs: style [data-outline] as relative positioned * feat(context-menu): add initial ContextMenu * feat(context-menu): annotate props, generate types * feat(context-menu): add initial focus logic * fix(context-menu): correctly tab in/out of nested menus * chore(context-menu): update types * fix(context-menu): obtain radio id from node directly * docs(context-menu): add examples and test * fix(context-menu): prevent default keydown behavior
This commit is contained in:
parent
afed4fa2fa
commit
5fad0cb3c7
52 changed files with 1758 additions and 103 deletions
130
src/ContextMenu/ContextMenu.svelte
Normal file
130
src/ContextMenu/ContextMenu.svelte
Normal file
|
@ -0,0 +1,130 @@
|
|||
<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 currentIndex = writable(-1);
|
||||
const hasPopup = writable(false);
|
||||
const ctx = getContext("ContextMenu");
|
||||
|
||||
let options = [];
|
||||
let direction = 1;
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
let focusIndex = -1;
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
x = 0;
|
||||
y = 0;
|
||||
prevX = 0;
|
||||
prevY = 0;
|
||||
focusIndex = -1;
|
||||
}
|
||||
|
||||
setContext("ContextMenu", {
|
||||
currentIndex,
|
||||
position,
|
||||
close,
|
||||
setPopup: (popup) => {
|
||||
hasPopup.set(popup);
|
||||
},
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (open) {
|
||||
options = [...ref.querySelectorAll("li[data-nested='false']")];
|
||||
|
||||
if (level === 1) {
|
||||
if (prevX !== x || prevY !== y) ref.focus();
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
|
||||
dispatch("open");
|
||||
} else {
|
||||
dispatch("close");
|
||||
}
|
||||
|
||||
if (!$hasPopup && options[focusIndex]) options[focusIndex].focus();
|
||||
});
|
||||
|
||||
$: level = !ctx ? 1 : 2;
|
||||
$: currentIndex.set(focusIndex);
|
||||
</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();
|
||||
}}"
|
||||
on:keydown="{(e) => {
|
||||
if (open && e.key === 'Escape') 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 }) => {
|
||||
const closestOption = target.closest('[tabindex]');
|
||||
|
||||
if (closestOption && closestOption.getAttribute('role') !== 'menuitem') {
|
||||
close();
|
||||
}
|
||||
}}"
|
||||
on:keydown
|
||||
on:keydown|preventDefault="{(e) => {
|
||||
if ($hasPopup) return;
|
||||
|
||||
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 />
|
||||
</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>
|
36
src/ContextMenu/ContextMenuGroup.svelte
Normal file
36
src/ContextMenu/ContextMenuGroup.svelte
Normal file
|
@ -0,0 +1,36 @@
|
|||
<script>
|
||||
/** @type {string[]} */
|
||||
export let selectedIds = [];
|
||||
|
||||
/** Specify the label text */
|
||||
export let labelText = "";
|
||||
|
||||
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="{labelText}">
|
||||
<slot />
|
||||
</ul>
|
||||
</li>
|
263
src/ContextMenu/ContextMenuOption.svelte
Normal file
263
src/ContextMenu/ContextMenuOption.svelte
Normal file
|
@ -0,0 +1,263 @@
|
|||
<script>
|
||||
/** Set to `true` to enable the disabled state */
|
||||
export let disabled = false;
|
||||
|
||||
/** Set to `true` to indent the label */
|
||||
export let indented = false;
|
||||
|
||||
/**
|
||||
* Specify the icon from `carbon-icons-svelte` to render
|
||||
* Icon is rendered to the left of the label text
|
||||
* @type {typeof import("carbon-icons-svelte").CarbonIcon}
|
||||
*/
|
||||
export let icon = undefined;
|
||||
|
||||
/**
|
||||
* Specify the label text
|
||||
* Alternatively, use the "labelText" slot (e.g., <span slot="labelText">...</span>)
|
||||
*/
|
||||
export let labelText = "";
|
||||
|
||||
/** Set to `true` to use the selected variant */
|
||||
export let selected = false;
|
||||
|
||||
/**
|
||||
* Set to `true` to enable the selectable variant
|
||||
* Automatically set to `true` if `selected` is `true`
|
||||
*/
|
||||
export let selectable = false;
|
||||
|
||||
/**
|
||||
* Specify the shortcut text
|
||||
* Alternatively, use the "shortcutText" slot (e.g., <span slot="shortcutText">...</span>)
|
||||
*/
|
||||
export let shortcutText = "";
|
||||
|
||||
/**
|
||||
* Specify the id
|
||||
* 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);
|
||||
|
||||
/** Obtain a reference to the list item HTML element */
|
||||
export let ref = null;
|
||||
|
||||
import { onMount, getContext, createEventDispatcher, tick } 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");
|
||||
|
||||
// "moderate-01" duration (ms) from Carbon motion recommended for small expansion, short distance movements
|
||||
const moderate01 = 150;
|
||||
|
||||
let unsubCurrentIds = undefined;
|
||||
let unsubCurrentId = undefined;
|
||||
let timeoutHover = undefined;
|
||||
let rootMenuPosition = [0, 0];
|
||||
let focusIndex = 0;
|
||||
let options = [];
|
||||
let role = "menuitem";
|
||||
let submenuOpen = false;
|
||||
let submenuPosition = [0, 0];
|
||||
|
||||
const unsubPosition = ctx.position.subscribe((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: opts.id });
|
||||
} else {
|
||||
ctxRadioGroup.setOption({ id });
|
||||
}
|
||||
} else {
|
||||
selected = !selected;
|
||||
}
|
||||
|
||||
ctx.close();
|
||||
dispatch("click");
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (selected === true) selectable = true;
|
||||
|
||||
if (ctxGroup) {
|
||||
unsubCurrentIds = ctxGroup.currentIds.subscribe((_currentIds) => {
|
||||
selected = _currentIds.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 || selectable;
|
||||
$: isRadio = !!ctxRadioGroup;
|
||||
$: subOptions = $$slots.default;
|
||||
$: ctx.setPopup(submenuOpen);
|
||||
$: if (submenuOpen) {
|
||||
const { width, y } = ref.getBoundingClientRect();
|
||||
submenuPosition = [rootMenuPosition[0] + width, y];
|
||||
}
|
||||
$: {
|
||||
if (isSelectable) {
|
||||
indented = true;
|
||||
role = "menuitemcheckbox";
|
||||
|
||||
if (selected) {
|
||||
if (ctxGroup) ctxGroup.addOption({ id });
|
||||
icon = Checkmark16;
|
||||
} else {
|
||||
icon = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRadio) {
|
||||
indented = true;
|
||||
role = "menuitemradio";
|
||||
ctxRadioGroup.addOption({ id });
|
||||
|
||||
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="{indented}"
|
||||
aria-checked="{isSelectable || isRadio ? selected : undefined}"
|
||||
data-nested="{ref &&
|
||||
ref.closest('.bx--context-menu').getAttribute('data-level') === '2'}"
|
||||
data-sub="{subOptions}"
|
||||
data-id="{id}"
|
||||
{...$$restProps}
|
||||
on:keydown
|
||||
on:keydown="{async ({ key, target }) => {
|
||||
if (
|
||||
subOptions &&
|
||||
(key === 'ArrowRight' || key === ' ' || key === 'Enter')
|
||||
) {
|
||||
submenuOpen = true;
|
||||
await tick();
|
||||
options = [...ref.querySelectorAll('li[tabindex]')];
|
||||
if (options[focusIndex]) options[focusIndex].focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (submenuOpen) {
|
||||
if (key === 'ArrowLeft') {
|
||||
submenuOpen = false;
|
||||
focusIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
if (focusIndex < options.length - 1) focusIndex++;
|
||||
} else if (key === 'ArrowUp') {
|
||||
if (focusIndex === -1) {
|
||||
focusIndex = options.length - 1;
|
||||
} else {
|
||||
if (focusIndex > 0) focusIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
if (options[focusIndex]) options[focusIndex].focus();
|
||||
}
|
||||
|
||||
if (key === ' ' || key === 'Enter') {
|
||||
handleClick({ fromKeyboard: true, id: target.getAttribute('data-id') });
|
||||
}
|
||||
}}"
|
||||
on:mouseenter
|
||||
on:mouseenter="{() => {
|
||||
if (subOptions) {
|
||||
timeoutHover = setTimeout(() => {
|
||||
submenuOpen = true;
|
||||
}, moderate01);
|
||||
}
|
||||
}}"
|
||||
on:mouseleave
|
||||
on:mouseleave="{(e) => {
|
||||
if (subOptions) {
|
||||
if (typeof timeoutHover === 'number') clearTimeout(timeoutHover);
|
||||
submenuOpen = false;
|
||||
}
|
||||
}}"
|
||||
on:click="{handleClick}"
|
||||
>
|
||||
{#if subOptions}
|
||||
<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="{labelText}">
|
||||
<slot name="labelText">{labelText}</slot>
|
||||
</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="{labelText}">
|
||||
<slot name="labelText">{labelText}</slot>
|
||||
</span>
|
||||
<div class:bx--context-menu-option__info="{true}">
|
||||
<slot name="shortcutText">{shortcutText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
34
src/ContextMenu/ContextMenuRadioGroup.svelte
Normal file
34
src/ContextMenu/ContextMenuRadioGroup.svelte
Normal file
|
@ -0,0 +1,34 @@
|
|||
<script>
|
||||
/** Set the selected radio group id */
|
||||
export let selectedId = "";
|
||||
|
||||
/** Specify the label text */
|
||||
export let labelText = "";
|
||||
|
||||
import { setContext } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const currentId = writable("");
|
||||
const radioIds = writable([]);
|
||||
|
||||
setContext("ContextMenuRadioGroup", {
|
||||
currentId,
|
||||
radioIds,
|
||||
addOption: ({ id }) => {
|
||||
if (!$radioIds.includes(id)) {
|
||||
radioIds.update((_) => [..._, id]);
|
||||
}
|
||||
},
|
||||
setOption: ({ id }) => {
|
||||
selectedId = id;
|
||||
},
|
||||
});
|
||||
|
||||
$: currentId.set(selectedId);
|
||||
</script>
|
||||
|
||||
<li role="none">
|
||||
<ul role="group" aria-label="{labelText}">
|
||||
<slot />
|
||||
</ul>
|
||||
</li>
|
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";
|
Loading…
Add table
Add a link
Reference in a new issue