Skip to content

Commit 7c5814b

Browse files
committed
feat: Add single-pointer support for sortable lists
1 parent a0595cc commit 7c5814b

File tree

21 files changed

+394
-131
lines changed

21 files changed

+394
-131
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(await page.containsOptionsInOrder(['Item 2', 'Item 1'])).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
},
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React from 'react';
4+
import { render } from '@testing-library/react';
5+
6+
import InternalIcon from '../../../lib/components/icon/internal';
7+
8+
import styles from '../../../lib/components/icon/styles.css.js';
9+
10+
describe('internal icon props', () => {
11+
test('should prevent pointer events', () => {
12+
const { container } = render(<InternalIcon name="add-plus" __preventPointerEvents={true} />);
13+
expect(container.querySelector('span')).toHaveClass(styles['prevent-pointer-events']);
14+
});
15+
});

src/icon/internal.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import React, { useContext, useLayoutEffect, useRef, useState } from 'react';
3+
import React, { CSSProperties, useContext, useLayoutEffect, useRef, useState } from 'react';
44
import clsx from 'clsx';
55

66
import { useMergeRefs, warnOnce } from '@cloudscape-design/component-toolkit/internal';
@@ -16,6 +16,7 @@ import styles from './styles.css.js';
1616
type InternalIconProps = IconProps &
1717
InternalBaseComponentProps & {
1818
badge?: boolean;
19+
__preventPointerEvents?: boolean;
1920
};
2021

2122
function iconSizeMap(height: number | null) {
@@ -46,6 +47,7 @@ const InternalIcon = ({
4647
ariaLabel,
4748
svg,
4849
badge,
50+
__preventPointerEvents,
4951
__internalRootRef = null,
5052
...props
5153
}: InternalIconProps) => {
@@ -56,7 +58,7 @@ const InternalIcon = ({
5658
const [parentHeight, setParentHeight] = useState<number | null>(null);
5759
const contextualSize = size === 'inherit';
5860
const iconSize = contextualSize ? iconSizeMap(parentHeight) : size;
59-
const inlineStyles = contextualSize && parentHeight !== null ? { height: `${parentHeight}px` } : {};
61+
const inlineStyles: CSSProperties = contextualSize && parentHeight !== null ? { height: `${parentHeight}px` } : {};
6062
const baseProps = getBaseProps(props);
6163

6264
baseProps.className = clsx(
@@ -67,7 +69,8 @@ const InternalIcon = ({
6769
!contextualSize && styles[`size-${iconSize}-mapped-height`],
6870
styles[`size-${iconSize}`],
6971
styles[`variant-${variant}`],
70-
styles[`name-${name}`]
72+
styles[`name-${name}`],
73+
__preventPointerEvents && styles['prevent-pointer-events']
7174
);
7275

7376
// Possible infinite loop is not a concern here because line

src/icon/styles.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,7 @@
6767
inset-block-start: 0px;
6868
inset-inline-end: -3px;
6969
}
70+
71+
.prevent-pointer-events {
72+
pointer-events: none;
73+
}

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')}

0 commit comments

Comments
 (0)