diff --git a/app/javascript/mastodon/components/sortable_list/handle.tsx b/app/javascript/mastodon/components/sortable_list/handle.tsx new file mode 100644 index 00000000000..ef4a9474ad3 --- /dev/null +++ b/app/javascript/mastodon/components/sortable_list/handle.tsx @@ -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?: As; +} & React.ComponentPropsWithoutRef; + +export const SortableListHandle = ({ + as: asComp, + children, + className, + ...props +}: SortableListHandleProps) => { + const Component = asComp ?? 'button'; + const { attributes, listeners, isDragging } = useSortableHandle(); + + return ( + + {children ?? } + + ); +}; diff --git a/app/javascript/mastodon/components/sortable_list/hooks.ts b/app/javascript/mastodon/components/sortable_list/hooks.ts new file mode 100644 index 00000000000..60de075da04 --- /dev/null +++ b/app/javascript/mastodon/components/sortable_list/hooks.ts @@ -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 { + ids: Id[]; + onSort: (ids: Id[]) => void; + onCancel: () => void; +} + +export function useSortableList({ + onCancel, +}: UseSortableListArgs) { + 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, + }; +} diff --git a/app/javascript/mastodon/components/sortable_list/index.ts b/app/javascript/mastodon/components/sortable_list/index.ts new file mode 100644 index 00000000000..a3970f1b49a --- /dev/null +++ b/app/javascript/mastodon/components/sortable_list/index.ts @@ -0,0 +1,3 @@ +export { SortableList } from './list'; +export { SortableListItem } from './item'; +export { SortableListHandle } from './handle'; diff --git a/app/javascript/mastodon/components/sortable_list/item.tsx b/app/javascript/mastodon/components/sortable_list/item.tsx new file mode 100644 index 00000000000..5070bdc7369 --- /dev/null +++ b/app/javascript/mastodon/components/sortable_list/item.tsx @@ -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?: As; + id: UniqueIdentifier; + draggingClassName?: string; + overClassName?: string; +} + +export type SortableListItemProps = + SortableListItemOwnProps & + Omit< + React.ComponentPropsWithoutRef, + keyof SortableListItemOwnProps + >; + +export const SortableListItem = ({ + id, + as: AsComp, + children, + className, + draggingClassName, + overClassName, + style: componentStyle, + ...props +}: SortableListItemProps) => { + 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 ( + )} + {...(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, + )} + > + + {children} + + + ); +}; diff --git a/app/javascript/mastodon/components/sortable_list/list.tsx b/app/javascript/mastodon/components/sortable_list/list.tsx new file mode 100644 index 00000000000..27e2c8a6661 --- /dev/null +++ b/app/javascript/mastodon/components/sortable_list/list.tsx @@ -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>, + '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 & + Omit, keyof SortableListOwnProps>; + +export const SortableList = < + As extends ValidListElement = 'ol', + Id extends UniqueIdentifier = string, +>({ + ids, + renderItem, + as: AsComp, + onSort, + onDragStart, + onDragEnd: onDragEndParent, + messages, + children, + ...props +}: SortableListProps) => { + 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 ( + + )}> + + {children ?? + (renderItem && + ids.map((id) => ( + + {renderItem(id)} + + )))} + + + + ); +}; diff --git a/app/javascript/mastodon/components/sortable_list/sortable_list.stories.tsx b/app/javascript/mastodon/components/sortable_list/sortable_list.stories.tsx new file mode 100644 index 00000000000..830efcf7f19 --- /dev/null +++ b/app/javascript/mastodon/components/sortable_list/sortable_list.stories.tsx @@ -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 ( + + {ids.map((id) => ( + + {handle} + {id} + + ))} + + ); +}; + +const meta = { + title: 'Components/SortableList', + args: { + count: 4, + }, + render({ count }) { + return ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Plain: Story = {}; + +export const Handles: Story = { + render({ count }) { + return ( + } + /> + ); + }, +}; diff --git a/app/javascript/mastodon/components/sortable_list/styles.module.scss b/app/javascript/mastodon/components/sortable_list/styles.module.scss new file mode 100644 index 00000000000..6aa33e9fecd --- /dev/null +++ b/app/javascript/mastodon/components/sortable_list/styles.module.scss @@ -0,0 +1,18 @@ +.item { + touch-action: manipulation; +} + +.handle { + cursor: grab; +} + +.active { + cursor: grabbing; +} + +.defaultHandle { + appearance: none; + border: none; + background: none; + vertical-align: middle; +}