diff --git a/packages/calcite-components/src/components/block-group/block-group.e2e.ts b/packages/calcite-components/src/components/block-group/block-group.e2e.ts new file mode 100755 index 00000000000..c690a554aeb --- /dev/null +++ b/packages/calcite-components/src/components/block-group/block-group.e2e.ts @@ -0,0 +1,519 @@ +import { newE2EPage, E2EPage } from "@arcgis/lumina-compiler/puppeteerTesting"; +import { describe, expect, it } from "vitest"; +import { accessible, hidden, renders, focusable, disabled, defaults, reflects } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; +import { GlobalTestProps, dragAndDrop, findAll } from "../../tests/utils"; +import { DEBOUNCE } from "../../utils/resources"; +import { Reorder } from "../sort-handle/interfaces"; +import { SLOTS as BLOCK_SLOTS } from "../block/resources"; +import { Block } from "../block/block"; +import { BlockDragDetail } from "./interfaces"; +import type { BlockGroup } from "./block-group"; + +const blockHTML = html` +
+
content
+ +
`; + +describe("calcite-block-group", () => { + describe("defaults", () => { + defaults("calcite-block-group", [ + { + propertyName: "disabled", + defaultValue: false, + }, + { + propertyName: "dragEnabled", + defaultValue: false, + }, + { + propertyName: "group", + defaultValue: undefined, + }, + { + propertyName: "label", + defaultValue: undefined, + }, + { + propertyName: "loading", + defaultValue: false, + }, + ]); + }); + + describe("reflects", () => { + reflects("calcite-block-group", [ + { + propertyName: "disabled", + value: true, + }, + { + propertyName: "dragEnabled", + value: true, + }, + { + propertyName: "group", + value: "test", + }, + { + propertyName: "loading", + value: true, + }, + ]); + }); + + describe("renders", () => { + renders("calcite-block-group", { display: "block" }); + }); + + describe("is focusable", () => { + focusable(html` ${blockHTML} `, { + focusTargetSelector: "calcite-block", + }); + }); + + describe("honors hidden attribute", () => { + hidden("calcite-block-group"); + }); + + describe("accessible", () => { + accessible(html` ${blockHTML} `); + }); + + describe("disabled", () => { + disabled(html` ${blockHTML} `, { focusTarget: "child" }); + }); + + it("should set the dragHandle property on items", async () => { + const page = await newE2EPage(); + await page.setContent( + html` + + + + `, + ); + + await page.waitForChanges(); + await page.waitForTimeout(DEBOUNCE.nextTick); + + const items = await findAll(page, "calcite-block"); + + for (let i = 0; i < items.length; i++) { + expect(await items[i].getProperty("dragHandle")).toBe(true); + } + + const root = await page.find("#root"); + + root.setProperty("dragEnabled", false); + await page.waitForChanges(); + await page.waitForTimeout(DEBOUNCE.nextTick); + + for (let i = 0; i < items.length; i++) { + expect(await items[i].getProperty("dragHandle")).toBe(false); + } + }); + + describe("drag and drop", () => { + async function createSimpleBlockGroup(): Promise { + const page = await newE2EPage(); + await page.setContent( + html` + + + + `, + ); + await page.waitForChanges(); + await page.waitForTimeout(DEBOUNCE.nextTick); + return page; + } + + type TestWindow = GlobalTestProps<{ + calledTimes: number; + component1CalledTimes: number; + component2CalledTimes: number; + newIndex: number; + oldIndex: number; + fromEl: string; + toEl: string; + el: string; + startCalledTimes: number; + endCalledTimes: number; + endNewIndex: number; + endOldIndex: number; + startNewIndex: number; + startOldIndex: number; + }>; + + it("works using a mouse", async () => { + const page = await createSimpleBlockGroup(); + + // Workaround for page.spyOnEvent() failing due to drag event payload being serialized and there being circular JSON structures from the payload elements. See: https://github.com/Esri/calcite-design-system/issues/7643 + await page.$eval("calcite-block-group", (blockGroup: BlockGroup["el"]) => { + const testWindow = window as TestWindow; + testWindow.calledTimes = 0; + testWindow.newIndex = -1; + testWindow.oldIndex = -1; + testWindow.startCalledTimes = 0; + testWindow.endCalledTimes = 0; + blockGroup.addEventListener("calciteBlockGroupOrderChange", (event: CustomEvent) => { + testWindow.calledTimes++; + testWindow.newIndex = event.detail.newIndex; + testWindow.oldIndex = event.detail.oldIndex; + }); + blockGroup.addEventListener("calciteBlockGroupDragEnd", (event: CustomEvent) => { + testWindow.endCalledTimes++; + testWindow.endNewIndex = event.detail.newIndex; + testWindow.endOldIndex = event.detail.oldIndex; + }); + blockGroup.addEventListener("calciteBlockGroupDragStart", (event: CustomEvent) => { + testWindow.startCalledTimes++; + testWindow.startNewIndex = event.detail.newIndex; + testWindow.startOldIndex = event.detail.oldIndex; + }); + }); + + await dragAndDrop( + page, + { + element: `calcite-block[heading="one"]`, + shadow: "calcite-sort-handle", + }, + { + element: `calcite-block[heading="two"]`, + shadow: "calcite-sort-handle", + }, + ); + + const [first, second] = await findAll(page, "calcite-block"); + expect(await first.getProperty("heading")).toBe("two"); + expect(await second.getProperty("heading")).toBe("one"); + await page.waitForChanges(); + + const results = await page.evaluate(() => { + const testWindow = window as TestWindow; + + return { + calledTimes: testWindow.calledTimes, + oldIndex: testWindow.oldIndex, + newIndex: testWindow.newIndex, + endCalledTimes: testWindow.endCalledTimes, + startCalledTimes: testWindow.startCalledTimes, + endNewIndex: testWindow.endNewIndex, + endOldIndex: testWindow.endOldIndex, + startNewIndex: testWindow.startNewIndex, + startOldIndex: testWindow.startOldIndex, + }; + }); + + expect(results.calledTimes).toBe(1); + expect(results.startCalledTimes).toBe(1); + expect(results.endCalledTimes).toBe(1); + expect(results.oldIndex).toBe(0); + expect(results.newIndex).toBe(1); + expect(results.startNewIndex).toBe(null); + expect(results.startOldIndex).toBe(0); + expect(results.endNewIndex).toBe(1); + expect(results.endOldIndex).toBe(0); + }); + + it("supports dragging items between block groups", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + + + + + + + + + + + + + + + + + `); + + await page.waitForChanges(); + + // Workaround for page.spyOnEvent() failing due to drag event payload being serialized and there being circular JSON structures from the payload elements. See: https://github.com/Esri/calcite-design-system/issues/7643 + await page.evaluate(() => { + const testWindow = window as TestWindow; + testWindow.calledTimes = 0; + const blockGroups = document.querySelectorAll("calcite-block-group"); + blockGroups.forEach((blockGroup) => + blockGroup.addEventListener("calciteBlockGroupOrderChange", () => { + testWindow.calledTimes++; + }), + ); + }); + + await dragAndDrop( + page, + { + element: `calcite-block[heading="d"]`, + shadow: "calcite-sort-handle", + }, + { + element: `#first-letters`, + pointerPosition: { + vertical: "bottom", + }, + }, + ); + + await dragAndDrop( + page, + { + element: `calcite-block[heading="e"]`, + shadow: "calcite-sort-handle", + }, + { + element: `#numbers`, + pointerPosition: { + vertical: "bottom", + }, + }, + ); + + await dragAndDrop( + page, + { + element: `calcite-block[heading="e"]`, + shadow: "calcite-sort-handle", + }, + { + element: `#no-group`, + pointerPosition: { + vertical: "bottom", + }, + }, + ); + + const [first, second, third, fourth, fifth, sixth, seventh, eight, ninth] = await findAll(page, "calcite-block"); + expect(await first.getProperty("heading")).toBe("a"); + expect(await second.getProperty("heading")).toBe("b"); + expect(await third.getProperty("heading")).toBe("d"); + expect(await fourth.getProperty("heading")).toBe("1"); + expect(await fifth.getProperty("heading")).toBe("2"); + expect(await sixth.getProperty("heading")).toBe("no-group"); + expect(await seventh.getProperty("heading")).toBe("c"); + expect(await eight.getProperty("heading")).toBe("e"); + expect(await ninth.getProperty("heading")).toBe("f"); + + expect(await page.evaluate(() => (window as TestWindow).calledTimes)).toBe(2); + }); + + it("reorders using a keyboard", async () => { + const page = await createSimpleBlockGroup(); + + let totalMoves = 0; + + // Workaround for page.spyOnEvent() failing due to drag event payload being serialized and there being circular JSON structures from the payload elements. See: https://github.com/Esri/calcite-design-system/issues/7643 + await page.$eval("calcite-block-group", (blockGroup: BlockGroup["el"]) => { + const testWindow = window as TestWindow; + testWindow.calledTimes = 0; + blockGroup.addEventListener("calciteBlockGroupOrderChange", (event: CustomEvent) => { + testWindow.calledTimes++; + testWindow.newIndex = event.detail.newIndex; + testWindow.oldIndex = event.detail.oldIndex; + testWindow.fromEl = event.detail.fromEl.id; + testWindow.toEl = event.detail.toEl.id; + testWindow.el = event.detail.dragEl.id; + }); + }); + + async function assertReorder( + reorder: Reorder, + expectedOrder: string[], + newIndex: number, + oldIndex: number, + ): Promise { + const eventName = `calciteSortHandleReorder`; + const event = page.waitForEvent(eventName); + await page.$eval( + `calcite-block[heading="one"]`, + (item1: Block["el"], reorder, eventName) => { + item1.dispatchEvent(new CustomEvent(eventName, { detail: { reorder }, bubbles: true })); + }, + reorder, + eventName, + ); + await event; + await page.waitForChanges(); + const itemsAfter = await findAll(page, "calcite-block"); + expect(itemsAfter.length).toBe(3); + + for (let i = 0; i < itemsAfter.length; i++) { + expect(await itemsAfter[i].getProperty("heading")).toBe(expectedOrder[i]); + } + + const results = await page.evaluate(() => { + const testWindow = window as TestWindow; + + return { + calledTimes: testWindow.calledTimes, + oldIndex: testWindow.oldIndex, + newIndex: testWindow.newIndex, + fromEl: testWindow.fromEl, + toEl: testWindow.toEl, + el: testWindow.el, + }; + }); + + const id = "component1"; + + expect(results.calledTimes).toBe(++totalMoves); + expect(results.newIndex).toBe(newIndex); + expect(results.oldIndex).toBe(oldIndex); + expect(results.fromEl).toBe(id); + expect(results.toEl).toBe(id); + expect(results.el).toBe("one"); + } + + await assertReorder("down", ["two", "one", "three"], 1, 0); + await assertReorder("down", ["two", "three", "one"], 2, 1); + await assertReorder("down", ["two", "three", "one"], 2, 2); + + await assertReorder("up", ["two", "one", "three"], 1, 2); + await assertReorder("up", ["one", "two", "three"], 0, 1); + await assertReorder("up", ["one", "two", "three"], 0, 0); + + await assertReorder("bottom", ["two", "three", "one"], 2, 0); + await assertReorder("top", ["one", "two", "three"], 0, 2); + }); + + it("moves using a keyboard", async () => { + const page = await newE2EPage(); + const group = "my-group"; + await page.setContent( + html` + + + + + + `, + ); + await page.waitForChanges(); + await page.waitForTimeout(DEBOUNCE.nextTick); + + let component1Moves = 0; + let component2Moves = 0; + + // Workaround for page.spyOnEvent() failing due to drag event payload being serialized and there being circular JSON structures from the payload elements. See: https://github.com/Esri/calcite-design-system/issues/7643 + await page.$eval("#component1", (blockGroup: BlockGroup["el"]) => { + const testWindow = window as TestWindow; + testWindow.component1CalledTimes = 0; + blockGroup.addEventListener("calciteBlockGroupOrderChange", (event: CustomEvent) => { + testWindow.component1CalledTimes++; + testWindow.newIndex = event.detail.newIndex; + testWindow.oldIndex = event.detail.oldIndex; + testWindow.fromEl = event.detail.fromEl.id; + testWindow.toEl = event.detail.toEl.id; + testWindow.el = event.detail.dragEl.id; + }); + }); + + // Workaround for page.spyOnEvent() failing due to drag event payload being serialized and there being circular JSON structures from the payload elements. See: https://github.com/Esri/calcite-design-system/issues/7643 + await page.$eval("#component2", (blockGroup: BlockGroup["el"]) => { + const testWindow = window as TestWindow; + testWindow.component2CalledTimes = 0; + blockGroup.addEventListener("calciteBlockGroupOrderChange", (event: CustomEvent) => { + testWindow.component2CalledTimes++; + testWindow.newIndex = event.detail.newIndex; + testWindow.oldIndex = event.detail.oldIndex; + testWindow.fromEl = event.detail.fromEl.id; + testWindow.toEl = event.detail.toEl.id; + testWindow.el = event.detail.dragEl.id; + }); + }); + + async function assertMove( + componentItemId: string, + moveFromId: string, + moveToId: string, + component1Order: string[], + component2Order: string[], + newIndex: number, + oldIndex: number, + ): Promise { + const eventName = `calciteSortHandleMove`; + const event = page.waitForEvent(eventName); + await page.$eval( + `#${componentItemId}`, + (item: Block["el"], moveToId, eventName) => { + const element = document.querySelector(`#${moveToId}`); + item.dispatchEvent( + new CustomEvent(eventName, { + detail: { + moveTo: { + element, + id: element.id, + label: element.label, + }, + }, + bubbles: true, + }), + ); + }, + moveToId, + eventName, + ); + await event; + await page.waitForChanges(); + const component1Id = "component1"; + const component2Id = "component2"; + + const component1After = await findAll(page, `#${component1Id} calcite-block`); + expect(component1After.length).toBe(component1Order.length); + + for (let i = 0; i < component1After.length; i++) { + expect(await component1After[i].getProperty("heading")).toBe(component1Order[i]); + } + + const component2After = await findAll(page, `#${component2Id} calcite-block`); + expect(component2After.length).toBe(component2Order.length); + + for (let i = 0; i < component2After.length; i++) { + expect(await component2After[i].getProperty("heading")).toBe(component2Order[i]); + } + + const results = await page.evaluate(() => { + const testWindow = window as TestWindow; + + return { + component1CalledTimes: testWindow.component1CalledTimes, + component2CalledTimes: testWindow.component2CalledTimes, + oldIndex: testWindow.oldIndex, + newIndex: testWindow.newIndex, + fromEl: testWindow.fromEl, + toEl: testWindow.toEl, + el: testWindow.el, + }; + }); + + expect(results.component1CalledTimes).toBe(moveFromId === component1Id ? ++component1Moves : component1Moves); + expect(results.component2CalledTimes).toBe(moveFromId === component2Id ? ++component2Moves : component2Moves); + expect(results.newIndex).toBe(newIndex); + expect(results.oldIndex).toBe(oldIndex); + expect(results.fromEl).toBe(moveFromId); + expect(results.toEl).toBe(moveToId); + expect(results.el).toBe(componentItemId); + } + + await assertMove("one", "component1", "component2", ["two"], ["one", "three"], 0, 0); + await assertMove("three", "component2", "component1", ["three", "two"], ["one"], 0, 1); + }); + }); +}); diff --git a/packages/calcite-components/src/components/block-group/block-group.scss b/packages/calcite-components/src/components/block-group/block-group.scss new file mode 100755 index 00000000000..b1796796b28 --- /dev/null +++ b/packages/calcite-components/src/components/block-group/block-group.scss @@ -0,0 +1,15 @@ +:host { + @apply block; +} + +.container { + position: relative; +} + +.assistive-text { + @apply sr-only; +} + +@include base-component(); + +@include disabled(); diff --git a/packages/calcite-components/src/components/block-group/block-group.stories.ts b/packages/calcite-components/src/components/block-group/block-group.stories.ts new file mode 100644 index 00000000000..387ddf9aac1 --- /dev/null +++ b/packages/calcite-components/src/components/block-group/block-group.stories.ts @@ -0,0 +1,80 @@ +import { boolean } from "../../../.storybook/utils"; +import { html } from "../../../support/formatting"; +import { BlockGroup } from "./block-group"; + +type BlockGroupStoryArgs = Pick; + +export default { + title: "Components/Block Group", + args: { + disabled: false, + dragEnabled: false, + group: "", + label: "My Group", + loading: false, + }, + parameters: { + chromatic: { + delay: 500, + }, + }, +}; + +const blockHTML = html`My block content! + My block content! + My block content! + My block content! + My block content! + My block content! + My block content!`; + +export const simple = (args: BlockGroupStoryArgs): string => html` + + ${blockHTML} + +`; + +export const dragEnabled = (): string => html` + ${blockHTML} +`; + +export const sortHandleOpen = (): string => html` + + My block content! + ${blockHTML} + +`; + +export const loading = (): string => html` + ${blockHTML} +`; + +export const disabled = (): string => html` + ${blockHTML} +`; diff --git a/packages/calcite-components/src/components/block-group/block-group.tsx b/packages/calcite-components/src/components/block-group/block-group.tsx new file mode 100755 index 00000000000..af7a698d126 --- /dev/null +++ b/packages/calcite-components/src/components/block-group/block-group.tsx @@ -0,0 +1,393 @@ +// @ts-strict-ignore +import Sortable from "sortablejs"; +import { debounce } from "lodash-es"; +import { PropertyValues } from "lit"; +import { LitElement, property, createEvent, h, method, state, JsxNode } from "@arcgis/lumina"; +import { + InteractiveComponent, + InteractiveContainer, + updateHostInteraction, +} from "../../utils/interactive"; +import { createObserver } from "../../utils/observers"; +import { + connectSortableComponent, + disconnectSortableComponent, + SortableComponent, +} from "../../utils/sortableComponent"; +import { componentFocusable } from "../../utils/loadable"; +import { MoveEventDetail, MoveTo, ReorderEventDetail } from "../sort-handle/interfaces"; +import { DEBOUNCE } from "../../utils/resources"; +import { Block } from "../block/block"; +import { focusFirstTabbable, getRootNode } from "../../utils/dom"; +import { guid } from "../../utils/guid"; +import { isBlock } from "../block/utils"; +import { blockGroupSelector, blockSelector, CSS } from "./resources"; +import { styles } from "./block-group.scss"; +import { BlockDragDetail } from "./interfaces"; +import { updateBlockChildren } from "./utils"; + +declare global { + interface DeclareElements { + "calcite-block-group": BlockGroup; + } +} + +/** + * @slot - A slot for adding `calcite-block` elements. + */ +export class BlockGroup extends LitElement implements InteractiveComponent, SortableComponent { + // #region Static Members + + static override styles = styles; + + // #endregion + + // #region Private Properties + + dragSelector = blockSelector; + + handleSelector = "calcite-sort-handle"; + + mutationObserver = createObserver("mutation", () => { + this.updateBlockItems(); + }); + + private parentBlockGroupEl: BlockGroup["el"]; + + sortable: Sortable; + + private updateBlockItems = debounce((): void => { + this.updateGroupItems(); + const { dragEnabled, el, moveToItems } = this; + + const items = Array.from(this.el.querySelectorAll(blockSelector)); + + items.forEach((item) => { + if (item.closest(blockGroupSelector) === el) { + item.moveToItems = moveToItems.filter( + (moveToItem) => moveToItem.element !== el && !item.contains(moveToItem.element), + ); + item.dragHandle = dragEnabled; + } + }); + + this.setUpSorting(); + }, DEBOUNCE.nextTick); + + // #endregion + + // #region State Properties + + @state() assistiveText: string; + + @state() moveToItems: MoveTo[] = []; + + // #endregion + + // #region Public Properties + + /** When provided, the method will be called to determine whether the element is sortable in the component. */ + @property() canPull: (detail: BlockDragDetail) => boolean; + + /** When provided, the method will be called to determine whether the element can be added from another component. */ + @property() canPut: (detail: BlockDragDetail) => boolean; + + /** When `true`, interaction is prevented and the component is displayed with lower opacity. */ + @property({ reflect: true }) disabled = false; + + /** When `true`, `calcite-block`s are sortable via a draggable button. */ + @property({ reflect: true }) dragEnabled = false; + + /** + * The block-group's group identifier. + * + * To drag elements from one group into another, both groups must have the same group value. + */ + @property({ reflect: true }) group?: string; + + /** + * Specifies an accessible name for the component. + * + * When `dragEnabled` is `true` and multiple group sorting is enabled with `group`, specifies the component's name for dragging between groups. + * + * @required + */ + @property() label: string; + + /** When `true`, a busy indicator is displayed. */ + @property({ reflect: true }) loading = false; + + // #endregion + + // #region Public Methods + + /** + * Sets focus on the component's first focusable element. + * + * @returns {Promise} + */ + @method() + async setFocus(): Promise { + await componentFocusable(this); + + focusFirstTabbable(this.el); + } + + // #endregion + + // #region Events + + /** Fires when the component's dragging has ended. */ + calciteBlockGroupDragEnd = createEvent({ cancelable: false }); + + /** Fires when the component's dragging has started. */ + calciteBlockGroupDragStart = createEvent({ cancelable: false }); + + /** Fires when the component's item order changes. */ + calciteBlockGroupOrderChange = createEvent({ cancelable: false }); + + // #endregion + + // #region Lifecycle + + constructor() { + super(); + + this.listen( + "calciteInternalAssistiveTextChange", + this.handleCalciteInternalAssistiveTextChange, + ); + this.listen("calciteSortHandleReorder", this.handleSortReorder); + this.listen("calciteSortHandleMove", this.handleSortMove); + } + + override connectedCallback(): void { + this.connectObserver(); + this.updateBlockItems(); + this.setUpSorting(); + this.setParentBlockGroup(); + } + + override willUpdate(changes: PropertyValues): void { + if ( + changes.has("group") || + (changes.has("dragEnabled") && (this.hasUpdated || this.dragEnabled !== false)) + ) { + this.updateBlockItems(); + } + } + + override updated(): void { + updateHostInteraction(this); + } + + override disconnectedCallback(): void { + this.disconnectObserver(); + disconnectSortableComponent(this); + } + + // #endregion + + // #region Private Methods + + private updateGroupItems(): void { + const { el, group } = this; + + const rootNode = getRootNode(el); + + const blockGroups = group + ? Array.from( + rootNode.querySelectorAll(`${blockGroupSelector}[group="${group}"]`), + ).filter((blockGroup) => !blockGroup.disabled && blockGroup.dragEnabled) + : []; + + this.moveToItems = blockGroups.map((element) => ({ + element, + label: element.label ?? element.id, + id: el.id || guid(), + })); + } + + private handleCalciteInternalAssistiveTextChange(event: CustomEvent): void { + this.assistiveText = event.detail.message; + event.stopPropagation(); + } + + private handleSortReorder(event: CustomEvent): void { + if (this.parentBlockGroupEl) { + return; + } + + this.handleReorder(event); + } + + private handleSortMove(event: CustomEvent): void { + if (this.parentBlockGroupEl) { + return; + } + + this.handleMove(event); + } + + private connectObserver(): void { + this.mutationObserver?.observe(this.el, { childList: true, subtree: true }); + } + + private disconnectObserver(): void { + this.mutationObserver?.disconnect(); + } + + private setUpSorting(): void { + const { dragEnabled } = this; + + if (!dragEnabled) { + return; + } + + connectSortableComponent(this); + } + + onGlobalDragStart(): void { + this.disconnectObserver(); + } + + onGlobalDragEnd(): void { + this.connectObserver(); + } + + onDragEnd(detail: BlockDragDetail): void { + this.calciteBlockGroupDragEnd.emit(detail); + } + + onDragStart(detail: BlockDragDetail): void { + detail.dragEl.sortHandleOpen = false; + this.calciteBlockGroupDragStart.emit(detail); + } + + onDragSort(detail: BlockDragDetail): void { + this.setParentBlockGroup(); + this.updateBlockItems(); + + this.calciteBlockGroupOrderChange.emit(detail); + } + + private setParentBlockGroup(): void { + this.parentBlockGroupEl = this.el.parentElement?.closest(blockGroupSelector); + } + + private handleDefaultSlotChange(event: Event): void { + updateBlockChildren(event.target as HTMLSlotElement); + } + + private handleMove(event: CustomEvent): void { + const { moveTo } = event.detail; + + const dragEl = event.target as Block["el"]; + const fromEl = dragEl?.parentElement as BlockGroup["el"]; + const toEl = moveTo.element as BlockGroup["el"]; + const fromElItems = Array.from(fromEl.children).filter(isBlock); + const oldIndex = fromElItems.indexOf(dragEl); + + if (!fromEl) { + return; + } + + dragEl.sortHandleOpen = false; + + this.disconnectObserver(); + + toEl.prepend(dragEl); + const toElItems = Array.from(toEl.children).filter(isBlock); + const newIndex = toElItems.indexOf(dragEl); + + this.updateBlockItems(); + this.connectObserver(); + + this.calciteBlockGroupOrderChange.emit({ + dragEl, + fromEl, + toEl, + newIndex, + oldIndex, + }); + } + + private handleReorder(event: CustomEvent): void { + const { reorder } = event.detail; + + const dragEl = event.target as Block["el"]; + const parentEl = dragEl?.parentElement as BlockGroup["el"]; + + if (!parentEl) { + return; + } + + dragEl.sortHandleOpen = false; + + const sameParentItems = Array.from(parentEl.children).filter(isBlock); + + const lastIndex = sameParentItems.length - 1; + const oldIndex = sameParentItems.indexOf(dragEl); + let newIndex: number = oldIndex; + + switch (reorder) { + case "top": + newIndex = 0; + break; + case "bottom": + newIndex = lastIndex; + break; + case "up": + newIndex = oldIndex === 0 ? 0 : oldIndex - 1; + break; + case "down": + newIndex = oldIndex === lastIndex ? lastIndex : oldIndex + 1; + break; + } + + this.disconnectObserver(); + + const referenceEl = + reorder === "up" || reorder === "top" + ? sameParentItems[newIndex] + : sameParentItems[newIndex].nextSibling; + + parentEl.insertBefore(dragEl, referenceEl); + + this.updateBlockItems(); + this.connectObserver(); + + this.calciteBlockGroupOrderChange.emit({ + dragEl, + fromEl: parentEl, + toEl: parentEl, + newIndex, + oldIndex, + }); + } + + // #endregion + + // #region Rendering + + override render(): JsxNode { + const { loading, label } = this; + return ( + +
+ {this.dragEnabled ? ( + + {this.assistiveText} + + ) : null} + {loading ? : null} +
+ +
+
+
+ ); + } + + // #endregion +} diff --git a/packages/calcite-components/src/components/block-group/interfaces.ts b/packages/calcite-components/src/components/block-group/interfaces.ts new file mode 100644 index 00000000000..53dba8e8442 --- /dev/null +++ b/packages/calcite-components/src/components/block-group/interfaces.ts @@ -0,0 +1,16 @@ +import { DragDetail, MoveDetail } from "../../utils/sortableComponent"; +import type { Block } from "../block/block"; +import type { BlockGroup } from "./block-group"; + +export interface BlockDragDetail extends DragDetail { + toEl: BlockGroup["el"]; + fromEl: BlockGroup["el"]; + dragEl: Block["el"]; +} + +export interface BlockMoveDetail extends MoveDetail { + toEl: BlockGroup["el"]; + fromEl: BlockGroup["el"]; + dragEl: Block["el"]; + relatedEl: Block["el"]; +} diff --git a/packages/calcite-components/src/components/block-group/resources.ts b/packages/calcite-components/src/components/block-group/resources.ts new file mode 100644 index 00000000000..040e205cd00 --- /dev/null +++ b/packages/calcite-components/src/components/block-group/resources.ts @@ -0,0 +1,10 @@ +export const CSS = { + container: "container", + groupContainer: "group-container", + scrim: "scrim", + assistiveText: "assistive-text", +}; + +export const blockGroupSelector = "calcite-block-group"; + +export const blockSelector = "calcite-block"; diff --git a/packages/calcite-components/src/components/block-group/utils.ts b/packages/calcite-components/src/components/block-group/utils.ts new file mode 100644 index 00000000000..9dbf29de59e --- /dev/null +++ b/packages/calcite-components/src/components/block-group/utils.ts @@ -0,0 +1,13 @@ +import { Block } from "../block/block"; +import { blockSelector } from "./resources"; + +export function updateBlockChildren(slotEl: HTMLSlotElement): void { + const blockChildren = slotEl + .assignedElements({ flatten: true }) + .filter((el): el is Block["el"] => el.matches(blockSelector)); + + blockChildren.forEach((block) => { + block.setPosition = blockChildren.indexOf(block) + 1; + block.setSize = blockChildren.length; + }); +} diff --git a/packages/calcite-components/src/components/block/block.e2e.ts b/packages/calcite-components/src/components/block/block.e2e.ts index b5d093b745d..80b18ff5eee 100644 --- a/packages/calcite-components/src/components/block/block.e2e.ts +++ b/packages/calcite-components/src/components/block/block.e2e.ts @@ -36,6 +36,10 @@ describe("calcite-block", () => { propertyName: "collapsible", defaultValue: false, }, + { + propertyName: "dragDisabled", + defaultValue: false, + }, { propertyName: "headingLevel", defaultValue: undefined, @@ -56,6 +60,10 @@ describe("calcite-block", () => { propertyName: "menuFlipPlacements", defaultValue: undefined, }, + { + propertyName: "sortHandleOpen", + defaultValue: false, + }, ]); }); @@ -81,6 +89,14 @@ describe("calcite-block", () => { propertyName: "menuPlacement", value: "bottom", }, + { + propertyName: "dragDisabled", + value: true, + }, + { + propertyName: "sortHandleOpen", + value: true, + }, ]); }); diff --git a/packages/calcite-components/src/components/block/block.stories.ts b/packages/calcite-components/src/components/block/block.stories.ts index b9b3da25997..572be6b3d7d 100644 --- a/packages/calcite-components/src/components/block/block.stories.ts +++ b/packages/calcite-components/src/components/block/block.stories.ts @@ -11,7 +11,16 @@ const { toggleDisplay } = ATTRIBUTES; interface BlockStoryArgs extends Pick< Block, - "heading" | "description" | "open" | "collapsible" | "loading" | "disabled" | "headingLevel" | "menuPlacement" + | "heading" + | "description" + | "open" + | "collapsible" + | "loading" + | "disabled" + | "headingLevel" + | "menuPlacement" + | "dragDisabled" + | "sortHandleOpen" >, Pick { text: string; @@ -28,6 +37,8 @@ export default { collapsible: true, loading: false, disabled: false, + dragDisabled: false, + sortHandleOpen: false, headingLevel: 2, text: "Animals", sectionOpen: true, @@ -57,6 +68,8 @@ export const simple = (args: BlockStoryArgs): string => html` ${boolean("collapsible", args.collapsible)} ${boolean("loading", args.loading)} ${boolean("disabled", args.disabled)} + ${boolean("drag-disabled", args.dragDisabled)} + ${boolean("sort-handle-open", args.dragDisabled)} heading-level="${args.headingLevel}" > (); + /** + * Sets the item to display a border. + * + * @private + */ + @property() moveToItems: MoveTo[] = []; + /** When `true`, expands the component and its contents. */ @property({ reflect: true }) open = false; @@ -143,6 +161,23 @@ export class Block */ @property({ reflect: true }) overlayPositioning: OverlayPositioning = "absolute"; + /** + * Used to determine what menu options are available in the sort-handle + * + * @private + */ + @property() setPosition: number = null; + + /** + * Used to determine what menu options are available in the sort-handle + * + * @private + */ + @property() setSize: number = null; + + /** When `true`, displays and positions the sort handle. */ + @property({ reflect: true }) sortHandleOpen = false; + /** * Displays a status-related indicator icon. * @@ -177,6 +212,18 @@ export class Block /** Fires when the component is open and animation is complete. */ calciteBlockOpen = createEvent({ cancelable: false }); + /** Fires when the sort handle is requested to be closed and before the closing transition begins. */ + calciteBlockSortHandleBeforeClose = createEvent({ cancelable: false }); + + /** Fires when the sort handle is added to the DOM but not rendered, and before the opening transition begins. */ + calciteBlockSortHandleBeforeOpen = createEvent({ cancelable: false }); + + /** Fires when the sort handle is closed and animation is complete. */ + calciteBlockSortHandleClose = createEvent({ cancelable: false }); + + /** Fires when the sort handle is open and animation is complete. */ + calciteBlockSortHandleOpen = createEvent({ cancelable: false }); + /** * Fires when the component's header is clicked. * @@ -210,6 +257,10 @@ export class Block if (changes.has("open") && (this.hasUpdated || this.open !== false)) { onToggleOpenCloseComponent(this); } + + if (changes.has("sortHandleOpen") && (this.hasUpdated || this.sortHandleOpen !== false)) { + this.sortHandleOpenHandler(); + } } override updated(): void { @@ -239,6 +290,42 @@ export class Block this.calciteBlockClose.emit(); } + private sortHandleOpenHandler(): void { + if (!this.sortHandleEl) { + return; + } + + // we set the property instead of the attribute to ensure open/close events are emitted properly + this.sortHandleEl.open = this.sortHandleOpen; + } + + private setSortHandleEl(el: SortHandle["el"]): void { + this.sortHandleEl = el; + this.sortHandleOpenHandler(); + } + + private handleSortHandleBeforeOpen(event: CustomEvent): void { + event.stopPropagation(); + this.calciteBlockSortHandleBeforeOpen.emit(); + } + + private handleSortHandleBeforeClose(event: CustomEvent): void { + event.stopPropagation(); + this.calciteBlockSortHandleBeforeClose.emit(); + } + + private handleSortHandleClose(event: CustomEvent): void { + event.stopPropagation(); + this.sortHandleOpen = false; + this.calciteBlockSortHandleClose.emit(); + } + + private handleSortHandleOpen(event: CustomEvent): void { + event.stopPropagation(); + this.sortHandleOpen = true; + this.calciteBlockSortHandleOpen.emit(); + } + private onHeaderClick(): void { this.open = !this.open; this.calciteBlockToggle.emit(); @@ -367,6 +454,10 @@ export class Block description, menuFlipPlacements, menuPlacement, + moveToItems, + setPosition, + setSize, + dragDisabled, } = this; const toggleLabel = open ? messages.collapse : messages.expand; @@ -387,7 +478,21 @@ export class Block const headerNode = (
- {this.dragHandle ? : null} + {this.dragHandle ? ( + + ) : null} {collapsible ? (