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:
@@ -27,8 +27,11 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
|
||||||
"@octokit/rest": "^21.0.2",
|
"@octokit/rest": "^21.0.2",
|
||||||
"@phosphor-icons/react": "^2.1.7",
|
"@phosphor-icons/react": "^2.1.7",
|
||||||
"@sentry/react": "^8.38.0",
|
"@sentry/react": "^8.38.0",
|
||||||
|
|||||||
4866
pnpm-lock.yaml
generated
4866
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,10 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
|||||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import { generateRandomId } from '@shared/util/random';
|
import { generateRandomId } from '@shared/util/random';
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import List from '@views/components/common/List';
|
|
||||||
import type { ScheduleDropdownProps } from '@views/components/common/ScheduleDropdown';
|
import type { ScheduleDropdownProps } from '@views/components/common/ScheduleDropdown';
|
||||||
import ScheduleDropdown from '@views/components/common/ScheduleDropdown';
|
import ScheduleDropdown from '@views/components/common/ScheduleDropdown';
|
||||||
import ScheduleListItem from '@views/components/common/ScheduleListItem';
|
import ScheduleListItem from '@views/components/common/ScheduleListItem';
|
||||||
|
import { SortableList } from '@views/components/common/SortableList';
|
||||||
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
import type { Serialized } from 'chrome-extension-toolkit';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
@@ -48,10 +48,10 @@ const meta: Meta<typeof ScheduleDropdown> = {
|
|||||||
return (
|
return (
|
||||||
<div className='w-80'>
|
<div className='w-80'>
|
||||||
<ScheduleDropdown {...args}>
|
<ScheduleDropdown {...args}>
|
||||||
<List
|
<SortableList
|
||||||
|
className='gap-spacing-3'
|
||||||
draggables={schedules}
|
draggables={schedules}
|
||||||
itemKey={s => s.id}
|
onChange={reordered => {
|
||||||
onReordered={reordered => {
|
|
||||||
const activeSchedule = getActiveSchedule();
|
const activeSchedule = getActiveSchedule();
|
||||||
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
|
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
|
||||||
|
|
||||||
@@ -59,18 +59,10 @@ const meta: Meta<typeof ScheduleDropdown> = {
|
|||||||
UserScheduleStore.set('schedules', reordered);
|
UserScheduleStore.set('schedules', reordered);
|
||||||
UserScheduleStore.set('activeIndex', activeIndex);
|
UserScheduleStore.set('activeIndex', activeIndex);
|
||||||
}}
|
}}
|
||||||
gap={10}
|
renderItem={schedule => (
|
||||||
>
|
<ScheduleListItem schedule={schedule} onClick={() => switchSchedule(schedule.id)} />
|
||||||
{(schedule, handleProps) => (
|
|
||||||
<ScheduleListItem
|
|
||||||
schedule={schedule}
|
|
||||||
onClick={() => {
|
|
||||||
switchSchedule(schedule.id);
|
|
||||||
}}
|
|
||||||
dragHandleProps={handleProps}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</List>
|
/>
|
||||||
</ScheduleDropdown>
|
</ScheduleDropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
19
src/stories/components/ImportantLinks.stories.tsx
Normal file
19
src/stories/components/ImportantLinks.stories.tsx
Normal 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: {},
|
||||||
|
};
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
|
|
||||||
import { Course, Status } from '@shared/types/Course';
|
import { Course, Status } from '@shared/types/Course';
|
||||||
import { CourseMeeting } from '@shared/types/CourseMeeting';
|
import { CourseMeeting } from '@shared/types/CourseMeeting';
|
||||||
import Instructor from '@shared/types/Instructor';
|
import Instructor from '@shared/types/Instructor';
|
||||||
import { tailwindColorways } from '@shared/util/storybook';
|
import { tailwindColorways } from '@shared/util/storybook';
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import List from '@views/components/common/List';
|
|
||||||
import PopupCourseBlock from '@views/components/common/PopupCourseBlock';
|
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';
|
import React from 'react';
|
||||||
|
|
||||||
const numberOfCourses = 5;
|
const numberOfCourses = 5;
|
||||||
|
|
||||||
// TODO: move into utils
|
|
||||||
/**
|
/**
|
||||||
* Generates an array of courses.
|
* Generates an array of courses.
|
||||||
*
|
*
|
||||||
@@ -73,36 +72,34 @@ const generateCourses = (count: number): Course[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exampleCourses = generateCourses(numberOfCourses);
|
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 = {
|
const meta = {
|
||||||
title: 'Components/Common/List',
|
title: 'Components/Common/List',
|
||||||
component: List,
|
component: SortableList,
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'centered',
|
layout: 'centered',
|
||||||
},
|
},
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
argTypes: {
|
} satisfies Meta<typeof SortableList<CourseWithId>>;
|
||||||
gap: { control: 'number' },
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof List<Course>>;
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<Meta<typeof List<Course>>>;
|
type Story = StoryObj<Meta<typeof SortableList<CourseWithId>>>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
draggables: exampleCourses,
|
draggables: exampleCourses.map(course => ({
|
||||||
children: generateCourseBlocks,
|
id: course.uniqueId,
|
||||||
itemKey: item => item.uniqueId,
|
...course,
|
||||||
gap: 12,
|
getConflicts: course.getConflicts,
|
||||||
onReordered: () => {},
|
})),
|
||||||
|
onChange: () => {},
|
||||||
|
renderItem: course => <PopupCourseBlock key={course.id} course={course} colors={course.colors} />,
|
||||||
},
|
},
|
||||||
render: args => (
|
render: args => (
|
||||||
<div className='w-sm'>
|
<div className='h-3xl w-3xl transform-none'>
|
||||||
<List {...args} />
|
<SortableList {...args} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
|||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
import { openReportWindow } from '@shared/util/openReportWindow';
|
import { openReportWindow } from '@shared/util/openReportWindow';
|
||||||
import Divider from '@views/components/common/Divider';
|
import Divider from '@views/components/common/Divider';
|
||||||
import List from '@views/components/common/List';
|
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import { useEnforceScheduleLimit } from '@views/hooks/useEnforceScheduleLimit';
|
import { useEnforceScheduleLimit } from '@views/hooks/useEnforceScheduleLimit';
|
||||||
import useSchedules, { getActiveSchedule, replaceSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
import useSchedules, { getActiveSchedule, replaceSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
||||||
@@ -20,6 +19,7 @@ import { SmallLogo } from './common/LogoIcon';
|
|||||||
import PopupCourseBlock from './common/PopupCourseBlock';
|
import PopupCourseBlock from './common/PopupCourseBlock';
|
||||||
import ScheduleDropdown from './common/ScheduleDropdown';
|
import ScheduleDropdown from './common/ScheduleDropdown';
|
||||||
import ScheduleListItem from './common/ScheduleListItem';
|
import ScheduleListItem from './common/ScheduleListItem';
|
||||||
|
import { SortableList } from './common/SortableList';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the main popup component.
|
* Renders the main popup component.
|
||||||
@@ -56,6 +56,7 @@ export default function PopupMain(): JSX.Element {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [activeSchedule, schedules] = useSchedules();
|
const [activeSchedule, schedules] = useSchedules();
|
||||||
|
|
||||||
// const [isRefreshing, setIsRefreshing] = useState(false);
|
// const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [funny, setFunny] = useState<string>('');
|
const [funny, setFunny] = useState<string>('');
|
||||||
|
|
||||||
@@ -103,10 +104,9 @@ export default function PopupMain(): JSX.Element {
|
|||||||
<Divider orientation='horizontal' size='100%' />
|
<Divider orientation='horizontal' size='100%' />
|
||||||
<div className='px-5 pb-2.5 pt-3.75'>
|
<div className='px-5 pb-2.5 pt-3.75'>
|
||||||
<ScheduleDropdown>
|
<ScheduleDropdown>
|
||||||
<List
|
<SortableList
|
||||||
draggables={schedules}
|
draggables={schedules}
|
||||||
itemKey={schedule => schedule.id}
|
onChange={reordered => {
|
||||||
onReordered={reordered => {
|
|
||||||
const activeSchedule = getActiveSchedule();
|
const activeSchedule = getActiveSchedule();
|
||||||
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
|
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('schedules', reordered);
|
||||||
UserScheduleStore.set('activeIndex', activeIndex);
|
UserScheduleStore.set('activeIndex', activeIndex);
|
||||||
}}
|
}}
|
||||||
gap={10}
|
renderItem={schedule => (
|
||||||
>
|
<ScheduleListItem schedule={schedule} onClick={() => switchSchedule(schedule.id)} />
|
||||||
{(schedule, handleProps) => (
|
|
||||||
<ScheduleListItem
|
|
||||||
schedule={schedule}
|
|
||||||
onClick={() => {
|
|
||||||
switchSchedule(schedule.id);
|
|
||||||
}}
|
|
||||||
dragHandleProps={handleProps}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</List>
|
/>
|
||||||
<div className='bottom-0 right-0 mt-2.5 w-full flex justify-end'>
|
<div className='bottom-0 right-0 mt-2.5 w-full flex justify-end'>
|
||||||
<Button
|
<Button
|
||||||
variant='filled'
|
variant='filled'
|
||||||
@@ -149,24 +141,20 @@ export default function PopupMain(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
<div className='flex-1 self-stretch overflow-y-auto px-5'>
|
<div className='flex-1 self-stretch overflow-y-auto px-5'>
|
||||||
{activeSchedule?.courses?.length > 0 && (
|
{activeSchedule?.courses?.length > 0 && (
|
||||||
<List
|
<SortableList
|
||||||
draggables={activeSchedule.courses}
|
draggables={activeSchedule.courses.map(course => ({
|
||||||
onReordered={reordered => {
|
id: course.uniqueId,
|
||||||
activeSchedule.courses = reordered;
|
...course,
|
||||||
|
getConflicts: course.getConflicts,
|
||||||
|
}))}
|
||||||
|
onChange={reordered => {
|
||||||
|
activeSchedule.courses = reordered.map(({ id: _id, ...course }) => course);
|
||||||
replaceSchedule(getActiveSchedule(), activeSchedule);
|
replaceSchedule(getActiveSchedule(), activeSchedule);
|
||||||
}}
|
}}
|
||||||
itemKey={e => e.uniqueId}
|
renderItem={course => (
|
||||||
gap={10}
|
<PopupCourseBlock key={course.id} course={course} colors={course.colors} />
|
||||||
>
|
|
||||||
{(course, handleProps) => (
|
|
||||||
<PopupCourseBlock
|
|
||||||
key={course.uniqueId}
|
|
||||||
course={course}
|
|
||||||
colors={course.colors}
|
|
||||||
dragHandleProps={handleProps}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</List>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='w-full flex flex-col items-center gap-1.25 p-5 pt-3.75'>
|
<div className='w-full flex flex-col items-center gap-1.25 p-5 pt-3.75'>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import createSchedule from '@pages/background/lib/createSchedule';
|
import createSchedule from '@pages/background/lib/createSchedule';
|
||||||
import { Plus } from '@phosphor-icons/react';
|
import { Plus } from '@phosphor-icons/react';
|
||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
import { getSpacingInPx } from '@shared/types/Spacing';
|
|
||||||
import { Button } from '@views/components/common/Button';
|
import { Button } from '@views/components/common/Button';
|
||||||
import List from '@views/components/common/List';
|
|
||||||
import ScheduleListItem from '@views/components/common/ScheduleListItem';
|
import ScheduleListItem from '@views/components/common/ScheduleListItem';
|
||||||
|
import { SortableList } from '@views/components/common/SortableList';
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import { useEnforceScheduleLimit } from '@views/hooks/useEnforceScheduleLimit';
|
import { useEnforceScheduleLimit } from '@views/hooks/useEnforceScheduleLimit';
|
||||||
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
||||||
@@ -41,11 +40,10 @@ export function CalendarSchedules() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='w-full flex flex-col'>
|
<div className='w-full flex flex-col'>
|
||||||
<List
|
<SortableList
|
||||||
gap={getSpacingInPx('spacing-3')}
|
className='gap-spacing-3'
|
||||||
draggables={schedules}
|
draggables={schedules}
|
||||||
itemKey={s => s.id}
|
onChange={reordered => {
|
||||||
onReordered={reordered => {
|
|
||||||
const activeSchedule = getActiveSchedule();
|
const activeSchedule = getActiveSchedule();
|
||||||
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
|
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
|
||||||
|
|
||||||
@@ -53,17 +51,10 @@ export function CalendarSchedules() {
|
|||||||
UserScheduleStore.set('schedules', reordered);
|
UserScheduleStore.set('schedules', reordered);
|
||||||
UserScheduleStore.set('activeIndex', activeIndex);
|
UserScheduleStore.set('activeIndex', activeIndex);
|
||||||
}}
|
}}
|
||||||
>
|
renderItem={schedule => (
|
||||||
{(schedule, handleProps) => (
|
<ScheduleListItem schedule={schedule} onClick={() => switchSchedule(schedule.id)} />
|
||||||
<ScheduleListItem
|
|
||||||
schedule={schedule}
|
|
||||||
onClick={() => {
|
|
||||||
switchSchedule(schedule.id);
|
|
||||||
}}
|
|
||||||
dragHandleProps={handleProps}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</List>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
|
|
||||||
import { Check, Copy, DotsSixVertical } from '@phosphor-icons/react';
|
import { Check, Copy, DotsSixVertical } from '@phosphor-icons/react';
|
||||||
import { background } from '@shared/messages';
|
import { background } from '@shared/messages';
|
||||||
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
@@ -12,6 +11,7 @@ import clsx from 'clsx';
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
import { SortableListDragHandle } from './SortableListDragHandle';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for PopupCourseBlock
|
* Props for PopupCourseBlock
|
||||||
@@ -20,9 +20,10 @@ export interface PopupCourseBlockProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
course: Course;
|
course: Course;
|
||||||
colors: CourseColors;
|
colors: CourseColors;
|
||||||
dragHandleProps?: DraggableProvidedDragHandleProps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IS_STORYBOOK = import.meta.env.STORYBOOK;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "course block" to be used in the extension popup.
|
* 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.
|
* @param dragHandleProps - The drag handle props for the course block.
|
||||||
* @returns The rendered PopupCourseBlock component.
|
* @returns The rendered PopupCourseBlock component.
|
||||||
*/
|
*/
|
||||||
export default function PopupCourseBlock({
|
export default function PopupCourseBlock({ className, course, colors }: PopupCourseBlockProps): JSX.Element {
|
||||||
className,
|
|
||||||
course,
|
|
||||||
colors,
|
|
||||||
dragHandleProps,
|
|
||||||
}: PopupCourseBlockProps): JSX.Element {
|
|
||||||
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
|
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
|
||||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||||
const lastCopyTime = useRef<number>(0);
|
const lastCopyTime = useRef<number>(0);
|
||||||
@@ -48,7 +44,6 @@ export default function PopupCourseBlock({
|
|||||||
|
|
||||||
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
|
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
|
||||||
setEnableCourseStatusChips(newValue);
|
setEnableCourseStatusChips(newValue);
|
||||||
// console.log('enableCourseStatusChips', newValue);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// adds transition for shadow hover after three frames
|
// adds transition for shadow hover after three frames
|
||||||
@@ -101,16 +96,23 @@ export default function PopupCourseBlock({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<div
|
{IS_STORYBOOK ? (
|
||||||
style={{
|
<DotsSixVertical weight='bold' className='h-6 w-6 cursor-move text-white' />
|
||||||
backgroundColor: colors.secondaryColor,
|
) : (
|
||||||
}}
|
<SortableListDragHandle
|
||||||
className='flex items-center self-stretch rounded rounded-r-0 cursor-move!'
|
style={{
|
||||||
{...dragHandleProps}
|
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.department} {course.number}
|
||||||
{course.instructors.length > 0 ? <> – </> : ''}
|
{course.instructors.length > 0 ? <> – </> : ''}
|
||||||
{course.instructors.map(v => v.toString({ format: 'last' })).join('; ')}
|
{course.instructors.map(v => v.toString({ format: 'last' })).join('; ')}
|
||||||
@@ -149,7 +151,7 @@ export default function PopupCourseBlock({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Text variant='h2' className='text-base!'>
|
<Text variant='h2' className='no-select text-base!'>
|
||||||
{formattedUniqueId}
|
{formattedUniqueId}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ export type ScheduleDropdownProps = {
|
|||||||
/**
|
/**
|
||||||
* This is a reusable dropdown component that can be used to toggle the visiblity of information
|
* 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();
|
const [activeSchedule] = useSchedules();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='border border-ut-offwhite rounded border-solid bg-white'>
|
<div className='border border-ut-offwhite rounded border-solid bg-white'>
|
||||||
<Disclosure defaultOpen={props.defaultOpen}>
|
<Disclosure defaultOpen={defaultOpen}>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
<DisclosureButton className='w-full flex items-center border-none bg-transparent px-3.5 py-2.5 text-left'>
|
<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
|
<Transition
|
||||||
as='div'
|
as='div'
|
||||||
className='contain-paint max-h-55 origin-top overflow-auto transition-all duration-400 ease-in-out-expo'
|
className='overflow-hidden'
|
||||||
enterFrom='transform scale-98 opacity-0 max-h-0!'
|
enter='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
||||||
enterTo='transform scale-100 opacity-100 max-h-55'
|
enterFrom='max-h-0 opacity-0 p-0.5'
|
||||||
leave='ease-out-expo'
|
enterTo='max-h-[440px] opacity-100 p-0'
|
||||||
leaveFrom='transform scale-100 opacity-100 max-h-55'
|
leave='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
||||||
leaveTo='transform scale-98 opacity-0 max-h-0!'
|
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>
|
</Transition>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,21 +21,22 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import DialogProvider, { usePrompt } from './DialogProvider/DialogProvider';
|
import DialogProvider, { usePrompt } from './DialogProvider/DialogProvider';
|
||||||
import { ExtensionRootWrapper, styleResetClass } from './ExtensionRoot/ExtensionRoot';
|
import { ExtensionRootWrapper, styleResetClass } from './ExtensionRoot/ExtensionRoot';
|
||||||
|
import { SortableListDragHandle } from './SortableListDragHandle';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the ScheduleListItem component.
|
* Props for the ScheduleListItem component.
|
||||||
*/
|
*/
|
||||||
export type Props = {
|
interface ScheduleListItemProps {
|
||||||
style?: React.CSSProperties;
|
|
||||||
schedule: UserSchedule;
|
schedule: UserSchedule;
|
||||||
dragHandleProps?: Omit<React.HTMLAttributes<HTMLDivElement>, 'className'>;
|
|
||||||
onClick?: React.DOMAttributes<HTMLDivElement>['onClick'];
|
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
|
* 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 [activeSchedule] = useSchedules();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editorValue, setEditorValue] = useState(schedule.name);
|
const [editorValue, setEditorValue] = useState(schedule.name);
|
||||||
@@ -101,14 +102,20 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='h-7.5 rounded bg-white'>
|
<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='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}>
|
{IS_STORYBOOK ? (
|
||||||
<DotsSixVertical
|
<DotsSixVertical
|
||||||
weight='bold'
|
weight='bold'
|
||||||
className='h-6 w-6 cursor-move text-zinc-300 btn-transition -ml-1.5 hover:text-zinc-400'
|
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 relative flex flex-1 items-center overflow-x-hidden'>
|
||||||
<div
|
<div
|
||||||
className='group/circle flex flex-grow items-center gap-spacing-3 overflow-x-hidden'
|
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 && (
|
{!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}
|
{schedule.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -203,7 +214,7 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
|
|||||||
</Menu>
|
</Menu>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/views/components/common/SortableItemOverlay.tsx
Normal file
21
src/views/components/common/SortableItemOverlay.tsx
Normal 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>;
|
||||||
|
}
|
||||||
149
src/views/components/common/SortableList.tsx
Normal file
149
src/views/components/common/SortableList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/views/components/common/SortableListDragHandle.tsx
Normal file
22
src/views/components/common/SortableListDragHandle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/views/contexts/SortableItemContext.tsx
Normal file
34
src/views/contexts/SortableItemContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
26
src/views/hooks/useCursor.tsx
Normal file
26
src/views/hooks/useCursor.tsx
Normal 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 };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user