From 1f635d2515fc403ad7f08fc3a244a17d262e3f7b Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 20 Jan 2025 17:48:52 -0600 Subject: [PATCH] feat(ui): course color picker (#382) * fix: update CourseCellColorPicker.tsx background to white * feat: add color picker to CalendarCourseCell component * feat: add color picker functionality to update course colors * fix: type issues with storybook components * feat: add useColorPicker hook, isValidHexColor and updateCourseColors utilities * refactor: color picker logic and UI components * refactor: update useFlattenedCourseSchedule hook to include courseID property * refactor: update storybook calendar components with updated props * refactor: update color picker ui logic to account for position of cell * fix: revert back to error handling for invalid rgb * refactor: update jsdocs * refactor: integrate ColorPickerContext into Calendar components and update props * refactor: integrate ColorPickerContext into Calendar components and update related props * refactor: change JSDocs comments and remove unused color inversion state * refactor: update story components * feat: add functionality for selecting secondary course colors * refactor: enhance HexColorEditor to dynamically adjust tag icon color based on preview color * refactor: simplify JSDoc comment in useColorPicker hook * fix: revert Button component * refactor: update CalendarCourseCell component positioning and styling * fix: correct types in color.ts * feat: add getDarkerShade function to compute darker shades of hex colors * feat: add shadow to color picker button * fix: update button size in ColorPatch component * feat: implement debounced input for hex color editor and add useDebounce hook * chore: utilize the logical and && operator instead of the ternary operator * fix: imports and palette icon * refactor: remove unused import * fix: bug when course add fails with custom colors * chore: run lint * chore: run check-types * feat: add HSL color type and conversion functions * refactor: rename colorway to theme * fix: hide color picker on screenshot * fix: undo important syntax * refactor: rename SomeFunction to DebouncedCallback * refactor: remove inner function * refactor: update return type to DebouncedCallback * fix: adjust sizes for hash and palette button * feat: create tests for hexToHSL and isValidHexColor * refactor: update parameter type to use HexColor * fix: increase size of palette button * fix: update dependency array for hex code debounce * fix: change colorPickerRef element ref --------- Co-authored-by: doprz <52579214+doprz@users.noreply.github.com> --- .../background/handler/userScheduleHandler.ts | 2 +- src/shared/messages/UserScheduleMessages.ts | 2 +- src/shared/types/Color.ts | 5 + src/shared/util/colors.ts | 176 ++++++++++++++++-- src/shared/util/tests/colors.test.ts | 81 ++++++++ .../calendar/CalendarBottomBar.stories.tsx | 23 ++- .../calendar/CalendarCourseCell.stories.tsx | 72 +++++-- .../calendar/CalendarGrid.stories.tsx | 113 ++++++++++- .../CourseCellColorPicker.stories.tsx | 13 +- .../components/calendar/CalendarBottomBar.tsx | 29 +-- .../calendar/CalendarCourseCell.tsx | 112 ++++++++++- .../ColorPatch.tsx | 25 ++- .../CourseCellColorPicker.tsx | 54 +++--- .../HexColorEditor.tsx | 28 ++- .../components/calendar/CalendarGrid.tsx | 7 +- src/views/components/common/Button.tsx | 2 +- .../HeadingAndActions.tsx | 2 +- src/views/contexts/ColorPickerContext.tsx | 45 +++++ src/views/hooks/useColorPicker.ts | 111 +++++++++++ src/views/hooks/useDebounce.tsx | 41 ++++ src/views/hooks/useFlattenedCourseSchedule.ts | 9 +- src/views/hooks/useSchedules.ts | 46 ++++- 22 files changed, 878 insertions(+), 120 deletions(-) create mode 100644 src/shared/util/tests/colors.test.ts create mode 100644 src/views/contexts/ColorPickerContext.tsx create mode 100644 src/views/hooks/useColorPicker.ts create mode 100644 src/views/hooks/useDebounce.tsx diff --git a/src/pages/background/handler/userScheduleHandler.ts b/src/pages/background/handler/userScheduleHandler.ts index 28d58579..cee7461d 100644 --- a/src/pages/background/handler/userScheduleHandler.ts +++ b/src/pages/background/handler/userScheduleHandler.ts @@ -12,7 +12,7 @@ import type { MessageHandler } from 'chrome-extension-toolkit'; const userScheduleHandler: MessageHandler = { addCourse({ data, sendResponse }) { - addCourse(data.scheduleId, new Course(data.course)).then(sendResponse); + addCourse(data.scheduleId, new Course(data.course), data.hasColor ?? false).then(sendResponse); }, removeCourse({ data, sendResponse }) { removeCourse(data.scheduleId, new Course(data.course)).then(sendResponse); diff --git a/src/shared/messages/UserScheduleMessages.ts b/src/shared/messages/UserScheduleMessages.ts index 53b8f372..f7c219e8 100644 --- a/src/shared/messages/UserScheduleMessages.ts +++ b/src/shared/messages/UserScheduleMessages.ts @@ -9,7 +9,7 @@ export interface UserScheduleMessages { * * @param data - The schedule id and course to add */ - addCourse: (data: { scheduleId: string; course: Course }) => void; + addCourse: (data: { scheduleId: string; course: Course; hasColor?: boolean }) => void; /** * Adds a course by URL diff --git a/src/shared/types/Color.ts b/src/shared/types/Color.ts index c9a72585..ae1374b4 100644 --- a/src/shared/types/Color.ts +++ b/src/shared/types/Color.ts @@ -25,3 +25,8 @@ export type sRGB = [r: number, g: number, b: number]; * Represents a Lab color value. */ export type Lab = [l: number, a: number, b: number]; + +/** + * Represents a HSL color value. + */ +export type HSL = [h: number, s: number, l: number]; diff --git a/src/shared/util/colors.ts b/src/shared/util/colors.ts index b1640e6e..451f1f09 100644 --- a/src/shared/util/colors.ts +++ b/src/shared/util/colors.ts @@ -1,11 +1,11 @@ import type { Serialized } from 'chrome-extension-toolkit'; import { theme } from 'unocss/preset-mini'; -import type { HexColor, Lab, RGB, sRGB } from '../types/Color'; +import type { HexColor, HSL, Lab, RGB, sRGB } from '../types/Color'; import { isHexColor } from '../types/Color'; import type { Course } from '../types/Course'; import type { CourseColors, TWColorway, TWIndex } from '../types/ThemeColors'; -import { colorwayIndexes } from '../types/ThemeColors'; +import { colors, colorwayIndexes } from '../types/ThemeColors'; import type { UserSchedule } from '../types/UserSchedule'; /** @@ -26,6 +26,19 @@ export function hexToRGB(hex: HexColor): RGB | undefined { return [parseInt(result[1]!, 16), parseInt(result[2]!, 16), parseInt(result[3]!, 16)]; } +/** + * Checks if a given string is a valid hex color. + * + * A valid hex color is a string that starts with a '#' followed by either + * 3 or 6 hexadecimal characters (0-9, A-F, a-f). + * + * @param hex - The hex color string to validate. + * @returns True if the string is a valid hex color, false otherwise. + */ +export function isValidHexColor(hex: string): boolean { + return /^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(hex); +} + export const useableColorways = Object.keys(theme.colors) // check that the color is a colorway (is an object) .filter(color => typeof theme.colors[color as keyof typeof theme.colors] === 'object') @@ -56,6 +69,13 @@ export function pickFontColor(bgColor: HexColor): 'text-white' | 'text-black' | return Ys < 0.365 ? 'text-black' : 'text-theme-black'; } +// Mapping of Tailwind CSS class names to their corresponding hex values +export const tailwindColorMap: Record = { + 'text-white': '#FFFFFF', + 'text-black': '#000000', + 'text-theme-black': colors.theme.black, +}; + /** * Get primary and secondary colors from a Tailwind colorway * @@ -82,10 +102,15 @@ export function getCourseColors(colorway: TWColorway, index?: number, offset: nu * @param color - The hexadecimal color value. * @returns The Tailwind colorway. */ -export function getColorwayFromColor(color: HexColor): TWColorway { +export function getColorwayFromColor(color: HexColor): { + colorway: TWColorway; + index: TWIndex; +} { for (const colorway of useableColorways) { - if (Object.values(theme.colors[colorway]).includes(color)) { - return colorway as TWColorway; + const colorValues = Object.values(theme.colors[colorway]); + const index = colorValues.indexOf(color); + if (index !== -1) { + return { colorway: colorway as TWColorway, index: (index * 100) as TWIndex }; } } @@ -121,6 +146,124 @@ export function getColorwayFromColor(color: HexColor): TWColorway { return getColorwayFromColor(closestColor); } +/** + * Converts a hexadecimal color value to HSL (Hue, Saturation, Lightness) format + * + * @param hex - The hexadecimal color string + * @returns An array of [hue (0-360), saturation (0-100), lightness (0-100)] + * @throws If the hex color cannot be converted to RGB + */ +export const hexToHSL = (hex: HexColor): HSL => { + const rgb = hexToRGB(hex); + + if (!rgb) { + throw new Error('hexToRGB returned undefined'); + } + + // Convert RGB to decimals + const r = rgb[0] / 255; + const g = rgb[1] / 255; + const b = rgb[2] / 255; + + // Find min/max/delta + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + + // Calculate HSL values + let h = 0; + let s = 0; + let l = (max + min) / 2; + + if (delta !== 0) { + // Calculate saturation + s = delta / (1 - Math.abs(2 * l - 1)); + + // Calculate hue + if (max === r) { + h = ((g - b) / delta) % 6; + } else if (max === g) { + h = (b - r) / delta + 2; + } else { + h = (r - g) / delta + 4; + } + h *= 60; + } + + // Normalize values + h = Math.round(h < 0 ? h + 360 : h); + s = Math.round(s * 100); + l = Math.round(l * 100); + + return [h, s, l]; +}; + +/** + * Converts an HSL color value to RGB format. + * + * @param hsl - The HSL color value + * @returns An RGB color value + */ +function hslToRGB([hue, saturation, lightness]: HSL): RGB { + // Convert percentages to decimals + const s = saturation / 100; + const l = lightness / 100; + + // Calculate intermediate values + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); + const m = l - c / 2; + + let r = 0; + let g = 0; + let b = 0; + + // Determine RGB values based on hue + if (hue >= 0 && hue < 60) { + [r, g, b] = [c, x, 0]; + } else if (hue >= 60 && hue < 120) { + [r, g, b] = [x, c, 0]; + } else if (hue >= 120 && hue < 180) { + [r, g, b] = [0, c, x]; + } else if (hue >= 180 && hue < 240) { + [r, g, b] = [0, x, c]; + } else if (hue >= 240 && hue < 300) { + [r, g, b] = [x, 0, c]; + } else { + [r, g, b] = [c, 0, x]; + } + + // Convert to 0-255 range and round + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; +} + +/** + * Returns a darker shade of the given hex color by reducing the lightness in HSL color space. + * + * @param color - The hexadecimal color value to darken. + * @param offset - The percentage to reduce the lightness by (default is 20). + * @returns The darker shade of the given hex color. + * @throws If the provided color is not a valid hex color. + */ +export function getDarkerShade(color: HexColor, offset: number = 20): HexColor { + const rgb = hexToRGB(color); + if (!rgb) { + throw new Error('color: Invalid hex.'); + } + + // Convert to HSL + const [h, s, l] = hexToHSL(color); + + // Reduce lightness by offset percentage, ensuring it doesn't go below 0 + const newL = Math.max(0, l - offset); + + // Convert back to RGB + const newRGB = hslToRGB([h, s, newL]); + + // Convert to hex + return `#${newRGB.map(c => Math.round(c).toString(16).padStart(2, '0')).join('')}`; +} + /** * Get next unused color in a tailwind colorway for a given schedule * @@ -153,17 +296,28 @@ export function getUnusedColor( const scheduleCourses = schedule.courses.map(c => ({ ...c, - colorway: getColorwayFromColor(c.colors.primaryColor), + theme: (() => { + try { + return getColorwayFromColor(c.colors.primaryColor); + } catch (error) { + // Default to emerald colorway with index 500 + return { + colorway: 'emerald' as TWColorway, + index: 500 as TWIndex, + }; + } + })(), })); - const usedColorways = new Set(scheduleCourses.map(c => c.colorway)); + + const usedColorways = new Set(scheduleCourses.map(c => c.theme.colorway)); const availableColorways = new Set(useableColorways.filter(c => !usedColorways.has(c))); if (availableColorways.size > 0) { let sameDepartment = scheduleCourses.filter(c => c.department === course.department); sameDepartment.sort((a, b) => { - const aIndex = useableColorways.indexOf(a.colorway); - const bIndex = useableColorways.indexOf(b.colorway); + const aIndex = useableColorways.indexOf(a.theme.colorway); + const bIndex = useableColorways.indexOf(b.theme.colorway); return aIndex - bIndex; }); @@ -172,8 +326,8 @@ export function getUnusedColor( // check to see if any adjacent colorways are available const centerCourse = sameDepartment[Math.floor(Math.random() * sameDepartment.length)]!; - let nextColorway = getNextColorway(centerCourse.colorway); - let prevColorway = getPreviousColorway(centerCourse.colorway); + let nextColorway = getNextColorway(centerCourse.theme.colorway); + let prevColorway = getPreviousColorway(centerCourse.theme.colorway); // eslint-disable-next-line no-constant-condition while (true) { diff --git a/src/shared/util/tests/colors.test.ts b/src/shared/util/tests/colors.test.ts new file mode 100644 index 00000000..69f54976 --- /dev/null +++ b/src/shared/util/tests/colors.test.ts @@ -0,0 +1,81 @@ +import { hexToHSL, isValidHexColor } from '@shared/util/colors'; +import { describe, expect, it } from 'vitest'; + +describe('hexToHSL', () => { + it('should convert pure red to HSL', () => { + const result = hexToHSL('#FF0000'); + expect(result).toEqual([0, 100, 50]); + }); + + it('should convert pure green to HSL', () => { + const result = hexToHSL('#00FF00'); + expect(result).toEqual([120, 100, 50]); + }); + + it('should convert pure blue to HSL', () => { + const result = hexToHSL('#0000FF'); + expect(result).toEqual([240, 100, 50]); + }); + + it('should convert white to HSL', () => { + const result = hexToHSL('#FFFFFF'); + expect(result).toEqual([0, 0, 100]); + }); + + it('should convert black to HSL', () => { + const result = hexToHSL('#000000'); + expect(result).toEqual([0, 0, 0]); + }); + + it('should convert UT burnt orange to HSL', () => { + const result = hexToHSL('#BF5700'); + expect(result).toEqual([27, 100, 37]); + }); + + it('should convert gray to HSL', () => { + const result = hexToHSL('#808080'); + expect(result).toEqual([0, 0, 50]); + }); + + it('should throw error for invalid hex color', () => { + expect(() => hexToHSL('#GGGGGG')).toThrow('hexToRGB returned undefined'); + }); +}); + +describe('isValidHexColor', () => { + it('should validate 6-digit hex colors with hash', () => { + expect(isValidHexColor('#000000')).toBe(true); + expect(isValidHexColor('#FFFFFF')).toBe(true); + expect(isValidHexColor('#BF5700')).toBe(true); + expect(isValidHexColor('#D6D2C4')).toBe(true); + }); + + it('should validate 6-digit hex colors without hash', () => { + expect(isValidHexColor('000000')).toBe(true); + expect(isValidHexColor('FFFFFF')).toBe(true); + expect(isValidHexColor('BF5700')).toBe(true); + }); + + it('should validate 3-digit hex colors with hash', () => { + expect(isValidHexColor('#000')).toBe(true); + expect(isValidHexColor('#FFF')).toBe(true); + expect(isValidHexColor('#F0F')).toBe(true); + }); + + it('should validate 3-digit hex colors without hash', () => { + expect(isValidHexColor('000')).toBe(true); + expect(isValidHexColor('FFF')).toBe(true); + expect(isValidHexColor('F0F')).toBe(true); + }); + + it('should reject invalid hex colors', () => { + expect(isValidHexColor('#')).toBe(false); + expect(isValidHexColor('#GGG')).toBe(false); + expect(isValidHexColor('#GGGGGG')).toBe(false); + expect(isValidHexColor('GGGGGG')).toBe(false); + expect(isValidHexColor('#12345')).toBe(false); + expect(isValidHexColor('#1234567')).toBe(false); + expect(isValidHexColor('not a color')).toBe(false); + expect(isValidHexColor('')).toBe(false); + }); +}); diff --git a/src/stories/components/calendar/CalendarBottomBar.stories.tsx b/src/stories/components/calendar/CalendarBottomBar.stories.tsx index da0f8de2..fdb2cb21 100644 --- a/src/stories/components/calendar/CalendarBottomBar.stories.tsx +++ b/src/stories/components/calendar/CalendarBottomBar.stories.tsx @@ -3,6 +3,7 @@ import Instructor from '@shared/types/Instructor'; import { getCourseColors } from '@shared/util/colors'; import type { Meta, StoryObj } from '@storybook/react'; import CalendarBottomBar from '@views/components/calendar/CalendarBottomBar'; +import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; import React from 'react'; const exampleGovCourse: Course = new Course({ @@ -91,9 +92,18 @@ export const Default: Story = { async: true, calendarGridPoint: { dayIndex: -1, endIndex: -1, startIndex: -1 }, componentProps: { - colors: getCourseColors('pink', 200), courseDeptAndInstr: `${exampleGovCourse.department} ${exampleGovCourse.number} – ${exampleGovCourse.instructors[0]!.lastName}`, status: exampleGovCourse.status, + blockData: { + calendarGridPoint: { dayIndex: -1, endIndex: -1, startIndex: -1 }, + componentProps: { + courseDeptAndInstr: `${exampleGovCourse.department} ${exampleGovCourse.number} – ${exampleGovCourse.instructors[0]!.lastName}`, + status: exampleGovCourse.status, + blockData: {} as CalendarGridCourse, + }, + course: exampleGovCourse, + async: true, + }, }, course: exampleGovCourse, }, @@ -101,9 +111,18 @@ export const Default: Story = { async: true, calendarGridPoint: { dayIndex: -1, endIndex: -1, startIndex: -1 }, componentProps: { - colors: getCourseColors('slate', 500), courseDeptAndInstr: `${examplePsyCourse.department} ${examplePsyCourse.number} – ${examplePsyCourse.instructors[0]!.lastName}`, status: examplePsyCourse.status, + blockData: { + calendarGridPoint: { dayIndex: -1, endIndex: -1, startIndex: -1 }, + componentProps: { + courseDeptAndInstr: `${examplePsyCourse.department} ${examplePsyCourse.number} – ${examplePsyCourse.instructors[0]!.lastName}`, + status: examplePsyCourse.status, + blockData: {} as CalendarGridCourse, + }, + course: examplePsyCourse, + async: true, + }, }, course: examplePsyCourse, }, diff --git a/src/stories/components/calendar/CalendarCourseCell.stories.tsx b/src/stories/components/calendar/CalendarCourseCell.stories.tsx index b41acb65..be584d37 100644 --- a/src/stories/components/calendar/CalendarCourseCell.stories.tsx +++ b/src/stories/components/calendar/CalendarCourseCell.stories.tsx @@ -1,8 +1,8 @@ import { Status } from '@shared/types/Course'; -import { getCourseColors } from '@shared/util/colors'; import type { Meta, StoryObj } from '@storybook/react'; import type { CalendarCourseCellProps } from '@views/components/calendar/CalendarCourseCell'; import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell'; +import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; import React from 'react'; import { ExampleCourse } from '../PopupCourseBlock.stories'; @@ -19,7 +19,6 @@ const meta = { className: { control: { type: 'text' } }, status: { control: { type: 'select', options: Object.values(Status) } }, timeAndLocation: { control: { type: 'text' } }, - colors: { control: { type: 'object' } }, }, render: (args: CalendarCourseCellProps) => (
@@ -31,23 +30,70 @@ const meta = { className: ExampleCourse.number, status: ExampleCourse.status, timeAndLocation: ExampleCourse.schedule.meetings[0]!.getTimeString({ separator: '–' }), - - colors: getCourseColors('emerald', 500), }, } satisfies Meta; export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + args: { + courseDeptAndInstr: ExampleCourse.department, + className: ExampleCourse.number, + status: ExampleCourse.status, + timeAndLocation: ExampleCourse.schedule.meetings[0]!.getTimeString({ separator: '-' }), + blockData: { + calendarGridPoint: { + dayIndex: 4, + startIndex: 10, + endIndex: 11, + }, + course: ExampleCourse, + async: false, + componentProps: { + courseDeptAndInstr: ExampleCourse.department, + status: ExampleCourse.status, + timeAndLocation: ExampleCourse.schedule.meetings[0]!.getTimeString({ separator: '-' }), + blockData: {} as CalendarGridCourse, + }, + }, + }, +}; export const Variants: Story = { - render: props => ( -
- - - - -
- ), + args: { + courseDeptAndInstr: ExampleCourse.department, + className: ExampleCourse.number, + status: ExampleCourse.status, + timeAndLocation: ExampleCourse.schedule.meetings[0]!.getTimeString({ separator: '-' }), + blockData: { + calendarGridPoint: { + dayIndex: 4, + startIndex: 10, + endIndex: 11, + }, + course: ExampleCourse, + async: false, + componentProps: { + courseDeptAndInstr: ExampleCourse.department, + status: ExampleCourse.status, + timeAndLocation: ExampleCourse.schedule.meetings[0]!.getTimeString({ separator: '-' }), + blockData: { + calendarGridPoint: { + dayIndex: 4, + startIndex: 10, + endIndex: 11, + }, + componentProps: { + courseDeptAndInstr: ExampleCourse.department, + status: ExampleCourse.status, + timeAndLocation: ExampleCourse.schedule.meetings[0]!.getTimeString({ separator: '-' }), + blockData: {} as CalendarGridCourse, + }, + course: ExampleCourse, + async: false, + }, + }, + }, + }, }; diff --git a/src/stories/components/calendar/CalendarGrid.stories.tsx b/src/stories/components/calendar/CalendarGrid.stories.tsx index 30979ebf..ed0f6396 100644 --- a/src/stories/components/calendar/CalendarGrid.stories.tsx +++ b/src/stories/components/calendar/CalendarGrid.stories.tsx @@ -1,5 +1,4 @@ import { Status } from '@shared/types/Course'; -import { getCourseColors } from '@shared/util/colors'; import type { Meta, StoryObj } from '@storybook/react'; import CalendarGrid from '@views/components/calendar/CalendarGrid'; import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; @@ -32,7 +31,21 @@ const testData: CalendarGridCourse[] = [ courseDeptAndInstr: 'Course 1', timeAndLocation: '9:00 AM - 10:00 AM, Room 101', status: Status.OPEN, - colors: getCourseColors('emerald', 500), + blockData: { + calendarGridPoint: { + dayIndex: 4, + startIndex: 10, + endIndex: 11, + }, + componentProps: { + courseDeptAndInstr: 'Course 1', + timeAndLocation: '9:00 AM - 10:00 AM, Room 101', + status: Status.OPEN, + blockData: {} as CalendarGridCourse, + }, + course: ExampleCourse, + async: false, + }, }, course: ExampleCourse, async: false, @@ -47,7 +60,21 @@ const testData: CalendarGridCourse[] = [ courseDeptAndInstr: 'Course 1', timeAndLocation: '9:00 AM - 10:00 AM, Room 101', status: Status.OPEN, - colors: getCourseColors('emerald', 500), + blockData: { + calendarGridPoint: { + dayIndex: 2, + startIndex: 5, + endIndex: 6, + }, + componentProps: { + courseDeptAndInstr: 'Course 1', + timeAndLocation: '9:00 AM - 10:00 AM, Room 101', + status: Status.OPEN, + blockData: {} as CalendarGridCourse, + }, + course: ExampleCourse, + async: false, + }, }, course: ExampleCourse, async: false, @@ -62,7 +89,21 @@ const testData: CalendarGridCourse[] = [ courseDeptAndInstr: 'Course 2', timeAndLocation: '10:00 AM - 11:00 AM, Room 102', status: Status.CLOSED, - colors: getCourseColors('emerald', 500), + blockData: { + calendarGridPoint: { + dayIndex: 1, + startIndex: 10, + endIndex: 12, + }, + componentProps: { + courseDeptAndInstr: 'Course 2', + timeAndLocation: '10:00 AM - 11:00 AM, Room 102', + status: Status.CLOSED, + blockData: {} as CalendarGridCourse, + }, + course: ExampleCourse, + async: false, + }, }, course: ExampleCourse, async: false, @@ -77,7 +118,21 @@ const testData: CalendarGridCourse[] = [ courseDeptAndInstr: 'Course 1', timeAndLocation: '9:00 AM - 10:00 AM, Room 101', status: Status.OPEN, - colors: getCourseColors('emerald', 500), + blockData: { + calendarGridPoint: { + dayIndex: 4, + startIndex: 10, + endIndex: 11, + }, + componentProps: { + courseDeptAndInstr: 'Course 1', + timeAndLocation: '9:00 AM - 10:00 AM, Room 101', + status: Status.OPEN, + blockData: {} as CalendarGridCourse, + }, + course: ExampleCourse, + async: false, + }, }, course: ExampleCourse, async: false, @@ -92,7 +147,21 @@ const testData: CalendarGridCourse[] = [ courseDeptAndInstr: 'Course 2', timeAndLocation: '10:00 AM - 11:00 AM, Room 102', status: Status.CLOSED, - colors: getCourseColors('emerald', 500), + blockData: { + calendarGridPoint: { + dayIndex: 1, + startIndex: 10, + endIndex: 12, + }, + componentProps: { + courseDeptAndInstr: 'Course 2', + timeAndLocation: '10:00 AM - 11:00 AM, Room 102', + status: Status.CLOSED, + blockData: {} as CalendarGridCourse, + }, + course: ExampleCourse, + async: false, + }, }, course: ExampleCourse, async: false, @@ -107,7 +176,21 @@ const testData: CalendarGridCourse[] = [ courseDeptAndInstr: 'Course 3', timeAndLocation: '10:00 AM - 11:00 AM, Room 102', status: Status.CLOSED, - colors: getCourseColors('emerald', 500), + blockData: { + calendarGridPoint: { + dayIndex: 1, + startIndex: 10, + endIndex: 12, + }, + componentProps: { + courseDeptAndInstr: 'Course 3', + timeAndLocation: '10:00 AM - 11:00 AM, Room 102', + status: Status.CLOSED, + blockData: {} as CalendarGridCourse, + }, + course: ExampleCourse, + async: false, + }, }, course: ExampleCourse, async: false, @@ -122,7 +205,21 @@ const testData: CalendarGridCourse[] = [ courseDeptAndInstr: 'Course 4', timeAndLocation: '10:00 AM - 11:00 AM, Room 102', status: Status.CLOSED, - colors: getCourseColors('emerald', 500), + blockData: { + calendarGridPoint: { + dayIndex: 1, + startIndex: 10, + endIndex: 12, + }, + componentProps: { + courseDeptAndInstr: 'Course 4', + timeAndLocation: '10:00 AM - 11:00 AM, Room 102', + status: Status.CLOSED, + blockData: {} as CalendarGridCourse, + }, + course: ExampleCourse, + async: false, + }, }, course: ExampleCourse, async: false, diff --git a/src/stories/components/calendar/CourseCellColorPicker.stories.tsx b/src/stories/components/calendar/CourseCellColorPicker.stories.tsx index 3345cc44..c5c1a9e4 100644 --- a/src/stories/components/calendar/CourseCellColorPicker.stories.tsx +++ b/src/stories/components/calendar/CourseCellColorPicker.stories.tsx @@ -1,7 +1,6 @@ -import type { ThemeColor } from '@shared/types/ThemeColors'; import type { Meta, StoryObj } from '@storybook/react'; import CourseCellColorPicker from '@views/components/calendar/CalendarCourseCellColorPicker/CourseCellColorPicker'; -import React, { useState } from 'react'; +import React from 'react'; const meta = { title: 'Components/Calendar/CourseCellColorPicker', @@ -12,15 +11,7 @@ export default meta; type Story = StoryObj; function CourseCellColorPickerWithState() { - const [, setSelectedColor] = useState(null); - const [isInvertColorsToggled, setIsInvertColorsToggled] = useState(false); - return ( - - ); + return ; } export const Default: Story = { diff --git a/src/views/components/calendar/CalendarBottomBar.tsx b/src/views/components/calendar/CalendarBottomBar.tsx index 4d140620..ec3099a1 100644 --- a/src/views/components/calendar/CalendarBottomBar.tsx +++ b/src/views/components/calendar/CalendarBottomBar.tsx @@ -4,6 +4,7 @@ import { saveAsCal, saveCalAsPng } from '@views/components/calendar/utils'; import { Button } from '@views/components/common/Button'; import Divider from '@views/components/common/Divider'; import Text from '@views/components/common/Text/Text'; +import { ColorPickerProvider } from '@views/contexts/ColorPickerContext'; import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; import clsx from 'clsx'; import React from 'react'; @@ -41,19 +42,21 @@ export default function CalendarBottomBar({ courseCells, setCourse }: CalendarBo —
- {asyncCourseCells.map(block => { - const { courseDeptAndInstr, status, colors, className } = block.componentProps; - return ( - setCourse(block.course)} - /> - ); - })} + + {asyncCourseCells.map(block => { + const { courseDeptAndInstr, status, className } = block.componentProps; + return ( + setCourse(block.course)} + blockData={block} + /> + ); + })} +
)} diff --git a/src/views/components/calendar/CalendarCourseCell.tsx b/src/views/components/calendar/CalendarCourseCell.tsx index 53a97566..00654eaa 100644 --- a/src/views/components/calendar/CalendarCourseCell.tsx +++ b/src/views/components/calendar/CalendarCourseCell.tsx @@ -1,12 +1,16 @@ -import { ClockUser, LockKey, Prohibit } from '@phosphor-icons/react'; +import { ClockUser, LockKey, Palette, Prohibit } from '@phosphor-icons/react'; import { initSettings, OptionsStore } from '@shared/storage/OptionsStore'; import type { StatusType } from '@shared/types/Course'; import { Status } from '@shared/types/Course'; -import type { CourseColors } from '@shared/types/ThemeColors'; -import { pickFontColor } from '@shared/util/colors'; +import { hexToRGB, pickFontColor } from '@shared/util/colors'; import Text from '@views/components/common/Text/Text'; +import { useColorPickerContext } from '@views/contexts/ColorPickerContext'; +import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; import clsx from 'clsx'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; + +import { Button } from '../common/Button'; +import CourseCellColorPicker from './CalendarCourseCellColorPicker/CourseCellColorPicker'; /** * Props for the CalendarCourseCell component. @@ -15,9 +19,9 @@ export interface CalendarCourseCellProps { courseDeptAndInstr: string; timeAndLocation?: string; status: StatusType; - colors: CourseColors; - className?: string; onClick?: React.MouseEventHandler; + blockData: CalendarGridCourse; + className?: string; } /** @@ -34,11 +38,25 @@ export default function CalendarCourseCell({ courseDeptAndInstr, timeAndLocation, status, - colors, - className, onClick, + blockData, + className, }: CalendarCourseCellProps): JSX.Element { const [enableCourseStatusChips, setEnableCourseStatusChips] = useState(false); + const colorPickerRef = useRef(null); + const { selectedColor, setSelectedCourse, handleCloseColorPicker, isSelectedBlock, isSelectedCourse } = + useColorPickerContext(); + + const { colors, uniqueId: courseID } = blockData.course; + const { dayIndex, startIndex } = blockData.calendarGridPoint; + + let selectedCourse = false; + let selectedBlock = false; + + if (isSelectedCourse && isSelectedBlock) { + selectedCourse = isSelectedCourse(courseID); + selectedBlock = isSelectedBlock(courseID, dayIndex, startIndex); + } useEffect(() => { initSettings().then(({ enableCourseStatusChips }) => setEnableCourseStatusChips(enableCourseStatusChips)); @@ -53,6 +71,26 @@ export default function CalendarCourseCell({ }; }, []); + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (selectedBlock && colorPickerRef.current) { + const path = event.composedPath(); + const isClickOutside = !path.some( + element => (element as HTMLElement).classList === colorPickerRef.current?.classList + ); + + if (isClickOutside) { + handleCloseColorPicker(); + } + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [handleCloseColorPicker, selectedBlock]); + let rightIcon: React.ReactNode | null = null; if (enableCourseStatusChips) { if (status === Status.WAITLISTED) { @@ -71,7 +109,7 @@ export default function CalendarCourseCell({ return (
)} + +
{ + e.stopPropagation(); + }} + className={clsx( + 'absolute screenshot:opacity-0! text-black transition-all ease-in-out group-focus-within:pointer-events-auto group-hover:pointer-events-auto group-focus-within:opacity-100 group-hover:opacity-100 gap-y-0.75', + dayIndex === 4 ? 'left-0 -translate-x-full pr-0.75 items-end' : 'right-0 translate-x-full pl-0.75', // If the cell is on the right side of the screen + selectedBlock ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none' + )} + style={{ + // Prevents from button from appear on top of color picker + zIndex: selectedBlock ? 30 : 29, + }} + > +
+
+
); } diff --git a/src/views/components/calendar/CalendarCourseCellColorPicker/ColorPatch.tsx b/src/views/components/calendar/CalendarCourseCellColorPicker/ColorPatch.tsx index 9cac5c9a..38dd88a9 100644 --- a/src/views/components/calendar/CalendarCourseCellColorPicker/ColorPatch.tsx +++ b/src/views/components/calendar/CalendarCourseCellColorPicker/ColorPatch.tsx @@ -1,5 +1,5 @@ import { Check } from '@phosphor-icons/react'; -import { getThemeColorHexByName } from '@shared/util/themeColors'; +import { useColorPickerContext } from '@views/contexts/ColorPickerContext'; import React from 'react'; /** @@ -8,7 +8,8 @@ import React from 'react'; interface ColorPatchProps { color: string; isSelected: boolean; - handleSetSelectedColor: (color: string) => void; + handleSelectColorPatch: (color: string) => void; + defaultColor: string; } /** @@ -19,13 +20,25 @@ interface ColorPatchProps { * @param handleSetSelectedColor - Function from parent component to control selection state of a patch. * @returns The rendered color patch button. */ -export default function ColorPatch({ color, isSelected, handleSetSelectedColor }: ColorPatchProps): JSX.Element { - const handleClick = () => { - handleSetSelectedColor(isSelected ? getThemeColorHexByName('ut-gray') : color); +export default function ColorPatch({ + color, + isSelected, + handleSelectColorPatch, + defaultColor, +}: ColorPatchProps): JSX.Element { + const { handleCloseColorPicker } = useColorPickerContext(); + + const handleClick = async () => { + // If the color patch is already selected, close the color picker + if (isSelected) { + handleCloseColorPicker(); + } else { + handleSelectColorPatch(isSelected ? defaultColor : color); + } }; return (
{selectedBaseColor && ( <> @@ -138,9 +128,11 @@ export default function CourseCellColorPicker({ .get(selectedBaseColor) ?.map(shadeColor => ( ))} diff --git a/src/views/components/calendar/CalendarCourseCellColorPicker/HexColorEditor.tsx b/src/views/components/calendar/CalendarCourseCellColorPicker/HexColorEditor.tsx index 7673392f..64a06393 100644 --- a/src/views/components/calendar/CalendarCourseCellColorPicker/HexColorEditor.tsx +++ b/src/views/components/calendar/CalendarCourseCellColorPicker/HexColorEditor.tsx @@ -1,5 +1,8 @@ import { Hash } from '@phosphor-icons/react'; +import { isValidHexColor, pickFontColor } from '@shared/util/colors'; import { getThemeColorHexByName } from '@shared/util/themeColors'; +import { useDebounce } from '@views/hooks/useDebounce'; +import clsx from 'clsx'; import React from 'react'; /** @@ -7,7 +10,7 @@ import React from 'react'; */ export interface HexColorEditorProps { hexCode: string; - setHexCode: React.Dispatch>; + setHexCode: (hexCode: string) => void; } /** @@ -20,23 +23,34 @@ export interface HexColorEditorProps { */ export default function HexColorEditor({ hexCode, setHexCode }: HexColorEditorProps): JSX.Element { const baseColor = React.useMemo(() => getThemeColorHexByName('ut-gray'), []); - const previewColor = hexCode.length === 6 ? `#${hexCode}` : baseColor; + const previewColor = isValidHexColor(`#${hexCode}`) ? `#${hexCode}` : baseColor; + const tagColor = pickFontColor(previewColor.slice(1) as `#${string}`); + + const [localHexCode, setLocalHexCode] = React.useState(hexCode); + const debouncedSetHexCode = useDebounce((value: string) => setHexCode(value), 500); + + React.useEffect(() => { + debouncedSetHexCode(localHexCode); + + // This is on purpose + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localHexCode]); return ( <>
- +
-
+
setHexCode(e.target.value)} + value={localHexCode} + onChange={e => setLocalHexCode(e.target.value)} />
diff --git a/src/views/components/calendar/CalendarGrid.tsx b/src/views/components/calendar/CalendarGrid.tsx index 3b60fe2b..fd97ba6b 100644 --- a/src/views/components/calendar/CalendarGrid.tsx +++ b/src/views/components/calendar/CalendarGrid.tsx @@ -1,6 +1,7 @@ import type { Course } from '@shared/types/Course'; import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell'; import Text from '@views/components/common/Text/Text'; +import { ColorPickerProvider } from '@views/contexts/ColorPickerContext'; import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule'; import React from 'react'; @@ -73,7 +74,9 @@ export default function CalendarGrid({ .map(() => (
))} - {courseCells ? : null} + + {courseCells && } +
); } @@ -147,8 +150,8 @@ function AccountForCourseConflicts({ courseCells, setCourse }: AccountForCourseC courseDeptAndInstr={courseDeptAndInstr} timeAndLocation={timeAndLocation} status={status} - colors={block.course.colors} onClick={() => setCourse(block.course)} + blockData={block} />
); diff --git a/src/views/components/common/Button.tsx b/src/views/components/common/Button.tsx index e603356f..68752cdc 100644 --- a/src/views/components/common/Button.tsx +++ b/src/views/components/common/Button.tsx @@ -44,9 +44,9 @@ export function Button({