Create SortableList helpers (#40182)

This commit is contained in:
Echo
2026-08-17 12:48:08 +00:00
committed by GitHub
parent d0992cc096
commit a12438a5a6
7 changed files with 498 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
import classNames from 'classnames';
import { DotsSixVerticalIcon } from '@phosphor-icons/react';
import { useSortableHandle } from './hooks';
import classes from './styles.module.scss';
type ValidHandleElement = 'button' | 'span';
type SortableListHandleProps<As extends ValidHandleElement> = {
as?: As;
} & React.ComponentPropsWithoutRef<As>;
export const SortableListHandle = <As extends ValidHandleElement = 'button'>({
as: asComp,
children,
className,
...props
}: SortableListHandleProps<As>) => {
const Component = asComp ?? 'button';
const { attributes, listeners, isDragging } = useSortableHandle();
return (
<Component
type={asComp === 'button' ? 'button' : undefined}
{...props}
{...attributes}
{...listeners}
className={classNames(
className,
classes.handle,
classes.defaultHandle,
isDragging && classes.active,
)}
>
{children ?? <DotsSixVerticalIcon />}
</Component>
);
};

View File

@@ -0,0 +1,84 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import type { UniqueIdentifier } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { normalizeKey } from '../hotkeys/utils';
interface UseSortableListArgs<Id extends UniqueIdentifier = string> {
ids: Id[];
onSort: (ids: Id[]) => void;
onCancel: () => void;
}
export function useSortableList<Id extends UniqueIdentifier = string>({
onCancel,
}: UseSortableListArgs<Id>) {
const [isDragging, setIsDragging] = useState(false);
const onDragStart = useCallback(() => {
setIsDragging(true);
}, []);
const onDragEnd = useCallback(() => {
setIsDragging(false);
}, []);
// Combines the Escape shortcut for closing the modal and for cancelling the drag, depending on the current state.
const onModalExit: React.KeyboardEventHandler = useCallback(
(event) => {
const key = normalizeKey(event.key);
if (key === 'escape') {
// Stops propagation to avoid triggering the handler in ModalRoot.
event.stopPropagation();
// Trigger the drag cancel here, since onDragCancel triggers before this handler.
if (isDragging) {
setIsDragging(false);
} else {
onCancel();
}
}
},
[isDragging, onCancel],
);
return {
isDragging,
onDragStart,
onDragEnd,
onCancel,
onModalExit,
};
}
export const ItemHandleContext = createContext<{
registerHandle: () => void;
id: UniqueIdentifier;
}>({
registerHandle: () => {
// Empty
},
id: '',
});
export function useSortableHandle() {
const { registerHandle, id } = useContext(ItemHandleContext);
useEffect(() => {
registerHandle();
}, [registerHandle]);
const { listeners, attributes, isDragging } = useSortable({ id });
return {
isDragging,
listeners,
attributes,
};
}

View File

@@ -0,0 +1,3 @@
export { SortableList } from './list';
export { SortableListItem } from './item';
export { SortableListHandle } from './handle';

View File

@@ -0,0 +1,83 @@
import { useCallback, useState } from 'react';
import classNames from 'classnames';
import type { UniqueIdentifier } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ItemHandleContext } from './hooks';
import classes from './styles.module.scss';
type ValidItemElement = 'li' | 'div' | 'span' | 'p';
interface SortableListItemOwnProps<As extends ValidItemElement> {
as?: As;
id: UniqueIdentifier;
draggingClassName?: string;
overClassName?: string;
}
export type SortableListItemProps<As extends ValidItemElement> =
SortableListItemOwnProps<As> &
Omit<
React.ComponentPropsWithoutRef<As>,
keyof SortableListItemOwnProps<As>
>;
export const SortableListItem = <As extends ValidItemElement = 'li'>({
id,
as: AsComp,
children,
className,
draggingClassName,
overClassName,
style: componentStyle,
...props
}: SortableListItemProps<As>) => {
const ItemComponent = AsComp ?? 'li';
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isOver,
} = useSortable({
id,
});
const [hasHandle, setHasHandle] = useState(false);
const registerHandle = useCallback(() => {
setHasHandle(true);
}, []);
const style = {
...componentStyle,
transform: CSS.Translate.toString(transform),
transition,
};
return (
<ItemComponent
{...(props as React.HTMLAttributes<HTMLElement>)}
{...(hasHandle ? null : listeners)}
{...(hasHandle ? null : attributes)}
style={style}
ref={setNodeRef}
className={classNames(
className,
classes.item,
!hasHandle && classes.handle,
isDragging && classes.active,
isDragging && draggingClassName,
isOver && overClassName,
)}
>
<ItemHandleContext.Provider value={{ registerHandle, id }}>
{children}
</ItemHandleContext.Provider>
</ItemComponent>
);
};

View File

