Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backend logic, shake animation, and frontend draft for course notes #966

Merged
merged 12 commits into from
Dec 3, 2024
4 changes: 4 additions & 0 deletions src/assets/images/edit-note.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
148 changes: 133 additions & 15 deletions src/components/Course/Course.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<template>
<div class="course-container">
<div class="course-container" :class="{ 'figma-shake': isShaking }">
<div
:class="{
'course--min': compact,
conflict: isCourseConflict(courseObj.uniqueID),
active: active,
}"
class="course"
:id="`course-${courseObj.uniqueID}`"
>
<save-course-modal
:courseCode="courseCode"
Expand Down Expand Up @@ -88,7 +89,7 @@
:courseCode="courseObj.code"
@open-note-modal="openNoteModal"
@open-edit-color-modal="openEditColorModal"
@delete-course="deleteCourscoe"
@delete-course="deleteCourse"
@edit-course-credit="editCourseCredit"
@open-save-course-modal="openSaveCourseModal"
:getCreditRange="getCreditRange || []"
Expand All @@ -102,14 +103,18 @@
:expandedTranslateY="'-35px'"
:width="'calc(102.8% - 10px)'"
:color="cssVars['--bg-color']"
:expand="expandNote"
@toggle="handleToggleNote"
:initialNote="courseObj.note || ''"
:lastUpdated="courseObj.lastUpdated"
@save-note="saveNote"
@open-delete-note-modal="openDeleteNoteModal"
ref="note"
v-click-outside="handleClickOutsideNote"
/>
</div>
</template>

<script lang="ts">
import { CSSProperties, PropType, defineComponent } from 'vue';
import { PropType, defineComponent } from 'vue';
import CourseMenu from '@/components/Modals/CourseMenu.vue';
import CourseCaution from '@/components/Course/CourseCaution.vue';
import SaveCourseModal from '@/components/Modals/SaveCourseModal.vue';
Expand All @@ -125,6 +130,16 @@ import trashGrayIcon from '@/assets/images/trash-gray.svg';
import trashRedIcon from '@/assets/images/trash.svg';
import Note from '../Notes/Note.vue';

// MinimalNoteComponent is a representation of everything required for a functional,
// but minimal Note component to work statefully.
interface MinimalNoteComponent {
note: string;
isDirty: boolean;
isExpanded: boolean;
collapseNote: () => void;
expandNote: () => void;
}

