feat(ui): course unique number copy button (#490)

* feat: add copy icon for course unique ID

* feat: update Button component to support event parameter in onClick handler

* feat: add copy functionality for course unique ID

* refactor: use Text component instead of span

* fix: remove duplicate course number

* fix: remove unnecessary event forwarding

* fix: remove unnecessary boolean type

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>

* fix: remove double space

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>

* refactor: reduce clipboard copy delay and use formatted unique ID

* feat: add copy animation to dialog

---------

Co-authored-by: Samuel Gunter <29130894+Samathingamajig@users.noreply.github.com>
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
Co-authored-by: nikshitak <nikkikurva@gmail.com>
This commit is contained in:
Ethan L
2025-01-21 00:02:00 -06:00
committed by GitHub
parent 009de62828
commit 501f506677
3 changed files with 95 additions and 12 deletions

View File

@@ -10,7 +10,7 @@ interface Props {
style?: React.CSSProperties; style?: React.CSSProperties;
variant?: 'filled' | 'outline' | 'minimal'; variant?: 'filled' | 'outline' | 'minimal';
size?: 'regular' | 'small' | 'mini'; size?: 'regular' | 'small' | 'mini';
onClick?: () => void; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
icon?: Icon; icon?: Icon;
iconProps?: IconProps; iconProps?: IconProps;
disabled?: boolean; disabled?: boolean;

View File

@@ -1,5 +1,5 @@
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { DotsSixVertical } from '@phosphor-icons/react'; import { Check, Copy, DotsSixVertical } from '@phosphor-icons/react';
import { background } from '@shared/messages'; import { background } from '@shared/messages';
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore'; import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
import type { Course } from '@shared/types/Course'; import type { Course } from '@shared/types/Course';
@@ -11,6 +11,8 @@ import Text from '@views/components/common/Text/Text';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Button } from './Button';
/** /**
* Props for PopupCourseBlock * Props for PopupCourseBlock
*/ */
@@ -37,6 +39,8 @@ export default function PopupCourseBlock({
dragHandleProps, dragHandleProps,
}: PopupCourseBlockProps): JSX.Element { }: PopupCourseBlockProps): JSX.Element {
const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false); const [enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
const [isCopied, setIsCopied] = useState<boolean>(false);
const lastCopyTime = useRef<number>(0);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -72,13 +76,26 @@ export default function PopupCourseBlock({
window.close(); window.close();
}; };
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
const now = Date.now();
if (now - lastCopyTime.current < 500) {
return;
}
lastCopyTime.current = now;
await navigator.clipboard.writeText(formattedUniqueId);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 500);
};
return ( return (
<div <div
style={{ style={{
backgroundColor: colors.primaryColor, backgroundColor: colors.primaryColor,
}} }}
className={clsx( className={clsx(
'h-full w-full inline-flex items-center justify-center gap-1 rounded pr-3 focusable cursor-pointer text-left hover:shadow-md ease-out group-[.is-dragging]:shadow-md', 'h-full w-full inline-flex items-center justify-center gap-1 rounded pr-2 focusable cursor-pointer text-left hover:shadow-md ease-out group-[.is-dragging]:shadow-md',
className className
)} )}
onClick={handleClick} onClick={handleClick}
@@ -93,8 +110,8 @@ export default function PopupCourseBlock({
> >
<DotsSixVertical weight='bold' className='h-6 w-6 text-white' /> <DotsSixVertical weight='bold' className='h-6 w-6 text-white' />
</div> </div>
<Text className={clsx('flex-1 py-3.5 truncate', fontColor)} variant='h1-course'> <Text className={clsx('flex-1 py-spacing-5 truncate ml-spacing-3', fontColor)} variant='h1-course'>
<span className='px-0.5 font-450'>{formattedUniqueId}</span> {course.department} {course.number} {course.department} {course.number}
{course.instructors.length > 0 ? <> &ndash; </> : ''} {course.instructors.length > 0 ? <> &ndash; </> : ''}
{course.instructors.map(v => v.toString({ format: 'last' })).join('; ')} {course.instructors.map(v => v.toString({ format: 'last' })).join('; ')}
</Text> </Text>
@@ -103,11 +120,39 @@ export default function PopupCourseBlock({
style={{ style={{
backgroundColor: colors.secondaryColor, backgroundColor: colors.secondaryColor,
}} }}
className='ml-1 flex items-center justify-center justify-self-end rounded p-1px text-white' className='ml-1 flex items-center justify-center justify-self-end rounded p-[3px] text-white'
> >
<StatusIcon status={course.status} className='h-5 w-5' /> <StatusIcon status={course.status} className='h-6 w-6' />
</div> </div>
)} )}
<Button
color='ut-gray'
onClick={handleCopy}
className='h-full max-h-[30px] w-fit gap-spacing-2 rounded py-spacing-2 text-white px-spacing-3!'
style={{
backgroundColor: colors.secondaryColor,
}}
>
<div className='relative h-5.5 w-5.5'>
<Check
className={clsx(
'absolute size-full inset-0 text-white transition-all duration-250 ease-in-out',
isCopied ? 'opacity-100 scale-100' : 'opacity-0 scale-75'
)}
/>
<Copy
weight='fill'
className={clsx(
'absolute size-full inset-0 text-white transition-all duration-250 ease-in-out',
isCopied ? 'opacity-0 scale-75' : 'opacity-100 scale-100'
)}
/>
</div>
<Text variant='h2' className='text-base!'>
{formattedUniqueId}
</Text>
</Button>
</div> </div>
); );
} }

