diff --git a/pages/list/sortable-permutations.page.tsx b/pages/list/sortable-permutations.page.tsx index e7b230f15e..21ec7cd5a6 100644 --- a/pages/list/sortable-permutations.page.tsx +++ b/pages/list/sortable-permutations.page.tsx @@ -25,6 +25,11 @@ const items: Item[] = [ { content: 'Item 4', description: 'Description', timestamp: 'January 1 2025' }, ]; +const ControlledList = (props: ListProps) => { + const [items, setItems] = React.useState(props.items); + return setItems(e.detail.items)} />; +}; + const permutations = createPermutations & { viewportWidth: number; _sortable: boolean | 'disabled' }>([ { viewportWidth: [200, 400], @@ -57,7 +62,7 @@ export default function ListItemPermutations() { permutations={permutations} render={({ viewportWidth, _sortable, ...permutation }) => (
- +
)} /> diff --git a/src/collection-preferences/content-display/__integ__/content-reordering.test.ts b/src/collection-preferences/content-display/__integ__/content-reordering.test.ts index 1aefe6e716..3d4f452647 100644 --- a/src/collection-preferences/content-display/__integ__/content-reordering.test.ts +++ b/src/collection-preferences/content-display/__integ__/content-reordering.test.ts @@ -3,6 +3,7 @@ import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; import createWrapper from '../../../../lib/components/test-utils/selectors'; +import InternalDragHandleWrapper from '../../../../lib/components/test-utils/selectors/internal/drag-handle'; import ContentDisplayPageObject from './pages/content-display-page'; const windowDimensions = { @@ -113,9 +114,37 @@ describe('Collection preferences - Content Display preference', () => { }); }); + describe('reorders content with UAP buttons', () => { + const dragHandleWrapper = new InternalDragHandleWrapper('body'); + test( + 'can move item and commit by clicking away', + setupTest(async page => { + page.wrapper = createWrapper().findCollectionPreferences('.cp-1'); + await page.openCollectionPreferencesModal(); + + await page.click(page.findDragHandle(0).toSelector()); + await page.expectAnnouncement('Picked up item at position 1 of 6'); + + const downButton = dragHandleWrapper.findVisibleDirectionButtonBlockEnd().toSelector(); + const upButton = dragHandleWrapper.findVisibleDirectionButtonBlockStart().toSelector(); + + await page.click(downButton); + await page.expectAnnouncement('Moving item to position 2 of 6'); + await page.click(downButton); + await page.expectAnnouncement('Moving item to position 3 of 6'); + await page.click(upButton); + await page.expectAnnouncement('Moving item to position 2 of 6'); + + await page.click(page.wrapper.findModal().findContentDisplayPreference().findTitle().toSelector()); + await expect(page.containsOptionsInOrder(['Item 2', 'Item 1'])).resolves.toBe(true); + await page.expectAnnouncement('Item moved from position 1 to position 2 of 6'); + }) + ); + }); + describe('reorders content with keyboard', () => { test( - 'cancels reordering when pressing Tab', + 'cancels reordering when pressing Escape', setupTest(async page => { page.wrapper = createWrapper().findCollectionPreferences('.cp-1'); await page.openCollectionPreferencesModal(); @@ -127,7 +156,7 @@ describe('Collection preferences - Content Display preference', () => { await page.expectAnnouncement('Picked up item at position 1 of 6'); await page.keys('ArrowDown'); await page.expectAnnouncement('Moving item to position 2 of 6'); - await page.keys('Tab'); + await page.keys('Escape'); await expect(await page.containsOptionsInOrder(['Item 1', 'Item 2'])).toBe(true); await page.expectAnnouncement('Reordering canceled'); @@ -135,7 +164,7 @@ describe('Collection preferences - Content Display preference', () => { ); test( - 'cancels reordering when clicking somewhere else', + 'submits reordering when clicking somewhere else', setupTest(async page => { page.wrapper = createWrapper().findCollectionPreferences('.cp-1'); await page.openCollectionPreferencesModal(); @@ -149,8 +178,8 @@ describe('Collection preferences - Content Display preference', () => { await page.expectAnnouncement('Moving item to position 2 of 6'); await page.click(page.wrapper.findModal().findContentDisplayPreference().findTitle().toSelector()); - await expect(await page.containsOptionsInOrder(['Item 1', 'Item 2'])).toBe(true); - await page.expectAnnouncement('Reordering canceled'); + await expect(await page.containsOptionsInOrder(['Item 2', 'Item 1'])).toBe(true); + await page.expectAnnouncement('Item moved from position 1 to position 2 of 6'); }) ); diff --git a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts index 3f2f3f4ef1..2b5c52a690 100644 --- a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts +++ b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts @@ -6,7 +6,13 @@ import CollectionPreferencesPageObject from '../../../__integ__/pages/collection export default class ContentDisplayPageObject extends CollectionPreferencesPageObject { async containsOptionsInOrder(options: string[]) { const texts = await this.getElementsText(this.findOptions().toSelector()); - return texts.join(`\n`).includes(options.join('\n')); + const result = texts.join(`\n`).includes(options.join('\n')); + if (!result) { + throw new Error(`Options are not in the expected order: + Expected: ${options.join(', ')} + Found: ${texts.join(', ')}`); + } + return true; } async expectAnnouncement(announcement: string) { diff --git a/src/collection-preferences/content-display/__integ__/pages/dnd-page-object.ts b/src/collection-preferences/content-display/__integ__/pages/dnd-page-object.ts index d17bad4f22..78bbee22bc 100644 --- a/src/collection-preferences/content-display/__integ__/pages/dnd-page-object.ts +++ b/src/collection-preferences/content-display/__integ__/pages/dnd-page-object.ts @@ -27,7 +27,8 @@ export default class DndPageObject extends BasePageObject { id: 'event', parameters: { pointerType: 'mouse' }, actions: [ - { type: 'pointerMove', duration: 100, origin: 'pointer', x: xOffset, y: yOffset }, + { type: 'pointerMove', duration: 100, origin: 'pointer', x: xOffset / 2, y: yOffset / 2 }, + { type: 'pointerMove', duration: 100, origin: 'pointer', x: xOffset / 2, y: yOffset / 2 }, { type: 'pause', duration: 150 }, ], }, diff --git a/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx b/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx index 7ec379fce7..c16568a691 100644 --- a/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx +++ b/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx @@ -315,6 +315,118 @@ describe('triggerMode = keyboard-activate', () => { }); }); +describe('triggerMode = controlled', () => { + test('shows direction buttons when specified', () => { + const { dragHandle } = renderDragHandle({ + directions: { 'block-start': 'active', 'block-end': 'active' }, + triggerMode: 'controlled', + controlledShowButtons: true, + }); + + document.body.dataset.awsuiFocusVisible = 'true'; + dragHandle.focus(); + expect(getDirectionButton('block-start')).toBeInTheDocument(); + expect(getDirectionButton('block-end')).toBeInTheDocument(); + expect(getDirectionButton('inline-start')).toBeNull(); + expect(getDirectionButton('inline-end')).toBeNull(); + }); + + test('does not show direction buttons when focus enters the button', () => { + const { dragHandle } = renderDragHandle({ + directions: { 'block-start': 'active', 'block-end': 'active' }, + triggerMode: 'controlled', + }); + + document.body.dataset.awsuiFocusVisible = 'true'; + dragHandle.focus(); + expectDirectionButtonToBeHidden('block-start'); + expectDirectionButtonToBeHidden('block-end'); + expect(getDirectionButton('inline-start')).toBeNull(); + expect(getDirectionButton('inline-end')).toBeNull(); + }); + + test.each(['Enter', ' '])('does not show direction buttons when "%s" key is pressed on the focused button', key => { + const { dragHandle } = renderDragHandle({ + directions: { 'block-start': 'active', 'block-end': 'active' }, + triggerMode: 'controlled', + }); + + document.body.dataset.awsuiFocusVisible = 'true'; + dragHandle.focus(); + expectDirectionButtonToBeHidden('block-start'); + expectDirectionButtonToBeHidden('block-end'); + expect(getDirectionButton('inline-start')).not.toBeInTheDocument(); + expect(getDirectionButton('inline-end')).not.toBeInTheDocument(); + + fireEvent.keyDown(dragHandle, { key }); + + expectDirectionButtonToBeHidden('block-start'); + expectDirectionButtonToBeHidden('block-end'); + expect(getDirectionButton('inline-start')).not.toBeInTheDocument(); + expect(getDirectionButton('inline-end')).not.toBeInTheDocument(); + }); + + test('when focused and other key is pressed, it should not show the direction buttons', () => { + const { dragHandle } = renderDragHandle({ + directions: { 'block-start': 'active', 'block-end': 'active' }, + triggerMode: 'controlled', + }); + + document.body.dataset.awsuiFocusVisible = 'true'; + dragHandle.focus(); + expectDirectionButtonToBeHidden('block-start'); + expectDirectionButtonToBeHidden('block-end'); + expect(getDirectionButton('inline-start')).not.toBeInTheDocument(); + + fireEvent.keyDown(dragHandle, { key: 'A' }); + expectDirectionButtonToBeHidden('block-start'); + expectDirectionButtonToBeHidden('block-end'); + expect(getDirectionButton('inline-start')).not.toBeInTheDocument(); + }); + + test('does not hide direction buttons when focus leaves the button', () => { + const { dragHandle } = renderDragHandle({ + directions: { 'block-start': 'active', 'block-end': 'active' }, + triggerMode: 'controlled', + controlledShowButtons: true, + }); + + document.body.dataset.awsuiFocusVisible = 'true'; + + dragHandle.focus(); + expect(getDirectionButton('block-start')).toBeInTheDocument(); + expect(getDirectionButton('block-end')).toBeInTheDocument(); + expect(getDirectionButton('inline-start')).not.toBeInTheDocument(); + expect(getDirectionButton('inline-end')).not.toBeInTheDocument(); + + fireEvent.blur(dragHandle); + expect(getDirectionButton('block-start')).toBeInTheDocument(); + expect(getDirectionButton('block-end')).toBeInTheDocument(); + }); + + test.each(['Enter', ' '])('does not hide direction buttons when toggling "%s" key', key => { + const { dragHandle } = renderDragHandle({ + directions: { 'block-start': 'active', 'block-end': 'active' }, + triggerMode: 'controlled', + controlledShowButtons: true, + }); + + document.body.dataset.awsuiFocusVisible = 'true'; + + fireEvent.keyDown(dragHandle, { key }); + + expect(getDirectionButton('block-start')).toBeInTheDocument(); + expect(getDirectionButton('block-end')).toBeInTheDocument(); + expect(getDirectionButton('inline-start')).not.toBeInTheDocument(); + expect(getDirectionButton('inline-end')).not.toBeInTheDocument(); + + fireEvent.keyDown(dragHandle, { key }); + + expect(getDirectionButton('block-start')).toBeInTheDocument(); + expect(getDirectionButton('block-end')).toBeInTheDocument(); + }); +}); + test('shows direction buttons when clicked', () => { const { dragHandle } = renderDragHandle({ directions: { 'block-start': 'active' }, diff --git a/src/internal/components/drag-handle-wrapper/index.tsx b/src/internal/components/drag-handle-wrapper/index.tsx index 0c93adf2d4..00bbd83199 100644 --- a/src/internal/components/drag-handle-wrapper/index.tsx +++ b/src/internal/components/drag-handle-wrapper/index.tsx @@ -21,6 +21,7 @@ export default function DragHandleWrapper({ onDirectionClick, triggerMode = 'focus', initialShowButtons = false, + controlledShowButtons = false, hideButtonsOnDrag, clickDragThreshold, }: DragHandleWrapperProps) { @@ -161,7 +162,7 @@ export default function DragHandleWrapper({ event.key !== 'Control' && event.key !== 'Meta' && event.key !== 'Shift' && - triggerMode !== 'keyboard-activate' + triggerMode === 'focus' ) { // Pressing any other key will display the focus-visible ring around the // drag handle if it's in focus, so we should also show the buttons now. @@ -179,9 +180,11 @@ export default function DragHandleWrapper({ onDirectionClick?.(direction); }; + const _showButtons = triggerMode === 'controlled' ? controlledShowButtons : showButtons; + return (
- {!isDisabled && !showButtons && showTooltip && tooltipText && ( + {!isDisabled && !_showButtons && showTooltip && tooltipText && ( setShowTooltip(false)} /> )}
- + {directions['block-start'] && ( onInternalDirectionClick('block-start')} @@ -212,7 +215,7 @@ export default function DragHandleWrapper({ )} {directions['block-end'] && ( onInternalDirectionClick('block-end')} @@ -220,7 +223,7 @@ export default function DragHandleWrapper({ )} {directions['inline-start'] && ( onInternalDirectionClick('inline-start')} @@ -228,7 +231,7 @@ export default function DragHandleWrapper({ )} {directions['inline-end'] && ( onInternalDirectionClick('inline-end')} diff --git a/src/internal/components/drag-handle-wrapper/interfaces.ts b/src/internal/components/drag-handle-wrapper/interfaces.ts index 35af929565..342eed4c07 100644 --- a/src/internal/components/drag-handle-wrapper/interfaces.ts +++ b/src/internal/components/drag-handle-wrapper/interfaces.ts @@ -3,7 +3,7 @@ export type Direction = 'block-start' | 'block-end' | 'inline-start' | 'inline-end'; export type DirectionState = 'active' | 'disabled'; -export type TriggerMode = 'focus' | 'keyboard-activate'; +export type TriggerMode = 'focus' | 'keyboard-activate' | 'controlled'; export interface DragHandleWrapperProps { directions: Partial>; @@ -12,6 +12,7 @@ export interface DragHandleWrapperProps { children: React.ReactNode; triggerMode?: TriggerMode; initialShowButtons?: boolean; + controlledShowButtons?: boolean; hideButtonsOnDrag: boolean; clickDragThreshold: number; } diff --git a/src/internal/components/drag-handle/button.tsx b/src/internal/components/drag-handle/button.tsx index 79ff1d9513..b048b6887d 100644 --- a/src/internal/components/drag-handle/button.tsx +++ b/src/internal/components/drag-handle/button.tsx @@ -26,6 +26,7 @@ const DragHandleButton = forwardRef( ariaValue, disabled, onPointerDown, + onClick, onKeyDown, }: DragHandleProps, ref: React.Ref @@ -33,7 +34,10 @@ const DragHandleButton = forwardRef( const dragHandleRefObject = useRef(null); const iconProps: IconProps = (() => { - const shared = { variant: disabled ? ('disabled' as const) : undefined, size }; + const shared = { + variant: disabled ? ('disabled' as const) : undefined, + size, + }; switch (variant) { case 'drag-indicator': return { ...shared, name: 'drag-indicator' }; @@ -73,9 +77,13 @@ const DragHandleButton = forwardRef( aria-valuemin={ariaValue?.valueMin} aria-valuenow={ariaValue?.valueNow} onPointerDown={onPointerDown} + onClick={onClick} onKeyDown={onKeyDown} > - + {/* ensure that events happen on the parent div, not the icon */} +
+ +
); } diff --git a/src/internal/components/drag-handle/index.tsx b/src/internal/components/drag-handle/index.tsx index a6a2bcee31..dfc0d1d8df 100644 --- a/src/internal/components/drag-handle/index.tsx +++ b/src/internal/components/drag-handle/index.tsx @@ -22,10 +22,12 @@ const InternalDragHandle = forwardRef( disabled, directions = {}, onPointerDown, + onClick, onKeyDown, onDirectionClick, triggerMode, initialShowButtons, + controlledShowButtons, hideButtonsOnDrag = false, clickDragThreshold = 3, active, @@ -42,6 +44,7 @@ const InternalDragHandle = forwardRef( onDirectionClick={onDirectionClick} triggerMode={triggerMode} initialShowButtons={initialShowButtons} + controlledShowButtons={controlledShowButtons} hideButtonsOnDrag={hideButtonsOnDrag} clickDragThreshold={clickDragThreshold} > @@ -57,6 +60,7 @@ const InternalDragHandle = forwardRef( disabled={disabled} active={active} onPointerDown={onPointerDown} + onClick={onClick} onKeyDown={onKeyDown} /> diff --git a/src/internal/components/drag-handle/interfaces.ts b/src/internal/components/drag-handle/interfaces.ts index 724d29c91b..c7703c3675 100644 --- a/src/internal/components/drag-handle/interfaces.ts +++ b/src/internal/components/drag-handle/interfaces.ts @@ -19,12 +19,14 @@ export interface DragHandleProps { className?: string; onPointerDown?: React.PointerEventHandler; onKeyDown?: React.KeyboardEventHandler; + onClick?: React.MouseEventHandler; tooltipText?: string; directions?: Partial>; onDirectionClick?: (direction: DragHandleProps.Direction) => void; triggerMode?: TriggerMode; initialShowButtons?: boolean; + controlledShowButtons?: boolean; /** * Hide the UAP buttons when dragging is active. */ @@ -34,6 +36,7 @@ export interface DragHandleProps { * a drag. Small threshold needed for usability. */ clickDragThreshold?: number; + ref?: React.RefObject; } export namespace DragHandleProps { diff --git a/src/internal/components/drag-handle/styles.scss b/src/internal/components/drag-handle/styles.scss index 1a386c968e..06ed706dce 100644 --- a/src/internal/components/drag-handle/styles.scss +++ b/src/internal/components/drag-handle/styles.scss @@ -85,3 +85,7 @@ transform: rotate(90deg); } } + +.prevent-pointer { + pointer-events: none; +} diff --git a/src/internal/components/sortable-area/__tests__/sortable-area.test.tsx b/src/internal/components/sortable-area/__tests__/sortable-area.test.tsx index 3b4c28b337..aba7042bd7 100644 --- a/src/internal/components/sortable-area/__tests__/sortable-area.test.tsx +++ b/src/internal/components/sortable-area/__tests__/sortable-area.test.tsx @@ -45,6 +45,12 @@ test('renders all items with correct attributes', () => { disabled: false, onPointerDown: expect.anything(), onKeyDown: expect.anything(), + onDirectionClick: expect.anything(), + onClick: expect.anything(), + triggerMode: 'controlled', + controlledShowButtons: false, + ref: expect.anything(), + directions: undefined, }, }) ); diff --git a/src/internal/components/sortable-area/index.tsx b/src/internal/components/sortable-area/index.tsx index 2e96c7bd0c..b2eb82df74 100644 --- a/src/internal/components/sortable-area/index.tsx +++ b/src/internal/components/sortable-area/index.tsx @@ -12,6 +12,7 @@ import { Portal } from '@cloudscape-design/component-toolkit/internal'; import { fireNonCancelableEvent } from '../../events'; import { joinStrings } from '../../utils/strings'; import { SortableAreaProps } from './interfaces'; +import { EventName } from './keyboard-sensor/utilities/events'; import useDragAndDropReorder from './use-drag-and-drop-reorder'; import useLiveAnnouncements from './use-live-announcements'; @@ -27,10 +28,11 @@ export default function SortableArea({ disableReorder, i18nStrings, }: SortableAreaProps) { - const { activeItemId, setActiveItemId, collisionDetection, handleKeyDown, sensors } = useDragAndDropReorder({ - items, - itemDefinition, - }); + const { activeItemId, setActiveItemId, collisionDetection, handleKeyDown, sensors, isKeyboard } = + useDragAndDropReorder({ + items, + itemDefinition, + }); const activeItem = activeItemId ? items.find(item => itemDefinition.id(item) === activeItemId) : null; const isDragging = activeItemId !== null; const announcements = useLiveAnnouncements({ items, itemDefinition, isDragging, ...i18nStrings }); @@ -70,6 +72,7 @@ export default function SortableArea({ key={itemDefinition.id(item)} item={item} itemDefinition={itemDefinition} + showDirectionButtons={item === activeItem && isKeyboard.current} renderItem={renderItem} onKeyDown={handleKeyDown} dragHandleAriaLabel={i18nStrings?.dragHandleAriaLabel} @@ -84,6 +87,7 @@ export default function SortableArea({ className={clsx(styles['drag-overlay'], styles[`drag-overlay-${getBorderRadiusVariant(itemDefinition)}`])} dropAnimation={null} style={{ zIndex: 5000 }} + transition={isKeyboard.current ? 'transform 250ms' : ''} > {activeItem && renderItem({ @@ -126,12 +130,14 @@ function DraggableItem({ item, itemDefinition, dragHandleAriaLabel, + showDirectionButtons, onKeyDown, renderItem, }: { item: Item; itemDefinition: SortableAreaProps.ItemDefinition; dragHandleAriaLabel?: string; + showDirectionButtons: boolean; onKeyDown: (event: React.KeyboardEvent) => void; renderItem: (props: SortableAreaProps.RenderItemProps) => React.ReactNode; }) { @@ -157,6 +163,7 @@ function DraggableItem({ isDragging && clsx(styles.placeholder, styles[`placeholder-${getBorderRadiusVariant(itemDefinition)}`]), isSorting && styles.sorting ); + const dragHandleRef = useRef(null); return ( <> {renderItem({ @@ -173,6 +180,23 @@ function DraggableItem({ ariaLabel: joinStrings(dragHandleAriaLabel, itemDefinition.label(item)) ?? '', ariaDescribedby: attributes['aria-describedby'], disabled: attributes['aria-disabled'], + triggerMode: 'controlled', + controlledShowButtons: showDirectionButtons, + ref: dragHandleRef, + directions: showDirectionButtons + ? { + 'block-start': 'active', + 'block-end': 'active', + } + : undefined, + onDirectionClick: direction => { + const event = new Event(direction === 'block-start' ? EventName.CustomUp : EventName.CustomDown, { + bubbles: true, + cancelable: true, + }); + onKeyDown(event as any); + dragHandleRef.current?.dispatchEvent(event); + }, }, })} diff --git a/src/internal/components/sortable-area/keyboard-sensor/index.ts b/src/internal/components/sortable-area/keyboard-sensor/index.ts index 70cf1bf9c2..9450f36e13 100644 --- a/src/internal/components/sortable-area/keyboard-sensor/index.ts +++ b/src/internal/components/sortable-area/keyboard-sensor/index.ts @@ -1,9 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import type { Activators, SensorInstance } from '@dnd-kit/core'; -import { defaultCoordinates } from '@dnd-kit/core'; -import { KeyboardSensorOptions, KeyboardSensorProps } from '@dnd-kit/core'; +import type { Activators, SensorContext, SensorInstance, SensorProps, UniqueIdentifier } from '@dnd-kit/core'; +import { defaultCoordinates, KeyboardCode } from '@dnd-kit/core'; +import { KeyboardSensorOptions } from '@dnd-kit/core'; import { Coordinates, getOwnerDocument, @@ -18,23 +18,30 @@ import { EventName } from './utilities/events'; import { Listeners } from './utilities/listeners'; import { applyScroll } from './utilities/scroll'; -// Slightly modified version of @dnd-kit's KeyboardSensor: +// Heavily modified version of @dnd-kit's KeyboardSensor, to add support for "UAP"-button pointer interactions. // https://github.com/clauderic/dnd-kit/blob/master/packages/core/src/sensors/keyboard/KeyboardSensor.ts -// The only difference is that here, reordering is deactivated on blur, as in -// this PR: https://github.com/clauderic/dnd-kit/pull/1087. -// If it is merged, then @dnd-kit's KeyboardSensor can be used instead -// and all files under this directory (`keyboard-sensor`) can be removed. +export type KeyboardAndUAPCoordinateGetter = ( + event: Event, + args: { + active: UniqueIdentifier; + currentCoordinates: Coordinates; + context: SensorContext; + } +) => Coordinates | void; -// Changes from mainstream are marked below as "Customization" +type KeyboardAndUAPSensorOptions = KeyboardSensorOptions & { + coordinateGetter: KeyboardAndUAPCoordinateGetter; + onActivation?({ event }: { event: KeyboardEvent | MouseEvent }): void; +}; -export class KeyboardSensor implements SensorInstance { +export class KeyboardAndUAPSensor implements SensorInstance { public autoScrollEnabled = false; private referenceCoordinates: Coordinates | undefined; private listeners: Listeners; private windowListeners: Listeners; - constructor(private props: KeyboardSensorProps) { + constructor(private props: SensorProps) { const { event: { target }, } = props; @@ -43,6 +50,8 @@ export class KeyboardSensor implements SensorInstance { this.listeners = new Listeners(getOwnerDocument(target)); this.windowListeners = new Listeners(getWindow(target)); this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleCustomDirectionEvent = this.handleCustomDirectionEvent.bind(this); + this.handleEnd = this.handleEnd.bind(this); this.handleCancel = this.handleCancel.bind(this); this.attach(); @@ -54,10 +63,13 @@ export class KeyboardSensor implements SensorInstance { this.windowListeners.add(EventName.Resize, this.handleCancel); this.windowListeners.add(EventName.VisibilityChange, this.handleCancel); - // Customization: deactivate reordering on blur event - this.props.event.target?.addEventListener(EventName.Blur, this.handleCancel); + this.props.event.target?.addEventListener(EventName.Blur, this.handleEnd); - setTimeout(() => this.listeners.add(EventName.Keydown, this.handleKeyDown)); + setTimeout(() => { + this.listeners.add(EventName.Keydown, this.handleKeyDown); + this.listeners.add(EventName.CustomDown, this.handleCustomDirectionEvent); + this.listeners.add(EventName.CustomUp, this.handleCustomDirectionEvent); + }); } private handleStart() { @@ -73,8 +85,8 @@ export class KeyboardSensor implements SensorInstance { private handleKeyDown(event: Event) { if (isKeyboardEvent(event)) { - const { active, context, options } = this.props; - const { keyboardCodes = defaultKeyboardCodes, coordinateGetter } = options; + const { options } = this.props; + const { keyboardCodes = defaultKeyboardCodes } = options; const { code } = event; if (keyboardCodes.end.indexOf(code) !== -1) { @@ -87,32 +99,51 @@ export class KeyboardSensor implements SensorInstance { return; } - const { collisionRect } = context.current; - const currentCoordinates = collisionRect ? { x: collisionRect.left, y: collisionRect.top } : defaultCoordinates; - - if (!this.referenceCoordinates) { - this.referenceCoordinates = currentCoordinates; + switch (code) { + case KeyboardCode.Up: + this.handleDirectionalMove(event, 'up'); + break; + case KeyboardCode.Down: + this.handleDirectionalMove(event, 'down'); + break; } + } + } - if (!coordinateGetter) { - return; - } + private handleCustomDirectionEvent(event: Event) { + switch (event.type) { + case EventName.CustomUp: + this.handleDirectionalMove(event, 'up'); + break; + case EventName.CustomDown: + this.handleDirectionalMove(event, 'down'); + break; + } + } + + private handleDirectionalMove(event: Event, direction: 'up' | 'down') { + const { active, context, options } = this.props; + const { coordinateGetter } = options; + const { collisionRect } = context.current; + const currentCoordinates = collisionRect ? { x: collisionRect.left, y: collisionRect.top } : defaultCoordinates; - const newCoordinates = coordinateGetter(event, { - active, - context: context.current, - currentCoordinates, - }); + if (!this.referenceCoordinates) { + this.referenceCoordinates = currentCoordinates; + } - if (newCoordinates) { - const { scrollableAncestors } = context.current; - const direction = event.code; + const newCoordinates = coordinateGetter(event, { + active, + context: context.current, + currentCoordinates, + }); - const scrolled = applyScroll({ currentCoordinates, direction, newCoordinates, scrollableAncestors }); + if (newCoordinates) { + const { scrollableAncestors } = context.current; - if (!scrolled) { - this.handleMove(event, getCoordinatesDelta(newCoordinates, this.referenceCoordinates)); - } + const scrolled = applyScroll({ currentCoordinates, direction, newCoordinates, scrollableAncestors }); + + if (!scrolled) { + this.handleMove(event, getCoordinatesDelta(newCoordinates, this.referenceCoordinates)); } } } @@ -135,7 +166,6 @@ export class KeyboardSensor implements SensorInstance { private handleCancel(event: Event) { const { onCancel } = this.props; - // Customization: do not prevent browser from managing native focus if (event.type !== EventName.Blur) { event.preventDefault(); } @@ -144,16 +174,15 @@ export class KeyboardSensor implements SensorInstance { } private detach() { - // Customization: clean up listener for blur event this.props.event.target?.removeEventListener(EventName.Blur, this.handleCancel); this.listeners.removeAll(); this.windowListeners.removeAll(); } - static activators: Activators = [ + static activators: Activators = [ { - eventName: 'onKeyDown' as const, + eventName: 'onKeyDown', handler: (event: React.KeyboardEvent, { keyboardCodes = defaultKeyboardCodes, onActivation }, { active }) => { const { code } = event.nativeEvent; @@ -174,5 +203,17 @@ export class KeyboardSensor implements SensorInstance { return false; }, }, + { + eventName: 'onClick', + handler: ({ nativeEvent: event }: React.MouseEvent, { onActivation }) => { + if (event.button !== 0) { + return false; + } + + onActivation?.({ event }); + + return true; + }, + }, ]; } diff --git a/src/internal/components/sortable-area/keyboard-sensor/utilities/__tests__/scroll.test.ts b/src/internal/components/sortable-area/keyboard-sensor/utilities/__tests__/scroll.test.ts index e7df308714..843f9c6033 100644 --- a/src/internal/components/sortable-area/keyboard-sensor/utilities/__tests__/scroll.test.ts +++ b/src/internal/components/sortable-area/keyboard-sensor/utilities/__tests__/scroll.test.ts @@ -25,14 +25,14 @@ describe('applyScroll', () => { const newCoordinates = { x: 0, y: 10 }; it('returns true if scroll was applied', () => { - expect( - applyScroll({ currentCoordinates, direction: 'ArrowDown', newCoordinates, scrollableAncestors: [element] }) - ).toBe(true); + expect(applyScroll({ currentCoordinates, direction: 'down', newCoordinates, scrollableAncestors: [element] })).toBe( + true + ); }); it('returns false if scroll was not applied', () => { - expect( - applyScroll({ currentCoordinates, direction: 'ArrowUp', newCoordinates, scrollableAncestors: [element] }) - ).toBe(false); + expect(applyScroll({ currentCoordinates, direction: 'up', newCoordinates, scrollableAncestors: [element] })).toBe( + false + ); }); }); diff --git a/src/internal/components/sortable-area/keyboard-sensor/utilities/events.ts b/src/internal/components/sortable-area/keyboard-sensor/utilities/events.ts index d08b9afa96..be92d52e4b 100644 --- a/src/internal/components/sortable-area/keyboard-sensor/utilities/events.ts +++ b/src/internal/components/sortable-area/keyboard-sensor/utilities/events.ts @@ -5,4 +5,6 @@ export enum EventName { Keydown = 'keydown', Resize = 'resize', VisibilityChange = 'visibilitychange', + CustomDown = 'custom-movedown', + CustomUp = 'custom-moveup', } diff --git a/src/internal/components/sortable-area/keyboard-sensor/utilities/scroll.ts b/src/internal/components/sortable-area/keyboard-sensor/utilities/scroll.ts index d5fb042c9f..f46e18ab2f 100644 --- a/src/internal/components/sortable-area/keyboard-sensor/utilities/scroll.ts +++ b/src/internal/components/sortable-area/keyboard-sensor/utilities/scroll.ts @@ -1,6 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { KeyboardCode } from '@dnd-kit/core'; import { canUseDOM, Coordinates, subtract as getCoordinatesDelta } from '@dnd-kit/utilities'; function isDocumentScrollingElement(element: Element | null) { @@ -78,7 +77,7 @@ export function applyScroll({ scrollableAncestors, }: { currentCoordinates: Coordinates; - direction: string; + direction: 'up' | 'down'; newCoordinates: Coordinates; scrollableAncestors: Element[]; }) { @@ -89,25 +88,21 @@ export function applyScroll({ const clampedCoordinates = { y: Math.min( - direction === KeyboardCode.Down - ? scrollElementRect.bottom - scrollElementRect.height / 2 - : scrollElementRect.bottom, + direction === 'down' ? scrollElementRect.bottom - scrollElementRect.height / 2 : scrollElementRect.bottom, Math.max( - direction === KeyboardCode.Down - ? scrollElementRect.top - : scrollElementRect.top + scrollElementRect.height / 2, + direction === 'down' ? scrollElementRect.top : scrollElementRect.top + scrollElementRect.height / 2, newCoordinates.y ) ), }; - const canScrollY = (direction === KeyboardCode.Down && !isBottom) || (direction === KeyboardCode.Up && !isTop); + const canScrollY = (direction === 'down' && !isBottom) || (direction === 'up' && !isTop); if (canScrollY && clampedCoordinates.y !== newCoordinates.y) { const newScrollCoordinates = scrollContainer.scrollTop + coordinatesDelta.y; const canScrollToNewCoordinates = - (direction === KeyboardCode.Down && newScrollCoordinates <= maxScroll.y) || - (direction === KeyboardCode.Up && newScrollCoordinates >= minScroll.y); + (direction === 'down' && newScrollCoordinates <= maxScroll.y) || + (direction === 'up' && newScrollCoordinates >= minScroll.y); if (canScrollToNewCoordinates) { // We don't need to update coordinates, the scroll adjustment alone will trigger diff --git a/src/internal/components/sortable-area/styles.scss b/src/internal/components/sortable-area/styles.scss index d96bde948f..78c76a5bb4 100644 --- a/src/internal/components/sortable-area/styles.scss +++ b/src/internal/components/sortable-area/styles.scss @@ -19,9 +19,7 @@ border-end-start-radius: $border-radius; border-end-end-radius: $border-radius; - @include focus-visible.when-visible-unfocused { - @include styles.focus-highlight(0px, $border-radius); - } + @include styles.focus-highlight(0px, $border-radius); } .drag-overlay { diff --git a/src/internal/components/sortable-area/use-drag-and-drop-reorder.ts b/src/internal/components/sortable-area/use-drag-and-drop-reorder.ts index 0172256df0..59e82ba0d3 100644 --- a/src/internal/components/sortable-area/use-drag-and-drop-reorder.ts +++ b/src/internal/components/sortable-area/use-drag-and-drop-reorder.ts @@ -6,7 +6,6 @@ import { closestCenter, CollisionDetection, DroppableContainer, - KeyboardCoordinateGetter, PointerSensor, UniqueIdentifier, useSensor, @@ -15,17 +14,8 @@ import { import { hasSortableData } from '@dnd-kit/sortable'; import { SortableAreaProps } from './interfaces'; -import { KeyboardSensor } from './keyboard-sensor'; - -enum KeyboardCode { - Space = 'Space', - Down = 'ArrowDown', - Right = 'ArrowRight', - Left = 'ArrowLeft', - Up = 'ArrowUp', - Esc = 'Escape', - Enter = 'Enter', -} +import { KeyboardAndUAPCoordinateGetter, KeyboardAndUAPSensor } from './keyboard-sensor'; +import { EventName } from './keyboard-sensor/utilities/events'; // A custom collision detection algorithm is used when using a keyboard to // work around an unexpected behavior when reordering items of variable height @@ -67,9 +57,9 @@ export default function useDragAndDropReorder({ if (isKeyboard.current && activeItemId) { const currentTargetIndex = items.findIndex(item => itemDefinition.id(item) === activeItemId) + positionDelta.current; - if (event.key === 'ArrowDown' && currentTargetIndex < items.length - 1) { + if ((event.key === 'ArrowDown' || event.type === EventName.CustomDown) && currentTargetIndex < items.length - 1) { positionDelta.current += 1; - } else if (event.key === 'ArrowUp' && currentTargetIndex > 0) { + } else if ((event.key === 'ArrowUp' || event.type === EventName.CustomUp) && currentTargetIndex > 0) { positionDelta.current -= 1; } } @@ -110,48 +100,52 @@ export default function useDragAndDropReorder({ } }; - const coordinateGetter: KeyboardCoordinateGetter = ( + const coordinateGetter: KeyboardAndUAPCoordinateGetter = ( event, { context: { active, collisionRect, droppableRects, droppableContainers } } ) => { - if (event.code === KeyboardCode.Up || event.code === KeyboardCode.Down) { - event.preventDefault(); + event.preventDefault(); - if (!active || !collisionRect) { - return; - } + if (!active || !collisionRect) { + return; + } - const closestId = getClosestId(active); - - if (closestId !== null) { - const activeDroppable = droppableContainers.get(active.id); - const newDroppable = droppableContainers.get(closestId); - const newRect = newDroppable ? droppableRects.get(newDroppable.id) : null; - const newNode = newDroppable?.node.current; - - if (newNode && newRect && activeDroppable && newDroppable) { - const isAfterActive = isAfter(activeDroppable, newDroppable); - const offset = { - x: isAfterActive ? collisionRect.width - newRect.width : 0, - y: isAfterActive ? collisionRect.height - newRect.height : 0, - }; - const rectCoordinates = { - x: newRect.left, - y: newRect.top, - }; - - return { - x: rectCoordinates.x - offset.x, - y: rectCoordinates.y - offset.y, - }; - } + const closestId = getClosestId(active); + + if (closestId !== null) { + const activeDroppable = droppableContainers.get(active.id); + const newDroppable = droppableContainers.get(closestId); + const newRect = newDroppable ? droppableRects.get(newDroppable.id) : null; + const newNode = newDroppable?.node.current; + + if (newNode && newRect && activeDroppable && newDroppable) { + const isAfterActive = isAfter(activeDroppable, newDroppable); + const offset = { + x: isAfterActive ? collisionRect.width - newRect.width : 0, + y: isAfterActive ? collisionRect.height - newRect.height : 0, + }; + const rectCoordinates = { + x: newRect.left, + y: newRect.top, + }; + + return { + x: rectCoordinates.x - offset.x, + y: rectCoordinates.y - offset.y, + }; } } }; const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { + useSensor(PointerSensor, { + activationConstraint: { + // allow KeyboardSensor (click to display UAP) to take priority + // if handle is clicked without movement + distance: 1, + }, + }), + useSensor(KeyboardAndUAPSensor, { coordinateGetter, onActivation: () => { isKeyboard.current = true; @@ -166,6 +160,7 @@ export default function useDragAndDropReorder({ coordinateGetter, handleKeyDown, sensors, + isKeyboard, }; } diff --git a/src/list/__tests__/list-sortable.test.tsx b/src/list/__tests__/list-sortable.test.tsx index ea1dced685..b1b2023992 100644 --- a/src/list/__tests__/list-sortable.test.tsx +++ b/src/list/__tests__/list-sortable.test.tsx @@ -6,6 +6,7 @@ import { fireEvent, render } from '@testing-library/react'; import TestI18nProvider from '../../../lib/components/i18n/testing'; import List, { ListProps } from '../../../lib/components/list'; import createWrapper from '../../../lib/components/test-utils/dom'; +import InternalDragHandleWrapper from '../../../lib/components/test-utils/dom/internal/drag-handle'; interface Item { id: string; @@ -133,4 +134,48 @@ describe('List - Sortable', () => { expect(wrapper.findItemByIndex(2)!.getElement()).toHaveTextContent('Item 1'); await expectAnnouncement('Item moved from position 1 to position 2 of 3'); }); + + test('ignores other keys', async () => { + const { wrapper } = renderSortableList(); + const dragHandle = wrapper.findItemByIndex(1)!.findDragHandle()!.getElement(); + pressKey(dragHandle, 'Space'); + await expectAnnouncement('Picked up item at position 1 of 3'); + pressKey(dragHandle, 'D'); + pressKey(dragHandle, 'Space'); + expect(wrapper.findItemByIndex(1)!.getElement()).toHaveTextContent('Item 1'); + expect(wrapper.findItemByIndex(2)!.getElement()).toHaveTextContent('Item 2'); + }); + + test('can move an item with UAP buttons', async () => { + const { wrapper } = renderSortableList(); + const dragHandle = wrapper.findItemByIndex(1)!.findDragHandle()!.getElement(); + const dragWrapper = new InternalDragHandleWrapper(document.body); + expect(dragWrapper.findVisibleDirectionButtonBlockEnd()).toBeFalsy(); + + dragHandle.click(); + expect(dragWrapper.findVisibleDirectionButtonBlockEnd()).toBeTruthy(); + await expectAnnouncement('Picked up item at position 1 of 3'); + + dragWrapper.findVisibleDirectionButtonBlockEnd()!.click(); + await expectAnnouncement('Moving item to position 2 of 3'); + dragWrapper.findVisibleDirectionButtonBlockEnd()!.click(); + await expectAnnouncement('Moving item to position 3 of 3'); + dragWrapper.findVisibleDirectionButtonBlockStart()!.click(); + await expectAnnouncement('Moving item to position 2 of 3'); + + pressKey(dragHandle, 'Enter'); + expect(wrapper.findItemByIndex(1)!.getElement()).toHaveTextContent('Item 2'); + expect(wrapper.findItemByIndex(2)!.getElement()).toHaveTextContent('Item 1'); + await expectAnnouncement('Item moved from position 1 to position 2 of 3'); + }); + + test('non-primary pointer events do not activate UAP', () => { + const { wrapper } = renderSortableList(); + const dragHandle = wrapper.findItemByIndex(1)!.findDragHandle()!; + const dragWrapper = new InternalDragHandleWrapper(document.body); + expect(dragWrapper.findVisibleDirectionButtonBlockEnd()).toBeFalsy(); + + dragHandle.click({ button: 1 }); + expect(dragWrapper.findVisibleDirectionButtonBlockEnd()).toBeFalsy(); + }); });