diff --git a/pages/shared/i18n.ts b/pages/shared/i18n.ts index 8a5c8232..2f8f067c 100644 --- a/pages/shared/i18n.ts +++ b/pages/shared/i18n.ts @@ -61,6 +61,8 @@ export const boardItemI18nStrings: BoardItemProps.I18nStrings = { resizeHandleAriaLabel: "Resize handle", resizeHandleAriaDescription: "Use Space or Enter to activate resize, arrow keys to move, Space or Enter to submit, or Escape to discard. Be sure to temporarily disable any screen reader navigation feature that may interfere with the functionality of the arrow keys.", + dragHandleTooltipText: "Drag or select to move", + resizeHandleTooltipText: "Drag or select to resize", }; export const itemsPaletteI18nStrings: ItemsPaletteProps.I18nStrings = { diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index ca8abe3f..18e93b1e 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -201,7 +201,9 @@ ARIA labels: * \`dragHandleAriaLabel\` (string) - the ARIA label for the drag handle. * \`dragHandleAriaDescription\` (string, optional) - the ARIA description for the drag handle. * \`resizeHandleAriaLabel\` (string) - the ARIA label for the resize handle. -* \`resizeHandleAriaDescription\` (string, optional) - the ARIA description for the resize handle.", +* \`resizeHandleAriaDescription\` (string, optional) - the ARIA description for the resize handle. +* \`dragHandleTooltipText\` (string, optional) - the ARIA description for the resize handle. +* \`resizeHandleTooltipText\` (string, optional) - the Text for the resize handle Tooltip.", "inlineType": { "name": "BoardItemProps.I18nStrings", "properties": [ @@ -215,6 +217,11 @@ ARIA labels: "optional": false, "type": "string", }, + { + "name": "dragHandleTooltipText", + "optional": true, + "type": "string", + }, { "name": "resizeHandleAriaDescription", "optional": true, @@ -225,6 +232,11 @@ ARIA labels: "optional": false, "type": "string", }, + { + "name": "resizeHandleTooltipText", + "optional": true, + "type": "string", + }, ], "type": "object", }, diff --git a/src/board-item/__tests__/board-item-wrapper.tsx b/src/board-item/__tests__/board-item-wrapper.tsx index 6d579ecd..b002eccd 100644 --- a/src/board-item/__tests__/board-item-wrapper.tsx +++ b/src/board-item/__tests__/board-item-wrapper.tsx @@ -9,16 +9,19 @@ export function ItemContextWrapper({ children }: { children: ReactNode }) { {}, onKeyDown: () => {}, - isActive: false, + activeState: null, + onDirectionClick: () => {}, }, resizeHandle: { onPointerDown: () => {}, onKeyDown: () => {}, - isActive: false, + activeState: null, + onDirectionClick: () => {}, }, }} > diff --git a/src/board-item/__tests__/board-item.test.tsx b/src/board-item/__tests__/board-item.test.tsx index 4e617155..cb1e7169 100644 --- a/src/board-item/__tests__/board-item.test.tsx +++ b/src/board-item/__tests__/board-item.test.tsx @@ -2,13 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { ReactElement } from "react"; -import { cleanup, render as libRender } from "@testing-library/react"; +import { cleanup, fireEvent, render as libRender } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import Button from "@cloudscape-design/components/button"; import Container from "@cloudscape-design/components/container"; import ExpandableSection from "@cloudscape-design/components/expandable-section"; import Header from "@cloudscape-design/components/header"; +import DragHandleWrapper from "@cloudscape-design/components/test-utils/dom/internal/drag-handle"; +import TooltipWrapper from "@cloudscape-design/components/test-utils/dom/internal/tooltip"; import "@cloudscape-design/components/test-utils/dom"; import BoardItem from "../../../lib/components/board-item"; @@ -18,6 +20,8 @@ import { ItemContextWrapper } from "./board-item-wrapper"; const i18nStrings = { dragHandleAriaLabel: "Drag handle", resizeHandleAriaLabel: "Resize handle", + dragHandleTooltipText: "Drag or select to move", + resizeHandleTooltipText: "Drag or select to resize", }; function render(jsx: ReactElement) { @@ -61,4 +65,72 @@ describe("WidgetContainer", () => { expect(getByLabelText("Drag handle")).toBeDefined(); expect(getByLabelText("Resize handle")).toBeDefined(); }); + + test("renders drag handle tooltip text if provided", () => { + render(); + const wrapper = createWrapper(); + const dragHandleEl = wrapper.findBoardItem()!.findDragHandle().getElement(); + + fireEvent(dragHandleEl, new MouseEvent("pointerover", { bubbles: true })); + const tooltipEl = wrapper.findByClassName(TooltipWrapper.rootSelector)!.getElement(); + expect(tooltipEl.textContent).toBe("Drag or select to move"); + }); + + test("does not render drag handle tooltip text if not provided", () => { + render(); + const wrapper = createWrapper(); + const dragHandleEl = wrapper.findBoardItem()!.findDragHandle().getElement(); + + fireEvent(dragHandleEl, new MouseEvent("pointerover", { bubbles: true })); + expect(wrapper.findByClassName(TooltipWrapper.rootSelector)).toBeNull(); + }); + + test("renders drag handle UAP actions on handle click", () => { + render(); + const dragHandleEl = createWrapper().findBoardItem()!.findDragHandle()!.getElement(); + + fireEvent(dragHandleEl, new MouseEvent("pointerdown", { bubbles: true })); + fireEvent(dragHandleEl, new MouseEvent("pointerup", { bubbles: true })); + + const dragHandleWrapper = new DragHandleWrapper(document.body); + expect(dragHandleWrapper.findAllVisibleDirectionButtons()).toHaveLength(4); + expect(dragHandleWrapper.findVisibleDirectionButtonBlockStart()).toBeDefined(); + expect(dragHandleWrapper.findVisibleDirectionButtonBlockEnd()).toBeDefined(); + expect(dragHandleWrapper.findVisibleDirectionButtonInlineStart()).toBeDefined(); + expect(dragHandleWrapper.findVisibleDirectionButtonInlineEnd()).toBeDefined(); + }); + + test("renders resize handle tooltip text", () => { + render(); + const wrapper = createWrapper(); + const resizeHandleEl = wrapper.findBoardItem()!.findResizeHandle()!.getElement(); + + fireEvent(resizeHandleEl, new MouseEvent("pointerover", { bubbles: true })); + const tooltipEl = wrapper.findByClassName(TooltipWrapper.rootSelector)!.getElement(); + expect(tooltipEl.textContent).toBe("Drag or select to resize"); + }); + + test("does not render resize handle tooltip text if not provided", () => { + render(); + const wrapper = createWrapper(); + const resizeHandleEl = wrapper.findBoardItem()!.findResizeHandle()!.getElement(); + + fireEvent(resizeHandleEl, new MouseEvent("pointerover", { bubbles: true })); + expect(wrapper.findByClassName(TooltipWrapper.rootSelector)).toBeNull(); + }); + + test("renders resize handle UAP actions on handle click", () => { + render(); + const resizeHandleEl = createWrapper().findBoardItem()!.findResizeHandle()!.getElement(); + + fireEvent(resizeHandleEl, new MouseEvent("pointerdown", { bubbles: true })); + fireEvent(resizeHandleEl, new MouseEvent("pointerup", { bubbles: true })); + + const dragHandleWrapper = new DragHandleWrapper(document.body); + expect(dragHandleWrapper.findAllVisibleDirectionButtons()).toHaveLength(4); + expect(dragHandleWrapper.findVisibleDirectionButtonBlockStart()).toBeDefined(); + expect(dragHandleWrapper.findVisibleDirectionButtonBlockEnd()).toBeDefined(); + expect(dragHandleWrapper.findVisibleDirectionButtonInlineStart()).toBeDefined(); + expect(dragHandleWrapper.findVisibleDirectionButtonInlineEnd()).toBeDefined(); + }); }); diff --git a/src/board-item/interfaces.ts b/src/board-item/interfaces.ts index 26957a4d..e5cb9b2a 100644 --- a/src/board-item/interfaces.ts +++ b/src/board-item/interfaces.ts @@ -37,6 +37,8 @@ export interface BoardItemProps { * * `dragHandleAriaDescription` (string, optional) - the ARIA description for the drag handle. * * `resizeHandleAriaLabel` (string) - the ARIA label for the resize handle. * * `resizeHandleAriaDescription` (string, optional) - the ARIA description for the resize handle. + * * `dragHandleTooltipText` (string, optional) - the ARIA description for the resize handle. + * * `resizeHandleTooltipText` (string, optional) - the Text for the resize handle Tooltip. */ i18nStrings: BoardItemProps.I18nStrings; } @@ -47,5 +49,7 @@ export namespace BoardItemProps { dragHandleAriaDescription?: string; resizeHandleAriaLabel: string; resizeHandleAriaDescription?: string; + dragHandleTooltipText?: string; + resizeHandleTooltipText?: string; } } diff --git a/src/board-item/internal.tsx b/src/board-item/internal.tsx index 88d6fde9..8bb8bf52 100644 --- a/src/board-item/internal.tsx +++ b/src/board-item/internal.tsx @@ -4,10 +4,12 @@ import { useId } from "react"; import clsx from "clsx"; import Container from "@cloudscape-design/components/container"; +import { InternalDragHandleProps } from "@cloudscape-design/components/internal/do-not-use/drag-handle"; import { getDataAttributes } from "../internal/base-component/get-data-attributes"; import { InternalBaseComponentProps } from "../internal/base-component/use-base-component"; import DragHandle from "../internal/drag-handle"; +import { Direction } from "../internal/interfaces"; import { useItemContext } from "../internal/item-container"; import ResizeHandle from "../internal/resize-handle"; import ScreenreaderOnly from "../internal/screenreader-only"; @@ -16,6 +18,16 @@ import type { BoardItemProps } from "./interfaces"; import styles from "./styles.css.js"; +const mapToKeyboardDirection = (direction: InternalDragHandleProps.Direction) => { + const directionMap: Record = { + "inline-start": "left", + "inline-end": "right", + "block-start": "up", + "block-end": "down", + }; + return directionMap[direction]; +}; + export function InternalBoardItem({ children, header, @@ -26,7 +38,7 @@ export function InternalBoardItem({ __internalRootRef, ...rest }: BoardItemProps & InternalBaseComponentProps) { - const { dragHandle, resizeHandle, isActive } = useItemContext(); + const { dragHandle, resizeHandle, isActive, isHidden } = useItemContext(); const dragHandleAriaLabelledBy = useId(); const dragHandleAriaDescribedBy = useId(); @@ -34,6 +46,12 @@ export function InternalBoardItem({ const resizeHandleAriaLabelledBy = useId(); const resizeHandleAriaDescribedBy = useId(); + // A board item is hidden while moving a board item from the palette to the board via keyboard or UAP. + // The wrapping container is set to invisible, so we don't need to render anything. + if (isHidden) { + return null; + } + return (
dragHandle.onDirectionClick(mapToKeyboardDirection(direction), "drag")} + dragHandleTooltipText={i18nStrings.dragHandleTooltipText} /> } settings={settings} @@ -69,7 +90,11 @@ export function InternalBoardItem({ ariaDescribedBy={resizeHandleAriaDescribedBy} onPointerDown={resizeHandle.onPointerDown} onKeyDown={resizeHandle.onKeyDown} - isActive={resizeHandle.isActive} + activeState={resizeHandle.activeState} + onDirectionClick={(direction) => { + resizeHandle.onDirectionClick(mapToKeyboardDirection(direction), "resize"); + }} + resizeHandleTooltipText={i18nStrings.resizeHandleTooltipText} />
)} diff --git a/src/board-item/styles.scss b/src/board-item/styles.scss index 3ab73386..c069d725 100644 --- a/src/board-item/styles.scss +++ b/src/board-item/styles.scss @@ -13,9 +13,7 @@ .container-override.active { box-shadow: cs.$shadow-container-active; - :global([data-awsui-focus-visible]) & { - @include shared.focus-highlight(0px, cs.$border-radius-container); - } + @include shared.focus-highlight(0px, cs.$border-radius-container); } .header { diff --git a/src/board/__tests__/board.test.tsx b/src/board/__tests__/board.test.tsx index 31b078b2..c0484e0b 100644 --- a/src/board/__tests__/board.test.tsx +++ b/src/board/__tests__/board.test.tsx @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { vi } from "vitest"; -import { afterEach, beforeAll, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import DragHandleWrapper from "@cloudscape-design/components/test-utils/dom/internal/drag-handle"; import { KeyCode } from "@cloudscape-design/test-utils-core/utils"; import Board from "../../../lib/components/board"; @@ -11,8 +12,10 @@ import createWrapper from "../../../lib/components/test-utils/dom"; import { defaultProps } from "./utils"; import dragHandleStyles from "../../../lib/components/internal/drag-handle/styles.css.js"; +import dragHandleTestUtilsStyles from "../../../lib/components/internal/drag-handle/test-classes/styles.css.js"; import globalStateStyles from "../../../lib/components/internal/global-drag-state-styles/styles.css.js"; import resizeHandleStyles from "../../../lib/components/internal/resize-handle/styles.css.js"; +import resizeHandleTestUtilsStyles from "../../../lib/components/internal/resize-handle/test-classes/styles.css.js"; describe("Board", () => { beforeAll(() => { @@ -46,7 +49,7 @@ describe("Board", () => { itemDragHandle.keydown(KeyCode.escape); }); - test("applies reorder operation classname", () => { + test("applies reorder operation classname on pointer interaction", () => { const { container } = render(); const reorderClass = globalStateStyles["show-grab-cursor"]; @@ -63,7 +66,7 @@ describe("Board", () => { expect(container.ownerDocument.body).not.toHaveClass(reorderClass); }); - test("applies resize operation classname", () => { + test("applies resize operation classname on pointer interaction", () => { const { container } = render(); const resizeClass = globalStateStyles["show-resize-cursor"]; @@ -110,54 +113,117 @@ describe("Board", () => { expect(container.ownerDocument.body).not.toHaveClass(disableSelectionClass); }); - test("sets active state for drag handle", () => { - render(); - - const findBoardItem = () => createWrapper().findBoard()!.findItemById("1")!; - const findDragHandle = () => findBoardItem().findDragHandle().getElement(); - const findResizeHandle = () => findBoardItem().findResizeHandle().getElement(); - - expect(findDragHandle()).not.toHaveClass(dragHandleStyles.active); - - // Start operation - fireEvent(findDragHandle(), new MouseEvent("pointerdown", { bubbles: true })); - expect(findDragHandle()).toHaveClass(dragHandleStyles.active); - expect(findResizeHandle()).not.toHaveClass(dragHandleStyles.active); - - // End operation - fireEvent(window, new MouseEvent("pointerup", { bubbles: true })); - expect(findDragHandle()).not.toHaveClass(dragHandleStyles.active); + describe("sets pointer active states for drag and resize handles", () => { + let dragHandle: HTMLElement; + let resizeHandle: HTMLElement; + let dragHandlePointerActiveClassName: string; + let dragHandleUapActiveClassName: string; + let resizeHandlePointerActiveClassName: string; + let resizeHandleUapActiveClassName: string; + + beforeEach(() => { + render(); + + dragHandle = createWrapper().findBoardItem()!.findDragHandle()!.getElement(); + resizeHandle = createWrapper().findBoardItem()!.findResizeHandle()!.getElement(); + dragHandlePointerActiveClassName = dragHandleStyles.active; + dragHandleUapActiveClassName = dragHandleTestUtilsStyles["active-uap"]; + resizeHandlePointerActiveClassName = resizeHandleStyles.active; + resizeHandleUapActiveClassName = resizeHandleTestUtilsStyles["active-uap"]; + }); + test("drag handle", () => { + expect(dragHandle).not.toHaveClass(dragHandlePointerActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandleUapActiveClassName); + + // Pointerdown - activates pointer class + fireEvent(dragHandle, new MouseEvent("pointerdown", { bubbles: true })); + expect(dragHandle).toHaveClass(dragHandlePointerActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandleUapActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandlePointerActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandleUapActiveClassName); + + // Pointerup - removes pointer class - adds UAP active class + fireEvent(window, new MouseEvent("pointerup", { bubbles: true })); + expect(dragHandle).toHaveClass(dragHandleUapActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandlePointerActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandlePointerActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandleUapActiveClassName); + + // Blur handle - removes UAP active class + fireEvent.blur(dragHandle); + expect(dragHandle).not.toHaveClass(dragHandlePointerActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandleUapActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandlePointerActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandleUapActiveClassName); + }); + + test("resize handle", () => { + expect(resizeHandle).not.toHaveClass(resizeHandlePointerActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandleUapActiveClassName); + + // Pointerdown - activates pointer class + fireEvent(resizeHandle, new MouseEvent("pointerdown", { bubbles: true })); + expect(resizeHandle).toHaveClass(resizeHandlePointerActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandleUapActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandlePointerActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandleUapActiveClassName); + + // Pointerup - removes pointer class - adds UAP active class + fireEvent(window, new MouseEvent("pointerup", { bubbles: true })); + expect(resizeHandle).toHaveClass(resizeHandleUapActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandlePointerActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandlePointerActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandleUapActiveClassName); + + // Blur handle - removes UAP active class + fireEvent.blur(resizeHandle); + expect(resizeHandle).not.toHaveClass(resizeHandlePointerActiveClassName); + expect(resizeHandle).not.toHaveClass(resizeHandleUapActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandlePointerActiveClassName); + expect(dragHandle).not.toHaveClass(dragHandleUapActiveClassName); + }); }); - test("sets active state for resize handle", () => { - render(); - - const dragHandle = createWrapper().findBoardItem()!.findDragHandle()!.getElement(); - const resizeHandle = createWrapper().findBoardItem()!.findResizeHandle()!.getElement(); + test("triggers onItemsChange on drag via keyboard", () => { + const onItemsChange = vi.fn(); + render(); - expect(resizeHandle).not.toHaveClass(resizeHandleStyles.active); + const dragHandle = createWrapper().findBoardItem()!.findDragHandle()!; - // Start operation - fireEvent(resizeHandle, new MouseEvent("pointerdown", { bubbles: true })); - expect(resizeHandle).toHaveClass(resizeHandleStyles.active); - expect(dragHandle).not.toHaveClass(resizeHandleStyles.active); + dragHandle.keydown(KeyCode.enter); + dragHandle.keydown(KeyCode.down); + dragHandle.keydown(KeyCode.down); + dragHandle.keydown(KeyCode.enter); - // End operation - fireEvent(window, new MouseEvent("pointerup", { bubbles: true })); - expect(resizeHandle).not.toHaveClass(resizeHandleStyles.active); + expect(onItemsChange).toBeCalledWith( + expect.objectContaining({ + detail: { + movedItem: expect.objectContaining({ id: "1" }), + items: [ + { id: "2", data: { title: "Item 2" }, columnOffset: { 1: 0 } }, + { id: "1", data: { title: "Item 1" }, columnOffset: { 1: 0 } }, + ], + }, + }), + ); }); - test("triggers onItemsChange on drag", () => { + test("triggers onItemsChange on drag via UAP action button click", () => { const onItemsChange = vi.fn(); render(); const dragHandle = createWrapper().findBoardItem()!.findDragHandle()!; + const dragHandleEl = dragHandle.getElement(); - dragHandle.keydown(KeyCode.enter); - dragHandle.keydown(KeyCode.down); - dragHandle.keydown(KeyCode.down); - dragHandle.keydown(KeyCode.enter); + fireEvent(dragHandleEl, new MouseEvent("pointerdown", { bubbles: true })); + fireEvent(dragHandleEl, new MouseEvent("pointerup", { bubbles: true })); + const dragHandleWrapper = new DragHandleWrapper(document.body); + const blockEndUapAction = dragHandleWrapper.findVisibleDirectionButtonBlockEnd()!.getElement(); + expect(dragHandleWrapper.findAllVisibleDirectionButtons()).toHaveLength(4); + fireEvent.click(blockEndUapAction); + fireEvent.click(blockEndUapAction); + fireEvent.blur(blockEndUapAction); expect(onItemsChange).toBeCalledWith( expect.objectContaining({ detail: { @@ -171,7 +237,7 @@ describe("Board", () => { ); }); - test("triggers onItemsChange on resize", () => { + test("triggers onItemsChange on resize via keyboard", () => { const onItemsChange = vi.fn(); render(); @@ -195,6 +261,35 @@ describe("Board", () => { ); }); + test("triggers onItemsChange on resize via UAP action button click", () => { + const onItemsChange = vi.fn(); + render(); + + const resizeHandle = createWrapper().findBoardItem()!.findResizeHandle()!; + const resizeHandleEl = resizeHandle.getElement(); + + fireEvent(resizeHandleEl, new MouseEvent("pointerdown", { bubbles: true })); + fireEvent(resizeHandleEl, new MouseEvent("pointerup", { bubbles: true })); + const resizeHandleWrapper = new DragHandleWrapper(document.body); + const blockEndUapAction = resizeHandleWrapper.findVisibleDirectionButtonBlockEnd()!.getElement(); + expect(resizeHandleWrapper.findAllVisibleDirectionButtons()).toHaveLength(4); + + fireEvent.click(blockEndUapAction); + fireEvent.click(blockEndUapAction); + fireEvent.blur(blockEndUapAction); + expect(onItemsChange).toBeCalledWith( + expect.objectContaining({ + detail: { + resizedItem: expect.objectContaining({ id: "1" }), + items: [ + { id: "1", data: { title: "Item 1" }, columnOffset: { 1: 0 }, columnSpan: 1, rowSpan: 4 }, + { id: "2", data: { title: "Item 2" }, columnOffset: { 1: 0 } }, + ], + }, + }), + ); + }); + test("triggers onItemsChange on remove", async () => { const onItemsChange = vi.fn(); render(); diff --git a/src/internal/drag-handle/index.tsx b/src/internal/drag-handle/index.tsx index b334ce14..d3b04449 100644 --- a/src/internal/drag-handle/index.tsx +++ b/src/internal/drag-handle/index.tsx @@ -3,35 +3,66 @@ import { ForwardedRef, forwardRef, KeyboardEvent, PointerEvent } from "react"; import clsx from "clsx"; -import Icon from "@cloudscape-design/components/icon"; +import { + InternalDragHandle, + InternalDragHandleProps, +} from "@cloudscape-design/components/internal/do-not-use/drag-handle"; -import Handle from "../handle"; +import { CLICK_DRAG_THRESHOLD, HandleActiveState } from "../item-container"; import styles from "./styles.css.js"; +import testUtilsStyles from "./test-classes/styles.css.js"; export interface DragHandleProps { ariaLabelledBy: string; ariaDescribedBy: string; onPointerDown: (event: PointerEvent) => void; onKeyDown: (event: KeyboardEvent) => void; - isActive: boolean; + activeState: HandleActiveState; + initialShowButtons?: boolean; + onDirectionClick: InternalDragHandleProps["onDirectionClick"]; + dragHandleTooltipText?: string; } function DragHandle( - { ariaLabelledBy, ariaDescribedBy, onPointerDown, onKeyDown, isActive }: DragHandleProps, - ref: ForwardedRef, + { + ariaLabelledBy, + ariaDescribedBy, + onPointerDown, + onKeyDown, + activeState, + initialShowButtons, + onDirectionClick, + dragHandleTooltipText, + }: DragHandleProps, + ref: ForwardedRef, ) { return ( - - - + onPointerDown={onPointerDown} + directions={{ + "block-start": "active", + "block-end": "active", + "inline-start": "active", + "inline-end": "active", + }} + triggerMode="keyboard-activate" + onDirectionClick={onDirectionClick} + initialShowButtons={initialShowButtons} + hideButtonsOnDrag={true} + clickDragThreshold={CLICK_DRAG_THRESHOLD} + /> ); } diff --git a/src/internal/drag-handle/styles.scss b/src/internal/drag-handle/styles.scss index fa65c8b1..6321947c 100644 --- a/src/internal/drag-handle/styles.scss +++ b/src/internal/drag-handle/styles.scss @@ -7,16 +7,13 @@ .handle { cursor: grab; + @include shared.handle(); } .handle:active { cursor: grabbing; } -.handle:not(.active):focus-visible { - @include shared.focus-highlight(); -} - .active { outline: none; } diff --git a/src/internal/drag-handle/test-classes/styles.scss b/src/internal/drag-handle/test-classes/styles.scss new file mode 100644 index 00000000..29629953 --- /dev/null +++ b/src/internal/drag-handle/test-classes/styles.scss @@ -0,0 +1,8 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.active-uap { + /* used in test-utils */ +} diff --git a/src/internal/global-drag-state-styles/index.ts b/src/internal/global-drag-state-styles/index.ts index d9d2f03a..1f332b60 100644 --- a/src/internal/global-drag-state-styles/index.ts +++ b/src/internal/global-drag-state-styles/index.ts @@ -9,20 +9,25 @@ function assertNever(value: never) { } function setup({ operation, interactionType }: DragAndDropData) { + const isPointerInteraction = interactionType === "pointer"; switch (operation) { case "insert": case "reorder": - document.body.classList.add(styles["show-grab-cursor"]); + if (isPointerInteraction) { + document.body.classList.add(styles["show-grab-cursor"]); + } break; case "resize": - document.body.classList.add(styles["show-resize-cursor"]); + if (isPointerInteraction) { + document.body.classList.add(styles["show-resize-cursor"]); + } break; default: // there will be a type error if not all operation types are handled assertNever(operation); } - if (interactionType === "pointer") { + if (isPointerInteraction) { document.body.classList.add(styles["disable-selection"]); } } diff --git a/src/internal/handle/index.tsx b/src/internal/handle/index.tsx deleted file mode 100644 index c8501cd5..00000000 --- a/src/internal/handle/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { ButtonHTMLAttributes, ForwardedRef, forwardRef, PointerEvent } from "react"; -import clsx from "clsx"; - -import styles from "./styles.css.js"; - -function Handle(props: ButtonHTMLAttributes, ref: ForwardedRef) { - function handlePointerDown(event: PointerEvent) { - if (event.button !== 0) { - return; - } - props.onPointerDown?.(event); - } - - return ( - - @@ -46,22 +56,71 @@ test("renders item container", () => { expect(getByTestId("content")).not.toBe(null); }); -test("starts drag transition when drag handle is clicked and item belongs to grid", () => { - const { getByTestId } = render(); - getByTestId("drag-handle").click(); - expect(mockDraggable.start).toBeCalledWith("reorder", "pointer", expect.any(Coordinates)); -}); +describe("pointer interaction", () => { + const clickHandle = (selector: HTMLElement, pointerDownOptions?: MouseEventInit) => { + fireEvent(selector, new MouseEvent("pointerdown", { bubbles: true, button: 0, ...pointerDownOptions })); + fireEvent(selector, new MouseEvent("pointerup", { bubbles: true })); + }; + afterEach(vi.resetAllMocks); -test("starts insert transition when drag handle is clicked and item does not belong to grid", () => { - const { getByTestId } = render(); - getByTestId("drag-handle").click(); - expect(mockDraggable.start).toBeCalledWith("insert", "pointer", expect.any(Coordinates)); + test.each(["drag-handle", "resize-handle"])("ignores right-click on %s", (handleSelector) => { + const { getByTestId } = render(); + clickHandle(getByTestId(handleSelector), { button: 1 }); + expect(mockDraggable.start).not.toHaveBeenCalled(); + }); + + test("starts drag transition when drag handle is clicked and item belongs to grid", () => { + const { getByTestId } = render(); + clickHandle(getByTestId("drag-handle")); + expect(mockDraggable.start).toBeCalledWith("reorder", "pointer", expect.any(Coordinates)); + }); + + test("starts insert transition when drag handle is clicked and item does not belong to grid", () => { + const { getByTestId } = render(); + clickHandle(getByTestId("drag-handle")); + expect(mockDraggable.start).toBeCalledWith("insert", "pointer", expect.any(Coordinates)); + }); + + test("starts resize transition when resize handle is clicked", () => { + const { getByTestId } = render(); + clickHandle(getByTestId("resize-handle")); + expect(mockDraggable.start).toBeCalledWith("resize", "pointer", expect.any(Coordinates)); + }); + + test("does not call updateTransition on pointer down and a mouse movement within the CLICK_DRAG_THRESHOLD", () => { + const { getByTestId } = render(); + clickHandle(getByTestId("drag-handle")); + expect(mockDraggable.updateTransition).not.toBeCalledWith(); + }); + + test("call updateTransition on pointer down and a mouse movement outside the CLICK_DRAG_THRESHOLD", () => { + const { getByTestId } = render(); + const dragHandleEl = getByTestId("drag-handle"); + fireEvent(dragHandleEl, new MouseEvent("pointerdown", { clientX: 10, clientY: 20, bubbles: true, button: 0 })); + fireEvent(dragHandleEl, new MouseEvent("pointermove", { clientX: 15, clientY: 20, bubbles: true })); + expect(mockDraggable.updateTransition).toBeCalledWith(expect.any(Coordinates)); + }); }); -test("starts resize transition when resize handle is clicked", () => { - const { getByTestId } = render(); - getByTestId("resize-handle").click(); - expect(mockDraggable.start).toBeCalledWith("resize", "pointer", expect.any(Coordinates)); +describe("keyboard interaction", () => { + describe.each(["drag", "resize"])("%s handle", (handle: string) => { + test(`starts keyboard transition when ${handle} handle receives enter and item belongs to grid`, () => { + const { getByTestId } = render(); + fireEvent.keyDown(getByTestId(`${handle}-handle`), { key: "Enter" }); + expect(mockDraggable.start).toBeCalledWith("reorder", "keyboard", expect.any(Coordinates)); + }); + + test.each([ + { key: "ArrowUp", direction: "up" }, + { key: "ArrowDown", direction: "down" }, + { key: "ArrowLeft", direction: "left" }, + { key: "ArrowRight", direction: "right" }, + ])(`calls onKeyMove($direction) when ${handle} handle receives $key keyDown event`, () => { + const { getByTestId } = render(); + fireEvent.keyDown(getByTestId(`${handle}-handle`), { key: "ArrowUp" }); + expect(onKeyMoveMock).toBeCalledWith("up"); + }); + }); }); test("does not renders in portal when item in reorder state by a pointer", () => { diff --git a/src/internal/item-container/__tests__/utils.test.ts b/src/internal/item-container/__tests__/utils.test.ts new file mode 100644 index 00000000..e146f7f5 --- /dev/null +++ b/src/internal/item-container/__tests__/utils.test.ts @@ -0,0 +1,382 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { beforeEach, describe, expect, Mock, test, vi } from "vitest"; + +import { getLogicalClientX as originalGetLogicalClientX } from "@cloudscape-design/component-toolkit/internal"; + +import type { Operation } from "../../../../lib/components/internal/dnd-controller/controller"; +import type { Transition } from "../../../../lib/components/internal/item-container"; +import { determineHandleActiveState } from "../../../../lib/components/internal/item-container/utils"; +import { + calculateInitialPointerData, + DetermineHandleActiveStateArgs, + getDndOperationType, + hasPointerMovedBeyondThreshold, +} from "../../../../lib/components/internal/item-container/utils"; +import { Coordinates } from "../../../../lib/components/internal/utils/coordinates"; + +const mockRect = { + insetInlineStart: 10, + insetBlockStart: 20, + insetInlineEnd: 110, // 10 + 100 (width) + insetBlockEnd: 120, // 20 + 100 (height) + inlineSize: 100, + blockSize: 100, + left: 10, + right: 110, + top: 20, + bottom: 120, + width: 100, + height: 100, + x: 10, + y: 20, +}; + +const mockPointerEvent = (clientX: number, clientY: number): Partial => ({ + clientX, + clientY, +}); + +vi.mock("@cloudscape-design/component-toolkit/internal", async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { + ...actual, + getLogicalClientX: vi.fn(), + }; +}); +const mockGetLogicalClientX = originalGetLogicalClientX as Mock; + +describe("getDndOperationType", () => { + interface TestCases { + operation: "drag" | "resize"; + isPlaced: boolean; + expected: Operation; + description: string; + } + + const testCases: Array = [ + { operation: "resize", isPlaced: true, expected: "resize", description: "resize when placed" }, + { + operation: "resize", + isPlaced: false, + expected: "resize", + description: "resize when not placed (should still be resize)", + }, + { operation: "drag", isPlaced: true, expected: "reorder", description: "reorder when drag and placed" }, + { operation: "drag", isPlaced: false, expected: "insert", description: "insert when drag and not placed" }, + ]; + + test.each(testCases)('should return "$expected" for $description', ({ operation, isPlaced, expected }) => { + expect(getDndOperationType(operation, isPlaced)).toBe(expected); + }); +}); + +describe("calculateInitialPointerData", () => { + const getMinSizeMock = vi.fn(); + const MOCK_DOCUMENT_CLIENT_WIDTH = 1000; // For RTL simulation + + beforeEach(() => { + getMinSizeMock.mockReset(); + mockGetLogicalClientX.mockReset(); + }); + + describe('when operation is "drag"', () => { + test("should calculate pointerOffset from top-left and null boundaries for LTR", () => { + mockGetLogicalClientX.mockImplementation((event: PointerEvent) => event.clientX); + const event = mockPointerEvent(50, 60) as PointerEvent; + const result = calculateInitialPointerData({ + event, + operation: "drag", + rect: mockRect, + getMinSize: getMinSizeMock, + isRtl: false, + }); + expect(mockGetLogicalClientX).toHaveBeenCalledWith(event, false); + + const expectedPointerOffsetX = event.clientX - mockRect.insetInlineStart; // 50 - 10 = 40 + const expectedPointerOffsetY = event.clientY - mockRect.insetBlockStart; // 60 - 20 = 40 + expect(result.pointerOffset).toEqual(new Coordinates({ x: expectedPointerOffsetX, y: expectedPointerOffsetY })); + expect(result.pointerBoundaries).toBeNull(); + expect(getMinSizeMock).not.toHaveBeenCalled(); + }); + + test("should calculate pointerOffset from top-left and null boundaries for RTL", () => { + mockGetLogicalClientX.mockImplementation((event: PointerEvent) => MOCK_DOCUMENT_CLIENT_WIDTH - event.clientX); + const event = mockPointerEvent(950, 60) as PointerEvent; + const result = calculateInitialPointerData({ + event, + operation: "drag", + rect: mockRect, + getMinSize: getMinSizeMock, + isRtl: true, + }); + expect(mockGetLogicalClientX).toHaveBeenCalledWith(event, true); + + const logicalClientX = MOCK_DOCUMENT_CLIENT_WIDTH - event.clientX; // 1000 - 950 = 50 + const expectedPointerOffsetX = logicalClientX - mockRect.insetInlineStart; // 50 - 10 = 40 + const expectedPointerOffsetY = event.clientY - mockRect.insetBlockStart; // 60 - 20 = 40 + expect(result.pointerOffset).toEqual(new Coordinates({ x: expectedPointerOffsetX, y: expectedPointerOffsetY })); + expect(result.pointerBoundaries).toBeNull(); + expect(getMinSizeMock).not.toHaveBeenCalled(); + }); + }); + + describe('when operation is "resize"', () => { + const minWidth = 50; + const minHeight = 50; + + beforeEach(() => { + getMinSizeMock.mockReturnValue({ minWidth, minHeight }); + }); + + test("should calculate pointerOffset from bottom-right and boundaries for LTR", () => { + mockGetLogicalClientX.mockImplementation((event: PointerEvent) => event.clientX); + const event = mockPointerEvent(150, 160) as PointerEvent; // Pointer beyond item + const result = calculateInitialPointerData({ + event, + operation: "resize", + rect: mockRect, + getMinSize: getMinSizeMock, + isRtl: false, + }); + + expect(mockGetLogicalClientX).toHaveBeenCalledWith(event, false); + expect(getMinSizeMock).toHaveBeenCalledTimes(1); + + const expectedPointerOffsetX = event.clientX - mockRect.insetInlineEnd; // 150 - 110 = 40 + const expectedPointerOffsetY = event.clientY - mockRect.insetBlockEnd; // 160 - 120 = 40 + expect(result.pointerOffset).toEqual(new Coordinates({ x: expectedPointerOffsetX, y: expectedPointerOffsetY })); + + const expectedBoundaryX = event.clientX - mockRect.inlineSize + minWidth; // 150 - 100 + 50 = 100 + const expectedBoundaryY = event.clientY - mockRect.blockSize + minHeight; // 160 - 100 + 50 = 110 + expect(result.pointerBoundaries).toEqual(new Coordinates({ x: expectedBoundaryX, y: expectedBoundaryY })); + }); + + test("should calculate pointerOffset from bottom-right and boundaries for RTL", () => { + mockGetLogicalClientX.mockImplementation((event: PointerEvent) => MOCK_DOCUMENT_CLIENT_WIDTH - event.clientX); + const event = mockPointerEvent(850, 160) as PointerEvent; + const result = calculateInitialPointerData({ + event, + operation: "resize", + rect: mockRect, + getMinSize: getMinSizeMock, + isRtl: true, + }); + + expect(mockGetLogicalClientX).toHaveBeenCalledWith(event, true); + expect(getMinSizeMock).toHaveBeenCalledTimes(1); + + const logicalClientX = MOCK_DOCUMENT_CLIENT_WIDTH - event.clientX; // 1000 - 850 = 150 + const expectedPointerOffsetX = logicalClientX - mockRect.insetInlineEnd; // 150 - 110 = 40 + const expectedPointerOffsetY = event.clientY - mockRect.insetBlockEnd; // 160 - 120 = 40 + expect(result.pointerOffset).toEqual(new Coordinates({ x: expectedPointerOffsetX, y: expectedPointerOffsetY })); + + const expectedBoundaryX = logicalClientX - mockRect.inlineSize + minWidth; // 150 - 100 + 50 = 100 + const expectedBoundaryY = event.clientY - mockRect.blockSize + minHeight; // 160 - 100 + 50 = 110 + expect(result.pointerBoundaries).toEqual(new Coordinates({ x: expectedBoundaryX, y: expectedBoundaryY })); + }); + }); +}); + +describe("hasPointerMovedBeyondThreshold", () => { + const threshold = 3; + + test("should return false when initialPosition is undefined", () => { + const event = mockPointerEvent(50, 60) as PointerEvent; + expect(hasPointerMovedBeyondThreshold(event, undefined)).toBe(false); + }); + + test("should return false when pointer hasn't moved beyond threshold", () => { + const initialPosition = { x: 50, y: 60 }; + const event = mockPointerEvent(51, 61) as PointerEvent; + expect(hasPointerMovedBeyondThreshold(event, initialPosition, threshold)).toBe(false); + }); + + test("should return true when pointer moved beyond threshold in positive X direction", () => { + const initialPosition = { x: 50, y: 60 }; + const event = mockPointerEvent(54, 60) as PointerEvent; + expect(hasPointerMovedBeyondThreshold(event, initialPosition, threshold)).toBe(true); + }); + + test("should return true when pointer moved beyond threshold in negative X direction", () => { + const initialPosition = { x: 50, y: 60 }; + const event = mockPointerEvent(46, 60) as PointerEvent; + expect(hasPointerMovedBeyondThreshold(event, initialPosition, threshold)).toBe(true); + }); + + test("should return true when pointer moved beyond threshold in positive Y direction", () => { + const initialPosition = { x: 50, y: 60 }; + const event = mockPointerEvent(50, 64) as PointerEvent; + expect(hasPointerMovedBeyondThreshold(event, initialPosition, threshold)).toBe(true); + }); + + test("should return true when pointer moved beyond threshold in negative Y direction", () => { + const initialPosition = { x: 50, y: 60 }; + const event = mockPointerEvent(50, 56) as PointerEvent; + expect(hasPointerMovedBeyondThreshold(event, initialPosition, threshold)).toBe(true); + }); + + test("should use default threshold when not provided", () => { + const initialPosition = { x: 50, y: 60 }; + const event = mockPointerEvent(54, 60) as PointerEvent; + expect(hasPointerMovedBeyondThreshold(event, initialPosition)).toBe(true); + }); +}); + +describe("determineHandleActiveState", () => { + const mockTransition = (operation: Operation): Transition => ({ + itemId: "test-item", + operation: operation, + interactionType: "pointer", // Default value while testing, doesn't affect function's logic + sizeTransform: null, + positionTransform: null, + }); + + type InteractionHookValue = DetermineHandleActiveStateArgs["interactionHookValue"]; + + const activeStateTestCases: Array<{ + description: string; + args: Partial; + expected: "pointer" | "uap" | null; + targetOperation?: Operation; + }> = [ + // "pointer" states + { + description: 'return "pointer" if globally active, resize transition, dnd-start', + args: { + isHandleActive: true, + currentTransition: mockTransition("resize"), + interactionHookValue: "dnd-start", + targetOperation: "resize", + }, + expected: "pointer", + }, + { + description: 'return "pointer" if globally active, reorder transition, dnd-start', + args: { + isHandleActive: true, + currentTransition: mockTransition("reorder"), + interactionHookValue: "dnd-start", + targetOperation: "reorder", + }, + expected: "pointer", + }, + // "uap" states + { + description: 'return "uap" if globally active, resize transition, uap-action-start', + args: { + isHandleActive: true, + currentTransition: mockTransition("resize"), + interactionHookValue: "uap-action-start", + targetOperation: "resize", + }, + expected: "uap", + }, + { + description: 'return "uap" if globally active, reorder transition, uap-action-start', + args: { + isHandleActive: true, + currentTransition: mockTransition("reorder"), + interactionHookValue: "uap-action-start", + targetOperation: "reorder", + }, + expected: "uap", + }, + // Null states + { + description: "return null if not globally active", + args: { + isHandleActive: false, + currentTransition: mockTransition("resize"), + interactionHookValue: "dnd-start", + targetOperation: "resize", + }, + expected: null, + }, + { + description: "return null if no current transition", + args: { + isHandleActive: true, + currentTransition: null, + interactionHookValue: "dnd-start", + targetOperation: "resize", + }, + expected: null, + }, + { + description: "return null if current transition operation mismatches target", + args: { + isHandleActive: true, + currentTransition: mockTransition("reorder"), + interactionHookValue: "dnd-start", + targetOperation: "resize", + }, + expected: null, + }, + { + description: 'return null if interaction hook is not "dnd-start" or "uap-action-start" (e.g., "dnd-active")', + args: { + isHandleActive: true, + currentTransition: mockTransition("resize"), + interactionHookValue: "dnd-active", + targetOperation: "resize", + }, + expected: null, + }, + { + description: "return null if interaction hook is null", + args: { + isHandleActive: true, + currentTransition: mockTransition("resize"), + interactionHookValue: null, + targetOperation: "resize", + }, + expected: null, + }, + { + description: "return null if interaction hook is undefined", + args: { + isHandleActive: true, + currentTransition: mockTransition("resize"), + interactionHookValue: undefined as unknown as InteractionHookValue, + targetOperation: "resize", + }, + expected: null, + }, + { + description: "return null if interaction hook is an arbitrary string", + args: { + isHandleActive: true, + currentTransition: mockTransition("resize"), + interactionHookValue: "some-other-state" as InteractionHookValue, + targetOperation: "resize", + }, + expected: null, + }, + // Combined null conditions + { + description: 'return null if not globally active, even if other conditions match for "pointer"', + args: { + isHandleActive: false, + currentTransition: mockTransition("resize"), + interactionHookValue: "dnd-start", + targetOperation: "resize", + }, + expected: null, + }, + { + description: 'return null if not globally active, even if other conditions match for "uap"', + args: { + isHandleActive: false, + currentTransition: mockTransition("resize"), + interactionHookValue: "uap-action-start", + targetOperation: "resize", + }, + expected: null, + }, + ]; + + test.each(activeStateTestCases)("should $description", ({ args, expected }) => { + expect(determineHandleActiveState(args as DetermineHandleActiveStateArgs)).toBe(expected); + }); +}); diff --git a/src/internal/item-container/index.tsx b/src/internal/item-container/index.tsx index 08bb745b..9ce02aa1 100644 --- a/src/internal/item-container/index.tsx +++ b/src/internal/item-container/index.tsx @@ -10,7 +10,6 @@ import { Ref, RefObject, useContext, - useEffect, useImperativeHandle, useRef, useState, @@ -19,7 +18,11 @@ import { createPortal } from "react-dom"; import { CSS as CSSUtil } from "@dnd-kit/utilities"; import clsx from "clsx"; -import { getLogicalBoundingClientRect, getLogicalClientX } from "@cloudscape-design/component-toolkit/internal"; +import { getLogicalBoundingClientRect } from "@cloudscape-design/component-toolkit/internal"; +import { + useInternalDragHandleInteractionState, + UseInternalDragHandleInteractionStateProps, +} from "@cloudscape-design/components/internal/do-not-use/drag-handle"; import { DragAndDropData, @@ -35,6 +38,12 @@ import { getNormalizedElementRect } from "../utils/screen"; import { throttle } from "../utils/throttle"; import { getCollisionRect } from "./get-collision-rect"; import { getNextDroppable } from "./get-next-droppable"; +import { + calculateInitialPointerData, + determineHandleActiveState, + getDndOperationType, + hasPointerMovedBeyondThreshold, +} from "./utils"; import styles from "./styles.css.js"; @@ -42,21 +51,79 @@ export interface ItemContainerRef { focusDragHandle(): void; } +export type HandleActiveState = null | "pointer" | "uap"; + interface ItemContextType { + /** + * Flag indicating if a drag or resize interaction is currently active. + */ isActive: boolean; + /** + * Flag indicating if the item is currently hidden. + * (When a board item is moved from the palette to the board and the transition is not submitted) + */ + isHidden: boolean; dragHandle: { - ref: RefObject; + /** + * Ref to the drag button. Used to focus the drag handle when moving an item + * from the palette to the board via keyboard or UAP actions. + */ + ref: RefObject; + /** + * Listen to pointerDown events on the drag handle. + * Used to start a transition and attach global event handlers. + */ onPointerDown(event: ReactPointerEvent): void; + /** + * Listen to keyDown events on the drag handle. + */ onKeyDown(event: KeyboardEvent): void; - isActive: boolean; + /** + * Indicating if drag handle is active. + */ + activeState: HandleActiveState; + /** + * Listen to UAP direction button clicks. + */ + onDirectionClick(direction: KeyboardEvent["key"], operation: HandleOperation): void; + /** + * Flag indicating if the UAP buttons should be shown. E.g. when a item is moved from + * the palette via keyboard or UAP to the board. + */ + initialShowButtons?: boolean; }; resizeHandle: null | { + /** + * Listen to pointerDown events on the drag handle. + * Used to start a transition and attach global event handlers. + */ onPointerDown(event: ReactPointerEvent): void; + /** + * Listen to keyDown events on the drag handle. + */ onKeyDown(event: KeyboardEvent): void; - isActive: boolean; + /** + * Indicating if resize handle is active. + */ + activeState: HandleActiveState; + /** + * Listen to UAP direction button clicks. + */ + onDirectionClick(direction: KeyboardEvent["key"], operation: HandleOperation): void; }; } +export interface Transition { + itemId: ItemId; + operation: Operation; + interactionType: InteractionType; + sizeTransform: null | { width: number; height: number }; + positionTransform: null | { x: number; y: number }; + hasDropTarget?: boolean; +} + +export type HandleOperation = "drag" | "resize"; + export const ItemContext = createContext(null); export function useItemContext() { @@ -67,15 +134,6 @@ export function useItemContext() { return ctx; } -interface Transition { - itemId: ItemId; - operation: Operation; - interactionType: InteractionType; - sizeTransform: null | { width: number; height: number }; - positionTransform: null | { x: number; y: number }; - hasDropTarget?: boolean; -} - /** * Defines item's parameters and its relation with the layout. * @@ -101,13 +159,21 @@ export interface ItemContainerProps { minHeight: number; maxHeight: number; }; + onKeyMove?(direction: Direction): void; + children: (hasDropTarget: boolean) => ReactNode; isRtl: () => boolean; } export const ItemContainer = forwardRef(ItemContainerComponent); +// The amount of distance after pointer down that the cursor is allowed to +// jitter for a subsequent mouseup to still register as a "press" instead of +// a drag. A little allowance is needed for usability reasons, but this number +// isn't set in stone. +export const CLICK_DRAG_THRESHOLD = 3; + function ItemContainerComponent( { item, placed, acquired, inTransition, transform, getItemSize, onKeyMove, children, isRtl }: ItemContainerProps, ref: Ref, @@ -119,6 +185,7 @@ function ItemContainerComponent( const [isHidden, setIsHidden] = useState(false); const muteEventsRef = useRef(false); const itemRef = useRef(null); + const initialPointerDownPosition = useRef<{ x: number; y: number } | undefined>(); const draggableApi = useDraggable({ draggableItem: item, getCollisionRect: (operation, coordinates, dropTarget) => { @@ -176,74 +243,31 @@ function ItemContainerComponent( muteEventsRef.current = false; }); - // During the transition listen to pointer move and pointer up events to update/submit transition. - const transitionInteractionType = transition?.interactionType ?? null; - const transitionItemId = transition?.itemId ?? null; - useEffect(() => { - const onPointerMove = throttle((event: PointerEvent) => { - const coordinates = Coordinates.fromEvent(event, { isRtl: isRtl() }); - draggableApi.updateTransition( - new Coordinates({ - x: Math.max(coordinates.x, pointerBoundariesRef.current?.x ?? Number.NEGATIVE_INFINITY), - y: Math.max(coordinates.y, pointerBoundariesRef.current?.y ?? Number.NEGATIVE_INFINITY), - }), - ); - }, 10); - const onPointerUp = () => { - onPointerMove.cancel(); - draggableApi.submitTransition(); - }; - - if (transitionInteractionType === "pointer" && transitionItemId === item.id) { - window.addEventListener("pointermove", onPointerMove); - window.addEventListener("pointerup", onPointerUp); - } - return () => { - window.removeEventListener("pointermove", onPointerMove); - window.removeEventListener("pointerup", onPointerUp); - }; - // draggableApi is not expected to change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item.id, transitionInteractionType, transitionItemId]); - - useEffect(() => { - if (transitionInteractionType === "keyboard" && transitionItemId === item.id) { - const onPointerDown = () => draggableApi.submitTransition(); - window.addEventListener("pointerdown", onPointerDown, true); - return () => { - window.removeEventListener("pointerdown", onPointerDown, true); - }; - } - // draggableApi is not expected to change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item.id, transitionInteractionType, transitionItemId]); - - function onKeyboardTransitionToggle(operation: "drag" | "resize") { + // Handles incremental transition logic shared between different keyboard and UAP interactions. + function handleIncrementalTransition(operation: HandleOperation, submitExisting = false) { // The acquired item is a copy and does not have the transition state. // However, pressing "Space" or "Enter" on the acquired item must submit the active transition. if (acquired) { return draggableApi.submitTransition(); } - // Create new transition if missing. - if (!transition) { - const rect = getNormalizedElementRect(itemRef.current!); - const coordinates = new Coordinates({ - x: operation === "drag" ? rect.left : rect.right, - y: operation === "drag" ? rect.top : rect.bottom, - }); - - if (operation === "drag" && !placed) { - draggableApi.start("insert", "keyboard", coordinates); - } else if (operation === "drag") { - draggableApi.start("reorder", "keyboard", coordinates); - } else { - draggableApi.start("resize", "keyboard", coordinates); - } + // Submit existing transition if requested and one exists + if (submitExisting && transition) { + return draggableApi.submitTransition(); } - // Submit a transition if existing. - else { - draggableApi.submitTransition(); + + const rect = getNormalizedElementRect(itemRef.current!); + const coordinates = new Coordinates({ + x: operation === "drag" ? rect.left : rect.right, + y: operation === "drag" ? rect.top : rect.bottom, + }); + + if (operation === "drag" && !placed) { + draggableApi.start("insert", "keyboard", coordinates); + } else if (operation === "drag") { + draggableApi.start("reorder", "keyboard", coordinates); + } else { + draggableApi.start("resize", "keyboard", coordinates); } } @@ -271,19 +295,17 @@ function ItemContainerComponent( muteEventsRef.current = true; } - function onHandleKeyDown(operation: "drag" | "resize", event: KeyboardEvent) { + function handleDirectionalMovement(direction: Direction, operation: HandleOperation) { const canInsert = transition && operation === "drag" && !placed; const canNavigate = transition || operation === "drag"; + if (canInsert) { + handleInsert(direction); + } else if (canNavigate) { + onKeyMove?.(direction); + } + } - // The insert is handled by the item and the navigation is delegated to the containing layout. - const move = (direction: Direction) => { - if (canInsert) { - handleInsert(direction); - } else if (canNavigate) { - onKeyMove?.(direction); - } - }; - + function onHandleKeyDown(operation: HandleOperation, event: KeyboardEvent) { const discard = () => { if (transition || acquired) { draggableApi.discardTransition(); @@ -292,16 +314,16 @@ function ItemContainerComponent( switch (event.key) { case "ArrowUp": - return move("up"); + return handleDirectionalMovement("up", operation); case "ArrowDown": - return move("down"); + return handleDirectionalMovement("down", operation); case "ArrowLeft": - return move("left"); + return handleDirectionalMovement("left", operation); case "ArrowRight": - return move("right"); + return handleDirectionalMovement("right", operation); case " ": case "Enter": - return onKeyboardTransitionToggle(operation); + return handleIncrementalTransition(operation, true); case "Escape": return discard(); } @@ -311,52 +333,82 @@ function ItemContainerComponent( // When drag- or resize handle on palette or board item loses focus the transition must be submitted with two exceptions: // 1. If the last interaction is not "keyboard" (the user clicked on another handle issuing a new transition); // 2. If the item is acquired by the board (in that case the focus moves to the board item which is expected, palette item is hidden and all events handlers must be muted). - if (transition && transition.interactionType === "keyboard" && !muteEventsRef.current) { + selectedHook.current.processBlur(); + initialPointerDownPosition.current = undefined; + if (acquired || (transition && transition.interactionType === "keyboard" && !muteEventsRef.current)) { draggableApi.submitTransition(); } } - function onDragHandlePointerDown(event: ReactPointerEvent) { - // Calculate the offset between item's top-left corner and the pointer landing position. - const rect = getLogicalBoundingClientRect(itemRef.current!); - const clientX = getLogicalClientX(event, isRtl()); - const clientY = event.clientY; - pointerOffsetRef.current = new Coordinates({ - x: clientX - rect.insetInlineStart, - y: clientY - rect.insetBlockStart, - }); - originalSizeRef.current = { width: rect.inlineSize, height: rect.blockSize }; - pointerBoundariesRef.current = null; + function handleGlobalPointerMove(event: PointerEvent) { + if (hasPointerMovedBeyondThreshold(event, initialPointerDownPosition.current)) { + selectedHook.current.processPointerMove(event); + } + } - draggableApi.start(!placed ? "insert" : "reorder", "pointer", Coordinates.fromEvent(event, { isRtl: isRtl() })); + function handleGlobalPointerUp(event: PointerEvent) { + selectedHook.current.processPointerUp(event); + initialPointerDownPosition.current = undefined; + // Clean up global listeners after interaction ends + window.removeEventListener("pointermove", handleGlobalPointerMove); + window.removeEventListener("pointerup", handleGlobalPointerUp); } - function onDragHandleKeyDown(event: KeyboardEvent) { - onHandleKeyDown("drag", event); + function onDragHandlePointerDown(event: ReactPointerEvent, operation: HandleOperation) { + // Prevent UI issues when right-clicking: in such a case the OS context menu will be shown and + // the board while the board-item is active. Because of the context menu under the pointer, + // onPointerUp is not called anymore. In such a case the board item would stuck in onPointerUp. + if (event.button !== 0) { + return; + } + initialPointerDownPosition.current = { x: event.clientX, y: event.clientY }; + + if (operation === "drag") { + selectedHook.current = dragInteractionHook; + } else { + selectedHook.current = resizeInteractionHook; + } + selectedHook.current.processPointerDown(event.nativeEvent); + + // If pointerdown is on our button, start listening for global move and up + window.addEventListener("pointermove", handleGlobalPointerMove); + window.addEventListener("pointerup", handleGlobalPointerUp); } - function onResizeHandlePointerDown(event: ReactPointerEvent) { - // Calculate the offset between item's bottom-right corner and the pointer landing position. - const rect = getLogicalBoundingClientRect(itemRef.current!); - const clientX = getLogicalClientX(event, isRtl()); - const clientY = event.clientY; - pointerOffsetRef.current = new Coordinates({ x: clientX - rect.insetInlineEnd, y: clientY - rect.insetBlockEnd }); + function handlePointerInteractionStart(event: PointerEvent, operation: "drag" | "resize") { + const currentItemElement = itemRef.current; + if (!currentItemElement) { + console.warn("ItemContainer: itemRef.current is not available on interaction start."); + return; + } + + const rect = getLogicalBoundingClientRect(currentItemElement); originalSizeRef.current = { width: rect.inlineSize, height: rect.blockSize }; - // Calculate boundaries below which the cursor cannot move. - const minWidth = getItemSize(null).minWidth; - const minHeight = getItemSize(null).minHeight; - pointerBoundariesRef.current = new Coordinates({ - x: clientX - rect.inlineSize + minWidth, - y: clientY - rect.blockSize + minHeight, + const { pointerOffset, pointerBoundaries } = calculateInitialPointerData({ + event, + operation, + rect, + getMinSize: () => getItemSize(null), + isRtl: isRtl(), }); + pointerOffsetRef.current = pointerOffset; + pointerBoundariesRef.current = pointerBoundaries; - draggableApi.start("resize", "pointer", Coordinates.fromEvent(event, { isRtl: isRtl() })); + const dndOperation = getDndOperationType(operation, placed); + const startCoordinates = Coordinates.fromEvent(event, { isRtl: isRtl() }); + draggableApi.start(dndOperation, "pointer", startCoordinates); } - function onResizeHandleKeyDown(event: KeyboardEvent) { - onHandleKeyDown("resize", event); - } + const onHandleDndTransitionActive = throttle((event: PointerEvent) => { + const coordinates = Coordinates.fromEvent(event, { isRtl: isRtl() }); + draggableApi.updateTransition( + new Coordinates({ + x: Math.max(coordinates.x, pointerBoundariesRef.current?.x ?? Number.NEGATIVE_INFINITY), + y: Math.max(coordinates.y, pointerBoundariesRef.current?.y ?? Number.NEGATIVE_INFINITY), + }), + ); + }, 10) as (event: PointerEvent) => void; const itemTransitionStyle: CSSProperties = {}; const itemTransitionClassNames: string[] = []; @@ -399,13 +451,35 @@ function ItemContainerComponent( } } - const dragHandleRef = useRef(null); + const dragHandleRef = useRef(null); useImperativeHandle(ref, () => ({ - focusDragHandle: () => dragHandleRef.current?.focus(), + focusDragHandle: () => { + return dragHandleRef.current?.focus(); + }, })); + const dragHookProps: UseInternalDragHandleInteractionStateProps = { + onDndStartAction: (event) => handlePointerInteractionStart(event, "drag"), + onDndActiveAction: onHandleDndTransitionActive, + onDndEndAction: () => transition && draggableApi.submitTransition(), + onUapActionStartAction: () => handleIncrementalTransition("drag"), + }; + const resizeHookProps: UseInternalDragHandleInteractionStateProps = { + onDndStartAction: (event) => handlePointerInteractionStart(event, "resize"), + onDndActiveAction: onHandleDndTransitionActive, + onDndEndAction: () => transition && draggableApi.submitTransition(), + onUapActionStartAction: () => handleIncrementalTransition("resize"), + }; + const dragInteractionHook = useInternalDragHandleInteractionState(dragHookProps); + const resizeInteractionHook = useInternalDragHandleInteractionState(resizeHookProps); + // We use a ref to the hook for the handle which is currently active. Distinguishment is managed in the handle button's onPointerDown callback. + const selectedHook = useRef(dragInteractionHook); + const isActive = (!!transition && !isHidden) || !!acquired; - const shouldUsePortal = transition?.operation === "insert" && transition?.interactionType === "pointer"; + const shouldUsePortal = + transition?.operation === "insert" && + transition?.interactionType === "pointer" && + selectedHook.current.interaction.value === "dnd-active"; const childrenRef = useRef(null); if (!inTransition || isActive) { childrenRef.current = children(!!transition?.hasDropTarget); @@ -422,17 +496,32 @@ function ItemContainerComponent( onDragHandlePointerDown(e, "drag"), + onKeyDown: (event: KeyboardEvent) => onHandleKeyDown("drag", event), + activeState: determineHandleActiveState({ + isHandleActive: isActive, + currentTransition: transition, + interactionHookValue: dragInteractionHook.interaction.value, + targetOperation: "reorder", + }), + onDirectionClick: handleDirectionalMovement, + initialShowButtons: + dragInteractionHook.interaction.value === "uap-action-start" || (inTransition && acquired), }, resizeHandle: placed ? { - onPointerDown: onResizeHandlePointerDown, - onKeyDown: onResizeHandleKeyDown, - isActive: isActive && transition?.operation === "resize", + onPointerDown: (e) => onDragHandlePointerDown(e, "resize"), + onKeyDown: (event: KeyboardEvent) => onHandleKeyDown("resize", event), + activeState: determineHandleActiveState({ + isHandleActive: isActive, + currentTransition: transition, + interactionHookValue: resizeInteractionHook.interaction.value, + targetOperation: "resize", + }), + onDirectionClick: handleDirectionalMovement, } : null, }} diff --git a/src/internal/item-container/utils.ts b/src/internal/item-container/utils.ts new file mode 100644 index 00000000..c43d9760 --- /dev/null +++ b/src/internal/item-container/utils.ts @@ -0,0 +1,110 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { getLogicalBoundingClientRect, getLogicalClientX } from "@cloudscape-design/component-toolkit/internal"; +import { InteractionState } from "@cloudscape-design/components/internal/components/drag-handle/hooks/interfaces"; + +import { Operation } from "../dnd-controller/controller"; // Adjust this path +import { Coordinates } from "../utils/coordinates"; // Adjust this path based on your project structure +import { CLICK_DRAG_THRESHOLD, Transition } from "."; + +export type HandleActiveState = null | "pointer" | "uap"; + +export interface DetermineHandleActiveStateArgs { + isHandleActive: boolean; + currentTransition: Transition | null; + interactionHookValue: InteractionState; + targetOperation: Operation; +} + +export function getDndOperationType(uiOperation: "drag" | "resize", isItemPlaced: boolean): Operation { + if (uiOperation === "resize") { + return "resize"; + } + return isItemPlaced ? "reorder" : "insert"; +} + +interface CalculateInitialPointerDataArgs { + event: PointerEvent; + operation: "drag" | "resize"; + rect: ReturnType; + getMinSize: () => { minWidth: number; minHeight: number }; + isRtl: boolean; +} + +/** + * Calculates the initial pointer offset and boundaries for a drag or resize interaction + * to help determine how the item's movement or resizing behaves relative to the pointer. + */ +export function calculateInitialPointerData({ + event, + operation, + rect, + getMinSize, + isRtl, +}: CalculateInitialPointerDataArgs): { + pointerOffset: Coordinates; + pointerBoundaries: Coordinates | null; +} { + const clientX = getLogicalClientX(event, isRtl); + const clientY = event.clientY; + + let pointerOffset: Coordinates; + let pointerBoundaries: Coordinates | null = null; + + if (operation === "resize") { + // For resize, offset is calculated from the bottom-right corner. + pointerOffset = new Coordinates({ + x: clientX - rect.insetInlineEnd, + y: clientY - rect.insetBlockEnd, + }); + + // Boundaries to ensure resize doesn't go below minimum dimensions. + const { minWidth, minHeight } = getMinSize(); + pointerBoundaries = new Coordinates({ + x: clientX - rect.inlineSize + minWidth, + y: clientY - rect.blockSize + minHeight, + }); + } else { + // For drag, offset is calculated from the top-left corner. + pointerOffset = new Coordinates({ + x: clientX - rect.insetInlineStart, + y: clientY - rect.insetBlockStart, + }); + } + + return { pointerOffset, pointerBoundaries }; +} + +export function hasPointerMovedBeyondThreshold( + event: PointerEvent, + initialPosition: { x: number; y: number } | undefined, + threshold: number = CLICK_DRAG_THRESHOLD, +): boolean { + if (!initialPosition) { + return false; + } + return ( + event.clientX > initialPosition.x + threshold || + event.clientX < initialPosition.x - threshold || + event.clientY > initialPosition.y + threshold || + event.clientY < initialPosition.y - threshold + ); +} + +export function determineHandleActiveState({ + isHandleActive, + currentTransition, + interactionHookValue, + targetOperation, +}: DetermineHandleActiveStateArgs): HandleActiveState { + if (isHandleActive && currentTransition?.operation === targetOperation && interactionHookValue === "dnd-start") { + return "pointer"; + } else if ( + isHandleActive && + currentTransition?.operation === targetOperation && + interactionHookValue === "uap-action-start" + ) { + return "uap"; + } + return null; +} diff --git a/src/internal/resize-handle/index.tsx b/src/internal/resize-handle/index.tsx index 16634aa4..c9712566 100644 --- a/src/internal/resize-handle/index.tsx +++ b/src/internal/resize-handle/index.tsx @@ -3,18 +3,24 @@ import { KeyboardEvent, PointerEvent } from "react"; import clsx from "clsx"; -import Icon from "@cloudscape-design/components/icon"; +import { + InternalDragHandle, + InternalDragHandleProps, +} from "@cloudscape-design/components/internal/do-not-use/drag-handle"; -import Handle from "../handle"; +import { CLICK_DRAG_THRESHOLD, HandleActiveState } from "../item-container"; import styles from "./styles.css.js"; +import testUtilsStyles from "./test-classes/styles.css.js"; export interface ResizeHandleProps { ariaLabelledBy: string; ariaDescribedBy: string; onPointerDown: (event: PointerEvent) => void; onKeyDown: (event: KeyboardEvent) => void; - isActive: boolean; + activeState: HandleActiveState; + onDirectionClick: InternalDragHandleProps["onDirectionClick"]; + resizeHandleTooltipText?: string; } export default function ResizeHandle({ @@ -22,17 +28,33 @@ export default function ResizeHandle({ ariaDescribedBy, onPointerDown, onKeyDown, - isActive, + activeState, + onDirectionClick, + resizeHandleTooltipText, }: ResizeHandleProps) { return ( - - - + onPointerDown={onPointerDown} + directions={{ + "block-start": "active", + "block-end": "active", + "inline-start": "active", + "inline-end": "active", + }} + triggerMode="keyboard-activate" + onDirectionClick={onDirectionClick} + hideButtonsOnDrag={true} + clickDragThreshold={CLICK_DRAG_THRESHOLD} + /> ); } diff --git a/src/internal/resize-handle/styles.scss b/src/internal/resize-handle/styles.scss index 881e0b0e..ce50a882 100644 --- a/src/internal/resize-handle/styles.scss +++ b/src/internal/resize-handle/styles.scss @@ -7,16 +7,13 @@ .handle { cursor: nwse-resize; + @include shared.handle(); @include shared.with-direction("rtl") { cursor: nesw-resize; } } -.handle:not(.active):focus-visible { - @include shared.focus-highlight(); -} - .active { outline: none; } diff --git a/src/internal/resize-handle/test-classes/styles.scss b/src/internal/resize-handle/test-classes/styles.scss new file mode 100644 index 00000000..29629953 --- /dev/null +++ b/src/internal/resize-handle/test-classes/styles.scss @@ -0,0 +1,8 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.active-uap { + /* used in test-utils */ +} diff --git a/src/internal/shared.scss b/src/internal/shared.scss index f204a39b..6d71eac0 100644 --- a/src/internal/shared.scss +++ b/src/internal/shared.scss @@ -30,3 +30,18 @@ @content; } } + +@mixin handle { + appearance: none; + background: transparent; + border: none; + padding-block: cs.$space-scaled-xxs; + padding-inline: cs.$space-scaled-xxs; + block-size: auto; + + color: cs.$color-text-interactive-default; + + &:hover { + color: cs.$color-text-interactive-hover; + } +} diff --git a/test/functional/board-layout/dnd-page-object.ts b/test/functional/board-layout/dnd-page-object.ts index 5341527f..b95fce3b 100644 --- a/test/functional/board-layout/dnd-page-object.ts +++ b/test/functional/board-layout/dnd-page-object.ts @@ -174,4 +174,11 @@ export class DndPageObject extends BasePageObject { const liveAnnouncements = await this.browser.execute(() => window.__liveAnnouncements ?? []); return liveAnnouncements; } + + // Clicking on a drag/resize handle performs an animation (show the UAP actions, moving the board item). + // The pause after the click makes the functional test stable. + async handleClick(selector: string) { + await this.click(selector); + await this.pause(100); + } } diff --git a/test/functional/board-layout/uap-action-button-interactions.test.ts b/test/functional/board-layout/uap-action-button-interactions.test.ts new file mode 100644 index 00000000..642699df --- /dev/null +++ b/test/functional/board-layout/uap-action-button-interactions.test.ts @@ -0,0 +1,263 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, test } from "vitest"; + +import DragHandleWrapper from "@cloudscape-design/components/test-utils/selectors/internal/drag-handle"; + +import createWrapper from "../../../lib/components/test-utils/selectors"; +import { makeQueryUrl, setupTest } from "../../utils"; +import { DndPageObject } from "./dnd-page-object"; + +const boardWrapper = createWrapper().findBoard(); +const itemsPaletteWrapper = createWrapper().findItemsPalette(); +const boardItemDragHandle = (id: string) => boardWrapper.findItemById(id).findDragHandle().toSelector(); +const boardItemResizeHandle = (id: string) => boardWrapper.findItemById(id).findResizeHandle().toSelector(); +const paletteItemDragHandle = (id: string) => itemsPaletteWrapper.findItemById(id).findDragHandle().toSelector(); +const dragHandleWrapper = new DragHandleWrapper("body"); +const directionButtonUp = () => dragHandleWrapper.findVisibleDirectionButtonBlockStart().toSelector(); +const directionButtonDown = () => dragHandleWrapper.findVisibleDirectionButtonBlockEnd().toSelector(); +const directionButtonLeft = () => dragHandleWrapper.findVisibleDirectionButtonInlineStart().toSelector(); +const directionButtonRight = () => dragHandleWrapper.findVisibleDirectionButtonInlineEnd().toSelector(); + +describe("items reordered with UAP actions", () => { + test( + "item move can be submitted", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(boardItemDragHandle("A")); + await page.handleClick(directionButtonRight()); + await page.handleClick(directionButtonRight()); + await page.handleClick(directionButtonDown()); + await page.handleClick(directionButtonLeft()); + await page.handleClick(directionButtonUp()); + await page.keys(["Enter"]); + + await expect(page.getGrid()).resolves.toEqual([ + ["B", "A", "C", "D"], + ["B", "A", "C", "D"], + ["E", "F", "G", "H"], + ["E", "F", "G", "H"], + ]); + }), + ); + + test( + "item move via UAP actions and keyboard can be submitted", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(boardItemDragHandle("A")); + await page.handleClick(directionButtonRight()); + await page.keys(["ArrowRight"]); + await page.keys(["ArrowDown"]); + await page.handleClick(directionButtonLeft()); + await page.keys(["ArrowUp"]); + await page.keys(["Enter"]); + + await expect(page.getGrid()).resolves.toEqual([ + ["B", "A", "C", "D"], + ["B", "A", "C", "D"], + ["E", "F", "G", "H"], + ["E", "F", "G", "H"], + ]); + }), + ); + + test( + "item move can be discarded", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(boardItemDragHandle("A")); + await page.handleClick(directionButtonRight()); + await page.keys(["Escape"]); + + await expect(page.getGrid()).resolves.toEqual([ + ["A", "B", "C", "D"], + ["A", "B", "C", "D"], + ["E", "F", "G", "H"], + ["E", "F", "G", "H"], + ]); + }), + ); + + test( + "item keyboard move automatically submits after leaving focus", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(boardItemDragHandle("A")); + await page.handleClick(directionButtonRight()); + await page.keys(["Tab"]); + await expect(page.getGrid()).resolves.toEqual([ + ["B", "A", "C", "D"], + ["B", "A", "C", "D"], + ["E", "F", "G", "H"], + ["E", "F", "G", "H"], + ]); + }), + ); +}); + +describe("items resized with keyboard", () => { + test( + "item resize can be submitted", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(boardItemResizeHandle("A")); + await page.handleClick(directionButtonRight()); + await page.handleClick(directionButtonDown()); + await page.keys(["Enter"]); + + await expect(page.getGrid()).resolves.toEqual([ + ["A", "A", "C", "D"], + ["A", "A", "C", "D"], + ["A", "A", "G", "H"], + ["E", "B", "G", "H"], + ["E", "B", " ", " "], + [" ", "F", " ", " "], + [" ", "F", " ", " "], + ]); + }), + ); + + test( + "item resize via UAP actions and keyboard can be submitted", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(boardItemResizeHandle("A")); + await page.handleClick(directionButtonRight()); + await page.keys(["ArrowDown"]); + await page.keys(["Enter"]); + + await expect(page.getGrid()).resolves.toEqual([ + ["A", "A", "C", "D"], + ["A", "A", "C", "D"], + ["A", "A", "G", "H"], + ["E", "B", "G", "H"], + ["E", "B", " ", " "], + [" ", "F", " ", " "], + [" ", "F", " ", " "], + ]); + }), + ); + + test( + "item resize can be discarded", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(boardItemResizeHandle("A")); + await page.handleClick(directionButtonRight()); + await page.handleClick(directionButtonDown()); + await page.keys(["Escape"]); + + await expect(page.getGrid()).resolves.toEqual([ + ["A", "B", "C", "D"], + ["A", "B", "C", "D"], + ["E", "F", "G", "H"], + ["E", "F", "G", "H"], + ]); + }), + ); + + test( + "can't resize below min row/col span", + setupTest( + makeQueryUrl( + [ + ["X", "X", " ", " "], + ["X", "X", " ", " "], + ["X", "X", " ", " "], + ["X", "X", " ", " "], + ], + [], + ), + DndPageObject, + async (page) => { + await page.handleClick(boardItemResizeHandle("X")); + await page.handleClick(directionButtonLeft()); + await page.handleClick(directionButtonUp()); + await page.keys(["Enter"]); + await expect(page.getGrid()).resolves.toEqual([ + ["X", "X", " ", " "], + ["X", "X", " ", " "], + ["X", "X", " ", " "], + ["X", "X", " ", " "], + ]); + }, + ), + ); +}); + +describe("items inserted with keyboard", () => { + test( + "item insert can be submitted", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(paletteItemDragHandle("I")); + await page.handleClick(directionButtonLeft()); + await page.handleClick(directionButtonDown()); + await page.handleClick(directionButtonDown()); + await page.handleClick(directionButtonDown()); + await page.handleClick(directionButtonDown()); + await page.keys(["Enter"]); + + await expect(page.getGrid()).resolves.toEqual([ + ["A", "B", "C", "D"], + ["A", "B", "C", "D"], + ["E", "F", "G", "H"], + ["E", "F", "G", "H"], + [" ", " ", " ", "I"], + [" ", " ", " ", "I"], + ]); + }), + ); + + test( + "item insert with keyboard automatically submits after mouse interaction", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(paletteItemDragHandle("I")); + await page.handleClick(directionButtonLeft()); + + // click anywhere on the page to submit the current transition, for example on another item handle + await page.handleClick(boardItemResizeHandle("A")); + + await expect(page.getGrid()).resolves.toEqual([ + ["A", "B", "C", "I"], + ["A", "B", "C", "I"], + ["E", "F", "G", "D"], + ["E", "F", "G", "D"], + [" ", " ", " ", "H"], + [" ", " ", " ", "H"], + ]); + }), + ); + + test( + "item insert can be discarded", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(paletteItemDragHandle("I")); + + await page.handleClick(directionButtonDown()); + await page.handleClick(directionButtonDown()); + await page.keys(["Escape"]); + + await expect(page.getGrid()).resolves.toEqual([ + ["A", "B", "C", "D"], + ["A", "B", "C", "D"], + ["E", "F", "G", "H"], + ["E", "F", "G", "H"], + ]); + }), + ); + + test( + "item to be inserted with keyboard has preview content", + setupTest("/index.html#/dnd/engine-a2h-test", DndPageObject, async (page) => { + await page.handleClick(paletteItemDragHandle("I")); + await page.handleClick(directionButtonLeft()); + await expect(page.getText(boardWrapper.find(`[data-item-id="I"]`).toSelector())).resolves.toBe( + "Widget I\n(preview) Empty widget", + ); + await expect(page.isDisplayed(boardItemResizeHandle("I"))).resolves.toBe(false); + }), + ); + + test( + "item in palette should be hidden when it is acquired by the board", + setupTest("/index.html#/micro-frontend/integration", DndPageObject, async (page) => { + await page.handleClick(paletteItemDragHandle("M")); + await page.handleClick(directionButtonLeft()); + await expect(page.isDisplayed(paletteItemDragHandle("M"))).resolves.toBe(false); + }), + ); +});