export default defineComponent({
name: 'Course',
components: { CourseCaution, CourseMenu, EditColor, SaveCourseModal, Note },
Expand Down Expand Up @@ -159,6 +174,9 @@ export default defineComponent({
typeof deletedFromCollection === 'object',
'add-collection': (name: string) => typeof name === 'string',
'delete-course-from-collection': (courseCode: string) => typeof courseCode === 'string',
'save-note': (uniqueID: number, note: string) =>
typeof uniqueID === 'number' && typeof note === 'string',
'open-delete-note-modal': (uniqueID: number) => typeof uniqueID === 'number',
},
data() {
return {
Expand All @@ -172,8 +190,11 @@ export default defineComponent({
trashIcon: trashGrayIcon, // Default icon
courseCode: '',
isExpanded: false,
isNoteVisible: false,
expandNote: false,
// isNotVisible represents a small open 'portrusion' indicating that there is a note
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small typo - isNotVisible in comment instead of isNoteVisible. it's for a comment though so no big deal lol

// for the course in question. The note itself will not be visible without `isExpanded`
// being true as well.
isNoteVisible: Boolean(this.courseObj.note),
isShaking: false,
};
},
computed: {
Expand All @@ -196,10 +217,10 @@ export default defineComponent({
return `${this.courseObj.credits} credits`;
},

cssVars(): CSSProperties {
cssVars(): Record<string, string> {
return {
'--bg-color': `#${this.courseObj.color}`,
} as CSSProperties;
};
},
},
methods: {
Expand Down Expand Up @@ -270,21 +291,94 @@ export default defineComponent({
unhoverTrashIcon() {
this.trashIcon = trashGrayIcon;
},
handleToggleNote() {
this.expandNote = !this.expandNote;
},
openNoteModal() {
this.isNoteVisible = true;
if (!this.isNoteVisible) {
this.isNoteVisible = true;
this.menuOpen = false;
// NOTE: should use $nextTick, as the browser engine could optimize away the
// UI update by applying changes in the same render pass. Also important for allowing
// for time for the note component to be rendered before attempting to access it.
this.$nextTick(() => {
const noteComponent = this.$refs.note as MinimalNoteComponent | undefined;
if (noteComponent) {
noteComponent.expandNote();
}
});
} else {
const noteComponent = this.$refs.note as MinimalNoteComponent | undefined;
if (!noteComponent) {
return;
}
// Note already open — trigger a shake to indicate this to the user.
if (noteComponent.isExpanded) {
this.triggerCourseCardShake();
} else {
noteComponent.expandNote();
}
}
this.menuOpen = false;
this.expandNote = true;
},
closeNote() {
this.isNoteVisible = false;
// NOTE: this function hides the note entirely!
// Grant time for the slide-back-in animation to play out.
setTimeout(() => {
this.isNoteVisible = false;
}, 300);
},
triggerCourseCardShake() {
this.isShaking = true;
setTimeout(() => {
this.isShaking = false;
}, 900); // 3 shakes * 0.3s = 0.9s
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we confirmed the shake animation speed with Joanna yet? May need to change this

},
saveNote(note: string) {
if (!note || note === this.courseObj.note) {
return;
}
this.$emit('save-note', this.courseObj.uniqueID, note);
},
handleClickOutsideNote(event: MouseEvent) {
// Don't count a click on the open note (.courseMenu) or three dots (.course-dotRow)
// as a click outside.
const target = event.target as HTMLElement;
if (target.closest('.courseMenu') || target.closest('.course-dotRow')) {
return;
}

const noteComponent = this.$refs.note as MinimalNoteComponent | undefined;

if (!noteComponent || !this.isNoteVisible) {
return;
}

if (noteComponent.isDirty) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding such clear comments for this! Makes it much easier to understand the animations with different states

// Warn if the user is trying to leave a note with unsaved changes.
this.triggerCourseCardShake();
} else if (noteComponent.note && this.isNoteVisible) {
noteComponent.collapseNote();
} else {
this.closeNote();
}
},
openDeleteNoteModal() {
this.$emit('open-delete-note-modal', this.courseObj.uniqueID);
},
},
directives: {
'click-outside': clickOutside,
},
watch: {
// NOTE: this is required for reactive deletion of notes client-side after the deletion
// modal is confirmed, as isNoteVisible is not reactive.
'courseObj.note': {
handler(newNote) {
if (!newNote) {
this.isNoteVisible = false;
}
},
immediate: true,
},
},
});
</script>

Expand All @@ -296,6 +390,30 @@ export default defineComponent({
padding-bottom: 20px;
}

// Emulates a slight side-to-side sway à la Figma micro-interaction.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much for looking into this Simon! The shake effect looks awesome

.figma-shake {
animation: tilt-shaking 0.3s cubic-bezier(0.36, 0.07, 0.19, 0.97) 3;
transform-origin: center center;
}

@keyframes tilt-shaking {
0% {
transform: rotate(0deg) translateX(0);
}
25% {
transform: rotate(4deg) translateX(6px);
}
50% {
transform: rotate(0deg) translateX(0);
}
75% {
transform: rotate(-4deg) translateX(-6px);
}
100% {
transform: rotate(0deg) translateX(0);
}
}

.course {
box-shadow: 0px 0px 10px 4px rgba(0, 0, 0, 0.055);
position: relative;
Expand Down
5 changes: 3 additions & 2 deletions src/components/Modals/CourseMenu.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="courseMenu">
<div class="courseMenu-content">
<div class="courseMenu-section" @click="openNoteModal(courseObj.code)">
<div class="courseMenu-section" @click="openNoteModal">
<div class="courseMenu-left">
<img
class="courseMenu-icon"
Expand Down Expand Up @@ -185,6 +185,7 @@ export default defineComponent({
'delete-course': () => true,
'open-edit-color-modal': (color: string) => typeof color === 'string',
'edit-course-credit': (credit: number) => typeof credit === 'number',
'open-note-modal': () => true,
},
methods: {
toggleDisplayColors() {
Expand Down Expand Up @@ -260,7 +261,7 @@ export default defineComponent({
}
return creditArray;
},
openNoteModal(courseCode: string) {
openNoteModal() {
this.$emit('open-note-modal');
},
},
Expand Down
66 changes: 66 additions & 0 deletions src/components/Modals/DeleteNoteModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<template>
<teleport-modal
title="Delete Note"
content-class="content-delete"
left-button-text="Cancel"
right-button-text="Delete"
:rightButtonIsDisabled="false"
:rightButtonImage="icon"
rightButtonAlt="delete note trashcan icon"
@modal-closed="closeCurrentModal"
@left-button-clicked="closeCurrentModal"
@right-button-clicked="deleteNote"
>
<div class="deleteNoteModal-body">
<div class="deleteNoteModal-body-text">{{ text }}</div>
</div>
</teleport-modal>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import TeleportModal from '@/components/Modals/TeleportModal.vue';
import trashIcon from '@/assets/images/trash-white.svg';

export default defineComponent({
components: { TeleportModal },
props: {
noteCourseUniqueID: { type: Number, required: true },
},
emits: {
'close-delete-note': () => true,
'delete-note': (noteCourseUniqueID: number) => typeof noteCourseUniqueID === 'number',
},
computed: {
text() {
return 'Are you sure you want to delete this note?';
},
icon() {
return trashIcon;
},
},
methods: {
closeCurrentModal() {
this.$emit('close-delete-note');
},
deleteNote() {
this.$emit('delete-note', this.noteCourseUniqueID);
this.closeCurrentModal();
},
},
});
</script>

<style lang="scss">
@import '@/assets/scss/_variables.scss';

.content-delete {
width: 24rem;
}

@media only screen and (max-width: $small-medium-breakpoint) {
.content-delete {
width: 100%;
}
}
</style>
Loading
Loading