mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-14 04:58:06 -05:00
Create SortableList helpers (#40182)
This commit is contained in:
39
app/javascript/mastodon/components/sortable_list/handle.tsx
Normal file
39
app/javascript/mastodon/components/sortable_list/handle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
84
app/javascript/mastodon/components/sortable_list/hooks.ts
Normal file
84
app/javascript/mastodon/components/sortable_list/hooks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { SortableList } from './list';
|
||||
export { SortableListItem } from './item';
|
||||
export { SortableListHandle } from './handle';
|
||||
83
app/javascript/mastodon/components/sortable_list/item.tsx
Normal file
83
app/javascript/mastodon/components/sortable_list/item.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
201
app/javascript/mastodon/components/sortable_list/list.tsx
Normal file
201
app/javascript/mastodon/components/sortable_list/list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
.item {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.defaultHandle {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
Reference in New Issue
Block a user