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="theme-color" content="#000000" />
|
||||
<title>Popup</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 360px;
|
||||
height: 540px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import PopupMain from '@views/components/PopupMain';
|
||||
import React from 'react';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Common/PopupMain',
|
||||
@@ -10,6 +11,14 @@ const meta = {
|
||||
tags: ['autodocs'],
|
||||
args: {},
|
||||
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>;
|
||||
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 Divider from '@views/components/common/Divider/Divider';
|
||||
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 { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript';
|
||||
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 RefreshIcon from '~icons/material-symbols/refresh';
|
||||
import SettingsIcon from '~icons/material-symbols/settings';
|
||||
|
||||
import CourseStatus from './common/CourseStatus/CourseStatus';
|
||||
import { LogoIcon } from './common/LogoIcon';
|
||||
import ScheduleDropdown from './common/ScheduleDropdown/ScheduleDropdown';
|
||||
import ScheduleListItem from './common/ScheduleListItem/ScheduleListItem';
|
||||
|
||||
/**
|
||||
* Renders the main popup component.
|
||||
@@ -24,38 +25,7 @@ import { LogoIcon } from './common/LogoIcon';
|
||||
*/
|
||||
export default function PopupMain(): JSX.Element {
|
||||
const [activeSchedule, schedules] = useSchedules();
|
||||
const [isPopupVisible, setIsPopupVisible] = 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 [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleOpenOptions = async () => {
|
||||
const url = chrome.runtime.getURL('/options.html');
|
||||
@@ -64,108 +34,79 @@ export default function PopupMain(): JSX.Element {
|
||||
|
||||
return (
|
||||
<ExtensionRoot>
|
||||
<div className='mx-auto max-w-sm rounded bg-white p-4 shadow-md'>
|
||||
<div className='mb-2 flex items-center justify-between bg-white'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<LogoIcon />
|
||||
<div>
|
||||
<Text as='div' variant='h1-course' className='color-ut-burntorange'>
|
||||
UT Registration
|
||||
</Text>
|
||||
<Text as='div' variant='h1-course' className='color-ut-orange'>
|
||||
Plus
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<button className='rounded-lg bg-ut-burntorange p2' onClick={handleOpenCalendar}>
|
||||
<CalendarIcon className='text-white' />
|
||||
</button>
|
||||
<button className='bg-transparent btn' onClick={handleOpenOptions}>
|
||||
<SettingsIcon className='h-5 w-5 color-ut-black' />
|
||||
</button>
|
||||
</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
|
||||
className={clsx(
|
||||
'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',
|
||||
{ 'rotate-180': isPopupVisible }
|
||||
)}
|
||||
/>
|
||||
</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 className='h-screen max-h-full flex flex-col 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'>
|
||||
<LogoIcon />
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-lg text-ut-burntorange font-medium leading-[18px]'>
|
||||
UT Registration
|
||||
<br />
|
||||
</span>
|
||||
<span className='text-lg text-ut-orange font-medium leading-[18px]'>Plus</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isPopupVisible && (
|
||||
<List
|
||||
draggableElements={activeSchedule?.courses.map((course, i) => (
|
||||
<PopupCourseBlock key={course.uniqueId} course={course} colors={tailwindColorways[i]} />
|
||||
))}
|
||||
gap={12}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
<Text as='span' variant='mini'>
|
||||
WAITLISTED
|
||||
</Text>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='rounded bg-ut-black p-1px'>
|
||||
<StatusIcon status={Status.CLOSED} className='h-5 w-5 text-white' />
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<button className='bg-ut-burntorange px-2 py-1.25 btn' onClick={handleOpenCalendar}>
|
||||
<CalendarIcon className='size-6 text-white' />
|
||||
</button>
|
||||
<button className='bg-transparent px-2 py-1.25 btn' onClick={handleOpenOptions}>
|
||||
<SettingsIcon className='size-6 color-ut-black' />
|
||||
</button>
|
||||
</div>
|
||||
<Text as='span' variant='mini'>
|
||||
CLOSED
|
||||
</Text>
|
||||
</div>
|
||||
<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' />
|
||||
</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' />
|
||||
<Divider orientation='horizontal' size='100%' />
|
||||
<div className='px-5 pb-2.5 pt-3.75'>
|
||||
<ScheduleDropdown>
|
||||
<List
|
||||
draggableElements={schedules.map((schedule, index) => (
|
||||
<ScheduleListItem
|
||||
active={false}
|
||||
name={schedule.name}
|
||||
onClick={() => {
|
||||
switchSchedule(schedule.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
gap={10}
|
||||
/>
|
||||
</ScheduleDropdown>
|
||||
</div>
|
||||
<div className='flex-1 self-stretch overflow-y-auto px-5'>
|
||||
{activeSchedule?.courses?.length > 0 && (
|
||||
<List
|
||||
draggableElements={activeSchedule?.courses.map((course, i) => (
|
||||
<PopupCourseBlock key={course.uniqueId} course={course} colors={tailwindColorways[i]} />
|
||||
))}
|
||||
gap={10}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button
|
||||
className='h-4 w-4 bg-transparent p-0 btn'
|
||||
onClick={() => {
|
||||
setIsRefreshing(true);
|
||||
}}
|
||||
>
|
||||
<RefreshIcon
|
||||
className={clsx('h-4 w-4 text-ut-black animate-duration-800', {
|
||||
'animate-spin': isRefreshing,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
@import 'src/views/styles/base.module.scss';
|
||||
|
||||
.extensionRoot {
|
||||
@apply font-sans;
|
||||
@apply font-sans h-full;
|
||||
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>
|
||||
|
||||
<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!'
|
||||
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!'
|
||||
>
|
||||
|
||||
@@ -2,52 +2,61 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||
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.
|
||||
* @returns A tuple containing the active schedule and an array of all schedules.
|
||||
*/
|
||||
export default function useSchedules(): [active: UserSchedule | null, schedules: UserSchedule[]] {
|
||||
const [schedules, setSchedules] = useState<UserSchedule[]>([]);
|
||||
const [activeSchedule, setActiveSchedule] = useState<UserSchedule | null>(null);
|
||||
const [schedules, setSchedules] = useState<UserSchedule[]>(schedulesCache);
|
||||
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(() => {
|
||||
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]));
|
||||
const l1 = UserScheduleStore.listen('schedules', ({ newValue }) => {
|
||||
setSchedules(newValue.map(s => new UserSchedule(s)));
|
||||
});
|
||||
|
||||
const l2 = UserScheduleStore.listen('activeIndex', ({ newValue }) => {
|
||||
setActiveIndex(newValue);
|
||||
});
|
||||
|
||||
return () => {
|
||||
UserScheduleStore.removeListener(l1);
|
||||
UserScheduleStore.removeListener(l2);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
const setupListeners = () => {
|
||||
const l1 = UserScheduleStore.listen('schedules', ({ newValue }) => {
|
||||
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 }) => {
|
||||
setSchedules(currentSchedules => {
|
||||
setActiveSchedule(new UserSchedule(currentSchedules[newValue]));
|
||||
return currentSchedules;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
UserScheduleStore.removeListener(l1);
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -57,6 +66,7 @@ export default function useSchedules(): [active: UserSchedule | null, schedules:
|
||||
* @returns A promise that resolves when the active schedule has been switched.
|
||||
*/
|
||||
export async function switchSchedule(name: string): Promise<void> {
|
||||
console.log('Switching schedule...');
|
||||
const schedules = await UserScheduleStore.get('schedules');
|
||||
const activeIndex = schedules.findIndex(s => s.name === name);
|
||||
await UserScheduleStore.set('activeIndex', activeIndex);
|
||||
|
||||
@@ -36,9 +36,7 @@ export default defineConfig({
|
||||
presetWebFonts({
|
||||
provider: 'none',
|
||||
fonts: {
|
||||
sans: {
|
||||
name: 'Roboto Flex',
|
||||
},
|
||||
sans: ['Roboto Flex', 'Roboto Flex Local'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user