feat: sticky calendar header and days (#568)

* feat: sticky calendar days

* feat: partial height borders for day labels

* feat: make calendar header actually sticky

* fix: remove unneeded gap

* refactor: add preston as co-author

Co-authored-by: Preston-Cook <preston.l.cook@gmail.com>

* fix: z-index issues with export sub-buttons

---------

Co-authored-by: Preston-Cook <preston.l.cook@gmail.com>
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
This commit is contained in:
Samuel Gunter
2025-03-23 19:49:11 -05:00
committed by GitHub
parent 4a5f67f0fd
commit fa9f78b46e
6 changed files with 39 additions and 20 deletions

View File

@@ -140,7 +140,7 @@ export default function Calendar(): JSX.Element {
// scrollbarGutter: 'stable', // scrollbarGutter: 'stable',
} }
} }
className='h-full flex flex-grow flex-col overflow-x-scroll px-spacing-5' className='z-1 h-full flex flex-grow flex-col overflow-x-scroll [&>*]:px-spacing-5'
> >
<CalendarHeader <CalendarHeader
sidebarOpen={showSidebar} sidebarOpen={showSidebar}
@@ -148,7 +148,7 @@ export default function Calendar(): JSX.Element {
setShowSidebar(!showSidebar); setShowSidebar(!showSidebar);
}} }}
/> />
<div className='min-h-2xl min-w-5xl flex-grow overflow-auto pl-spacing-3 pt-spacing-3 screenshot:min-h-xl'> <div className='min-h-2xl min-w-5xl flex-grow gap-0 pl-spacing-3 screenshot:min-h-xl'>
<CalendarGrid courseCells={courseCells} setCourse={setCourse} /> <CalendarGrid courseCells={courseCells} setCourse={setCourse} />
</div> </div>
<CalendarBottomBar courseCells={courseCells} setCourse={setCourse} /> <CalendarBottomBar courseCells={courseCells} setCourse={setCourse} />

View File

