Skip to content

Commit fa5bdbd

Browse files
chore: Add dnd drag buttons to list (#3652)
1 parent 36d71ad commit fa5bdbd

File tree

20 files changed

+411
-129
lines changed

20 files changed

+411
-129
lines changed

pages/list/sortable-permutations.page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ const items: Item[] = [
2525
{ content: 'Item 4', description: 'Description', timestamp: 'January 1 2025' },
2626
];
2727

28+
const ControlledList = (props: ListProps<Item>) => {
29+
const [items, setItems] = React.useState(props.items);
30+
return <List {...props} items={items} onSortingChange={e => setItems(e.detail.items)} />;
31+
};
32+
2833
const permutations = createPermutations<ListProps<Item> & { viewportWidth: number; _sortable: boolean | 'disabled' }>([
2934
{
3035
viewportWidth: [200, 400],
@@ -57,7 +62,7 @@ export default function ListItemPermutations() {
5762
permutations={permutations}
5863
render={({ viewportWidth, _sortable, ...permutation }) => (
5964
<div style={{ width: viewportWidth, borderRight: '1px solid red', padding: '4px', overflow: 'hidden' }}>
60-
<List {...permutation} sortable={!!_sortable} sortDisabled={_sortable === 'disabled'} />
65+
<ControlledList {...permutation} sortable={!!_sortable} sortDisabled={_sortable === 'disabled'} />
6166
</div>
6267
)}
6368
/>

src/collection-preferences/content-display/__integ__/content-reordering.test.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
44

55
import createWrapper from '../../../../lib/components/test-utils/selectors';
6+
import InternalDragHandleWrapper from '../../../../lib/components/test-utils/selectors/internal/drag-handle';
67
import ContentDisplayPageObject from './pages/content-display-page';
78

89
const windowDimensions = {
@@ -113,9 +114,37 @@ describe('Collection preferences - Content Display preference', () => {
113114
});
114115
});
115116

117+
describe('reorders content with UAP buttons', () => {
118+
const dragHandleWrapper = new InternalDragHandleWrapper('body');
119+
test(
120+
'can move item and commit by clicking away',
121+
setupTest(async page => {
122+
page.wrapper = createWrapper().findCollectionPreferences('.cp-1');
123+
await page.openCollectionPreferencesModal();
124+
125+
await page.click(page.findDragHandle(0).toSelector());
126+
await page.expectAnnouncement('Picked up item at position 1 of 6');
127+
128+
const downButton = dragHandleWrapper.findVisibleDirectionButtonBlockEnd().toSelector();
129+
const upButton = dragHandleWrapper.findVisibleDirectionButtonBlockStart().toSelector();
130+
131+
await page.click(downButton);
132+
await page.expectAnnouncement('Moving item to position 2 of 6');
133+
await page.click(downButton);
134+
await page.expectAnnouncement('Moving item to position 3 of 6');
135+
await page.click(upButton);
136+
await page.expectAnnouncement('Moving item to position 2 of 6');
137+
138+
await page.click(page.wrapper.findModal().findContentDisplayPreference().findTitle().toSelector());
139+
await expect(page.containsOptionsInOrder(['Item 2', 'Item 1'])).resolves.toBe(true);
140+
await page.expectAnnouncement('Item moved from position 1 to position 2 of 6');
141+
})
142+
);
143+
});
144+
116145
describe('reorders content with keyboard', () => {
117146
test(
118-
'cancels reordering when pressing Tab',
147+
'cancels reordering when pressing Escape',
119148
setupTest(async page => {
120149
page.wrapper = createWrapper().findCollectionPreferences('.cp-1');
121150
await page.openCollectionPreferencesModal();
@@ -127,15 +156,15 @@ describe('Collection preferences - Content Display preference', () => {
127156
await page.expectAnnouncement('Picked up item at position 1 of 6');
128157
await page.keys('ArrowDown');
129158
await page.expectAnnouncement('Moving item to position 2 of 6');
130-
await page.keys('Tab');
159+
await page.keys('Escape');
131160

132161
await expect(await page.containsOptionsInOrder(['Item 1', 'Item 2'])).toBe(true);
133162
await page.expectAnnouncement('Reordering canceled');
134163
})
135164
);
136165

137166
test(
138-
'cancels reordering when clicking somewhere else',
167+
'submits reordering when clicking somewhere else',
139168
setupTest(async page => {
140169
page.wrapper = createWrapper().findCollectionPreferences('.cp-1');
141170
await page.openCollectionPreferencesModal();
@@ -149,8 +178,8 @@ describe('Collection preferences - Content Display preference', () => {
149178
await page.expectAnnouncement('Moving item to position 2 of 6');
150179
await page.click(page.wrapper.findModal().findContentDisplayPreference().findTitle().toSelector());
151180

152-
await expect(await page.containsOptionsInOrder(['Item 1', 'Item 2'])).toBe(true);
153-
await page.expectAnnouncement('Reordering canceled');
181+
await expect(await page.containsOptionsInOrder(['Item 2', 'Item 1'])).toBe(true);
182+
await page.expectAnnouncement('Item moved from position 1 to position 2 of 6');
154183
})
155184
);
156185

src/collection-preferences/content-display/__integ__/pages/content-display-page.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import CollectionPreferencesPageObject from '../../../__integ__/pages/collection
66
export default class ContentDisplayPageObject extends CollectionPreferencesPageObject {
77
async containsOptionsInOrder(options: string[]) {
88
const texts = await this.getElementsText(this.findOptions().toSelector());
9-
return texts.join(`\n`).includes(options.join('\n'));
9+
const result = texts.join(`\n`).includes(options.join('\n'));
10+
if (!result) {
11+
throw new Error(`Options are not in the expected order:
12+
Expected: ${options.join(', ')}
13+
Found: ${texts.join(', ')}`);
14+
}
15+
return true;
1016
}
1117

1218
async expectAnnouncement(announcement: string) {

src/collection-preferences/content-display/__integ__/pages/dnd-page-object.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export default class DndPageObject extends BasePageObject {
2727
id: 'event',
2828
parameters: { pointerType: 'mouse' },
2929
actions: [
30-
{ type: 'pointerMove', duration: 100, origin: 'pointer', x: xOffset, y: yOffset },
30+
{ type: 'pointerMove', duration: 100, origin: 'pointer', x: xOffset / 2, y: yOffset / 2 },
31+
{ type: 'pointerMove', duration: 100, origin: 'pointer', x: xOffset / 2, y: yOffset / 2 },
3132
{ type: 'pause', duration: 150 },
3233
],
3334
},

src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,118 @@ describe('triggerMode = keyboard-activate', () => {
315315
});
316316
});
317317

318+
describe('triggerMode = controlled', () => {
319+
test('shows direction buttons when specified', () => {
320+
const { dragHandle } = renderDragHandle({
321+
directions: { 'block-start': 'active', 'block-end': 'active' },
322+
triggerMode: 'controlled',
323+
controlledShowButtons: true,
324+
});
325+
326+
document.body.dataset.awsuiFocusVisible = 'true';
327+
dragHandle.focus();
328+
expect(getDirectionButton('block-start')).toBeInTheDocument();
329+
expect(getDirectionButton('block-end')).toBeInTheDocument();
330+
expect(getDirectionButton('inline-start')).toBeNull();
331+
expect(getDirectionButton('inline-end')).toBeNull();
332+
});
333+
334+
test('does not show direction buttons when focus enters the button', () => {
335+
const { dragHandle } = renderDragHandle({
336+
directions: { 'block-start': 'active', 'block-end': 'active' },
337+
triggerMode: 'controlled',
338+
});
339+
340+
document.body.dataset.awsuiFocusVisible = 'true';
341+
dragHandle.focus();
342+
expectDirectionButtonToBeHidden('block-start');
343+
expectDirectionButtonToBeHidden('block-end');
344+
expect(getDirectionButton('inline-start')).toBeNull();
345+
expect(getDirectionButton('inline-end')).toBeNull();
346+
});
347+
348+
test.each(['Enter', ' '])('does not show direction buttons when "%s" key is pressed on the focused button', key => {
349+
const { dragHandle } = renderDragHandle({
350+
directions: { 'block-start': 'active', 'block-end': 'active' },
351+
triggerMode: 'controlled',
352+
});
353+
354+
document.body.dataset.awsuiFocusVisible = 'true';
355+
dragHandle.focus();
356+
expectDirectionButtonToBeHidden('block-start');
357+
expectDirectionButtonToBeHidden('block-end');
358+
expect(getDirectionButton('inline-start')).not.toBeInTheDocument();
359+
expect(getDirectionButton('inline-end')).not.toBeInTheDocument();
360+
361+
fireEvent.keyDown(dragHandle, { key });
362+
363+
expectDirectionButtonToBeHidden('block-start');
364+
expectDirectionButtonToBeHidden('block-end');
365+
expect(getDirectionButton('inline-start')).not.toBeInTheDocument();
366+
expect(getDirectionButton('inline-end')).not.toBeInTheDocument();
367+
});
368+
369+
test('when focused and other key is pressed, it should not show the direction buttons', () => {
370+
const { dragHandle } = renderDragHandle({
371+
directions: { 'block-start': 'active', 'block-end': 'active' },
372+
triggerMode: 'controlled',
373+
});
374+
375+
document.body.dataset.awsuiFocusVisible = 'true';
376+
dragHandle.focus();
377+
expectDirectionButtonToBeHidden('block-start');
378+
expectDirectionButtonToBeHidden('block-end');
379+
expect(getDirectionButton('inline-start')).not.toBeInTheDocument();
380+
381+
fireEvent.keyDown(dragHandle, { key: 'A' });
382+
expectDirectionButtonToBeHidden('block-start');
383+
expectDirectionButtonToBeHidden('block-end');
384+
expect(getDirectionButton('inline-start')).not.toBeInTheDocument();
385+
});
386+
387+
test('does not hide direction buttons when focus leaves the button', () => {
388+
const { dragHandle } = renderDragHandle({
389+
directions: { 'block-start': 'active', 'block-end': 'active' },
390+
triggerMode: 'controlled',
391+
controlledShowButtons: true,
392+
});
393+
394+
document.body.dataset.awsuiFocusVisible = 'true';
395+
396+
dragHandle.focus();
397+
expect(getDirectionButton('block-start')).toBeInTheDocument();
398+
expect(getDirectionButton('block-end')).toBeInTheDocument();
399+
expect(getDirectionButton('inline-start')).not.toBeInTheDocument();
400+
expect(getDirectionButton('inline-end')).not.toBeInTheDocument();
401+
402+
fireEvent.blur(dragHandle);
403+
expect(getDirectionButton('block-start')).toBeInTheDocument();
404+
expect(getDirectionButton('block-end')).toBeInTheDocument();
405+
});
406+
407+
test.each(['Enter', ' '])('does not hide direction buttons when toggling "%s" key', key => {
408+
const { dragHandle } = renderDragHandle({
409+
directions: { 'block-start': 'active', 'block-end': 'active' },
410+
triggerMode: 'controlled',
411+
controlledShowButtons: true,
412+
});
413+
414+
document.body.dataset.awsuiFocusVisible = 'true';
415+
416+
fireEvent.keyDown(dragHandle, { key });
417+
418+
expect(getDirectionButton('block-start')).toBeInTheDocument();
419+
expect(getDirectionButton('block-end')).toBeInTheDocument();
420+
expect(getDirectionButton('inline-start')).not.toBeInTheDocument();
421+
expect(getDirectionButton('inline-end')).not.toBeInTheDocument();
422+
423+
fireEvent.keyDown(dragHandle, { key });
424+
425+
expect(getDirectionButton('block-start')).toBeInTheDocument();
426+
expect(getDirectionButton('block-end')).toBeInTheDocument();
427+
});
428+
});
429+
318430
test('shows direction buttons when clicked', () => {
319431
const { dragHandle } = renderDragHandle({
320432
directions: { 'block-start': 'active' },

src/internal/components/drag-handle-wrapper/index.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default function DragHandleWrapper({
2121
onDirectionClick,
2222
triggerMode = 'focus',
2323
initialShowButtons = false,
24+
controlledShowButtons = false,
2425
hideButtonsOnDrag,
2526
clickDragThreshold,
2627
}: DragHandleWrapperProps) {
@@ -161,7 +162,7 @@ export default function DragHandleWrapper({
161162
event.key !== 'Control' &&
162163
event.key !== 'Meta' &&
163164
event.key !== 'Shift' &&
164-
triggerMode !== 'keyboard-activate'
165+
triggerMode === 'focus'
165166
) {
166167
// Pressing any other key will display the focus-visible ring around the
167168
// drag handle if it's in focus, so we should also show the buttons now.
@@ -179,9 +180,11 @@ export default function DragHandleWrapper({
179180
onDirectionClick?.(direction);
180181
};
181182

183+
const _showButtons = triggerMode === 'controlled' ? controlledShowButtons : showButtons;
184+
182185
return (
183186
<div
184-
className={clsx(styles['drag-handle-wrapper'], showButtons && styles['drag-handle-wrapper-open'])}
187+
className={clsx(styles['drag-handle-wrapper'], _showButtons && styles['drag-handle-wrapper-open'])}
185188
ref={wrapperRef}
186189
onFocus={onWrapperFocusIn}
187190
onBlur={onWrapperFocusOut}
@@ -196,39 +199,39 @@ export default function DragHandleWrapper({
196199
{children}
197200
</div>
198201

199-
{!isDisabled && !showButtons && showTooltip && tooltipText && (
202+
{!isDisabled && !_showButtons && showTooltip && tooltipText && (
200203
<Tooltip trackRef={dragHandleRef} value={tooltipText} onDismiss={() => setShowTooltip(false)} />
201204
)}
202205
</div>
203206

204-
<PortalOverlay track={dragHandleRef} isDisabled={!showButtons}>
207+
<PortalOverlay track={dragHandleRef} isDisabled={!_showButtons}>
205208
{directions['block-start'] && (
206209
<DirectionButton
207-
show={!isDisabled && showButtons}
210+
show={!isDisabled && _showButtons}
208211
direction="block-start"
209212
state={directions['block-start']}
210213
onClick={() => onInternalDirectionClick('block-start')}
211214
/>
212215
)}
213216
{directions['block-end'] && (
214217
<DirectionButton
215-
show={!isDisabled && showButtons}
218+
show={!isDisabled && _showButtons}
216219
direction="block-end"
217220
state={directions['block-end']}
218221
onClick={() => onInternalDirectionClick('block-end')}
219222
/>
220223
)}
221224
{directions['inline-start'] && (
222225
<DirectionButton
223-
show={!isDisabled && showButtons}
226+
show={!isDisabled && _showButtons}
224227
direction="inline-start"
225228
state={directions['inline-start']}
226229
onClick={() => onInternalDirectionClick('inline-start')}
227230
/>
228231
)}
229232
{directions['inline-end'] && (
230233
<DirectionButton
231-
show={!isDisabled && showButtons}
234+
show={!isDisabled && _showButtons}
232235
direction="inline-end"
233236
state={directions['inline-end']}
234237
onClick={() => onInternalDirectionClick('inline-end')}

src/internal/components/drag-handle-wrapper/interfaces.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
export type Direction = 'block-start' | 'block-end' | 'inline-start' | 'inline-end';
55
export type DirectionState = 'active' | 'disabled';
6-
export type TriggerMode = 'focus' | 'keyboard-activate';
6+
export type TriggerMode = 'focus' | 'keyboard-activate' | 'controlled';
77

88
export interface DragHandleWrapperProps {
99
directions: Partial<Record<Direction, DirectionState>>;
@@ -12,6 +12,7 @@ export interface DragHandleWrapperProps {
1212
children: React.ReactNode;
1313
triggerMode?: TriggerMode;
1414
initialShowButtons?: boolean;
15+
controlledShowButtons?: boolean;
1516
hideButtonsOnDrag: boolean;
1617
clickDragThreshold: number;
1718
}

src/internal/components/drag-handle/button.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,18 @@ const DragHandleButton = forwardRef(
2626
ariaValue,
2727
disabled,
2828
onPointerDown,
29+
onClick,
2930
onKeyDown,
3031
}: DragHandleProps,
3132
ref: React.Ref<Element>
3233
) => {
3334
const dragHandleRefObject = useRef<HTMLDivElement>(null);
3435

3536
const iconProps: IconProps = (() => {
36-
const shared = { variant: disabled ? ('disabled' as const) : undefined, size };
37+
const shared = {
38+
variant: disabled ? ('disabled' as const) : undefined,
39+
size,
40+
};
3741
switch (variant) {
3842
case 'drag-indicator':
3943
return { ...shared, name: 'drag-indicator' };
@@ -73,9 +77,13 @@ const DragHandleButton = forwardRef(
7377
aria-valuemin={ariaValue?.valueMin}
7478
aria-valuenow={ariaValue?.valueNow}
7579
onPointerDown={onPointerDown}
80+
onClick={onClick}
7681
onKeyDown={onKeyDown}
7782
>
78-
<InternalIcon {...iconProps} />
83+
{/* ensure that events happen on the parent div, not the icon */}
84+
<div className={styles['prevent-pointer']}>
85+
<InternalIcon {...iconProps} />
86+
</div>
7987
</div>
8088
);
8189
}

src/internal/components/drag-handle/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ const InternalDragHandle = forwardRef(
2222
disabled,
2323
directions = {},
2424
onPointerDown,
25+
onClick,
2526
onKeyDown,
2627
onDirectionClick,
2728
triggerMode,
2829
initialShowButtons,
30+
controlledShowButtons,
2931
hideButtonsOnDrag = false,
3032
clickDragThreshold = 3,
3133
active,
@@ -42,6 +44,7 @@ const InternalDragHandle = forwardRef(
4244
onDirectionClick={onDirectionClick}
4345
triggerMode={triggerMode}
4446
initialShowButtons={initialShowButtons}
47+
controlledShowButtons={controlledShowButtons}
4548
hideButtonsOnDrag={hideButtonsOnDrag}
4649
clickDragThreshold={clickDragThreshold}
4750
>
@@ -57,6 +60,7 @@ const InternalDragHandle = forwardRef(
5760
disabled={disabled}
5861
active={active}
5962
onPointerDown={onPointerDown}
63+
onClick={onClick}
6064
onKeyDown={onKeyDown}
6165
/>
6266
</DragHandleWrapper>

0 commit comments

Comments
 (0)