Skip to content

chore: Add dnd drag buttons to list #3652

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion pages/list/sortable-permutations.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ const items: Item[] = [
{ content: 'Item 4', description: 'Description', timestamp: 'January 1 2025' },
];

const ControlledList = (props: ListProps<Item>) => {
const [items, setItems] = React.useState(props.items);
return <List {...props} items={items} onSortingChange={e => setItems(e.detail.items)} />;
};

const permutations = createPermutations<ListProps<Item> & { viewportWidth: number; _sortable: boolean | 'disabled' }>([
{
viewportWidth: [200, 400],
Expand Down Expand Up @@ -57,7 +62,7 @@ export default function ListItemPermutations() {
permutations={permutations}
render={({ viewportWidth, _sortable, ...permutation }) => (
<div style={{ width: viewportWidth, borderRight: '1px solid red', padding: '4px', overflow: 'hidden' }}>
<List {...permutation} sortable={!!_sortable} sortDisabled={_sortable === 'disabled'} />
<ControlledList {...permutation} sortable={!!_sortable} sortDisabled={_sortable === 'disabled'} />
</div>
)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
Expand All @@ -127,15 +156,15 @@ 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');
})
);

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();
Expand All @@ -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');
})
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
19 changes: 11 additions & 8 deletions src/internal/components/drag-handle-wrapper/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function DragHandleWrapper({
onDirectionClick,
triggerMode = 'focus',
initialShowButtons = false,
controlledShowButtons = false,
hideButtonsOnDrag,
clickDragThreshold,
}: DragHandleWrapperProps) {
Expand Down Expand Up @@ -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.
Expand All @@ -179,9 +180,11 @@ export default function DragHandleWrapper({
onDirectionClick?.(direction);
};

const _showButtons = triggerMode === 'controlled' ? controlledShowButtons : showButtons;

return (
<div
className={clsx(styles['drag-handle-wrapper'], showButtons && styles['drag-handle-wrapper-open'])}
className={clsx(styles['drag-handle-wrapper'], _showButtons && styles['drag-handle-wrapper-open'])}
ref={wrapperRef}
onFocus={onWrapperFocusIn}
onBlur={onWrapperFocusOut}
Expand All @@ -196,39 +199,39 @@ export default function DragHandleWrapper({
{children}
</div>

{!isDisabled && !showButtons && showTooltip && tooltipText && (
{!isDisabled && !_showButtons && showTooltip && tooltipText && (
<Tooltip trackRef={dragHandleRef} value={tooltipText} onDismiss={() => setShowTooltip(false)} />
)}
</div>

<PortalOverlay track={dragHandleRef} isDisabled={!showButtons}>
<PortalOverlay track={dragHandleRef} isDisabled={!_showButtons}>
{directions['block-start'] && (
<DirectionButton
show={!isDisabled && showButtons}
show={!isDisabled && _showButtons}
direction="block-start"
state={directions['block-start']}
onClick={() => onInternalDirectionClick('block-start')}
/>
)}
{directions['block-end'] && (
<DirectionButton
show={!isDisabled && showButtons}
show={!isDisabled && _showButtons}
direction="block-end"
state={directions['block-end']}
onClick={() => onInternalDirectionClick('block-end')}
/>
)}
{directions['inline-start'] && (
<DirectionButton
show={!isDisabled && showButtons}
show={!isDisabled && _showButtons}
direction="inline-start"
state={directions['inline-start']}
onClick={() => onInternalDirectionClick('inline-start')}
/>
)}
{directions['inline-end'] && (
<DirectionButton
show={!isDisabled && showButtons}
show={!isDisabled && _showButtons}
direction="inline-end"
state={directions['inline-end']}
onClick={() => onInternalDirectionClick('inline-end')}
Expand Down
3 changes: 2 additions & 1 deletion src/internal/components/drag-handle-wrapper/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<Direction, DirectionState>>;
Expand All @@ -12,6 +12,7 @@ export interface DragHandleWrapperProps {
children: React.ReactNode;
triggerMode?: TriggerMode;
initialShowButtons?: boolean;
controlledShowButtons?: boolean;
hideButtonsOnDrag: boolean;
clickDragThreshold: number;
}
12 changes: 10 additions & 2 deletions src/internal/components/drag-handle/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@ const DragHandleButton = forwardRef(
ariaValue,
disabled,
onPointerDown,
onClick,
onKeyDown,
}: DragHandleProps,
ref: React.Ref<Element>
) => {
const dragHandleRefObject = useRef<HTMLDivElement>(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' };
Expand Down Expand Up @@ -73,9 +77,13 @@ const DragHandleButton = forwardRef(
aria-valuemin={ariaValue?.valueMin}
aria-valuenow={ariaValue?.valueNow}
onPointerDown={onPointerDown}
onClick={onClick}
onKeyDown={onKeyDown}
>
<InternalIcon {...iconProps} />
{/* ensure that events happen on the parent div, not the icon */}
<div className={styles['prevent-pointer']}>
<InternalIcon {...iconProps} />
</div>
</div>
);
}
Expand Down
4 changes: 4 additions & 0 deletions src/internal/components/drag-handle/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ const InternalDragHandle = forwardRef(
disabled,
directions = {},
onPointerDown,
onClick,
onKeyDown,
onDirectionClick,
triggerMode,
initialShowButtons,
controlledShowButtons,
hideButtonsOnDrag = false,
clickDragThreshold = 3,
active,
Expand All @@ -42,6 +44,7 @@ const InternalDragHandle = forwardRef(
onDirectionClick={onDirectionClick}
triggerMode={triggerMode}
initialShowButtons={initialShowButtons}
controlledShowButtons={controlledShowButtons}
hideButtonsOnDrag={hideButtonsOnDrag}
clickDragThreshold={clickDragThreshold}
>
Expand All @@ -57,6 +60,7 @@ const InternalDragHandle = forwardRef(
disabled={disabled}
active={active}
onPointerDown={onPointerDown}
onClick={onClick}
onKeyDown={onKeyDown}
/>
</DragHandleWrapper>
Expand Down
Loading
Loading