-
Notifications
You must be signed in to change notification settings - Fork 11
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
Changes from all commits
a5158d5
0dfbb06
a9cb55b
9425129
718dbd7
dade6fd
30468d3
b2630b5
40c073a
2ac629b
67a9b1b
31c151c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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" | ||
|
@@ -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 || []" | ||
|
@@ -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'; | ||
|
@@ -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 }, | ||
|
@@ -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 { | ||
|
@@ -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 | ||
// 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: { | ||
|
@@ -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: { | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
|
||
|
@@ -296,6 +390,30 @@ export default defineComponent({ | |
padding-bottom: 20px; | ||
} | ||
|
||
// Emulates a slight side-to-side sway à la Figma micro-interaction. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
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> |
There was a problem hiding this comment.
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