refactor(popup): match styles/reduce paint flicker (#136)
* refactor: match popup styles/reduce paint flicker
* fix: useSchedules hook
* feat: popup ✨
* fix: repaint issue on popup body
* fix: initial active schedule
* fix: center justification
* fix: reactivity error
This commit is contained in:
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
@@ -5,6 +5,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<title>Popup</title>
|
<title>Popup</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 360px;
|
||||||
|
height: 540px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import PopupMain from '@views/components/PopupMain';
|
import PopupMain from '@views/components/PopupMain';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Common/PopupMain',
|
title: 'Components/Common/PopupMain',
|
||||||
@@ -10,6 +11,14 @@ const meta = {
|
|||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
args: {},
|
args: {},
|
||||||
argTypes: {},
|
argTypes: {},
|
||||||
|
render: () => (
|
||||||
|
<div
|
||||||
|
style={{ width: '360px', height: '540px' }}
|
||||||
|
className='border-2 border-gray-300/40 shadow-gray/20 shadow-lg'
|
||||||
|
>
|
||||||
|
<PopupMain />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
} satisfies Meta<typeof PopupMain>;
|
} satisfies Meta<typeof PopupMain>;
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Status } from '@shared/types/Course';
|
|
||||||
import { StatusIcon } from '@shared/util/icons';
|
|
||||||
import { tailwindColorways } from '@shared/util/storybook';
|
import { tailwindColorways } from '@shared/util/storybook';
|
||||||
import Divider from '@views/components/common/Divider/Divider';
|
import Divider from '@views/components/common/Divider/Divider';
|
||||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||||
@@ -10,13 +8,16 @@ import { handleOpenCalendar } from '@views/components/injected/CourseCatalogInje
|
|||||||
import useSchedules, { switchSchedule } from '@views/hooks/useSchedules';
|
import useSchedules, { switchSchedule } from '@views/hooks/useSchedules';
|
||||||
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
|
import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import CalendarIcon from '~icons/material-symbols/calendar-month';
|
import CalendarIcon from '~icons/material-symbols/calendar-month';
|
||||||
import RefreshIcon from '~icons/material-symbols/refresh';
|
import RefreshIcon from '~icons/material-symbols/refresh';
|
||||||
import SettingsIcon from '~icons/material-symbols/settings';
|
import SettingsIcon from '~icons/material-symbols/settings';
|
||||||
|
|
||||||
|
import CourseStatus from './common/CourseStatus/CourseStatus';
|
||||||
import { LogoIcon } from './common/LogoIcon';
|
import { LogoIcon } from './common/LogoIcon';
|
||||||
|
import ScheduleDropdown from './common/ScheduleDropdown/ScheduleDropdown';
|
||||||
|
import ScheduleListItem from './common/ScheduleListItem/ScheduleListItem';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the main popup component.
|
* Renders the main popup component.
|
||||||
@@ -24,38 +25,7 @@ import { LogoIcon } from './common/LogoIcon';
|
|||||||
*/
|
*/
|
||||||
export default function PopupMain(): JSX.Element {
|
export default function PopupMain(): JSX.Element {
|
||||||
const [activeSchedule, schedules] = useSchedules();
|
const [activeSchedule, schedules] = useSchedules();
|
||||||
const [isPopupVisible, setIsPopupVisible] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const popupRef = useRef(null);
|
|
||||||
const toggleRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event) {
|
|
||||||
if (!popupRef.current?.contains(event.target) && !toggleRef.current?.contains(event.target)) {
|
|
||||||
setIsPopupVisible(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
setIsPopupVisible(prev => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!activeSchedule || schedules.length === 0) {
|
|
||||||
return <ExtensionRoot>No active schedule available.</ExtensionRoot>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectSchedule = async selectedSchedule => {
|
|
||||||
await switchSchedule(selectedSchedule.name);
|
|
||||||
handleClick();
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonActiveSchedules = schedules.filter(s => s.name !== activeSchedule.name);
|
|
||||||
|
|
||||||
const draggableElements = activeSchedule?.courses.map((course, i) => (
|
|
||||||
<PopupCourseBlock key={course.uniqueId} course={course} colors={tailwindColorways[i]} />
|
|
||||||
));
|
|
||||||
|
|
||||||
const handleOpenOptions = async () => {
|
const handleOpenOptions = async () => {
|
||||||
const url = chrome.runtime.getURL('/options.html');
|
const url = chrome.runtime.getURL('/options.html');
|
||||||
@@ -64,108 +34,79 @@ export default function PopupMain(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ExtensionRoot>
|
<ExtensionRoot>
|
||||||
<div className='mx-auto max-w-sm rounded bg-white p-4 shadow-md'>
|
<div className='h-screen max-h-full flex flex-col bg-white'>
|
||||||
<div className='mb-2 flex items-center justify-between bg-white'>
|
<div className='p-5 py-3.5'>
|
||||||
|
<div className='flex items-center justify-between bg-white'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<LogoIcon />
|
<LogoIcon />
|
||||||
<div>
|
<div className='flex flex-col'>
|
||||||
<Text as='div' variant='h1-course' className='color-ut-burntorange'>
|
<span className='text-lg text-ut-burntorange font-medium leading-[18px]'>
|
||||||
UT Registration
|
UT Registration
|
||||||
</Text>
|
<br />
|
||||||
<Text as='div' variant='h1-course' className='color-ut-orange'>
|
</span>
|
||||||
Plus
|
<span className='text-lg text-ut-orange font-medium leading-[18px]'>Plus</span>
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center gap-2.5'>
|
||||||
<button className='rounded-lg bg-ut-burntorange p2' onClick={handleOpenCalendar}>
|
<button className='bg-ut-burntorange px-2 py-1.25 btn' onClick={handleOpenCalendar}>
|
||||||
<CalendarIcon className='text-white' />
|
<CalendarIcon className='size-6 text-white' />
|
||||||
</button>
|
</button>
|
||||||
<button className='bg-transparent btn' onClick={handleOpenOptions}>
|
<button className='bg-transparent px-2 py-1.25 btn' onClick={handleOpenOptions}>
|
||||||
<SettingsIcon className='h-5 w-5 color-ut-black' />
|
<SettingsIcon className='size-6 color-ut-black' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Divider orientation='horizontal' className='my-4' size='100%' />
|
|
||||||
<div
|
|
||||||
ref={toggleRef}
|
|
||||||
className='mb-4 flex items-center justify-between border border-ut-offwhite rounded p-2 text-left'
|
|
||||||
onClick={handleClick}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Text as='div' variant='h1-course' className='color-ut-burntorange'>
|
|
||||||
{`${activeSchedule.name}`}:
|
|
||||||
</Text>
|
|
||||||
<div className='flex items-center justify-start gap2.5 color-ut-black'>
|
|
||||||
<Text variant='h1'>{activeSchedule.hours} HOURS</Text>
|
|
||||||
<Text variant='h2-course'>{activeSchedule.courses.length} Courses</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Divider orientation='horizontal' size='100%' />
|
||||||
<div
|
<div className='px-5 pb-2.5 pt-3.75'>
|
||||||
className={clsx(
|
<ScheduleDropdown>
|
||||||
'ml-auto inline-block h-0 w-0 border-l-5 border-r-5 border-t-5 border-transparent border-ut-orange transition-transform duration-300 ease-in-out',
|
<List
|
||||||
{ 'rotate-180': isPopupVisible }
|
draggableElements={schedules.map((schedule, index) => (
|
||||||
)}
|
<ScheduleListItem
|
||||||
|
active={false}
|
||||||
|
name={schedule.name}
|
||||||
|
onClick={() => {
|
||||||
|
switchSchedule(schedule.name);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{isPopupVisible && (
|
|
||||||
<div ref={popupRef}>
|
|
||||||
{nonActiveSchedules.map(schedule => (
|
|
||||||
<div
|
|
||||||
key={schedule.name}
|
|
||||||
className='my-2 cursor-pointer border border-gray-300 rounded-md border-solid bg-white p-2 shadow-sm hover:bg-gray-100'
|
|
||||||
onClick={() => selectSchedule(schedule)}
|
|
||||||
>
|
|
||||||
<Text as='div' variant='h1-course' className='color-ut-burntorange'>
|
|
||||||
{schedule.name}:
|
|
||||||
</Text>
|
|
||||||
<div className='flex items-center justify-start gap2.5 color-ut-black'>
|
|
||||||
<Text variant='h1'>{schedule.hours} HOURS</Text>
|
|
||||||
<Text variant='h2-course'>{schedule.courses.length} Courses</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
gap={10}
|
||||||
|
/>
|
||||||
|
</ScheduleDropdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className='flex-1 self-stretch overflow-y-auto px-5'>
|
||||||
{!isPopupVisible && (
|
{activeSchedule?.courses?.length > 0 && (
|
||||||
<List
|
<List
|
||||||
draggableElements={activeSchedule?.courses.map((course, i) => (
|
draggableElements={activeSchedule?.courses.map((course, i) => (
|
||||||
<PopupCourseBlock key={course.uniqueId} course={course} colors={tailwindColorways[i]} />
|
<PopupCourseBlock key={course.uniqueId} course={course} colors={tailwindColorways[i]} />
|
||||||
))}
|
))}
|
||||||
gap={12}
|
gap={10}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className='mt-4 flex gap-2 border-t border-gray-200 p-4 text-xs'>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<div className='rounded bg-ut-black p-1px'>
|
|
||||||
<StatusIcon status={Status.WAITLISTED} className='h-5 w-5 text-white' />
|
|
||||||
</div>
|
</div>
|
||||||
<Text as='span' variant='mini'>
|
|
||||||
WAITLISTED
|
<div className='w-full flex flex-col items-center gap-1.25 p-5 pt-3.75'>
|
||||||
|
<div className='flex gap-2.5'>
|
||||||
|
<CourseStatus status='WAITLISTED' size='mini' />
|
||||||
|
<CourseStatus status='CLOSED' size='mini' />
|
||||||
|
<CourseStatus status='CANCELLED' size='mini' />
|
||||||
|
</div>
|
||||||
|
<div className='inline-flex items-center self-center gap-1'>
|
||||||
|
<Text variant='mini' className='text-ut-gray'>
|
||||||
|
DATA UPDATED ON: 12:00 AM 02/01/2024
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
<button
|
||||||
<div className='flex items-center gap-1'>
|
className='h-4 w-4 bg-transparent p-0 btn'
|
||||||
<div className='rounded bg-ut-black p-1px'>
|
onClick={() => {
|
||||||
<StatusIcon status={Status.CLOSED} className='h-5 w-5 text-white' />
|
setIsRefreshing(true);
|
||||||
</div>
|
}}
|
||||||
<Text as='span' variant='mini'>
|
>
|
||||||
CLOSED
|
<RefreshIcon
|
||||||
</Text>
|
className={clsx('h-4 w-4 text-ut-black animate-duration-800', {
|
||||||
</div>
|
'animate-spin': isRefreshing,
|
||||||
<div className='flex items-center gap-1'>
|
})}
|
||||||
<div className='rounded bg-ut-black p-1px'>
|
/>
|
||||||
<StatusIcon status={Status.CANCELLED} className='h-5 w-5 text-white' />
|
</button>
|
||||||
</div>
|
|
||||||
<Text as='span' variant='mini'>
|
|
||||||
CANCELLED
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='mt-2 text-center text-xs'>
|
|
||||||
<div className='inline-flex items-center justify-center text-ut-gray'>
|
|
||||||
<Text variant='mini'>DATA UPDATED ON: 12:00 AM 02/01/2024</Text>
|
|
||||||
<RefreshIcon className='ml-2 h-4 w-4 color-gray-600' />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
@import 'src/views/styles/base.module.scss';
|
@import 'src/views/styles/base.module.scss';
|
||||||
|
|
||||||
.extensionRoot {
|
.extensionRoot {
|
||||||
@apply font-sans;
|
@apply font-sans h-full;
|
||||||
color: #303030;
|
color: #303030;
|
||||||
-webkit-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: Inter, sans-serif;
|
|
||||||
font-weight: 300;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ export default function ScheduleDropdown(props: Props) {
|
|||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
className='contain-paint max-h-55 origin-top overflow-auto transition-all duration-400 ease-out-expo'
|
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!'
|
enterFrom='transform scale-98 opacity-0 max-h-0!'
|
||||||
enterTo='transform scale-100 opacity-100 max-h-55'
|
enterTo='transform scale-100 opacity-100 max-h-55'
|
||||||
|
leave='ease-out-expo'
|
||||||
leaveFrom='transform scale-100 opacity-100 max-h-55'
|
leaveFrom='transform scale-100 opacity-100 max-h-55'
|
||||||
leaveTo='transform scale-98 opacity-0 max-h-0!'
|
leaveTo='transform scale-98 opacity-0 max-h-0!'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,52 +2,61 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
|||||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
let schedulesCache = [];
|
||||||
|
let activeIndexCache = 0;
|
||||||
|
let initialLoad = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the user schedules from storage and sets the cached state.
|
||||||
|
*/
|
||||||
|
async function fetchData() {
|
||||||
|
const [storedSchedules, storedActiveIndex] = await Promise.all([
|
||||||
|
UserScheduleStore.get('schedules'),
|
||||||
|
UserScheduleStore.get('activeIndex'),
|
||||||
|
]);
|
||||||
|
schedulesCache = storedSchedules.map(s => new UserSchedule(s));
|
||||||
|
activeIndexCache = storedActiveIndex;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook that manages user schedules.
|
* Custom hook that manages user schedules.
|
||||||
* @returns A tuple containing the active schedule and an array of all schedules.
|
* @returns A tuple containing the active schedule and an array of all schedules.
|
||||||
*/
|
*/
|
||||||
export default function useSchedules(): [active: UserSchedule | null, schedules: UserSchedule[]] {
|
export default function useSchedules(): [active: UserSchedule | null, schedules: UserSchedule[]] {
|
||||||
const [schedules, setSchedules] = useState<UserSchedule[]>([]);
|
const [schedules, setSchedules] = useState<UserSchedule[]>(schedulesCache);
|
||||||
const [activeSchedule, setActiveSchedule] = useState<UserSchedule | null>(null);
|
const [activeIndex, setActiveIndex] = useState<number>(activeIndexCache);
|
||||||
|
const [activeSchedule, setActiveSchedule] = useState<UserSchedule | null>(schedules[activeIndex]);
|
||||||
|
|
||||||
|
if (initialLoad) {
|
||||||
|
initialLoad = false;
|
||||||
|
|
||||||
|
// trigger suspense
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||||
|
throw new Promise(res => {
|
||||||
|
fetchData().then(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
|
||||||
const [storedSchedules, storedActiveIndex] = await Promise.all([
|
|
||||||
UserScheduleStore.get('schedules'),
|
|
||||||
UserScheduleStore.get('activeIndex'),
|
|
||||||
]);
|
|
||||||
setSchedules(storedSchedules.map(s => new UserSchedule(s)));
|
|
||||||
setActiveSchedule(new UserSchedule(storedSchedules[storedActiveIndex]));
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
|
|
||||||
const setupListeners = () => {
|
|
||||||
const l1 = UserScheduleStore.listen('schedules', ({ newValue }) => {
|
const l1 = UserScheduleStore.listen('schedules', ({ newValue }) => {
|
||||||
setSchedules(newValue.map(s => new UserSchedule(s)));
|
setSchedules(newValue.map(s => new UserSchedule(s)));
|
||||||
setActiveSchedule(currentActive => {
|
|
||||||
const newActiveIndex = newValue.findIndex(s => s.name === currentActive?.name);
|
|
||||||
return new UserSchedule(newValue[newActiveIndex]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const l2 = UserScheduleStore.listen('activeIndex', ({ newValue }) => {
|
const l2 = UserScheduleStore.listen('activeIndex', ({ newValue }) => {
|
||||||
setSchedules(currentSchedules => {
|
setActiveIndex(newValue);
|
||||||
setActiveSchedule(new UserSchedule(currentSchedules[newValue]));
|
|
||||||
return currentSchedules;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
UserScheduleStore.removeListener(l1);
|
UserScheduleStore.removeListener(l1);
|
||||||
UserScheduleStore.removeListener(l2);
|
UserScheduleStore.removeListener(l2);
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const init = UserScheduleStore.initialize();
|
|
||||||
init.then(() => setupListeners()).catch(console.error);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// recompute active schedule on a schedule/index change
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveSchedule(schedules[activeIndex]);
|
||||||
|
});
|
||||||
|
|
||||||
return [activeSchedule, schedules];
|
return [activeSchedule, schedules];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +66,7 @@ export default function useSchedules(): [active: UserSchedule | null, schedules:
|
|||||||
* @returns A promise that resolves when the active schedule has been switched.
|
* @returns A promise that resolves when the active schedule has been switched.
|
||||||
*/
|
*/
|
||||||
export async function switchSchedule(name: string): Promise<void> {
|
export async function switchSchedule(name: string): Promise<void> {
|
||||||
|
console.log('Switching schedule...');
|
||||||
const schedules = await UserScheduleStore.get('schedules');
|
const schedules = await UserScheduleStore.get('schedules');
|
||||||
const activeIndex = schedules.findIndex(s => s.name === name);
|
const activeIndex = schedules.findIndex(s => s.name === name);
|
||||||
await UserScheduleStore.set('activeIndex', activeIndex);
|
await UserScheduleStore.set('activeIndex', activeIndex);
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ export default defineConfig({
|
|||||||
presetWebFonts({
|
presetWebFonts({
|
||||||
provider: 'none',
|
provider: 'none',
|
||||||
fonts: {
|
fonts: {
|
||||||
sans: {
|
sans: ['Roboto Flex', 'Roboto Flex Local'],
|
||||||
name: 'Roboto Flex',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user