From 7324f7069e94a6d181a46ec271ba7cdc24517372 Mon Sep 17 00:00:00 2001 From: Matt Driscoll Date: Wed, 2 Aug 2023 09:56:47 -0700 Subject: [PATCH] feat(list): Add support for dragging items. (#7109) **Related Issue:** #6554 ## Summary - SortableComponent - Moves all configuration options into the interface so a component can consistently setup SortableJS. - Changes usage of `onUpdate` to `onSort` for moving between lists and getting `calciteListOrderChange` event. - Adds support for `canPut`/`canPull` so users can configure whether an item can be dragged to another list and vice versa. - List - Sets up sorting - keyboard sorting only works within a list. Cannot keyboard sort across lists at this time (including nested lists). - Adds dragHandle rendering to `list-item` - ListItemGroup and List emit an internal event when its default slot changes in order to update whether an expand caret shows or not. - Handle - Updates handle to support displaying ariaLabel (logic taken from value-list) - Handle will emit an internal event for parent components to update an aria-live region. - No breaking changes necessary. We can advise users to nest another `calcite-list` to work with sorting on children. - `calcite-list-item-group` will not be draggable/sortable at this time. --- .../handle/assets/handle/t9n/messages.json | 6 +- .../handle/assets/handle/t9n/messages_en.json | 6 +- .../src/components/handle/handle.tsx | 67 ++++- .../src/components/handle/interfaces.d.ts | 4 + .../list-item-group/list-item-group.tsx | 36 ++- .../src/components/list-item/list-item.e2e.ts | 22 ++ .../src/components/list-item/list-item.scss | 1 + .../src/components/list-item/list-item.tsx | 49 +++- .../src/components/list-item/resources.ts | 4 +- .../src/components/list-item/utils.ts | 11 +- .../src/components/list/list.e2e.ts | 163 ++++++++++- .../src/components/list/list.scss | 4 + .../src/components/list/list.stories.ts | 71 +++++ .../src/components/list/list.tsx | 258 ++++++++++++++++-- .../src/components/list/resources.ts | 1 + .../src/components/list/usage/DragEnabled.md | 16 ++ .../list/usage/DragEnabledNested.md | 19 ++ .../sortable-list/sortable-list.tsx | 55 ++-- .../src/components/value-list/value-list.tsx | 53 ++-- .../calcite-components/src/demos/list.html | 73 +++++ .../src/utils/sortableComponent.ts | 89 +++++- 21 files changed, 920 insertions(+), 88 deletions(-) create mode 100644 packages/calcite-components/src/components/list/usage/DragEnabled.md create mode 100644 packages/calcite-components/src/components/list/usage/DragEnabledNested.md diff --git a/packages/calcite-components/src/components/handle/assets/handle/t9n/messages.json b/packages/calcite-components/src/components/handle/assets/handle/t9n/messages.json index 42ca69fde8e..47a39263892 100644 --- a/packages/calcite-components/src/components/handle/assets/handle/t9n/messages.json +++ b/packages/calcite-components/src/components/handle/assets/handle/t9n/messages.json @@ -1,3 +1,7 @@ { - "dragHandle": "Drag handle" + "dragHandle": "Drag handle", + "dragHandleActive": "Reordering {itemLabel}, current position {position} of {total}.", + "dragHandleChange": "{itemLabel}, new position {position} of {total}. Press space to confirm.", + "dragHandleCommit": "{itemLabel}, current position {position} of {total}.", + "dragHandleIdle": "{itemLabel}, press space and use arrow keys to reorder content. Current position {position} of {total}." } diff --git a/packages/calcite-components/src/components/handle/assets/handle/t9n/messages_en.json b/packages/calcite-components/src/components/handle/assets/handle/t9n/messages_en.json index 42ca69fde8e..47a39263892 100644 --- a/packages/calcite-components/src/components/handle/assets/handle/t9n/messages_en.json +++ b/packages/calcite-components/src/components/handle/assets/handle/t9n/messages_en.json @@ -1,3 +1,7 @@ { - "dragHandle": "Drag handle" + "dragHandle": "Drag handle", + "dragHandleActive": "Reordering {itemLabel}, current position {position} of {total}.", + "dragHandleChange": "{itemLabel}, new position {position} of {total}. Press space to confirm.", + "dragHandleCommit": "{itemLabel}, current position {position} of {total}.", + "dragHandleIdle": "{itemLabel}, press space and use arrow keys to reorder content. Current position {position} of {total}." } diff --git a/packages/calcite-components/src/components/handle/handle.tsx b/packages/calcite-components/src/components/handle/handle.tsx index 5cafcce4198..cb906853596 100644 --- a/packages/calcite-components/src/components/handle/handle.tsx +++ b/packages/calcite-components/src/components/handle/handle.tsx @@ -26,7 +26,7 @@ import { updateMessages, } from "../../utils/t9n"; import { HandleMessages } from "./assets/handle/t9n"; -import { HandleNudge } from "./interfaces"; +import { HandleChange, HandleNudge } from "./interfaces"; import { CSS, ICONS } from "./resources"; @Component({ @@ -47,6 +47,21 @@ export class Handle implements LoadableComponent, T9nComponent { */ @Prop({ mutable: true, reflect: true }) activated = false; + @Watch("messages") + @Watch("label") + @Watch("activated") + @Watch("setPosition") + @Watch("setSize") + handleAriaTextChange(): void { + const message = this.getAriaText("live"); + + if (message) { + this.calciteInternalHandleChange.emit({ + message, + }); + } + } + /** * Value for the button title attribute */ @@ -59,6 +74,27 @@ export class Handle implements LoadableComponent, T9nComponent { */ @Prop() messages: HandleMessages; + /** + * + * + * @internal + */ + @Prop() setPosition: number; + + /** + * + * + * @internal + */ + @Prop() setSize: number; + + /** + * + * + * @internal + */ + @Prop() label: string; + /** * Use this property to override individual strings used by the component. */ @@ -124,6 +160,11 @@ export class Handle implements LoadableComponent, T9nComponent { */ @Event({ cancelable: false }) calciteHandleNudge: EventEmitter; + /** + * Emitted when the handle is activated or deactivated. + */ + @Event({ cancelable: false }) calciteInternalHandleChange: EventEmitter; + // -------------------------------------------------------------------------- // // Methods @@ -144,6 +185,27 @@ export class Handle implements LoadableComponent, T9nComponent { // // -------------------------------------------------------------------------- + getAriaText(type: "label" | "live"): string { + const { setPosition, setSize, label, messages, activated } = this; + + if (!messages || !label || typeof setSize !== "number" || typeof setPosition !== "number") { + return null; + } + + const text = + type === "label" + ? activated + ? messages.dragHandleChange + : messages.dragHandleIdle + : activated + ? messages.dragHandleActive + : messages.dragHandleCommit; + + const replacePosition = text.replace("{position}", setPosition.toString()); + const replaceLabel = replacePosition.replace("{itemLabel}", label); + return replaceLabel.replace("{total}", setSize.toString()); + } + handleKeyDown = (event: KeyboardEvent): void => { switch (event.key) { case " ": @@ -181,13 +243,14 @@ export class Handle implements LoadableComponent, T9nComponent { return ( // Needs to be a span because of https://github.com/SortableJS/Sortable/issues/1486 { this.handleButton = el; diff --git a/packages/calcite-components/src/components/handle/interfaces.d.ts b/packages/calcite-components/src/components/handle/interfaces.d.ts index 15b1b64c7ed..e81f65a5bf7 100644 --- a/packages/calcite-components/src/components/handle/interfaces.d.ts +++ b/packages/calcite-components/src/components/handle/interfaces.d.ts @@ -1,3 +1,7 @@ export interface HandleNudge { direction: "up" | "down"; } + +export interface HandleChange { + message: string; +} diff --git a/packages/calcite-components/src/components/list-item-group/list-item-group.tsx b/packages/calcite-components/src/components/list-item-group/list-item-group.tsx index 333d56c75ca..328590d20ae 100644 --- a/packages/calcite-components/src/components/list-item-group/list-item-group.tsx +++ b/packages/calcite-components/src/components/list-item-group/list-item-group.tsx @@ -1,4 +1,14 @@ -import { Component, Element, h, Host, Prop, State, VNode } from "@stencil/core"; +import { + Component, + Element, + Event, + EventEmitter, + h, + Host, + Prop, + State, + VNode, +} from "@stencil/core"; import { connectInteractive, disconnectInteractive, @@ -34,6 +44,18 @@ export class ListItemGroup implements InteractiveComponent { */ @Prop({ reflect: true }) heading: string; + //-------------------------------------------------------------------------- + // + // Events + // + //-------------------------------------------------------------------------- + + /** + * Emitted when the default slot has changes in order to notify parent lists. + */ + @Event({ cancelable: false }) + calciteInternalListItemGroupDefaultSlotChange: EventEmitter; + // -------------------------------------------------------------------------- // // Lifecycle @@ -82,8 +104,18 @@ export class ListItemGroup implements InteractiveComponent { {heading} - + ); } + + // -------------------------------------------------------------------------- + // + // Private Methods + // + // -------------------------------------------------------------------------- + + private handleDefaultSlotChange = (): void => { + this.calciteInternalListItemGroupDefaultSlotChange.emit(); + }; } diff --git a/packages/calcite-components/src/components/list-item/list-item.e2e.ts b/packages/calcite-components/src/components/list-item/list-item.e2e.ts index 3e2741b081b..759485f4070 100755 --- a/packages/calcite-components/src/components/list-item/list-item.e2e.ts +++ b/packages/calcite-components/src/components/list-item/list-item.e2e.ts @@ -43,6 +43,10 @@ describe("calcite-list-item", () => { propertyName: "open", defaultValue: false, }, + { + propertyName: "dragHandle", + defaultValue: false, + }, ]); }); @@ -54,6 +58,24 @@ describe("calcite-list-item", () => { disabled(``); }); + it("renders dragHandle when property is true", async () => { + const page = await newE2EPage(); + await page.setContent(``); + await page.waitForChanges(); + + let handleNode = await page.find("calcite-list-item >>> calcite-handle"); + + expect(handleNode).toBeNull(); + + const item = await page.find("calcite-list-item"); + item.setProperty("dragHandle", true); + await page.waitForChanges(); + + handleNode = await page.find("calcite-list-item >>> calcite-handle"); + + expect(handleNode).not.toBeNull(); + }); + it("renders content node when label is provided", async () => { const page = await newE2EPage({ html: `` }); diff --git a/packages/calcite-components/src/components/list-item/list-item.scss b/packages/calcite-components/src/components/list-item/list-item.scss index be59c487b4e..a65b32f5d4d 100755 --- a/packages/calcite-components/src/components/list-item/list-item.scss +++ b/packages/calcite-components/src/components/list-item/list-item.scss @@ -113,6 +113,7 @@ td:focus { .content-start, .content-end, .selection-container, +.drag-container, .open-container { @apply flex items-center; } diff --git a/packages/calcite-components/src/components/list-item/list-item.tsx b/packages/calcite-components/src/components/list-item/list-item.tsx index 642ecd92919..13eec92b2da 100644 --- a/packages/calcite-components/src/components/list-item/list-item.tsx +++ b/packages/calcite-components/src/components/list-item/list-item.tsx @@ -5,6 +5,7 @@ import { EventEmitter, h, Host, + Listen, Method, Prop, State, @@ -106,6 +107,13 @@ export class ListItem this.emitCalciteInternalListItemChange(); } + /** + * When `true`, the component displays a draggable button. + * + * @internal + */ + @Prop() dragHandle = false; + /** * The label text of the component. Displays above the description text. */ @@ -226,6 +234,13 @@ export class ListItem */ @Event({ cancelable: false }) calciteInternalListItemChange: EventEmitter; + @Listen("calciteInternalListItemGroupDefaultSlotChange") + @Listen("calciteInternalListDefaultSlotChange") + handleCalciteInternalListDefaultSlotChanges(event: CustomEvent): void { + event.stopPropagation(); + this.handleOpenableChange(this.defaultSlotEl); + } + // -------------------------------------------------------------------------- // // Private Properties @@ -269,6 +284,14 @@ export class ListItem actionsEndEl: HTMLTableCellElement; + defaultSlotEl: HTMLSlotElement; + + // -------------------------------------------------------------------------- + // + // Lifecycle + // + // -------------------------------------------------------------------------- + connectedCallback(): void { connectInteractive(this); connectLocalized(this); @@ -354,6 +377,14 @@ export class ListItem ); } + renderDragHandle(): VNode { + return this.dragHandle ? ( + + + + ) : null; + } + renderOpen(): VNode { const { el, open, openable, parentListEl } = this; const dir = getElementDir(el); @@ -533,6 +564,7 @@ export class ListItem // eslint-disable-next-line react/jsx-sort-props ref={(el) => (this.containerEl = el)} > + {this.renderDragHandle()} {this.renderSelected()} {this.renderOpen()} {this.renderActionsStart()} @@ -545,7 +577,10 @@ export class ListItem [CSS.nestedContainerHidden]: openable && !open, }} > - + (this.defaultSlotEl = el)} + /> ); @@ -602,9 +637,13 @@ export class ListItem } } - handleDefaultSlotChange = (event: Event): void => { + handleOpenableChange(slotEl: HTMLSlotElement): void { + if (!slotEl) { + return; + } + const { parentListEl } = this; - const listItemChildren = getListItemChildren(event); + const listItemChildren = getListItemChildren(slotEl); updateListItemChildren(listItemChildren); const openable = !!listItemChildren.length; @@ -617,6 +656,10 @@ export class ListItem if (!openable) { this.open = false; } + } + + handleDefaultSlotChange = (event: Event): void => { + this.handleOpenableChange(event.target as HTMLSlotElement); }; toggleOpen = (): void => { diff --git a/packages/calcite-components/src/components/list-item/resources.ts b/packages/calcite-components/src/components/list-item/resources.ts index 88fc86c1d00..3e40f74903d 100644 --- a/packages/calcite-components/src/components/list-item/resources.ts +++ b/packages/calcite-components/src/components/list-item/resources.ts @@ -17,6 +17,7 @@ export const CSS = { actionsEnd: "actions-end", selectionContainer: "selection-container", openContainer: "open-container", + dragContainer: "drag-container", }; export const SLOTS = { @@ -27,7 +28,8 @@ export const SLOTS = { actionsEnd: "actions-end", }; -export const MAX_COLUMNS = 5; +// Set to zero to extend until the end of the table section. +export const MAX_COLUMNS = 0; export const ICONS = { selectedMultiple: "check-circle-f", diff --git a/packages/calcite-components/src/components/list-item/utils.ts b/packages/calcite-components/src/components/list-item/utils.ts index 943236d9724..94520de9ca7 100644 --- a/packages/calcite-components/src/components/list-item/utils.ts +++ b/packages/calcite-components/src/components/list-item/utils.ts @@ -1,10 +1,11 @@ import { Build } from "@stencil/core"; +const listSelector = "calcite-list"; const listItemGroupSelector = "calcite-list-item-group"; const listItemSelector = "calcite-list-item"; -export function getListItemChildren(event: Event): HTMLCalciteListItemElement[] { - const assignedElements = (event.target as HTMLSlotElement).assignedElements({ flatten: true }); +export function getListItemChildren(slotEl: HTMLSlotElement): HTMLCalciteListItemElement[] { + const assignedElements = slotEl.assignedElements({ flatten: true }); const listItemGroupChildren = ( assignedElements.filter((el) => el?.matches(listItemGroupSelector)) as HTMLCalciteListItemGroupElement[] @@ -16,7 +17,11 @@ export function getListItemChildren(event: Event): HTMLCalciteListItemElement[] el?.matches(listItemSelector) ) as HTMLCalciteListItemElement[]; - return [...listItemGroupChildren, ...listItemChildren]; + const listItemListChildren = (assignedElements.filter((el) => el?.matches(listSelector)) as HTMLCalciteListElement[]) + .map((list) => Array.from(list.querySelectorAll(listItemSelector))) + .reduce((previousValue, currentValue) => [...previousValue, ...currentValue], []); + + return [...listItemListChildren, ...listItemGroupChildren, ...listItemChildren]; } export function updateListItemChildren(listItemChildren: HTMLCalciteListItemElement[]): void { diff --git a/packages/calcite-components/src/components/list/list.e2e.ts b/packages/calcite-components/src/components/list/list.e2e.ts index 88db7c9b0dc..b9e51b0d17b 100755 --- a/packages/calcite-components/src/components/list/list.e2e.ts +++ b/packages/calcite-components/src/components/list/list.e2e.ts @@ -1,7 +1,7 @@ import { accessible, hidden, renders, focusable, disabled, defaults } from "../../tests/commonTests"; import { placeholderImage } from "../../../.storybook/placeholderImage"; import { html } from "../../../support/formatting"; -import { newE2EPage } from "@stencil/core/testing"; +import { E2EPage, newE2EPage } from "@stencil/core/testing"; import { debounceTimeout } from "./resources"; import { CSS } from "../list-item/resources"; import { DEBOUNCE_TIMEOUT as FILTER_DEBOUNCE_TIMEOUT } from "../filter/resources"; @@ -61,6 +61,10 @@ describe("calcite-list", () => { propertyName: "filterPlaceholder", defaultValue: undefined, }, + { + propertyName: "dragEnabled", + defaultValue: false, + }, ]); }); @@ -451,4 +455,161 @@ describe("calcite-list", () => { expect(await isElementFocused(page, "calcite-filter", { shadowed: true })).toBe(true); }); }); + + describe("drag and drop", () => { + async function createSimpleList(): Promise { + const page = await newE2EPage(); + await page.setContent(` + + + + `); + await page.waitForChanges(); + await page.waitForTimeout(listDebounceTimeout); + return page; + } + + it("works using a keyboard", async () => { + const page = await createSimpleList(); + + const handle = await page.find(`calcite-list-item[value="one"] >>> calcite-handle`); + + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Space"); + expect(await handle.getProperty("activated")).toBe(true); + await page.waitForChanges(); + + let totalMoves = 0; + + const listOrderChangeSpy = await page.spyOnEvent("calciteListOrderChange"); + + async function assertKeyboardMove( + arrowKey: "ArrowDown" | "ArrowUp", + expectedValueOrder: string[] + ): Promise { + const calciteListOrderChangeEvent = page.waitForEvent("calciteListOrderChange"); + await page.waitForChanges(); + await page.keyboard.press(arrowKey); + await calciteListOrderChangeEvent; + const itemsAfter = await page.findAll("calcite-list-item"); + expect(itemsAfter.length).toBe(3); + + for (let i = 0; i < itemsAfter.length; i++) { + expect(await itemsAfter[i].getProperty("value")).toBe(expectedValueOrder[i]); + } + + expect(listOrderChangeSpy).toHaveReceivedEventTimes(++totalMoves); + } + + await assertKeyboardMove("ArrowDown", ["two", "one", "three"]); + await assertKeyboardMove("ArrowDown", ["two", "three", "one"]); + + await assertKeyboardMove("ArrowUp", ["two", "one", "three"]); + await assertKeyboardMove("ArrowUp", ["one", "two", "three"]); + }); + + it("is drag and drop list accessible", async () => { + const page = await createSimpleList(); + let startIndex = 0; + + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + const items = await page.findAll("calcite-list-item"); + const item = await page.find('calcite-list-item[value="one"]'); + const handle = await page.find('calcite-list-item[value="one"] >>> calcite-handle'); + const assistiveTextElement = await page.find("calcite-list >>> .assistive-text"); + + async function getAriaLabel(): Promise { + return page.$eval("calcite-list-item[value='one']", (el: HTMLCalciteListItemElement) => { + return el.shadowRoot + .querySelector("calcite-handle") + .shadowRoot.querySelector("span") + .getAttribute("aria-label"); + }); + } + + const handleAriaLabel = await getAriaLabel(); + const itemLabel = await item.getProperty("label"); + + /* eslint-disable import/no-dynamic-require -- allowing dynamic asset path for maintainability */ + const langTranslations = await import(`../handle/assets/handle/t9n/messages.json`); + /* eslint-enable import/no-dynamic-require */ + + function messageSubstitute({ + text, + setPosition, + label, + setSize, + }: { + text: string; + setPosition: number; + label: string; + setSize: number; + }): string { + const replacePosition = text.replace("{position}", setPosition.toString()); + const replaceLabel = replacePosition.replace("{itemLabel}", label); + return replaceLabel.replace("{total}", setSize.toString()); + } + + expect(handleAriaLabel).toBe( + messageSubstitute({ + text: langTranslations.dragHandleIdle, + setPosition: startIndex + 1, + label: itemLabel, + setSize: items.length, + }) + ); + + await page.keyboard.press("Space"); + expect(await handle.getProperty("activated")).toBe(true); + await page.waitForChanges(); + + expect(assistiveTextElement.textContent).toBe( + messageSubstitute({ + text: langTranslations.dragHandleActive, + setPosition: startIndex + 1, + label: itemLabel, + setSize: items.length, + }) + ); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await handle.getProperty("activated")).toBe(true); + await page.waitForTimeout(debounceTimeout); + + startIndex += 1; + const changeHandleLabel = await getAriaLabel(); + + expect(changeHandleLabel).toBe( + messageSubstitute({ + text: langTranslations.dragHandleChange, + setPosition: startIndex + 1, + label: itemLabel, + setSize: items.length, + }) + ); + await page.keyboard.press("Space"); + await page.waitForChanges(); + + expect(assistiveTextElement.textContent).toBe( + messageSubstitute({ + text: langTranslations.dragHandleCommit, + setPosition: startIndex + 1, + label: itemLabel, + setSize: items.length, + }) + ); + + await page.keyboard.press("Space"); + await page.waitForChanges(); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("Space"); + await page.waitForChanges(); + expect(await handle.getProperty("activated")).toBe(false); + }); + }); }); diff --git a/packages/calcite-components/src/components/list/list.scss b/packages/calcite-components/src/components/list/list.scss index 0ac94efeb3f..3d450907e49 100755 --- a/packages/calcite-components/src/components/list/list.scss +++ b/packages/calcite-components/src/components/list/list.scss @@ -48,4 +48,8 @@ } } +.assistive-text { + @apply sr-only; +} + @include base-component(); diff --git a/packages/calcite-components/src/components/list/list.stories.ts b/packages/calcite-components/src/components/list/list.stories.ts index 83825e5c966..f6b392bb04e 100644 --- a/packages/calcite-components/src/components/list/list.stories.ts +++ b/packages/calcite-components/src/components/list/list.stories.ts @@ -590,3 +590,74 @@ export const filterActions_TestOnly = (): string => html` `; + +export const sortableList_TestOnly = (): string => html` + + + + + + + + + + + + + + + + +`; + +export const sortableNestedList_TestOnly = (): string => html` + + + + + + + + + + + + + + + +`; diff --git a/packages/calcite-components/src/components/list/list.tsx b/packages/calcite-components/src/components/list/list.tsx index 760f6de2416..4d9757f1724 100755 --- a/packages/calcite-components/src/components/list/list.tsx +++ b/packages/calcite-components/src/components/list/list.tsx @@ -11,6 +11,7 @@ import { VNode, Watch, } from "@stencil/core"; +import Sortable, { SortableEvent } from "sortablejs"; import { debounce } from "lodash-es"; import { slotChangeHasAssignedElement, toAriaBoolean } from "../../utils/dom"; import { @@ -25,9 +26,16 @@ import { ItemData } from "../list-item/interfaces"; import { MAX_COLUMNS } from "../list-item/resources"; import { getListItemChildren, updateListItemChildren } from "../list-item/utils"; import { CSS, debounceTimeout, SelectionAppearance, SLOTS } from "./resources"; +import { + DragEvent, + connectSortableComponent, + disconnectSortableComponent, + SortableComponent, +} from "../../utils/sortableComponent"; import { SLOTS as STACK_SLOTS } from "../stack/resources"; const listItemSelector = "calcite-list-item"; +const listItemSelectorDirect = `:scope > calcite-list-item`; const parentSelector = "calcite-list-item-group, calcite-list-item"; import { @@ -36,6 +44,7 @@ import { setComponentLoaded, setUpLoadableComponent, } from "../../utils/loadable"; +import { HandleNudge } from "../handle/interfaces"; /** * A general purpose list that enables users to construct list items that conform to Calcite styling. @@ -49,7 +58,7 @@ import { styleUrl: "list.scss", shadow: true, }) -export class List implements InteractiveComponent, LoadableComponent { +export class List implements InteractiveComponent, LoadableComponent, SortableComponent { // -------------------------------------------------------------------------- // // Properties @@ -61,16 +70,33 @@ export class List implements InteractiveComponent, LoadableComponent { */ @Prop({ reflect: true }) disabled = false; + /** + * When provided, the method will be called to determine whether the element can move from the list. + */ + @Prop() canPull: (event: DragEvent) => boolean; + + /** + * When provided, the method will be called to determine whether the element can be added from another list. + */ + @Prop() canPut: (event: DragEvent) => boolean; + + /** + * When `true`, `calcite-list-item`s are sortable via a draggable button. + */ + @Prop({ reflect: true }) dragEnabled = false; + + /** + * The list's group identifier. + * + * To drag elements from one list into another, both lists must have the same group value. + */ + @Prop({ reflect: true }) group?: string; + /** * When `true`, an input appears at the top of the component that can be used by end users to filter `calcite-list-item`s. */ @Prop({ reflect: true }) filterEnabled = false; - @Watch("filterEnabled") - handleFilterEnabledChange(): void { - this.updateListItems(); - } - /** * The currently filtered `calcite-list-item`s. * @@ -137,9 +163,12 @@ export class List implements InteractiveComponent, LoadableComponent { */ @Prop({ reflect: true }) selectionAppearance: SelectionAppearance = "icon"; + @Watch("filterEnabled") + @Watch("group") + @Watch("dragEnabled") @Watch("selectionMode") @Watch("selectionAppearance") - handleSelectionAppearanceChange(): void { + handleListItemChange(): void { this.updateListItems(); } @@ -159,8 +188,22 @@ export class List implements InteractiveComponent, LoadableComponent { */ @Event({ cancelable: false }) calciteListFilter: EventEmitter; + /** + * Emitted when the order of the list has changed. + */ + @Event({ cancelable: false }) calciteListOrderChange: EventEmitter; + + /** + * Emitted when the default slot has changes in order to notify parent lists. + */ + @Event({ cancelable: false }) calciteInternalListDefaultSlotChange: EventEmitter; + @Listen("calciteInternalFocusPreviousItem") handleCalciteInternalFocusPreviousItem(event: CustomEvent): void { + if (this.parentListEl) { + return; + } + event.stopPropagation(); const { enabledListItems } = this; @@ -175,6 +218,10 @@ export class List implements InteractiveComponent, LoadableComponent { @Listen("calciteInternalListItemActive") handleCalciteInternalListItemActive(event: CustomEvent): void { + if (!!this.parentListEl) { + return; + } + event.stopPropagation(); const target = event.target as HTMLCalciteListItemElement; const { listItems } = this; @@ -186,11 +233,34 @@ export class List implements InteractiveComponent, LoadableComponent { @Listen("calciteListItemSelect") handleCalciteListItemSelect(): void { + if (!!this.parentListEl) { + return; + } + this.updateSelectedItems(true); } + @Listen("calciteInternalHandleChange") + handleCalciteInternalHandleChange(event: CustomEvent): void { + this.assistiveText = event.detail.message; + event.stopPropagation(); + } + + @Listen("calciteHandleNudge") + handleCalciteHandleNudge(event: CustomEvent): void { + if (!!this.parentListEl) { + return; + } + + this.handleNudgeEvent(event); + } + @Listen("calciteInternalListItemSelect") handleCalciteInternalListItemSelect(event: CustomEvent): void { + if (!!this.parentListEl) { + return; + } + event.stopPropagation(); const target = event.target as HTMLCalciteListItemElement; const { listItems, selectionMode } = this; @@ -204,10 +274,19 @@ export class List implements InteractiveComponent, LoadableComponent { @Listen("calciteInternalListItemChange") handleCalciteInternalListItemChange(event: CustomEvent): void { + if (!!this.parentListEl) { + return; + } + event.stopPropagation(); this.updateListItems(true); } + @Listen("calciteInternalListItemGroupDefaultSlotChange") + handleCalciteInternalListItemGroupDefaultSlotChange(event: CustomEvent): void { + event.stopPropagation(); + } + //-------------------------------------------------------------------------- // // Lifecycle @@ -215,13 +294,16 @@ export class List implements InteractiveComponent, LoadableComponent { //-------------------------------------------------------------------------- connectedCallback(): void { - this.mutationObserver?.observe(this.el, { childList: true, subtree: true }); + this.connectObserver(); this.updateListItems(); + this.setUpSorting(); connectInteractive(this); + this.parentListEl = this.el.parentElement.closest("calcite-list"); } disconnectedCallback(): void { - this.mutationObserver?.disconnect(); + this.disconnectObserver(); + disconnectSortableComponent(this); disconnectInteractive(this); } @@ -245,19 +327,29 @@ export class List implements InteractiveComponent, LoadableComponent { @Element() el: HTMLCalciteListElement; - listItems: HTMLCalciteListItemElement[] = []; + @State() assistiveText: string; + + @State() dataForFilter: ItemData = []; + + dragSelector = "calcite-list-item"; enabledListItems: HTMLCalciteListItemElement[] = []; - mutationObserver = createObserver("mutation", () => this.updateListItems()); + filterEl: HTMLCalciteFilterElement; - @State() dataForFilter: ItemData = []; + handleSelector = "calcite-handle"; + + @State() hasFilterActionsEnd = false; @State() hasFilterActionsStart = false; - @State() hasFilterActionsEnd = false; + listItems: HTMLCalciteListItemElement[] = []; - filterEl: HTMLCalciteFilterElement; + mutationObserver = createObserver("mutation", () => this.updateListItems()); + + parentListEl: HTMLCalciteListElement; + + sortable: Sortable; // -------------------------------------------------------------------------- // @@ -297,6 +389,11 @@ export class List implements InteractiveComponent, LoadableComponent { } = this; return (
+ {this.dragEnabled ? ( + + {this.assistiveText} + + ) : null} {loading ? : null} { - updateListItemChildren(getListItemChildren(event)); + updateListItemChildren(getListItemChildren(event.target as HTMLSlotElement)); + if (this.parentListEl) { + this.calciteInternalListDefaultSlotChange.emit(); + } }; private handleFilterActionsStartSlotChange = (event: Event): void => { @@ -484,11 +622,26 @@ export class List implements InteractiveComponent, LoadableComponent { }; private updateListItems = debounce((emit = false): void => { - const { selectionAppearance, selectionMode } = this; + const { selectionAppearance, selectionMode, dragEnabled } = this; + + if (!!this.parentListEl) { + const items = this.queryListItems(true); + + items.forEach((item) => { + item.selectionAppearance = selectionAppearance; + item.selectionMode = selectionMode; + item.dragHandle = dragEnabled; + }); + + this.setUpSorting(); + return; + } + const items = this.queryListItems(); items.forEach((item) => { item.selectionAppearance = selectionAppearance; item.selectionMode = selectionMode; + item.dragHandle = dragEnabled; }); this.listItems = items; if (this.filterEnabled) { @@ -501,10 +654,11 @@ export class List implements InteractiveComponent, LoadableComponent { this.enabledListItems = this.filteredItems.filter((item) => !item.disabled && !item.closed); this.setActiveListItem(); this.updateSelectedItems(emit); + this.setUpSorting(); }, debounceTimeout); - private queryListItems = (): HTMLCalciteListItemElement[] => { - return Array.from(this.el.querySelectorAll(listItemSelector)); + private queryListItems = (direct = false): HTMLCalciteListItemElement[] => { + return Array.from(this.el.querySelectorAll(direct ? listItemSelectorDirect : listItemSelector)); }; private focusRow = (focusEl: HTMLCalciteListItemElement): void => { @@ -530,7 +684,7 @@ export class List implements InteractiveComponent, LoadableComponent { }; private handleListKeydown = (event: KeyboardEvent): void => { - if (event.defaultPrevented) { + if (event.defaultPrevented || !!this.parentListEl) { return; } @@ -574,4 +728,70 @@ export class List implements InteractiveComponent, LoadableComponent { } } }; + + handleNudgeEvent(event: CustomEvent): void { + const { direction } = event.detail; + + const composedPath = event.composedPath(); + + const handle = composedPath.find( + (el: HTMLElement) => el.tagName === "CALCITE-HANDLE" + ) as HTMLCalciteHandleElement; + + const sortItem = composedPath.find( + (el: HTMLElement) => el.tagName === "CALCITE-LIST-ITEM" + ) as HTMLCalciteListItemElement; + + const parentEl = sortItem?.parentElement; + + if (!parentEl) { + return; + } + + const { enabledListItems } = this; + + const sameParentItems = enabledListItems.filter((item) => item.parentElement === parentEl); + + const lastIndex = sameParentItems.length - 1; + const startingIndex = sameParentItems.indexOf(sortItem); + let appendInstead = false; + let buddyIndex: number; + + if (direction === "up") { + if (startingIndex === 0) { + appendInstead = true; + } else { + buddyIndex = startingIndex - 1; + } + } else { + if (startingIndex === lastIndex) { + buddyIndex = 0; + } else if (startingIndex === lastIndex - 1) { + appendInstead = true; + } else { + buddyIndex = startingIndex + 2; + } + } + + this.disconnectObserver(); + + if (appendInstead) { + parentEl.appendChild(sortItem); + } else { + parentEl.insertBefore(sortItem, sameParentItems[buddyIndex]); + } + + this.updateListItems(); + this.connectObserver(); + + this.calciteListOrderChange.emit({ + dragEl: sortItem, + fromEl: null, + toEl: null, + }); + + handle.setFocus().then(() => { + handle.activated = true; + }); + } } diff --git a/packages/calcite-components/src/components/list/resources.ts b/packages/calcite-components/src/components/list/resources.ts index ab763af1d33..38640166296 100644 --- a/packages/calcite-components/src/components/list/resources.ts +++ b/packages/calcite-components/src/components/list/resources.ts @@ -5,6 +5,7 @@ export const CSS = { stack: "stack", tableContainer: "table-container", sticky: "sticky-pos", + assistiveText: "assistive-text", }; export const debounceTimeout = 0; diff --git a/packages/calcite-components/src/components/list/usage/DragEnabled.md b/packages/calcite-components/src/components/list/usage/DragEnabled.md new file mode 100644 index 00000000000..0009ab0e079 --- /dev/null +++ b/packages/calcite-components/src/components/list/usage/DragEnabled.md @@ -0,0 +1,16 @@ +```html + + + + + + + + + + + + + + +``` diff --git a/packages/calcite-components/src/components/list/usage/DragEnabledNested.md b/packages/calcite-components/src/components/list/usage/DragEnabledNested.md new file mode 100644 index 00000000000..bcc553fc4ee --- /dev/null +++ b/packages/calcite-components/src/components/list/usage/DragEnabledNested.md @@ -0,0 +1,19 @@ +```html + + + + + + + + + + + + + + + + + +``` diff --git a/packages/calcite-components/src/components/sortable-list/sortable-list.tsx b/packages/calcite-components/src/components/sortable-list/sortable-list.tsx index 4d2d5a61fb8..5883145855a 100644 --- a/packages/calcite-components/src/components/sortable-list/sortable-list.tsx +++ b/packages/calcite-components/src/components/sortable-list/sortable-list.tsx @@ -11,11 +11,10 @@ import { HandleNudge } from "../handle/interfaces"; import { Layout } from "../interfaces"; import { CSS } from "./resources"; import { + DragEvent, connectSortableComponent, disconnectSortableComponent, - onSortingStart, SortableComponent, - onSortingEnd, } from "../../utils/sortableComponent"; import { focusElement } from "../../utils/dom"; @@ -34,6 +33,16 @@ export class SortableList implements InteractiveComponent, SortableComponent { // // -------------------------------------------------------------------------- + /** + * When provided, the method will be called to determine whether the element can move from the list. + */ + @Prop() canPull: (event: DragEvent) => boolean; + + /** + * When provided, the method will be called to determine whether the element can be added from another list. + */ + @Prop() canPut: (event: DragEvent) => boolean; + /** * Specifies which items inside the element should be draggable. */ @@ -82,6 +91,8 @@ export class SortableList implements InteractiveComponent, SortableComponent { sortable: Sortable; + dragEnabled = true; + // -------------------------------------------------------------------------- // // Lifecycle @@ -126,6 +137,19 @@ export class SortableList implements InteractiveComponent, SortableComponent { // // -------------------------------------------------------------------------- + onDragStart(): void { + this.endObserving(); + } + + onDragEnd(): void { + this.beginObserving(); + } + + onDragSort(): void { + this.items = Array.from(this.el.children); + this.calciteListOrderChange.emit(); + } + handleNudgeEvent(event: CustomEvent): void { const { direction } = event.detail; @@ -177,33 +201,8 @@ export class SortableList implements InteractiveComponent, SortableComponent { } setUpSorting(): void { - const { dragSelector, group, handleSelector } = this; - this.items = Array.from(this.el.children); - - const sortableOptions: Sortable.Options = { - dataIdAttr: "id", - group, - handle: handleSelector, - onStart: () => { - this.endObserving(); - onSortingStart(this); - }, - onEnd: () => { - onSortingEnd(this); - this.beginObserving(); - }, - onUpdate: () => { - this.items = Array.from(this.el.children); - this.calciteListOrderChange.emit(); - }, - }; - - if (dragSelector) { - sortableOptions.draggable = dragSelector; - } - - connectSortableComponent(this, sortableOptions); + connectSortableComponent(this); } beginObserving(): void { diff --git a/packages/calcite-components/src/components/value-list/value-list.tsx b/packages/calcite-components/src/components/value-list/value-list.tsx index c44050e61a4..90f261abdb6 100644 --- a/packages/calcite-components/src/components/value-list/value-list.tsx +++ b/packages/calcite-components/src/components/value-list/value-list.tsx @@ -62,11 +62,10 @@ import { ValueListMessages } from "./assets/value-list/t9n"; import { CSS, ICON_TYPES } from "./resources"; import { getHandleAndItemElement, getScreenReaderText } from "./utils"; import { + DragEvent, connectSortableComponent, disconnectSortableComponent, - onSortingStart, SortableComponent, - onSortingEnd, } from "../../utils/sortableComponent"; import { focusElement } from "../../utils/dom"; @@ -101,6 +100,16 @@ export class ValueList< */ @Prop({ reflect: true }) disabled = false; + /** + * When provided, the method will be called to determine whether the element can move from the list. + */ + @Prop() canPull: (event: DragEvent) => boolean; + + /** + * When provided, the method will be called to determine whether the element can be added from another list. + */ + @Prop() canPut: (event: DragEvent) => boolean; + /** * When `true`, `calcite-value-list-item`s are sortable via a draggable button. */ @@ -218,6 +227,10 @@ export class ValueList< assistiveTextEl: HTMLSpanElement; + handleSelector = `.${CSS.handle}`; + + dragSelector = "calcite-value-list-item"; + // -------------------------------------------------------------------------- // // Lifecycle @@ -311,6 +324,20 @@ export class ValueList< // // -------------------------------------------------------------------------- + onDragStart(): void { + cleanUpObserver.call(this); + } + + onDragEnd(): void { + initializeObserver.call(this); + } + + onDragSort(): void { + this.items = Array.from(this.el.querySelectorAll("calcite-value-list-item")); + const values = this.items.map((item) => item.value); + this.calciteListOrderChange.emit(values); + } + getItems(): ItemElement[] { return Array.from(this.el.querySelectorAll("calcite-value-list-item")); } @@ -335,31 +362,13 @@ export class ValueList< }; setUpSorting(): void { - const { dragEnabled, group } = this; + const { dragEnabled } = this; if (!dragEnabled) { return; } - connectSortableComponent(this, { - dataIdAttr: "id", - group, - handle: `.${CSS.handle}`, - draggable: "calcite-value-list-item", - onStart: () => { - cleanUpObserver.call(this); - onSortingStart(this); - }, - onEnd: () => { - onSortingEnd(this); - initializeObserver.call(this); - }, - onUpdate: () => { - this.items = Array.from(this.el.querySelectorAll("calcite-value-list-item")); - const values = this.items.map((item) => item.value); - this.calciteListOrderChange.emit(values); - }, - }); + connectSortableComponent(this); } deselectRemovedItems = deselectRemovedItems.bind(this); diff --git a/packages/calcite-components/src/demos/list.html b/packages/calcite-components/src/demos/list.html index e440e0bc0fb..f66931df7b9 100644 --- a/packages/calcite-components/src/demos/list.html +++ b/packages/calcite-components/src/demos/list.html @@ -576,6 +576,79 @@

List

+ +
+
simple list drag enabled
+ +
+ + + + + + + + + + + + + + + + + + +
+
+ +
+
nested & draggable
+ +
+ + + + + + + + + + + + + + + + + +
+
diff --git a/packages/calcite-components/src/utils/sortableComponent.ts b/packages/calcite-components/src/utils/sortableComponent.ts index 15843b69167..c4133c9e3d7 100644 --- a/packages/calcite-components/src/utils/sortableComponent.ts +++ b/packages/calcite-components/src/utils/sortableComponent.ts @@ -3,6 +3,12 @@ import { containsCrossShadowBoundary } from "./dom"; const sortableComponentSet = new Set(); const inactiveSortableComponentSet = new WeakSet(); +export interface DragEvent { + toEl: HTMLElement; + fromEl: HTMLElement; + dragEl: HTMLElement; +} + /** * Defines interface for components with sorting functionality. */ @@ -12,19 +18,63 @@ export interface SortableComponent { */ readonly el: HTMLElement; + /** + * When `true`, dragging is enabled. + */ + dragEnabled: boolean; + + /** + * Specifies which items inside the element should be draggable. + */ + dragSelector?: string; + + /** + * The list's group identifier. + */ + group?: string; + + /** + * The selector for the handle elements. + */ + handleSelector: string; + /** * The Sortable instance. */ sortable: Sortable; + + /** + * Whether the element can move from the list. + */ + canPull: (event: DragEvent) => boolean; + + /** + * Whether the element can be added from another list. + */ + canPut: (event: DragEvent) => boolean; + + /** + * Called by any change to the list (add / update / remove). + */ + onDragSort: (event: Sortable.SortableEvent) => void; + + /** + * Element dragging started. + */ + onDragStart?: (event: Sortable.SortableEvent) => void; + + /** + * Element dragging ended. + */ + onDragEnd?: (event: Sortable.SortableEvent) => void; } /** * Helper to keep track of a SortableComponent. This should be called in the `connectedCallback` lifecycle method as well as any other method necessary to rebuild the sortable instance. * * @param {SortableComponent} component - The sortable component. - * @param {SortableComponent} [options] - Sortable options object. */ -export function connectSortableComponent(component: SortableComponent, options?: Sortable.Options): void { +export function connectSortableComponent(component: SortableComponent): void { disconnectSortableComponent(component); sortableComponentSet.add(component); @@ -32,7 +82,36 @@ export function connectSortableComponent(component: SortableComponent, options?: return; } - component.sortable = Sortable.create(component.el, options); + const dataIdAttr = "id"; + const { group, handleSelector: handle, dragSelector: draggable } = component; + + component.sortable = Sortable.create(component.el, { + dataIdAttr, + ...(!!draggable && { draggable }), + ...(!!group && { + group: { + name: group, + ...(!!component.canPull && { + pull: (to, from, dragEl) => component.canPull({ toEl: to.el, fromEl: from.el, dragEl }), + }), + ...(!!component.canPut && { + put: (to, from, dragEl) => component.canPut({ toEl: to.el, fromEl: from.el, dragEl }), + }), + }, + }), + handle, + onStart: (event) => { + onSortingStart(component); + component.onDragStart(event); + }, + onEnd: (event) => { + onSortingEnd(component); + component.onDragEnd(event); + }, + onSort: (event) => { + component.onDragSort(event); + }, + }); } /** @@ -62,7 +141,7 @@ function getNestedSortableComponents(activeComponent: SortableComponent): Sortab * * @param {SortableComponent} activeComponent - The active sortable component. */ -export function onSortingStart(activeComponent: SortableComponent): void { +function onSortingStart(activeComponent: SortableComponent): void { getNestedSortableComponents(activeComponent).forEach((component) => inactiveSortableComponentSet.add(component)); } @@ -71,6 +150,6 @@ export function onSortingStart(activeComponent: SortableComponent): void { * * @param {SortableComponent} activeComponent - The active sortable component. */ -export function onSortingEnd(activeComponent: SortableComponent): void { +function onSortingEnd(activeComponent: SortableComponent): void { getNestedSortableComponents(activeComponent).forEach((component) => inactiveSortableComponentSet.delete(component)); }