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"
|
||||
},
|
||||
"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
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 { 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>
|
||||
);
|
||||
|
||||
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 { 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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 { 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 ? <> – </> : ''}
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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