diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 00000000..ae5482b6 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,26 @@ +name: "Chromatic" + +on: [push, pull_request_target] + +jobs: + chromatic: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Install dependencies + run: pnpm install + + - name: Publish to Chromatic + uses: chromaui/action@latest + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + exitZeroOnChanges: true + autoAcceptChanges: "main" diff --git a/package.json b/package.json index e8515d4e..f9a374c4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@types/sql.js": "^1.4.9", "@vitejs/plugin-react": "^4.2.1", "chrome-extension-toolkit": "^0.0.51", - "classnames": "^2.5.1", + "clsx": "^2.1.0", "highcharts": "^11.3.0", "highcharts-react-official": "^3.2.1", "react": "^18.2.0", @@ -55,9 +55,11 @@ "@unocss/postcss": "^0.58.4", "@unocss/preset-uno": "^0.58.4", "@unocss/preset-web-fonts": "^0.58.4", + "@unocss/reset": "^0.58.5", "@unocss/transformer-directives": "^0.58.4", "@unocss/transformer-variant-group": "^0.58.4", "@vitejs/plugin-react-swc": "^3.6.0", + "chromatic": "^10.9.1", "cssnano": "^6.0.3", "cssnano-preset-advanced": "^6.0.3", "dotenv": "^16.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4310cf74..27a985f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,9 +22,9 @@ dependencies: chrome-extension-toolkit: specifier: ^0.0.51 version: 0.0.51 - classnames: - specifier: ^2.5.1 - version: 2.5.1 + clsx: + specifier: ^2.1.0 + version: 2.1.0 highcharts: specifier: ^11.3.0 version: 11.3.0 @@ -123,6 +123,9 @@ devDependencies: '@unocss/preset-web-fonts': specifier: ^0.58.4 version: 0.58.4 + '@unocss/reset': + specifier: ^0.58.5 + version: 0.58.5 '@unocss/transformer-directives': specifier: ^0.58.4 version: 0.58.4 @@ -132,6 +135,9 @@ devDependencies: '@vitejs/plugin-react-swc': specifier: ^3.6.0 version: 3.6.0(vite@5.0.12) + chromatic: + specifier: ^10.9.1 + version: 10.9.1 cssnano: specifier: ^6.0.3 version: 6.0.3(postcss@8.4.33) @@ -5046,6 +5052,10 @@ packages: resolution: {integrity: sha512-ZZTrAdl4WWmMjQdOqcOSWdgFH6kdFKZjPu4c6Ijxk7KvY2BW3nttTTBa7IYeuXFHVfcExUFqlOgRurt+NeWYyQ==} dev: true + /@unocss/reset@0.58.5: + resolution: {integrity: sha512-2wMrkCj3SSb5hrx9TKs5jZa34QIRkHv9FotbJutAPo7o8hx+XXn56ogzdoUrcFPJZJUx2R2nyOVbSlGMIjtFtw==} + dev: true + /@unocss/rule-utils@0.58.4: resolution: {integrity: sha512-52Jp4I+joGTaDm7ehB/7uZ2kJL+9BZcYRDUVk4IDacDH5W9yxf1F75LzYT8jJVWXD/HIhiS0r9V6qhcBq2OWZw==} engines: {node: '>=14'} @@ -6077,6 +6087,19 @@ packages: engines: {node: '>=10'} dev: true + /chromatic@10.9.1: + resolution: {integrity: sha512-6Ypho74fQu45ns48KaBuIMKqlzik0fJo//dLB3lkiphz8vCiMm75uU3xhkfZh4NbOS51MbWZKjPfefM53bIHpg==} + hasBin: true + peerDependencies: + '@chromatic-com/cypress': ^0.5.2 || ^1.0.0 + '@chromatic-com/playwright': ^0.5.2 || ^1.0.0 + peerDependenciesMeta: + '@chromatic-com/cypress': + optional: true + '@chromatic-com/playwright': + optional: true + dev: true + /chrome-extension-toolkit@0.0.51: resolution: {integrity: sha512-XzOOE2+/aYG43bJOwuJT4oWcn80jBJr5mwGyrSzKKFoqALixT15AsPcfZId/UOoc4pIavu2XcHeJga6ng0m1jQ==} dependencies: @@ -6104,10 +6127,6 @@ packages: consola: 3.2.3 dev: true - /classnames@2.5.1: - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - dev: false - /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -6159,6 +6178,11 @@ packages: engines: {node: '>=0.8'} dev: true + /clsx@2.1.0: + resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: diff --git a/src/shared/util/colors.ts b/src/shared/util/colors.ts new file mode 100644 index 00000000..807a2c16 --- /dev/null +++ b/src/shared/util/colors.ts @@ -0,0 +1,50 @@ +import { theme } from 'unocss/preset-mini'; + +export interface CourseColors { + primaryColor: string; + secondaryColor: string; +} + +// calculates luminance of a hex string +function getLuminance(hex: string): number { + let r = parseInt(hex.substring(1, 3), 16); + let g = parseInt(hex.substring(3, 5), 16); + let b = parseInt(hex.substring(5, 7), 16); + + [r, g, b] = [r, g, b].map(color => { + let c = color / 255; + + c = c > 0.03928 ? ((c + 0.055) / 1.055) ** 2.4 : (c /= 12.92); + + return c; + }); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +// calculates contrast ratio between two hex strings +function contrastRatioPair(hex1: string, hex2: string) { + const lum1 = getLuminance(hex1); + const lum2 = getLuminance(hex2); + + return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); +} + +/** + * Generate a tailwind classname for the font color based on the background color + * @param bgColor the tailwind classname for background ex. "bg-emerald-500" + */ +export function pickFontColor(bgColor: string): 'text-white' | 'text-black' { + return contrastRatioPair(bgColor, '#606060') > contrastRatioPair(bgColor, '#ffffff') ? 'text-black' : 'text-white'; +} + +/** + * Get primary and secondary colors from a tailwind colorway + * @param colorway the tailwind colorway ex. "emerald" + */ +export function getCourseColors(colorway: keyof typeof theme.colors): CourseColors { + return { + primaryColor: theme.colors[colorway][600] as string, + secondaryColor: theme.colors[colorway][800] as string, + }; +} diff --git a/src/shared/util/icons.tsx b/src/shared/util/icons.tsx new file mode 100644 index 00000000..88f4ebfd --- /dev/null +++ b/src/shared/util/icons.tsx @@ -0,0 +1,24 @@ +import React, { SVGProps } from 'react'; +import ClosedIcon from '~icons/material-symbols/lock'; +import WaitlistIcon from '~icons/material-symbols/timelapse'; +import CancelledIcon from '~icons/material-symbols/warning'; +import { Status } from '../types/Course'; + +/** + * Get Icon component based on status + * @param props.status status + * @returns React.ReactElement - the icon component + */ +export function StatusIcon(props: SVGProps & { status: Status }): React.ReactElement { + const { status, ...rest } = props; + + switch (props.status) { + case Status.WAITLISTED: + return ; + case Status.CLOSED: + return ; + case Status.CANCELLED: + return ; + default: + } +} diff --git a/src/stories/components/CourseStatus.stories.tsx b/src/stories/components/CourseStatus.stories.tsx new file mode 100644 index 00000000..0e35fd26 --- /dev/null +++ b/src/stories/components/CourseStatus.stories.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { Status } from '@shared/types/Course'; +import { Meta, StoryObj } from '@storybook/react'; +import CourseStatus from '@views/components/common/CourseStatus/CourseStatus'; + +const meta = { + title: 'Components/Common/CourseStatus', + component: CourseStatus, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + args: { + status: Status.WAITLISTED, + size: 'small', + }, + argTypes: { + status: { + options: Object.values(Status), + mapping: Object.values(Status), + control: { + type: 'select', + labels: Object.keys(Status), + }, + }, + size: { + options: ['small', 'mini'], + control: { + type: 'radio', + }, + }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Variants: Story = { + render: args => ( +
+ + +
+ ), +}; diff --git a/src/stories/components/PopupCourseBlock.stories.tsx b/src/stories/components/PopupCourseBlock.stories.tsx new file mode 100644 index 00000000..636617f5 --- /dev/null +++ b/src/stories/components/PopupCourseBlock.stories.tsx @@ -0,0 +1,118 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Course, Status } from 'src/shared/types/Course'; +import { CourseMeeting } from 'src/shared/types/CourseMeeting'; +import Instructor from 'src/shared/types/Instructor'; +import PopupCourseBlock from '@views/components/common/PopupCourseBlock/PopupCourseBlock'; +import { getCourseColors } from 'src/shared/util/colors'; +import { theme } from 'unocss/preset-mini'; + +const exampleCourse: Course = new Course({ + courseName: 'ELEMS OF COMPTRS/PROGRAMMNG-WB', + creditHours: 3, + department: 'C S', + description: [ + 'Problem solving and fundamental algorithms for various applications in science, business, and on the World Wide Web, and introductory programming in a modern object-oriented programming language.', + 'Only one of the following may be counted: Computer Science 303E, 312, 312H. Credit for Computer Science 303E may not be earned after a student has received credit for Computer Science 314, or 314H. May not be counted toward a degree in computer science.', + 'May be counted toward the Quantitative Reasoning flag requirement.', + 'Designed to accommodate 100 or more students.', + 'Taught as a Web-based course.', + ], + flags: ['Quantitative Reasoning'], + fullName: 'C S 303E ELEMS OF COMPTRS/PROGRAMMNG-WB', + instructionMode: 'Online', + instructors: [ + new Instructor({ + firstName: 'Bevo', + lastName: 'Bevo', + fullName: 'Bevo Bevo', + }), + ], + isReserved: false, + number: '303E', + schedule: { + meetings: [ + new CourseMeeting({ + days: ['Tuesday', 'Thursday'], + endTime: 660, + startTime: 570, + }), + ], + }, + semester: { + code: '12345', + season: 'Spring', + year: 2024, + }, + status: Status.WAITLISTED, + uniqueId: 12345, + url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20242/12345/', +}); + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = { + title: 'Components/Common/PopupCourseBlock', + component: PopupCourseBlock, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/api/argtypes + args: { + colors: getCourseColors('emerald'), + course: exampleCourse, + }, + argTypes: { + colors: { + description: 'the colors to use for the course block', + control: 'object', + }, + course: { + description: 'the course to show data for', + control: 'object', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Default: Story = { + args: {}, +}; + +export const Variants: Story = { + render: props => ( +
+ + + + +
+ ), +}; + +const colors = Object.keys(theme.colors) + // check that the color is a colorway (is an object) + .filter(color => typeof theme.colors[color] === 'object') + .slice(0, 17) + .map(color => getCourseColors(color as keyof typeof theme.colors)); + +export const AllColors: Story = { + render: props => ( +
+ {colors.map((color, i) => ( + + ))} +
+ ), + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/8tsCay2FRqctrdcZ3r9Ahw/UTRP?type=design&node-id=1046-6714&mode=design&t=5Bjr7qGHNXmjfMTc-0', + }, + }, +}; diff --git a/src/views/components/common/Button/Button.tsx b/src/views/components/common/Button/Button.tsx index 902325bd..98d1e4ef 100644 --- a/src/views/components/common/Button/Button.tsx +++ b/src/views/components/common/Button/Button.tsx @@ -1,4 +1,4 @@ -import classNames from 'classnames'; +import clsx from 'clsx'; import React from 'react'; import styles from './Button.module.scss'; @@ -30,7 +30,7 @@ export function Button({