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:
Razboy20
2024-03-13 12:33:54 -05:00
committed by GitHub
parent 0dff12232c
commit 1d8da6579e
8 changed files with 149 additions and 179 deletions

9
.editorconfig Normal file
View 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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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!'
>

View File

@@ -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);

View File

@@ -36,9 +36,7 @@ export default defineConfig({
presetWebFonts({
provider: 'none',
fonts: {
sans: {
name: 'Roboto Flex',
},
sans: ['Roboto Flex', 'Roboto Flex Local'],
},
}),
],