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>
This commit is contained in:
Abhinav Chadaga
2024-03-03 22:00:59 -06:00
committed by doprz
parent adbe8ac163
commit 471e55dcea
7 changed files with 702 additions and 0 deletions

View File

@@ -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<string, Record<string, string>>;
type NestedKeys<T> = {

View File

@@ -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<typeof CourseCellColorPicker>;
export default meta;
type Story = StoryObj<typeof CourseCellColorPicker>;
export const Default: Story = {
render: () => {
const [selectedColor, setSelectedColor] = useState<ThemeColor | null>(null);
return <CourseCellColorPicker setSelectedColor={setSelectedColor} />;
},
};

View File

@@ -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<ColorPatchProps> = ({
color,
index,
selectedColor,
handleSetSelectedColorPatch,
}: ColorPatchProps): JSX.Element => {
const isSelected = selectedColor === index;
const handleClick = () => {
handleSetSelectedColorPatch(isSelected ? -1 : index);
};
return (
<Button
style={{ backgroundColor: color }}
className='h-[22px] w-[22px] p-0'
variant='filled'
onClick={handleClick}
color={color}
>
{isSelected && <CheckIcon className='h-[20px] w-[20px]' />}
</Button>
);
};
export default ColorPatch;

View File

@@ -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<React.SetStateAction<string | null>>;
}
/**
* @param {CourseCellColorPickerProps} props - the props for the component
* @param {React.Dispatch<React.SetStateAction<string | null>>} 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<string | null>(null);
* ...
* return (
* <div style={{ backgroundColor: selectedColor }}>
...
* <CourseCellColorPicker setSelectedColor={setSelectedColor} />
* );
* };
* ```
*
* @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<CourseCellColorPickerProps> = ({
setSelectedColor: setFinalColor,
}: CourseCellColorPickerProps): JSX.Element => {
const [selectedBaseColorPatch, setSelectedBaseColorPatch] = React.useState<number>(-1);
const [selectedShadeColorPatch, setSelectShadeColorPatch] = React.useState<number>(-1);
const [hexCode, setHexCode] = React.useState<string>('');
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 (
<div className='inline-flex flex-col border border-1 border-ut-offwhite rounded-1 p-[5px]'>
{Array.from({ length: numFullRows }, (_, rowIndex) => (
<div className='flex gap-0 flex-content-between' key={rowIndex}>
{colorPatches.map((color: Color, index) => {
if (index >= rowIndex * numColumns && index < (rowIndex + 1) * numColumns) {
return (
<DivWrapper key={color.baseColor}>
<ColorPatch
color={color.baseColor}
index={index}
selectedColor={selectedBaseColorPatch}
handleSetSelectedColorPatch={handleSelectBaseColorPatch}
/>
</DivWrapper>
);
}
return null;
})}
</div>
))}
<div className='flex gap-0 flex-content-between'>
<DivWrapper>
<ColorPatch
color={colorPatches[colorPatches.length - 2].baseColor}
index={colorPatches.length - 2}
selectedColor={selectedBaseColorPatch}
handleSetSelectedColorPatch={handleSelectBaseColorPatch}
/>
</DivWrapper>
<DivWrapper>
<ColorPatch
color={colorPatches[colorPatches.length - 1].baseColor}
index={colorPatches.length - 1}
selectedColor={selectedBaseColorPatch}
handleSetSelectedColorPatch={handleSelectBaseColorPatch}
/>
</DivWrapper>
<div className='flex items-center justify-center overflow-hidden p-[2px]'>
<HexColorEditor hexCode={hexCode} setHexCode={setHexCode} />
</div>
<DivWrapper>
{/* TODO (achadaga): Not really sure what this button is actually supposed to do */}
<Button className='h-[22px] w-[22px] p-0' variant='filled' color='ut-black' onClick={() => {}}>
<InvertColorsOffIcon className='h-[14px] w-[14px]' />
</Button>
</DivWrapper>
</div>
<Divider orientation='horizontal' size='100%' className='my-1' />
{selectedBaseColorPatch !== -1 && (
<HuePicker
shades={colorPatches[selectedBaseColorPatch].shades}
selectedColor={selectedShadeColorPatch}
setSelectedColor={handleSelectShadeColorPatch}
/>
)}
</div>
);
};
export default CourseCellColorPicker;

View File

@@ -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<ItemWrapperProps> = ({ children }: ItemWrapperProps) => (
<div className='h-[26px] w-[26px] flex items-center justify-center p-[2px]'>{children}</div>
);
export default DivWrapper;

View File

@@ -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<React.SetStateAction<string>>;
}
/**
* 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<React.SetStateAction<string>>} 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<HexColorEditorProps> = ({ hexCode, setHexCode }: HexColorEditorProps): JSX.Element => {
const baseColor = React.useMemo(() => getThemeColorHexByName('ut-gray'), []);
const previewColor = hexCode.length === 6 ? `#${hexCode}` : baseColor;
return (
<div className='h-[22px] w-[74px] flex items-center border-[0.5px] border-ut-gray/50 rounded-1'>
<div
style={{ backgroundColor: previewColor }}
className='h-[22px] w-[21px] flex items-center justify-center rounded-l-1 -m-[0.5px]'
>
<TagIcon className='h-[16px] w-[16px] text-ut-white' />
</div>
<div className='flex flex-1 items-center justify-center p-[5px]'>
<input
type='text'
maxLength={6}
className='box-border w-full border-none bg-transparent font-size-[11px] font-400 font-normal outline-none focus:outline-none'
value={hexCode}
onChange={e => setHexCode(e.target.value)}
/>
</div>
</div>
);
};
export default HexColorEditor;

View File

@@ -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<React.SetStateAction<number>>;
}
/**
* 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<React.SetStateAction<number>>} props.setSelectedColor - set state fn to control the selected color patch from parent
* @returns {JSX.Element} - the hue picker component
*/
const HuePicker: React.FC<HuePickerProps> = ({
shades,
selectedColor,
setSelectedColor,
}: HuePickerProps): JSX.Element => {
const numColumns = 6;
return (
<div className='flex gap-0 flex-content-between'>
{Array.from({ length: numColumns }, (_, index) => (
<DivWrapper key={shades[index]}>
<ColorPatch
color={shades[index]}
index={index}
selectedColor={selectedColor}
handleSetSelectedColorPatch={setSelectedColor}
/>
</DivWrapper>
))}
</div>
);
};
export default HuePicker;