Skip to content

Commit 4d6bf1e

Browse files
committed
feat: Add single-pointer support for sortable lists
1 parent 190a4a8 commit 4d6bf1e

File tree

18 files changed

+351
-124
lines changed

18 files changed

+351
-124
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
/>
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')}

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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,20 @@ 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+
// ensure that events happen on the div, not the icon
41+
__preventPointerEvents: true,
42+
};
3743
switch (variant) {
3844
case 'drag-indicator':
3945
return { ...shared, name: 'drag-indicator' };
@@ -73,6 +79,7 @@ const DragHandleButton = forwardRef(
7379
aria-valuemin={ariaValue?.valueMin}
7480
aria-valuenow={ariaValue?.valueNow}
7581
onPointerDown={onPointerDown}
82+
onClick={onClick}
7683
onKeyDown={onKeyDown}
7784
>
7885
<InternalIcon {...iconProps} />

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>

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ export interface DragHandleProps {
1919
className?: string;
2020
onPointerDown?: React.PointerEventHandler;
2121
onKeyDown?: React.KeyboardEventHandler;
22+
onClick?: React.MouseEventHandler;
2223

2324
tooltipText?: string;
2425
directions?: Partial<Record<DragHandleProps.Direction, DragHandleProps.DirectionState>>;
2526
onDirectionClick?: (direction: DragHandleProps.Direction) => void;
2627
triggerMode?: TriggerMode;
2728
initialShowButtons?: boolean;
29+
controlledShowButtons?: boolean;
2830
/**
2931
* Hide the UAP buttons when dragging is active.
3032
*/
@@ -34,6 +36,7 @@ export interface DragHandleProps {
3436
* a drag. Small threshold needed for usability.
3537
*/
3638
clickDragThreshold?: number;
39+
ref?: React.RefObject<HTMLElement>;
3740
}
3841

3942
export namespace DragHandleProps {

0 commit comments

Comments
 (0)