@@ -3,7 +3,7 @@ import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
import { ColorPickerProvider } from '@views/contexts/ColorPickerContext'; import { ColorPickerProvider } from '@views/contexts/ColorPickerContext';
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
import React from 'react'; import React, { Fragment } from 'react';
import CalendarCell from './CalendarGridCell'; import CalendarCell from './CalendarGridCell';
@@ -30,13 +30,13 @@ function makeGridRow(row: number, cols: number): JSX.Element {
const hour = hoursOfDay[row]!; const hour = hoursOfDay[row]!;
return ( return (
<> <Fragment key={row}>
<CalendarHour hour={hour} /> <CalendarHour hour={hour} />
<div className='grid-row-span-2 w-4 border-b border-r border-gray-300' /> <div className='grid-row-span-2 w-4 border-b border-r border-gray-300' />
{[...Array(cols).keys()].map(col => ( {[...Array(cols).keys()].map(col => (
<CalendarCell key={`${row}${col}`} row={row} col={col} /> <CalendarCell key={`${row}${col}`} row={row} col={col} />
))} ))}
</> </Fragment>
); );
} }
@@ -56,23 +56,40 @@ export default function CalendarGrid({
setCourse, setCourse,
}: React.PropsWithChildren<Props>): JSX.Element { }: React.PropsWithChildren<Props>): JSX.Element {
return ( return (
<div className='grid grid-cols-[auto_auto_repeat(5,1fr)] grid-rows-[auto_repeat(26,1fr)] h-full'> <div className='grid grid-cols-[auto_auto_repeat(5,1fr)] grid-rows-[auto_auto_repeat(27,1fr)] h-full'>
{/* Cover top left corner of grid, so time gets cut off at the top of the partial border */}
<div className='sticky top-[85px] z-10 col-span-2 h-3 bg-white' />
{/* Displaying day labels */} {/* Displaying day labels */}
<div />
<div className='w-4 border-b border-r border-gray-300' />
{daysOfWeek.map(day => ( {daysOfWeek.map(day => (
<div className='h-4 flex items-end justify-center border-b border-r border-gray-300 pb-1.5'> <div
<Text key={day} variant='small' className='text-center text-ut-burntorange' as='div'> // Full height with background to prevent grid lines from showing behind
{day} className='sticky top-[85px] z-10 row-span-2 h-7 flex flex-col items-end self-start justify-end bg-white'
</Text> key={day}
>
{/* Partial border height because that's what Isaiah wants */}
<div className='h-4 w-full flex items-end border-b border-r border-gray-300'>
{/* Alignment for text */}
<div className='h-[calc(1.75rem_-_1px)] w-full flex items-center justify-center'>
<Text variant='small' className='text-center text-ut-burntorange' as='div'>
{day}
</Text>
</div>
</div>
</div> </div>
))} ))}
{/* empty slot, for alignment */}
<div />
{/* time tick for the first hour */}
<div className='h-4 w-4 self-end border-b border-r border-gray-300' />
{[...Array(13).keys()].map(i => makeGridRow(i, 5))} {[...Array(13).keys()].map(i => makeGridRow(i, 5))}
<CalendarHour hour={21} /> <CalendarHour hour={21} />
{Array(6) {Array(6)
.fill(1) .fill(1)
.map(() => ( .map((_, i) => (
<div className='h-4 flex items-end justify-center border-r border-gray-300' /> // Key suppresses warning about duplicate keys,
// and index is fine because it doesn't change between renders
// eslint-disable-next-line react/no-array-index-key
<div key={i} className='h-4 flex items-end justify-center border-r border-gray-300' />
))} ))}
<ColorPickerProvider> <ColorPickerProvider>
{courseCells && <AccountForCourseConflicts courseCells={courseCells} setCourse={setCourse} />} {courseCells && <AccountForCourseConflicts courseCells={courseCells} setCourse={setCourse} />}

View File

@@ -17,8 +17,8 @@ function CalendarCell({ row, col }: Props): JSX.Element {
<div <div
className='h-full w-full flex items-center border-b border-r border-gray-300' className='h-full w-full flex items-center border-b border-r border-gray-300'
style={{ style={{
gridColumn: col + 3, gridColumn: col + 3, // start in the 3rd 1-index column
gridRow: `${2 * row + 2} / ${2 * row + 4}`, gridRow: `${2 * row + 3} / ${2 * row + 5}`, // Span 2 rows, skip 2 header rows
}} }}
> >
<div className='h-0 w-full border-t border-gray-300/25' /> <div className='h-0 w-full border-t border-gray-300/25' />

View File

@@ -27,7 +27,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
return ( return (
<div <div
style={{ scrollbarGutter: 'stable' }} style={{ scrollbarGutter: 'stable' }}
className='sticky left-0 right-0 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden pl-spacing-7 pt-spacing-5' className='sticky left-0 right-0 top-0 z-10 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden bg-white pl-spacing-7 pt-spacing-5'
> >
{!sidebarOpen && ( {!sidebarOpen && (
<Button <Button
@@ -62,7 +62,7 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
className={clsx([ className={clsx([
styleResetClass, styleResetClass,
'mt-spacing-3', 'mt-spacing-3',
'min-w-max cursor-pointer origin-top-right rounded bg-white p-1 text-black shadow-lg transition border border-ut-offwhite/50 focus:outline-none', 'min-w-max cursor-pointer origin-top-right rounded bg-white p-1 text-black shadow-lg transition border border-ut-offwhite/50 focus:outline-none z-20',
'data-[closed]:(opacity-0 scale-95)', 'data-[closed]:(opacity-0 scale-95)',
'data-[enter]:(ease-out-expo duration-150)', 'data-[enter]:(ease-out-expo duration-150)',
'data-[leave]:(ease-out duration-50)', 'data-[leave]:(ease-out duration-50)',

View File

@@ -5,7 +5,7 @@ import { convertMinutesToIndex } from '../useFlattenedCourseSchedule';
describe('useFlattenedCourseSchedule', () => { describe('useFlattenedCourseSchedule', () => {
it('should convert minutes to index correctly', () => { it('should convert minutes to index correctly', () => {
const minutes = 480; // 8:00 AM const minutes = 480; // 8:00 AM
const expectedIndex = 2; // (480 - 420) / 30 = 2 const expectedIndex = 3; // (480 - 480) / 30 + 2 + 1 = 3
const result = convertMinutesToIndex(minutes); const result = convertMinutesToIndex(minutes);
expect(result).toBe(expectedIndex); expect(result).toBe(expectedIndex);
}); });

View File

@@ -52,7 +52,9 @@ export interface FlattenedCourseSchedule {
* @param minutes - The number of minutes. * @param minutes - The number of minutes.
* @returns The index value. * @returns The index value.
*/ */
export const convertMinutesToIndex = (minutes: number): number => Math.floor((minutes - 420) / 30); export const convertMinutesToIndex = (minutes: number): number =>
// 480 = 8 a.m., 30 = 30 minute slots, 2 header rows, and grid rows start at 1
Math.floor((minutes - 480) / 30) + 2 + 1;
/** /**
* Get the active schedule, and convert it to be render-able into a calendar. * Get the active schedule, and convert it to be render-able into a calendar.