@@ -0,0 +1,201 @@
import type React from 'react';
import { useCallback, useMemo } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { useIntl } from 'react-intl';
import type {
Announcements,
DragEndEvent,
DragStartEvent,
ScreenReaderInstructions,
UniqueIdentifier,
} from '@dnd-kit/core';
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import type { SetRequired } from 'type-fest';
import { SortableListItem } from './item';
export type MessageKeys =
| 'screenReaderInstructions'
| 'onDragStart'
| 'onDragMoveOver'
| 'onDragMove'
| 'onDragEnd'
| 'onDragCancel';
export type SortableListMessages = SetRequired<
Partial<Record<MessageKeys, MessageDescriptor>>,
'screenReaderInstructions'
>;
type ValidListElement =
| 'ol'
| 'ul'
| 'div'
| 'section'
| 'nav'
| 'article'
| 'main'
| 'aside';
interface SortableListOwnProps<
As extends ValidListElement,
Id extends UniqueIdentifier = string,
> {
ids: Id[];
renderItem?: (id: Id) => React.ReactNode;
messages?: SortableListMessages;
as?: As;
onSort?: (ids: Id[]) => void;
onDragStart?: (event: DragStartEvent) => void;
onDragEnd?: (event: DragEndEvent) => void;
}
export type SortableListProps<
As extends ValidListElement,
Id extends UniqueIdentifier,
> = SortableListOwnProps<As, Id> &
Omit<React.ComponentPropsWithoutRef<As>, keyof SortableListOwnProps<As>>;
export const SortableList = <
As extends ValidListElement = 'ol',
Id extends UniqueIdentifier = string,
>({
ids,
renderItem,
as: AsComp,
onSort,
onDragStart,
onDragEnd: onDragEndParent,
messages,
children,
...props
}: SortableListProps<As, Id>) => {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
if (onDragEndParent) {
onDragEndParent(event);
}
const { active, over } = event;
if (!over || !onSort) {
return;
}
const oldIndex = ids.indexOf(active.id as Id);
const newIndex = ids.indexOf(over.id as Id);
onSort(arrayMove(ids, oldIndex, newIndex));
},
[ids, onDragEndParent, onSort],
);
const intl = useIntl();
const accessibility = useMemo(() => {
if (!messages) {
return undefined;
}
return {
screenReaderInstructions: {
draggable: intl.formatMessage(messages.screenReaderInstructions),
} satisfies ScreenReaderInstructions,
announcements: {
onDragStart({ active }) {
if (!messages.onDragStart) {
return undefined;
}
return intl.formatMessage(messages.onDragStart, {
item: active.id,
});
},
onDragOver({ active, over }) {
if (over && active.id !== over.id && messages.onDragMoveOver) {
return intl.formatMessage(messages.onDragMoveOver, {
item: active.id,
over: over.id,
});
}
if (!messages.onDragMove) {
return undefined;
}
return intl.formatMessage(messages.onDragMove, {
item: active.id,
});
},
onDragEnd({ active }) {
if (!messages.onDragEnd) {
return undefined;
}
return intl.formatMessage(messages.onDragEnd, {
item: active.id,
});
},
onDragCancel({ active }) {
if (!messages.onDragCancel) {
return undefined;
}
return intl.formatMessage(messages.onDragCancel, {
item: active.id,
});
},
} satisfies Announcements,
};
}, [intl, messages]);
const ListComponent = AsComp ?? 'ol';
return (
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
accessibility={accessibility}
>
<ListComponent {...(props as React.HTMLAttributes<HTMLElement>)}>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{children ??
(renderItem &&
ids.map((id) => (
<SortableListItem id={id} key={id}>
{renderItem(id)}
</SortableListItem>
)))}
</SortableContext>
</ListComponent>
</DndContext>
);
};

View File

@@ -0,0 +1,70 @@
import type React from 'react';
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SortableList, SortableListItem, SortableListHandle } from './index';
const listStyle = {
display: 'flex',
flexDirection: 'column',
gap: 2,
width: '400px',
} satisfies React.CSSProperties;
const itemStyle = {
padding: '8px',
background: 'pink',
borderRadius: '4px',
} satisfies React.CSSProperties;
function countToIds(count: number) {
return [...(Array(count) as unknown[])].map(
(_, index) => `Item ${index + 1}`,
);
}
const SortableListStory: React.FC<{
count: number;
handle?: React.ReactNode;
}> = ({ count, handle }) => {
const [ids, setIds] = useState(() => countToIds(count));
return (
<SortableList ids={ids} onSort={setIds} style={listStyle}>
{ids.map((id) => (
<SortableListItem id={id} key={id} style={itemStyle}>
{handle}
{id}
</SortableListItem>
))}
</SortableList>
);
};
const meta = {
title: 'Components/SortableList',
args: {
count: 4,
},
render({ count }) {
return <SortableListStory count={count} key={count} />;
},
} satisfies Meta<typeof SortableListStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Plain: Story = {};
export const Handles: Story = {
render({ count }) {
return (
<SortableListStory
count={count}
key={count}
handle={<SortableListHandle />}
/>
);
},
};

View File

@@ -0,0 +1,18 @@
.item {
touch-action: manipulation;
}
.handle {
cursor: grab;
}
.active {
cursor: grabbing;
}
.defaultHandle {
appearance: none;
border: none;
background: none;
vertical-align: middle;
}