fix(ui): fix longstanding drag-and-drop duplication issue (#502)

* fix(ui): fixed color switching on list reordering

* chore: remove lock file

* chore: add back lock file

* feat(ui): fix color duplication issue and prevent scrolling beyond parent

* feat(ui): add to storybook

* fix(ui): remove white background while dragging

* chore: remove dnd pangea from package.json

* chore: rebuild lock file

* chore: remove nested li element issue

* fix(ui): allow grabbing cursor while dragging

* fix(ui): address chromatic errors

* fix(ui): address chromatic errors

* fix(ui): address linting issues and pass tests

* fix(ui): create hook for modifying the cursor globally

* chore: add check for storybook env

* chore: add back unused import to AddAllButton

* fix: make cursor grabbing hook more explicit

* chore: move sortable list item into sortable list file

* fix: remove isStorybook prop from ScheduleListItem

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
This commit is contained in:
Preston Cook
2025-02-04 17:28:54 -06:00
committed by GitHub
parent c2328e461e
commit 4752f5860a
16 changed files with 2913 additions and 2622 deletions

View File

@@ -27,8 +27,11 @@
"prepare": "husky"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.0",
"@hello-pangea/dnd": "^17.0.0",
"@octokit/rest": "^21.0.2",
"@phosphor-icons/react": "^2.1.7",
"@sentry/react": "^8.38.0",

4866
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,10 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import { UserSchedule } from '@shared/types/UserSchedule';
import { generateRandomId } from '@shared/util/random';
import type { Meta, StoryObj } from '@storybook/react';
import List from '@views/components/common/List';
import type { ScheduleDropdownProps } from '@views/components/common/ScheduleDropdown';
import ScheduleDropdown from '@views/components/common/ScheduleDropdown';
import ScheduleListItem from '@views/components/common/ScheduleListItem';
import { SortableList } from '@views/components/common/SortableList';
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
import type { Serialized } from 'chrome-extension-toolkit';
import React, { useEffect } from 'react';
@@ -48,10 +48,10 @@ const meta: Meta<typeof ScheduleDropdown> = {
return (
<div className='w-80'>
<ScheduleDropdown {...args}>
<List
<SortableList
className='gap-spacing-3'
draggables={schedules}
itemKey={s => s.id}
onReordered={reordered => {
onChange={reordered => {
const activeSchedule = getActiveSchedule();
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
@@ -59,18 +59,10 @@ const meta: Meta<typeof ScheduleDropdown> = {
UserScheduleStore.set('schedules', reordered);
UserScheduleStore.set('activeIndex', activeIndex);
}}
gap={10}
>
{(schedule, handleProps) => (
<ScheduleListItem
schedule={schedule}
onClick={() => {
switchSchedule(schedule.id);
}}
dragHandleProps={handleProps}
/>
renderItem={schedule => (
<ScheduleListItem schedule={schedule} onClick={() => switchSchedule(schedule.id)} />
)}
</List>
/>
</ScheduleDropdown>
</div>
);

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/react';
import ImportantLinks from '@views/components/calendar/ImportantLinks';
const meta = {
title: 'Components/Common/ImportantLinks',
component: ImportantLinks,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {},
} satisfies Meta<typeof ImportantLinks>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {},
};

View File

@@ -1,16 +1,15 @@
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { Course, Status } from '@shared/types/Course';
import { CourseMeeting } from '@shared/types/CourseMeeting';
import Instructor from '@shared/types/Instructor';
import { tailwindColorways } from '@shared/util/storybook';
import type { Meta, StoryObj } from '@storybook/react';
import List from '@views/components/common/List';
import PopupCourseBlock from '@views/components/common/PopupCourseBlock';
import type { BaseItem } from '@views/components/common/SortableList';
import { SortableList } from '@views/components/common/SortableList';
import React from 'react';
const numberOfCourses = 5;
// TODO: move into utils
/**
* Generates an array of courses.
*
@@ -73,36 +72,34 @@ const generateCourses = (count: number): Course[] => {
};
const exampleCourses = generateCourses(numberOfCourses);
const generateCourseBlocks = (course: Course, dragHandleProps: DraggableProvidedDragHandleProps) => (
<PopupCourseBlock key={course.uniqueId} course={course} colors={course.colors} dragHandleProps={dragHandleProps} />
);
type CourseWithId = Course & BaseItem;
const meta = {
title: 'Components/Common/List',
component: List,
component: SortableList,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
gap: { control: 'number' },
},
} satisfies Meta<typeof List<Course>>;
} satisfies Meta<typeof SortableList<CourseWithId>>;
export default meta;
type Story = StoryObj<Meta<typeof List<Course>>>;
type Story = StoryObj<Meta<typeof SortableList<CourseWithId>>>;
export const Default: Story = {
args: {
draggables: exampleCourses,
children: generateCourseBlocks,
itemKey: item => item.uniqueId,
gap: 12,
onReordered: () => {},
draggables: exampleCourses.map(course => ({
id: course.uniqueId,
...course,
getConflicts: course.getConflicts,
})),
onChange: () => {},
renderItem: course => <PopupCourseBlock key={course.id} course={course} colors={course.colors} />,
},
render: args => (
<div className='w-sm'>
<List {...args} />
<div className='h-3xl w-3xl transform-none'>
<SortableList {...args} />
</div>
),
};

View File

@@ -6,7 +6,6 @@ import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import { openReportWindow } from '@shared/util/openReportWindow';
import Divider from '@views/components/common/Divider';
import List from '@views/components/common/List';
import Text from '@views/components/common/Text/Text';
import { useEnforceScheduleLimit } from '@views/hooks/useEnforceScheduleLimit';
import useSchedules, { getActiveSchedule, replaceSchedule, switchSchedule } from '@views/hooks/useSchedules';
@@ -20,6 +19,7 @@ import { SmallLogo } from './common/LogoIcon';
import PopupCourseBlock from './common/PopupCourseBlock';
import ScheduleDropdown from './common/ScheduleDropdown';
import ScheduleListItem from './common/ScheduleListItem';
import { SortableList } from './common/SortableList';
/**
* Renders the main popup component.
@@ -56,6 +56,7 @@ export default function PopupMain(): JSX.Element {
}, []);
const [activeSchedule, schedules] = useSchedules();
// const [isRefreshing, setIsRefreshing] = useState(false);
const [funny, setFunny] = useState<string>('');
@@ -103,10 +104,9 @@ export default function PopupMain(): JSX.Element {
<Divider orientation='horizontal' size='100%' />
<div className='px-5 pb-2.5 pt-3.75'>
<ScheduleDropdown>
<List
<SortableList
draggables={schedules}
itemKey={schedule => schedule.id}
onReordered={reordered => {
onChange={reordered => {
const activeSchedule = getActiveSchedule();
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
@@ -114,18 +114,10 @@ export default function PopupMain(): JSX.Element {
UserScheduleStore.set('schedules', reordered);
UserScheduleStore.set('activeIndex', activeIndex);
}}
gap={10}
>
{(schedule, handleProps) => (
<ScheduleListItem
schedule={schedule}
onClick={() => {
switchSchedule(schedule.id);
}}
dragHandleProps={handleProps}
/>
renderItem={schedule => (
<ScheduleListItem schedule={schedule} onClick={() => switchSchedule(schedule.id)} />
)}
</List>
/>
<div className='bottom-0 right-0 mt-2.5 w-full flex justify-end'>
<Button
variant='filled'
@@ -149,24 +141,20 @@ export default function PopupMain(): JSX.Element {
)}
<div className='flex-1 self-stretch overflow-y-auto px-5'>
{activeSchedule?.courses?.length > 0 && (
<List
draggables={activeSchedule.courses}
onReordered={reordered => {
activeSchedule.courses = reordered;
<SortableList
draggables={activeSchedule.courses.map(course => ({
id: course.uniqueId,
...course,
getConflicts: course.getConflicts,
}))}
onChange={reordered => {
activeSchedule.courses = reordered.map(({ id: _id, ...course }) => course);
replaceSchedule(getActiveSchedule(), activeSchedule);
}}
itemKey={e => e.uniqueId}
gap={10}
>
{(course, handleProps) => (
<PopupCourseBlock
key={course.uniqueId}
course={course}
colors={course.colors}
dragHandleProps={handleProps}
/>
renderItem={course => (
<PopupCourseBlock key={course.id} course={course} colors={course.colors} />
)}
</List>
/>
)}
</div>
<div className='w-full flex flex-col items-center gap-1.25 p-5 pt-3.75'>

View File

@@ -1,10 +1,9 @@
import createSchedule from '@pages/background/lib/createSchedule';
import { Plus } from '@phosphor-icons/react';
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import { getSpacingInPx } from '@shared/types/Spacing';
import { Button } from '@views/components/common/Button';
import List from '@views/components/common/List';
import ScheduleListItem from '@views/components/common/ScheduleListItem';
import { SortableList } from '@views/components/common/SortableList';
import Text from '@views/components/common/Text/Text';
import { useEnforceScheduleLimit } from '@views/hooks/useEnforceScheduleLimit';
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
@@ -41,11 +40,10 @@ export function CalendarSchedules() {
/>
</div>
<div className='w-full flex flex-col'>
<List
gap={getSpacingInPx('spacing-3')}
<SortableList
className='gap-spacing-3'
draggables={schedules}
itemKey={s => s.id}
onReordered={reordered => {
onChange={reordered => {
const activeSchedule = getActiveSchedule();
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
@@ -53,17 +51,10 @@ export function CalendarSchedules() {
UserScheduleStore.set('schedules', reordered);
UserScheduleStore.set('activeIndex', activeIndex);
}}
>
{(schedule, handleProps) => (
<ScheduleListItem
schedule={schedule}
onClick={() => {
switchSchedule(schedule.id);
}}
dragHandleProps={handleProps}
/>
renderItem={schedule => (
<ScheduleListItem schedule={schedule} onClick={() => switchSchedule(schedule.id)} />
)}
</List>
/>
</div>
</div>
);

View File

@@ -1,171 +0,0 @@
import type { DraggableProvided, DraggableProvidedDragHandleProps, OnDragEndResponder } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import ExtensionRoot from './ExtensionRoot/ExtensionRoot';
/*
* Ctrl + f dragHandleProps on PopupCourseBlock.tsx for example implementation of drag handle (two lines of code)
*/
/**
* Props for the List component.
*/
export interface ListProps<T> {
draggables: T[];
children: (draggable: T, handleProps: DraggableProvidedDragHandleProps) => ReactElement;
onReordered: (elements: T[]) => void;
itemKey: (item: T) => React.Key;
gap?: number; // Impacts the spacing between items in the list
}
function wrap<T>(draggableElements: T[], keyTransform: ListProps<T>['itemKey']) {
return draggableElements.map(element => ({
id: keyTransform(element),
content: element,
}));
}
function reorder<T>(list: T[], startIndex: number, endIndex: number) {
const listCopy = [...list];
const [removed] = listCopy.splice(startIndex, 1);
if (removed) {
listCopy.splice(endIndex, 0, removed);
}
return listCopy;
}
function getStyle(provided: DraggableProvided, style: React.CSSProperties) {
const combined = {
...style,
...provided.draggableProps.style,
};
return combined;
}
function Item<T>(props: {
provided: DraggableProvided;
style: React.CSSProperties;
item: T;
isDragging: boolean;
children: React.ReactElement;
}) {
return (
<div
{...props.provided.draggableProps}
ref={props.provided.innerRef}
style={getStyle(props.provided, props.style)}
className={props.isDragging ? 'group is-dragging' : ''}
>
{props.children}
</div>
);
}
/**
* `List` is a functional component that displays a course meeting.
*
* @example
* ```
* <List draggableElements={elements} />
* ```
*/
function List<T>({ draggables, itemKey, children, onReordered, gap }: ListProps<T>): JSX.Element {
const [items, setItems] = useState(wrap(draggables, itemKey));
const transformFunction = children;
useEffect(() => {
setItems(wrap(draggables, itemKey));
// This is on purpose
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draggables]);
const onDragEnd: OnDragEndResponder = useCallback(
({ destination, source }) => {
if (!destination) return;
if (source.index === destination.index) return;
// will reorder in place
const reordered = reorder(items, source.index, destination.index);
setItems(reordered);
onReordered(reordered.map(item => item.content));
},
// This is on purpose
// eslint-disable-next-line react-hooks/exhaustive-deps
[items]
);
return (
<div style={{ overflow: 'clip', overflowClipMargin: `${gap}px` }}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable
droppableId='droppable'
direction='vertical'
renderClone={(provided, snapshot, rubric) => {
let { style } = provided.draggableProps;
const transform = style?.transform;
if (snapshot.isDragging && transform) {
let [, , y] = transform.match(/translate\(([-\d]+)px, ([-\d]+)px\)/) || [];
style.transform = `translate3d(0px, ${y}px, 0px)`; // Apply constrained y value
}
return (
<Item
provided={provided}
isDragging={snapshot.isDragging}
item={items[rubric.source.index]}
style={{
...style,
}}
>
<ExtensionRoot>
{transformFunction(items[rubric.source.index]!.content, provided.dragHandleProps!)}
</ExtensionRoot>
</Item>
);
}}
>
{provided => (
<div {...provided.droppableProps} ref={provided.innerRef} style={{ marginBottom: `-${gap}px` }}>
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id.toString()} index={index}>
{({ innerRef, draggableProps, dragHandleProps }) => (
<div
ref={innerRef}
{...draggableProps}
style={{
...draggableProps.style,
// if last item, don't add margin
marginBottom: `${gap}px`,
}}
>
{
transformFunction(
item.content,
dragHandleProps!
) /* always exists; only doesn't when "isDragDisabled" is set */
}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
);
}
export default React.memo(List) as typeof List;

View File

@@ -1,4 +1,3 @@
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { Check, Copy, DotsSixVertical } from '@phosphor-icons/react';
import { background } from '@shared/messages';
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
@@ -12,6 +11,7 @@ import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { Button } from './Button';
import { SortableListDragHandle } from './SortableListDragHandle';
/**
* Props for PopupCourseBlock
@@ -20,9 +20,10 @@ export interface PopupCourseBlockProps {
className?: string;
course: Course;
colors: CourseColors;
dragHandleProps?: DraggableProvidedDragHandleProps;
}
const IS_STORYBOOK = import.meta.env.STORYBOOK;
/**
* The "course block" to be used in the extension popup.
*
@@ -32,12 +33,7 @@ export interface PopupCourseBlockProps {
* @param dragHandleProps - The drag handle props for the course block.
* @returns The rendered PopupCourseBlock component.
*/
export default function PopupCourseBlock({
className,
course,
colors,
dragHandleProps,
}: PopupCourseBlockProps): JSX.Element {
export default function PopupCourseBlock({ className, course, colors }: PopupCourseBlockProps): JSX.Element {
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
const [isCopied, setIsCopied] = useState<boolean>(false);
const lastCopyTime = useRef<number>(0);
@@ -48,7 +44,6 @@ export default function PopupCourseBlock({
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
setEnableCourseStatusChips(newValue);
// console.log('enableCourseStatusChips', newValue);
});
// adds transition for shadow hover after three frames
@@ -101,16 +96,23 @@ export default function PopupCourseBlock({
onClick={handleClick}
ref={ref}
>
<div
style={{
backgroundColor: colors.secondaryColor,
}}
className='flex items-center self-stretch rounded rounded-r-0 cursor-move!'
{...dragHandleProps}
{IS_STORYBOOK ? (
<DotsSixVertical weight='bold' className='h-6 w-6 cursor-move text-white' />
) : (
<SortableListDragHandle
style={{
backgroundColor: colors.secondaryColor,
}}
className='flex cursor-move items-center self-stretch rounded rounded-r-0'
>
<DotsSixVertical weight='bold' className='h-6 w-6 cursor-move text-white' />
</SortableListDragHandle>
)}
<Text
className={clsx('flex-1 py-spacing-5 truncate ml-spacing-3 select-none', fontColor)}
variant='h1-course'
>
<DotsSixVertical weight='bold' className='h-6 w-6 text-white' />
</div>
<Text className={clsx('flex-1 py-spacing-5 truncate ml-spacing-3', fontColor)} variant='h1-course'>
{course.department} {course.number}
{course.instructors.length > 0 ? <> &ndash; </> : ''}
{course.instructors.map(v => v.toString({ format: 'last' })).join('; ')}
@@ -149,7 +151,7 @@ export default function PopupCourseBlock({
)}
/>
</div>
<Text variant='h2' className='text-base!'>
<Text variant='h2' className='no-select text-base!'>
{formattedUniqueId}
</Text>
</Button>

View File

@@ -15,12 +15,12 @@ export type ScheduleDropdownProps = {
/**
* This is a reusable dropdown component that can be used to toggle the visiblity of information
*/
export default function ScheduleDropdown(props: ScheduleDropdownProps) {
export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDropdownProps) {
const [activeSchedule] = useSchedules();
return (
<div className='border border-ut-offwhite rounded border-solid bg-white'>
<Disclosure defaultOpen={props.defaultOpen}>
<Disclosure defaultOpen={defaultOpen}>
{({ open }) => (
<>
<DisclosureButton className='w-full flex items-center border-none bg-transparent px-3.5 py-2.5 text-left'>
@@ -50,14 +50,17 @@ export default function ScheduleDropdown(props: ScheduleDropdownProps) {
<Transition
as='div'
className='contain-paint max-h-55 origin-top overflow-auto transition-all duration-400 ease-in-out-expo'
enterFrom='transform scale-98 opacity-0 max-h-0!'
enterTo='transform scale-100 opacity-100 max-h-55'
leave='ease-out-expo'
leaveFrom='transform scale-100 opacity-100 max-h-55'
leaveTo='transform scale-98 opacity-0 max-h-0!'
className='overflow-hidden'
enter='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
enterFrom='max-h-0 opacity-0 p-0.5'
enterTo='max-h-[440px] opacity-100 p-0'
leave='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
leaveFrom='max-h-[440px] opacity-100 p-0'
leaveTo='max-h-0 opacity-0 p-0.5'
>
<DisclosurePanel className='px-3.5 pb-2.5 pt-2'>{props.children}</DisclosurePanel>
<div className='px-3.5 pb-2.5 pt-2'>
<DisclosurePanel>{children}</DisclosurePanel>
</div>
</Transition>
</>
)}

View File

@@ -21,21 +21,22 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Button } from './Button';
import DialogProvider, { usePrompt } from './DialogProvider/DialogProvider';
import { ExtensionRootWrapper, styleResetClass } from './ExtensionRoot/ExtensionRoot';
import { SortableListDragHandle } from './SortableListDragHandle';
/**
* Props for the ScheduleListItem component.
*/
export type Props = {
style?: React.CSSProperties;
interface ScheduleListItemProps {
schedule: UserSchedule;
dragHandleProps?: Omit<React.HTMLAttributes<HTMLDivElement>, 'className'>;
onClick?: React.DOMAttributes<HTMLDivElement>['onClick'];
};
}
const IS_STORYBOOK = import.meta.env.STORYBOOK;
/**
* This is a reusable dropdown component that can be used to toggle the visiblity of information
*/
export default function ScheduleListItem({ schedule, dragHandleProps, onClick }: Props): JSX.Element {
export default function ScheduleListItem({ schedule, onClick }: ScheduleListItemProps): JSX.Element {
const [activeSchedule] = useSchedules();
const [isEditing, setIsEditing] = useState(false);
const [editorValue, setEditorValue] = useState(schedule.name);
@@ -101,14 +102,20 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
return (
<div className='h-7.5 rounded bg-white'>
<li className='h-full w-full flex cursor-pointer items-center gap-[1px] text-ut-burntorange'>
<div className='flex cursor-move items-center justify-center focusable' {...dragHandleProps}>
<div className='h-full w-full flex cursor-pointer items-center gap-[1px] text-ut-burntorange'>
{IS_STORYBOOK ? (
<DotsSixVertical
weight='bold'
className='h-6 w-6 cursor-move text-zinc-300 btn-transition -ml-1.5 hover:text-zinc-400'
/>
</div>
) : (
<SortableListDragHandle className='flex cursor-move items-center justify-center'>
<DotsSixVertical
weight='bold'
className='h-6 w-6 cursor-move text-zinc-300 btn-transition -ml-1.5 hover:text-zinc-400'
/>
</SortableListDragHandle>
)}
<div className='group relative flex flex-1 items-center overflow-x-hidden'>
<div
className='group/circle flex flex-grow items-center gap-spacing-3 overflow-x-hidden'
@@ -140,7 +147,11 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
/>
)}
{!isEditing && (
<Text variant='p' className='flex-1 truncate' onDoubleClick={() => setIsEditing(true)}>
<Text
variant='p'
className='flex-1 select-none truncate'
onDoubleClick={() => setIsEditing(true)}
>
{schedule.name}
</Text>
)}
@@ -203,7 +214,7 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
</Menu>
</DialogProvider>
</div>
</li>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { DropAnimation } from '@dnd-kit/core';
import { defaultDropAnimationSideEffects, DragOverlay } from '@dnd-kit/core';
import type { PropsWithChildren } from 'react';
import React from 'react';
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
visibility: 'hidden',
},
},
}),
};
/**
* @returns Renders a visibly hidden sortable item in the sortable list while it is being dragged
*/
export function SortableItemOverlay({ children }: PropsWithChildren) {
return <DragOverlay dropAnimation={dropAnimationConfig}>{children}</DragOverlay>;
}

View File

@@ -0,0 +1,149 @@
import type { Active, UniqueIdentifier } from '@dnd-kit/core';
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { restrictToParentElement } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { SortableItemProvider } from '@views/contexts/SortableItemContext';
import { useCursor } from '@views/hooks/useCursor';
import clsx from 'clsx';
import type { CSSProperties, PropsWithChildren, ReactNode } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { SortableItemOverlay } from './SortableItemOverlay';
/**
* Extendable Prop for Sortable Item Id
*/
export interface BaseItem {
id: UniqueIdentifier;
}
/**
* Props for the SortableList component.
*/
export interface SortableListProps<T extends BaseItem> {
draggables: T[];
/**
*
* @param items - Handles the behavior of the list when items change order
*/
onChange(items: T[]): void;
/**
*
* @param item - Renders a sortable item component
*/
renderItem(item: T): ReactNode;
className?: string; // Impacts the spacing between items in the list
}
/**
* Props for the SortableListItem Component
*/
export interface SortableListItemProps {
id: UniqueIdentifier;
}
function SortableListItem({ children, id }: PropsWithChildren<SortableListItemProps>) {
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
id,
});
const context = useMemo(
() => ({
attributes,
listeners,
ref: setActivatorNodeRef,
}),
[attributes, listeners, setActivatorNodeRef]
);
const style = {
listStyle: 'none',
visibility: isDragging ? 'hidden' : 'visible',
transform: CSS.Translate.toString(transform),
transition,
} satisfies CSSProperties;
return (
<SortableItemProvider value={context}>
<li ref={setNodeRef} style={style}>
{children}
</li>
</SortableItemProvider>
);
}
/**
* SortableList is a component that renders a drag-and-drop sortable list restricted by its parent
*/
export function SortableList<T extends BaseItem>({
draggables,
renderItem,
onChange,
className,
}: SortableListProps<T>): JSX.Element {
const [active, setActive] = useState<Active | null>(null);
const [items, setItems] = useState<T[]>(draggables);
const { setCursorGrabbing } = useCursor();
useEffect(() => {
setItems(draggables);
}, [draggables]);
const activeItem = useMemo(() => items.find(item => item.id === active?.id), [active, items]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
return (
<div className={clsx('h-full w-full')}>
<ul className={clsx('overflow-clip flex gap-spacing-3 flex-col', className)}>
<DndContext
modifiers={[restrictToParentElement]}
sensors={sensors}
onDragStart={({ active }) => {
setCursorGrabbing(true);
setActive(active);
}}
onDragEnd={({ active, over }) => {
setCursorGrabbing(false);
if (over && active.id !== over.id) {
const activeIndex = items.findIndex(({ id }) => id === active.id);
const overIndex = items.findIndex(({ id }) => id === over.id);
const reorderedItems = arrayMove(items, activeIndex, overIndex);
onChange(reorderedItems);
setItems(reorderedItems);
}
setActive(null);
}}
onDragCancel={() => {
setCursorGrabbing(false);
setActive(null);
}}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map(item => (
<SortableListItem key={item.id} id={item.id}>
{renderItem(item)}
</SortableListItem>
))}
</SortableContext>
<SortableItemOverlay>
{activeItem ? (
<SortableListItem id={activeItem.id}>{renderItem(activeItem)}</SortableListItem>
) : null}
</SortableItemOverlay>
</DndContext>
</ul>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { useSortableItemContext } from '@views/contexts/SortableItemContext';
import type { CSSProperties } from 'react';
import React from 'react';
interface SortableListDragHandleProps {
className?: string;
style?: CSSProperties;
children: React.ReactNode;
}
/**
* @returns A wrapper component around sortable list item drag handles
*/
export function SortableListDragHandle({ className, style, children }: SortableListDragHandleProps) {
const { attributes, listeners, ref } = useSortableItemContext();
return (
<div className={className} {...attributes} {...listeners} ref={ref} style={style}>
{children}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
import type { ReactNode } from 'react';
import React, { createContext } from 'react';
interface Context {
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
ref(node: HTMLElement | null): void;
}
export const SortableItemContext = createContext<Context | null>(null);
interface SortableItemProviderProps {
value: Context;
children: ReactNode;
}
/**
* Provides the sortable item context to its children
*/
export const SortableItemProvider: React.FC<SortableItemProviderProps> = ({ value, children }) => (
<SortableItemContext.Provider value={value}>{children}</SortableItemContext.Provider>
);
/**
* @returns The sortable item context
*/
export const useSortableItemContext = () => {
const context = React.useContext(SortableItemContext);
if (!context) {
throw new Error('useSortableItemContext must be used within a SortableItemContext.Provider');
}
return context;
};

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
/**
* Dynamically applies a grabbing cursor to the root element with the important flag
*/
export const useCursor = () => {
useEffect(() => {
const html = document.documentElement;
return () => {
html.style.removeProperty('cursor');
html.classList.remove('[&_*]:!cursor-grabbing');
};
}, []);
const setCursorGrabbing = (isGrabbing: boolean) => {
const html = document.documentElement;
if (isGrabbing) {
html.classList.add('[&_*]:!cursor-grabbing');
} else {
html.classList.remove('[&_*]:!cursor-grabbing');
}
};
return { setCursorGrabbing };
};