From 471e55dcea1ae439658d00ed41570e2f218f0c3d Mon Sep 17 00:00:00 2001 From: Abhinav Chadaga Date: Sun, 3 Mar 2024 22:00:59 -0600 Subject: [PATCH] feat: color palette for calendar (#118) * feat: work on the palette * feat: palette basically done? * fix: lint warnings and errors * fix: minor fixes * fix: color patch colors and shades * fix: prettier issue * chore: use TS satisfies * chore: remove eslint-disable comment --------- Co-authored-by: doprz <52579214+doprz@users.noreply.github.com> --- src/shared/util/themeColors.ts | 123 ++++++ .../CourseCellColorPicker.stories.tsx | 19 + .../CourseCellColorPicker/ColorPatch.tsx | 50 +++ .../CourseCellColorPicker.tsx | 393 ++++++++++++++++++ .../CourseCellColorPicker/DivWrapper.tsx | 22 + .../CourseCellColorPicker/HexColorEditor.tsx | 48 +++ .../CourseCellColorPicker/HuePicker.tsx | 47 +++ 7 files changed, 702 insertions(+) create mode 100644 src/stories/components/CourseCellColorPicker.stories.tsx create mode 100644 src/views/components/common/CourseCellColorPicker/ColorPatch.tsx create mode 100644 src/views/components/common/CourseCellColorPicker/CourseCellColorPicker.tsx create mode 100644 src/views/components/common/CourseCellColorPicker/DivWrapper.tsx create mode 100644 src/views/components/common/CourseCellColorPicker/HexColorEditor.tsx create mode 100644 src/views/components/common/CourseCellColorPicker/HuePicker.tsx diff --git a/src/shared/util/themeColors.ts b/src/shared/util/themeColors.ts index 840a2e2e..98604066 100644 --- a/src/shared/util/themeColors.ts +++ b/src/shared/util/themeColors.ts @@ -12,6 +12,7 @@ export const colors = { offwhite: '#D6D2C4', concrete: '#95A5A6', red: '#B91C1C', // Not sure if this should be here, but it's used for remove course, and add course is ut-green + white: '#FFFFFF', }, theme: { red: '#AF2E2D', @@ -31,6 +32,128 @@ export const colors = { dminus: '#B91C1C', f: '#B91C1C', }, + palette: { + slateBase: '#64748B', + slate200: '#e2e8f0', + slate300: '#cbd5e1', + slate400: '#94a3b8', + slate600: '#475569', + slate700: '#334155', + grayBase: '#6b7280', + gray200: '#e5e7eb', + gray300: '#d1d5db', + gray400: '#9ca3af', + gray600: '#4b5563', + gray700: '#374151', + stoneBase: '#78716c', + stone200: '#e7e5e4', + stone300: '#d6d3d1', + stone400: '#a8a29e', + stone600: '#57534e', + stone700: '#44403c', + redBase: '#ef4444', + red200: '#fecaca', + red300: '#fca5a5', + red400: '#f87171', + red600: '#dc2626', + red700: '#b91c1c', + orangeBase: '#f97316', + orange200: '#fed7aa', + orange300: '#fdba74', + orange400: '#fb923c', + orange600: '#ea580c', + orange700: '#c2410c', + amberBase: '#f59e0b', + amber200: '#fde68a', + amber300: '#fcd34d', + amber400: '#fbbf24', + amber600: '#d97706', + amber700: '#b45309', + yellowBase: '#eab308', + yellow200: '#fef08a', + yellow300: '#fde047', + yellow400: '#facc15', + yellow600: '#ca8a04', + yellow700: '#a16207', + limeBase: '#84cc16', + lime200: '#d9f99d', + lime300: '#bef264', + lime400: '#a3e635', + lime600: '#65a30d', + lime700: '#4d7c0f', + greenBase: '#22c55e', + green200: '#bbf7d0', + green300: '#86efac', + green400: '#4ade80', + green600: '#16a34a', + green700: '#15803d', + emeraldBase: '#10b981', + emerald200: '#a7f3d0', + emerald300: '#6ee7b7', + emerald400: '#34d399', + emerald600: '#059669', + emerald700: '#047857', + tealBase: '#14b8a6', + teal200: '#99f6e4', + teal300: '#5eead4', + teal400: '#2dd4bf', + teal600: '#0d9488', + teal700: '#0f766e', + cyanBase: '#06b6d4', + cyan200: '#a5f3fc', + cyan300: '#67e8f9', + cyan400: '#22d3ee', + cyan600: '#0891b2', + cyan700: '#0e7490', + skyBase: '#0ea5e9', + sky200: '#bae6fd', + sky300: '#7dd3fc', + sky400: '#38bdf8', + sky600: '#0284c7', + sky700: '#0369a1', + blueBase: '#3b82f6', + blue200: '#bfdbfe', + blue300: '#93c5fd', + blue400: '#60a5fa', + blue600: '#2563eb', + blue700: '#1d4ed8', + indigoBase: '#6366f1', + indigo200: '#c7d2fe', + indigo300: '#a5b4fc', + indigo400: '#818cf8', + indigo600: '#4f46e5', + indigo700: '#4338ca', + violetBase: '#8b5cf6', + violet200: '#ddd6fe', + violet300: '#c4b5fd', + violet400: '#a78bfa', + violet600: '#7c3aed', + violet700: '#6d28d9', + purpleBase: '#a855f7', + purple200: '#e9d5ff', + purple300: '#d8b4fe', + purple400: '#c084fc', + purple600: '#9333ea', + purple700: '#7e22ce', + fuschiaBase: '#d946ef', + fuschia200: '#f5d0fe', + fuschia300: '#f0abfc', + fuschia400: '#e879f9', + fuschia600: '#c026d3', + fuschia700: '#a21caf', + pinkBase: '#ec4899', + pink200: '#fbcfe8', + pink300: '#f9a8d4', + pink400: '#f472b6', + pink600: '#db2777', + pink700: '#be185d', + roseBase: '#f43f5e', + rose200: '#fecdd3', + rose300: '#fda4af', + rose400: '#fb7185', + rose600: '#e11d48', + rose700: '#be123c', + }, } as const satisfies Record>; type NestedKeys = { diff --git a/src/stories/components/CourseCellColorPicker.stories.tsx b/src/stories/components/CourseCellColorPicker.stories.tsx new file mode 100644 index 00000000..b236ace8 --- /dev/null +++ b/src/stories/components/CourseCellColorPicker.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import CourseCellColorPicker from '@views/components/common/CourseCellColorPicker/CourseCellColorPicker'; +import React, { useState } from 'react'; +import type { ThemeColor } from 'src/shared/util/themeColors'; + +const meta = { + title: 'Components/Common/CourseCellColorPicker', + component: CourseCellColorPicker, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [selectedColor, setSelectedColor] = useState(null); + return ; + }, +}; diff --git a/src/views/components/common/CourseCellColorPicker/ColorPatch.tsx b/src/views/components/common/CourseCellColorPicker/ColorPatch.tsx new file mode 100644 index 00000000..d599a4d4 --- /dev/null +++ b/src/views/components/common/CourseCellColorPicker/ColorPatch.tsx @@ -0,0 +1,50 @@ +import { Button } from '@views/components/common/Button/Button'; +import React from 'react'; +import type { ThemeColor } from 'src/shared/util/themeColors'; + +import CheckIcon from '~icons/material-symbols/check'; + +/** + * Props for the ColorPatch component + */ +interface ColorPatchProps { + color: ThemeColor; + index: number; + selectedColor: number; + handleSetSelectedColorPatch: (colorPatchIndex: number) => void; +} + +/** + * + * @param {ColorPatchProps} props - the props for the component + * @param {ThemeColor} props.color - the color to display + * @param {number} props.index - the index of this color patch in the parent color palette + * @param {number} props.selectedColor - the index of the selected color patch in the parent color palette + * @param {(colorPatchIndex: number) => void} props.handleSetSelectedColorPatch - fn called when a color patch is selected. This function + * is passed from the parent and updates the necessary parent state when this color patch is selected. + * @returns {JSX.Element} - the color patch component + */ +const ColorPatch: React.FC = ({ + color, + index, + selectedColor, + handleSetSelectedColorPatch, +}: ColorPatchProps): JSX.Element => { + const isSelected = selectedColor === index; + const handleClick = () => { + handleSetSelectedColorPatch(isSelected ? -1 : index); + }; + return ( + + ); +}; + +export default ColorPatch; diff --git a/src/views/components/common/CourseCellColorPicker/CourseCellColorPicker.tsx b/src/views/components/common/CourseCellColorPicker/CourseCellColorPicker.tsx new file mode 100644 index 00000000..adbed697 --- /dev/null +++ b/src/views/components/common/CourseCellColorPicker/CourseCellColorPicker.tsx @@ -0,0 +1,393 @@ +import { Button } from '@views/components/common/Button/Button'; +import React from 'react'; +import type { ThemeColor } from 'src/shared/util/themeColors'; +import { getThemeColorHexByName } from 'src/shared/util/themeColors'; + +import InvertColorsOffIcon from '~icons/material-symbols/invert-colors-off'; + +import Divider from '../Divider/Divider'; +import ColorPatch from './ColorPatch'; +import DivWrapper from './DivWrapper'; +import HexColorEditor from './HexColorEditor'; +import HuePicker from './HuePicker'; + +interface Color { + baseColor: ThemeColor; + shades: ThemeColor[]; +} + +const colorPatches: Color[] = [ + { + baseColor: 'palette-slateBase', + shades: [ + 'palette-slate200', + 'palette-slate300', + 'palette-slate400', + 'palette-slateBase', + 'palette-slate600', + 'palette-slate700', + ], + }, + { + baseColor: 'palette-grayBase', + shades: [ + 'palette-gray200', + 'palette-gray300', + 'palette-gray400', + 'palette-grayBase', + 'palette-gray600', + 'palette-gray700', + ], + }, + { + baseColor: 'palette-stoneBase', + shades: [ + 'palette-stone200', + 'palette-stone300', + 'palette-stone400', + 'palette-stoneBase', + 'palette-stone600', + 'palette-stone700', + ], + }, + { + baseColor: 'palette-redBase', + shades: [ + 'palette-red200', + 'palette-red300', + 'palette-red400', + 'palette-redBase', + 'palette-red600', + 'palette-red700', + ], + }, + { + baseColor: 'palette-orangeBase', + shades: [ + 'palette-orange200', + 'palette-orange300', + 'palette-orange400', + 'palette-orangeBase', + 'palette-orange600', + 'palette-orange700', + ], + }, + { + baseColor: 'palette-amberBase', + shades: [ + 'palette-amber200', + 'palette-amber300', + 'palette-amber400', + 'palette-amberBase', + 'palette-amber600', + 'palette-amber700', + ], + }, + { + baseColor: 'palette-yellowBase', + shades: [ + 'palette-yellow200', + 'palette-yellow300', + 'palette-yellow400', + 'palette-yellowBase', + 'palette-yellow600', + 'palette-yellow700', + ], + }, + { + baseColor: 'palette-limeBase', + shades: [ + 'palette-lime200', + 'palette-lime300', + 'palette-lime400', + 'palette-limeBase', + 'palette-lime600', + 'palette-lime700', + ], + }, + { + baseColor: 'palette-greenBase', + shades: [ + 'palette-green200', + 'palette-green300', + 'palette-green400', + 'palette-greenBase', + 'palette-green600', + 'palette-green700', + ], + }, + { + baseColor: 'palette-emeraldBase', + shades: [ + 'palette-emerald200', + 'palette-emerald300', + 'palette-emerald400', + 'palette-emeraldBase', + 'palette-emerald600', + 'palette-emerald700', + ], + }, + { + baseColor: 'palette-tealBase', + shades: [ + 'palette-teal200', + 'palette-teal300', + 'palette-teal400', + 'palette-tealBase', + 'palette-teal600', + 'palette-teal700', + ], + }, + { + baseColor: 'palette-cyanBase', + shades: [ + 'palette-cyan200', + 'palette-cyan300', + 'palette-cyan400', + 'palette-cyanBase', + 'palette-cyan600', + 'palette-cyan700', + ], + }, + { + baseColor: 'palette-skyBase', + shades: [ + 'palette-sky200', + 'palette-sky300', + 'palette-sky400', + 'palette-skyBase', + 'palette-sky600', + 'palette-sky700', + ], + }, + { + baseColor: 'palette-blueBase', + shades: [ + 'palette-blue200', + 'palette-blue300', + 'palette-blue400', + 'palette-blueBase', + 'palette-blue600', + 'palette-blue700', + ], + }, + { + baseColor: 'palette-indigoBase', + shades: [ + 'palette-indigo200', + 'palette-indigo300', + 'palette-indigo400', + 'palette-indigoBase', + 'palette-indigo600', + 'palette-indigo700', + ], + }, + { + baseColor: 'palette-violetBase', + shades: [ + 'palette-violet200', + 'palette-violet300', + 'palette-violet400', + 'palette-violetBase', + 'palette-violet600', + 'palette-violet700', + ], + }, + { + baseColor: 'palette-purpleBase', + shades: [ + 'palette-purple200', + 'palette-purple300', + 'palette-purple400', + 'palette-purpleBase', + 'palette-purple600', + 'palette-purple700', + ], + }, + { + baseColor: 'palette-fuschiaBase', + shades: [ + 'palette-fuschia200', + 'palette-fuschia300', + 'palette-fuschia400', + 'palette-fuschiaBase', + 'palette-fuschia600', + 'palette-fuschia700', + ], + }, + { + baseColor: 'palette-pinkBase', + shades: [ + 'palette-pink200', + 'palette-pink300', + 'palette-pink400', + 'palette-pinkBase', + 'palette-pink600', + 'palette-pink700', + ], + }, + { + baseColor: 'palette-roseBase', + shades: [ + 'palette-rose200', + 'palette-rose300', + 'palette-rose400', + 'palette-roseBase', + 'palette-rose600', + 'palette-rose700', + ], + }, +]; + +const hexCodeToBaseColorPatchIndex = new Map( + colorPatches.map((color: Color, index: number) => [getThemeColorHexByName(color.baseColor), index]) +); + +const hexCodeToShadeColorPatchIndex = new Map( + colorPatches.flatMap((color: Color, index: number) => + color.shades.map(shade => [getThemeColorHexByName(shade), index]) + ) +); + +/** + * Props for the CourseCellColorPicker component. + */ +export interface CourseCellColorPickerProps { + setSelectedColor: React.Dispatch>; +} + +/** + * @param {CourseCellColorPickerProps} props - the props for the component + * @param {React.Dispatch>} props.setSelectedColor - set state function passed down from the parent component + * that will be called when a color is selected. The user can set any valid hex color they want. + * + * @example + * ``` + * const CourseCell = () => { + * const [selectedColor, setSelectedColor] = useState(null); + * ... + * return ( + *
+ ... + * + * ); + * }; + * ``` + * + * @returns {JSX.Element} - the color picker component that displays a color palette with a list of color patches. + * This component is available when a user hovers over a course cell in their calendar to + * color for the course cell. The user can set any valid hex color they want. + */ +const CourseCellColorPicker: React.FC = ({ + setSelectedColor: setFinalColor, +}: CourseCellColorPickerProps): JSX.Element => { + const [selectedBaseColorPatch, setSelectedBaseColorPatch] = React.useState(-1); + const [selectedShadeColorPatch, setSelectShadeColorPatch] = React.useState(-1); + const [hexCode, setHexCode] = React.useState(''); + const numColumns = 6; + const numFullRows = 3; + + const handleSelectBaseColorPatch = (baseColorPatchIndex: number) => { + const color = baseColorPatchIndex > -1 ? colorPatches[baseColorPatchIndex].baseColor : 'ut-gray'; + const newHexCode = baseColorPatchIndex > -1 ? getThemeColorHexByName(color).slice(1) : ''; + setHexCode(newHexCode); + setSelectedBaseColorPatch(baseColorPatchIndex); + setSelectShadeColorPatch(3); + }; + + const handleSelectShadeColorPatch = (shadeColorPatchIndex: number) => { + const color = colorPatches[selectedBaseColorPatch].shades[shadeColorPatchIndex]; + const newHexCode = getThemeColorHexByName(color).slice(1); + setHexCode(newHexCode); + setSelectShadeColorPatch(shadeColorPatchIndex); + }; + + React.useEffect(() => { + const hexCodeWithHash = `#${hexCode}`; + if (hexCodeToBaseColorPatchIndex.has(hexCodeWithHash)) { + setSelectedBaseColorPatch(hexCodeToBaseColorPatchIndex.get(hexCodeWithHash)); + } + if (hexCodeToShadeColorPatchIndex.has(hexCodeWithHash)) { + setSelectedBaseColorPatch(hexCodeToShadeColorPatchIndex.get(hexCodeWithHash)); + } + if (!hexCodeToBaseColorPatchIndex.has(hexCodeWithHash) && !hexCodeToShadeColorPatchIndex.has(hexCodeWithHash)) { + setSelectedBaseColorPatch(-1); + } + }, [hexCode]); + + React.useEffect(() => { + let finalColor: string | null = null; + if (selectedBaseColorPatch === -1 && hexCode.length === 6) { + finalColor = `#${hexCode}`; + } else if (selectedBaseColorPatch > -1 && selectedShadeColorPatch === -1) { + finalColor = getThemeColorHexByName(colorPatches[selectedBaseColorPatch].baseColor); + } else if (selectedBaseColorPatch > -1 && selectedShadeColorPatch > -1) { + finalColor = getThemeColorHexByName(colorPatches[selectedBaseColorPatch].shades[selectedShadeColorPatch]); + } else { + finalColor = null; + } + console.log('finalColor', finalColor); + setFinalColor(finalColor); + }, [hexCode, selectedBaseColorPatch, selectedShadeColorPatch, setFinalColor]); + + return ( +
+ {Array.from({ length: numFullRows }, (_, rowIndex) => ( +
+ {colorPatches.map((color: Color, index) => { + if (index >= rowIndex * numColumns && index < (rowIndex + 1) * numColumns) { + return ( + + + + ); + } + return null; + })} +
+ ))} +
+ + + + + + +
+ +
+ + {/* TODO (achadaga): Not really sure what this button is actually supposed to do */} + + +
+ + {selectedBaseColorPatch !== -1 && ( + + )} +
+ ); +}; + +export default CourseCellColorPicker; diff --git a/src/views/components/common/CourseCellColorPicker/DivWrapper.tsx b/src/views/components/common/CourseCellColorPicker/DivWrapper.tsx new file mode 100644 index 00000000..f5fc8e8b --- /dev/null +++ b/src/views/components/common/CourseCellColorPicker/DivWrapper.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +/** + * Props for the DivWrapper component + */ +interface ItemWrapperProps { + children: React.ReactNode; +} + +/** + * Utility component to space all the color patches in the color picker component + * + * + * @param {ItemWrapperProps} props - the props for the component + * @param {React.ReactNode} props.children - the children to be wrapped in the div + * @returns {JSX.Element} - the div wrapper component + */ +const DivWrapper: React.FC = ({ children }: ItemWrapperProps) => ( +
{children}
+); + +export default DivWrapper; diff --git a/src/views/components/common/CourseCellColorPicker/HexColorEditor.tsx b/src/views/components/common/CourseCellColorPicker/HexColorEditor.tsx new file mode 100644 index 00000000..02c66c3f --- /dev/null +++ b/src/views/components/common/CourseCellColorPicker/HexColorEditor.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { getThemeColorHexByName } from 'src/shared/util/themeColors'; + +import TagIcon from '~icons/material-symbols/tag'; + +/** + * Props for the HexColorEditor component + */ +export interface HexColorEditorProps { + hexCode: string; + setHexCode: React.Dispatch>; +} + +/** + * Utility component to allow the user to enter a valid hex color code + * + * @param {HexColorEditorProps} props - the props for the component + * @param {string} props.hexCode - the current hex color code displayed in this component. Note that this code does not + * include the leading '#' character since it is already included in the component. Passed down from the parent component. + * @param {React.Dispatch>} props.setHexCode - set state fn to control the hex color code from parent + * @returns {JSX.Element} - the hex color editor component + */ +const HexColorEditor: React.FC = ({ hexCode, setHexCode }: HexColorEditorProps): JSX.Element => { + const baseColor = React.useMemo(() => getThemeColorHexByName('ut-gray'), []); + const previewColor = hexCode.length === 6 ? `#${hexCode}` : baseColor; + + return ( +
+
+ +
+
+ setHexCode(e.target.value)} + /> +
+
+ ); +}; + +export default HexColorEditor; diff --git a/src/views/components/common/CourseCellColorPicker/HuePicker.tsx b/src/views/components/common/CourseCellColorPicker/HuePicker.tsx new file mode 100644 index 00000000..a975fe33 --- /dev/null +++ b/src/views/components/common/CourseCellColorPicker/HuePicker.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import type { ThemeColor } from 'src/shared/util/themeColors'; + +import ColorPatch from './ColorPatch'; +import DivWrapper from './DivWrapper'; + +/** + * Props for the HuePicker component + */ +interface HuePickerProps { + shades: ThemeColor[]; + selectedColor: number; + setSelectedColor: React.Dispatch>; +} + +/** + * Bottom row of the color picker component that displays all the shades of a base color + * + * @param {HuePickerProps} props - the props for the component + * @param {ThemeColor[]} props.shades - the list of shades of the base color + * @param {number} props.selectedColor - the index of the selected color patch in the parent color palette + * @param {React.Dispatch>} props.setSelectedColor - set state fn to control the selected color patch from parent + * @returns {JSX.Element} - the hue picker component + */ +const HuePicker: React.FC = ({ + shades, + selectedColor, + setSelectedColor, +}: HuePickerProps): JSX.Element => { + const numColumns = 6; + return ( +
+ {Array.from({ length: numColumns }, (_, index) => ( + + + + ))} +
+ ); +}; + +export default HuePicker;