View File

@@ -1,4 +1,15 @@
import { ArrowUpRight, CalendarDots, ChatText, Copy, FileText, Minus, Plus, Smiley, X } from '@phosphor-icons/react'; import {
ArrowUpRight,
CalendarDots,
ChatText,
Check,
Copy,
FileText,
Minus,
Plus,
Smiley,
X,
} from '@phosphor-icons/react';
import { background } from '@shared/messages'; import { background } from '@shared/messages';
import type { Course } from '@shared/types/Course'; import type { Course } from '@shared/types/Course';
import type Instructor from '@shared/types/Instructor'; import type Instructor from '@shared/types/Instructor';
@@ -9,7 +20,8 @@ import Divider from '@views/components/common/Divider';
import Link from '@views/components/common/Link'; import Link from '@views/components/common/Link';
import Text from '@views/components/common/Text/Text'; import Text from '@views/components/common/Text/Text';
import { useCalendar } from '@views/contexts/CalendarContext'; import { useCalendar } from '@views/contexts/CalendarContext';
import React from 'react'; import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import DisplayMeetingInfo from './DisplayMeetingInfo'; import DisplayMeetingInfo from './DisplayMeetingInfo';
@@ -46,10 +58,22 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
const formattedUniqueId = uniqueId.toString().padStart(5, '0'); const formattedUniqueId = uniqueId.toString().padStart(5, '0');
const isInCalendar = useCalendar(); const isInCalendar = useCalendar();
const [isCopied, setIsCopied] = useState<boolean>(false);
const lastCopyTime = useRef<number>(0);
const getInstructorFullName = (instructor: Instructor) => instructor.toString({ format: 'first_last' }); const getInstructorFullName = (instructor: Instructor) => instructor.toString({ format: 'first_last' });
const handleCopy = () => { const handleCopy = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
navigator.clipboard.writeText(formattedUniqueId); e.stopPropagation();
const now = Date.now();
if (now - lastCopyTime.current < 500) {
return;
}
lastCopyTime.current = now;
await navigator.clipboard.writeText(formattedUniqueId);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 500);
}; };
const handleOpenRateMyProf = async () => { const handleOpenRateMyProf = async () => {
@@ -107,7 +131,21 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
<Text variant='h1' className='flex-1 whitespace-nowrap text-theme-black'> <Text variant='h1' className='flex-1 whitespace-nowrap text-theme-black'>
({department} {courseNumber}) ({department} {courseNumber})
</Text> </Text>
<Button color='ut-burntorange' variant='minimal' icon={Copy} onClick={handleCopy}> <Button color='ut-burntorange' variant='minimal' onClick={handleCopy}>
<div className='relative h-5.5 w-5.5'>
<Check
className={clsx(
'absolute size-full inset-0 text-ut-burntorange transition-all duration-250 ease-in-out',
isCopied ? 'opacity-100 scale-100' : 'opacity-0 scale-75'
)}
/>
<Copy
className={clsx(
'absolute size-full inset-0 text-ut-burntorange transition-all duration-250 ease-in-out',
isCopied ? 'opacity-0 scale-75' : 'opacity-100 scale-100'
)}
/>
</div>
{formattedUniqueId} {formattedUniqueId}
</Button> </Button>
<button className='bg-transparent p-0 text-ut-black btn' onClick={onClose}> <button className='bg-transparent p-0 text-ut-black btn' onClick={onClose}>