Skip to content

feat(block-group, list): Add event for when a move is halted due to canPut or canPull returning false #11567

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Apr 2, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ describe("calcite-block-group", () => {
endOldIndex: number;
startNewIndex: number;
startOldIndex: number;
moveHaltNewIndex: number;
moveHaltOldIndex: number;
moveHaltCalledTimes: number;
}>;

it("works using a mouse", async () => {
Expand Down Expand Up @@ -346,6 +349,87 @@ describe("calcite-block-group", () => {
expect(await page.evaluate(() => (window as TestWindow).calledTimes)).toBe(2);
});

it("calls canPull and canPut for move items", async () => {
const page = await newE2EPage();
await page.setContent(html`
<calcite-block-group id="first-letters" drag-enabled group="letters">
<calcite-block id="a" heading="a" label="A"></calcite-block>
<calcite-block id="b" heading="b" label="B"></calcite-block>
</calcite-block-group>
<calcite-block-group id="second-letters" drag-enabled group="letters">
<calcite-block id="c" heading="c" label="C"></calcite-block>
<calcite-block id="d" heading="d" label="D"></calcite-block>
</calcite-block-group>
`);

// 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.moveHaltCalledTimes = 0;
const firstLetters = document.getElementById("first-letters") as BlockGroup["el"];

firstLetters.addEventListener("calciteBlockGroupMoveHalt", (event: CustomEvent<BlockDragDetail>) => {
testWindow.moveHaltCalledTimes++;
testWindow.moveHaltNewIndex = event.detail.newIndex;
testWindow.moveHaltOldIndex = event.detail.oldIndex;
});

firstLetters.canPull = ({ dragEl }) => dragEl.id === "b";
firstLetters.canPut = ({ dragEl }) => dragEl.id === "c";
});
await page.waitForChanges();

async function clickMoveDropdownItem(id: string) {
const component = await page.find(`#${id}`);
component.setProperty("sortHandleOpen", true);
await page.waitForChanges();

const dropdownItem = await page.find(`#${id} >>> calcite-dropdown-group:last-child calcite-dropdown-item`);
expect(dropdownItem).not.toBeNull();
await dropdownItem.click();

await page.waitForChanges();
}

async function getResults() {
return await page.evaluate(() => {
const testWindow = window as TestWindow;

return {
moveHaltCalledTimes: testWindow.moveHaltCalledTimes,
moveHaltOldIndex: testWindow.moveHaltOldIndex,
moveHaltNewIndex: testWindow.moveHaltNewIndex,
};
});
}

await clickMoveDropdownItem("a");
let results = await getResults();

expect(results.moveHaltCalledTimes).toBe(1);
expect(results.moveHaltNewIndex).toBe(0);
expect(results.moveHaltOldIndex).toBe(0);

await clickMoveDropdownItem("b");
results = await getResults();

expect(results.moveHaltCalledTimes).toBe(1);
expect(results.moveHaltNewIndex).toBe(0);
expect(results.moveHaltNewIndex).toBe(0);

await clickMoveDropdownItem("c");
results = await getResults();

expect(results.moveHaltCalledTimes).toBe(1);

await clickMoveDropdownItem("d");
results = await getResults();

expect(results.moveHaltCalledTimes).toBe(2);
expect(results.moveHaltNewIndex).toBe(0);
expect(results.moveHaltOldIndex).toBe(1);
});

it("reorders using a keyboard", async () => {
const page = await createSimpleBlockGroup();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ export class BlockGroup extends LitElement implements InteractiveComponent, Sort
focusFirstTabbable(this.el);
}

/**
* Emits a `calciteBlockGroupMoveHalt` event.
*
* @private
* @param dragDetail
*/
@method()
putFailed(dragDetail: BlockDragDetail): void {
this.calciteBlockGroupMoveHalt.emit(dragDetail);
}

// #endregion

// #region Events
Expand All @@ -146,6 +157,9 @@ export class BlockGroup extends LitElement implements InteractiveComponent, Sort
/** Fires when the component's item order changes. */
calciteBlockGroupOrderChange = createEvent<BlockDragDetail>({ cancelable: false });

/** Fires when a user attempts to move an element using the sort menu and 'canPut' or 'canPull' returns falsy. */
calciteBlockGroupMoveHalt = createEvent<BlockDragDetail>({ cancelable: false });

// #endregion

// #region Lifecycle
Expand Down Expand Up @@ -287,18 +301,43 @@ export class BlockGroup extends LitElement implements InteractiveComponent, Sort
const toEl = moveTo.element as BlockGroup["el"];
const fromElItems = Array.from(fromEl.children).filter(isBlock);
const oldIndex = fromElItems.indexOf(dragEl);
const newIndex = 0;

if (!fromEl) {
return;
}

if (
fromEl.canPull?.({
toEl,
fromEl,
dragEl,
newIndex,
oldIndex,
}) === false
) {
this.calciteBlockGroupMoveHalt.emit({ toEl, fromEl, dragEl, oldIndex, newIndex });
return;
}

if (
toEl.canPut?.({
toEl,
fromEl,
dragEl,
newIndex,
oldIndex,
}) === false
) {
toEl.putFailed({ toEl, fromEl, dragEl, oldIndex, newIndex });
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();
Expand Down
86 changes: 86 additions & 0 deletions packages/calcite-components/src/components/list/list.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,9 @@ describe("calcite-list", () => {
endOldIndex: number;
startNewIndex: number;
startOldIndex: number;
moveHaltNewIndex: number;
moveHaltOldIndex: number;
moveHaltCalledTimes: number;
}>;

it("works using a mouse", async () => {
Expand Down Expand Up @@ -1754,6 +1757,89 @@ describe("calcite-list", () => {
expect(await page.evaluate(() => (window as TestWindow).calledTimes)).toBe(2);
});

it("calls canPull and canPut for move items", async () => {
const page = await newE2EPage();
await page.setContent(html`
<calcite-list id="first-letters" drag-enabled group="letters">
<calcite-list-item id="a" heading="a" label="A"></calcite-list-item>
<calcite-list-item id="b" heading="b" label="B"></calcite-list-item>
</calcite-list>
<calcite-list id="second-letters" drag-enabled group="letters">
<calcite-list-item id="c" heading="c" label="C"></calcite-list-item>
<calcite-list-item id="d" heading="d" label="D"></calcite-list-item>
</calcite-list>
`);

// 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.moveHaltCalledTimes = 0;
const firstLetters = document.getElementById("first-letters") as List["el"];

firstLetters.addEventListener("calciteListMoveHalt", (event: CustomEvent<ListDragDetail>) => {
testWindow.moveHaltCalledTimes++;
testWindow.moveHaltNewIndex = event.detail.newIndex;
testWindow.moveHaltOldIndex = event.detail.oldIndex;
});

firstLetters.canPull = ({ dragEl }) => dragEl.id === "b";
firstLetters.canPut = ({ dragEl }) => dragEl.id === "c";
});
await page.waitForChanges();

async function clickMoveDropdownItem(id: string) {
const component = await page.find(`#${id}`);
component.setProperty("sortHandleOpen", true);
await page.waitForChanges();

const dropdownItem = await page.find(`#${id} >>> calcite-dropdown-group:last-child calcite-dropdown-item`);
expect(dropdownItem).not.toBeNull();
await dropdownItem.click();

await page.waitForChanges();
}

async function getResults() {
return await page.evaluate(() => {
const testWindow = window as TestWindow;

return {
moveHaltCalledTimes: testWindow.moveHaltCalledTimes,
moveHaltOldIndex: testWindow.moveHaltOldIndex,
moveHaltNewIndex: testWindow.moveHaltNewIndex,
};
});
}

await clickMoveDropdownItem("a");
let results = await getResults();

expect(results.moveHaltCalledTimes).toBe(1);
expect(results.moveHaltNewIndex).toBe(0);
expect(results.moveHaltOldIndex).toBe(0);

await clickMoveDropdownItem("b");
results = await getResults();

expect(results.moveHaltCalledTimes).toBe(1);
expect(results.moveHaltNewIndex).toBe(0);
expect(results.moveHaltOldIndex).toBe(0);

await clickMoveDropdownItem("c");
results = await getResults();

expect(results.moveHaltCalledTimes).toBe(1);
expect(results.moveHaltNewIndex).toBe(0);
expect(results.moveHaltOldIndex).toBe(0);

await clickMoveDropdownItem("d");
results = await getResults();

expect(results.moveHaltCalledTimes).toBe(2);
expect(results.moveHaltNewIndex).toBe(0);
expect(results.moveHaltOldIndex).toBe(1);
});

it("reorders using a keyboard", async () => {
const page = await createSimpleList();

Expand Down
44 changes: 41 additions & 3 deletions packages/calcite-components/src/components/list/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,17 @@ export class List extends LitElement implements InteractiveComponent, SortableCo

// #region Public Methods

/**
* Emits a `calciteListMoveHalt` event.
*
* @private
* @param dragDetail
*/
@method()
putFailed(dragDetail: ListDragDetail): void {
this.calciteListMoveHalt.emit(dragDetail);
}

/**
* Sets focus on the component's first focusable element.
*
Expand Down Expand Up @@ -372,6 +383,9 @@ export class List extends LitElement implements InteractiveComponent, SortableCo
/** Fires when the component's item order changes. */
calciteListOrderChange = createEvent<ListDragDetail>({ cancelable: false });

/** Fires when a user attempts to move an element using the sort menu and 'canPut' or 'canPull' returns falsy. */
calciteListMoveHalt = createEvent<ListDragDetail>({ cancelable: false });

// #endregion

// #region Lifecycle
Expand Down Expand Up @@ -937,20 +951,44 @@ export class List extends LitElement implements InteractiveComponent, SortableCo
const toEl = moveTo.element as List["el"];
const fromElItems = Array.from(fromEl.children).filter(isListItem);
const oldIndex = fromElItems.indexOf(dragEl);
const newIndex = 0;

if (!fromEl) {
return;
}

if (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is shared, let’s create a utility for both list and block-list to use, and do the same for their tests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do this as part of a new refactor issue?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, but let’s make it a priority to unify these paths as soon as possible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created #11849

fromEl.canPull?.({
toEl,
fromEl,
dragEl,
newIndex,
oldIndex,
}) === false
) {
this.calciteListMoveHalt.emit({ toEl, fromEl, dragEl, oldIndex, newIndex });
return;
}

if (
toEl.canPut?.({
toEl,
fromEl,
dragEl,
newIndex,
oldIndex,
}) === false
) {
toEl.putFailed({ toEl, fromEl, dragEl, oldIndex, newIndex });
return;
}

dragEl.sortHandleOpen = false;

this.disconnectObserver();

toEl.prepend(dragEl);
expandedAncestors(dragEl);
const toElItems = Array.from(toEl.children).filter(isListItem);
const newIndex = toElItems.indexOf(dragEl);

this.updateListItems();
this.connectObserver();

Expand Down
Loading