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:
Eric Liu 2021-03-20 10:39:14 -07:00 committed by GitHub
commit 5fad0cb3c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1758 additions and 103 deletions

View 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>

View file

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

View 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>

View 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>

View 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
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";