From 06932ea5681fba2f7df21966825c5d596175925d Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Sat, 10 Aug 2024 10:18:26 +0200 Subject: [PATCH] It's alive! --- cypress/e2e/all.cy.ts | 1 + cypress/e2e/animations.cy.ts | 64 ++++++++++ cypress/e2e/cloud.cy.ts | 2 +- cypress/e2e/navigation.cy.ts | 5 +- cypress/support/commands/stubs.ts | 35 +++++- package-lock.json | 16 +-- package.json | 4 +- src/components/AppFooter.vue | 10 +- src/main.ts | 2 + src/pages/workspace/WorkspaceContentBody.vue | 118 ++++++++++++------ src/pages/workspace/WorkspaceSidebar.vue | 2 +- src/pages/workspace/WorkspaceTask.vue | 2 +- .../PanelAnimator.ts} | 2 +- src/pages/workspace/animations/tasks.ts | 94 ++++++++++++++ .../workspace/components/tasks/TasksList.vue | 10 +- .../components/tasks/TasksListItem.vue | 103 +++++++-------- src/services/Focus.state.ts | 9 ++ src/services/Focus.ts | 6 + src/vivant/aerogel/index.ts | 12 ++ src/vivant/core/AnimatedElement.ts | 48 +++++++ src/vivant/core/AnimatedGroup.ts | 11 ++ src/vivant/core/animations/Animation.ts | 7 ++ .../animations/classes/FreezeAnimation.ts | 11 ++ .../animations/classes/LayoutAnimation.ts | 23 ++++ .../animations/classes/SlideDownAnimation.ts | 12 ++ .../animations/classes/SlideUpAnimation.ts | 12 ++ src/vivant/core/animations/config.ts | 3 + src/vivant/core/animations/helpers/freeze.ts | 19 +++ .../core/animations/helpers/slide-down.ts | 14 +++ .../core/animations/helpers/slide-up.ts | 14 +++ src/vivant/core/animations/index.ts | 5 + src/vivant/core/helpers/animate-motion.ts | 10 ++ src/vivant/core/helpers/animate-styles.ts | 96 ++++++++++++++ src/vivant/core/helpers/bounds.ts | 14 +++ src/vivant/core/helpers/index.ts | 4 + .../core/helpers/prefers-reduced-motion.ts | 5 + src/vivant/core/index.ts | 5 + src/vivant/core/registry.ts | 79 ++++++++++++ src/vivant/vue/animations/HookAnimation.ts | 21 ++++ src/vivant/vue/animations/index.ts | 1 + src/vivant/vue/components/AnimatedElement.vue | 45 +++++++ src/vivant/vue/components/AnimatedGroup.ts | 5 + src/vivant/vue/components/AnimatedGroup.vue | 34 +++++ .../vue/components/AnimatedTransition.vue | 80 ++++++++++++ src/vivant/vue/components/index.ts | 1 + src/vivant/vue/components/utils.ts | 8 ++ src/vivant/vue/directives/animate-layout.ts | 34 +++++ src/vivant/vue/directives/index.ts | 14 +++ src/vivant/vue/helpers/element-animations.ts | 27 ++++ src/vivant/vue/helpers/group-animations.ts | 87 +++++++++++++ src/vivant/vue/helpers/index.ts | 2 + src/vivant/vue/index.ts | 4 + vite.config.ts | 2 +- 53 files changed, 1138 insertions(+), 116 deletions(-) create mode 100644 cypress/e2e/animations.cy.ts rename src/pages/workspace/{animations.ts => animations/PanelAnimator.ts} (98%) create mode 100644 src/pages/workspace/animations/tasks.ts create mode 100644 src/vivant/aerogel/index.ts create mode 100644 src/vivant/core/AnimatedElement.ts create mode 100644 src/vivant/core/AnimatedGroup.ts create mode 100644 src/vivant/core/animations/Animation.ts create mode 100644 src/vivant/core/animations/classes/FreezeAnimation.ts create mode 100644 src/vivant/core/animations/classes/LayoutAnimation.ts create mode 100644 src/vivant/core/animations/classes/SlideDownAnimation.ts create mode 100644 src/vivant/core/animations/classes/SlideUpAnimation.ts create mode 100644 src/vivant/core/animations/config.ts create mode 100644 src/vivant/core/animations/helpers/freeze.ts create mode 100644 src/vivant/core/animations/helpers/slide-down.ts create mode 100644 src/vivant/core/animations/helpers/slide-up.ts create mode 100644 src/vivant/core/animations/index.ts create mode 100644 src/vivant/core/helpers/animate-motion.ts create mode 100644 src/vivant/core/helpers/animate-styles.ts create mode 100644 src/vivant/core/helpers/bounds.ts create mode 100644 src/vivant/core/helpers/index.ts create mode 100644 src/vivant/core/helpers/prefers-reduced-motion.ts create mode 100644 src/vivant/core/index.ts create mode 100644 src/vivant/core/registry.ts create mode 100644 src/vivant/vue/animations/HookAnimation.ts create mode 100644 src/vivant/vue/animations/index.ts create mode 100644 src/vivant/vue/components/AnimatedElement.vue create mode 100644 src/vivant/vue/components/AnimatedGroup.ts create mode 100644 src/vivant/vue/components/AnimatedGroup.vue create mode 100644 src/vivant/vue/components/AnimatedTransition.vue create mode 100644 src/vivant/vue/components/index.ts create mode 100644 src/vivant/vue/components/utils.ts create mode 100644 src/vivant/vue/directives/animate-layout.ts create mode 100644 src/vivant/vue/directives/index.ts create mode 100644 src/vivant/vue/helpers/element-animations.ts create mode 100644 src/vivant/vue/helpers/group-animations.ts create mode 100644 src/vivant/vue/helpers/index.ts create mode 100644 src/vivant/vue/index.ts diff --git a/cypress/e2e/all.cy.ts b/cypress/e2e/all.cy.ts index 7409bce..34542e7 100644 --- a/cypress/e2e/all.cy.ts +++ b/cypress/e2e/all.cy.ts @@ -2,6 +2,7 @@ // See https://github.com/cypress-io/cypress/discussions/21628 // If you want to use it, just uncomment these lines: +// import './animations.cy'; // import './cloud.cy'; // import './navigation.cy'; // import './onboarding.cy'; diff --git a/cypress/e2e/animations.cy.ts b/cypress/e2e/animations.cy.ts new file mode 100644 index 0000000..a305dab --- /dev/null +++ b/cypress/e2e/animations.cy.ts @@ -0,0 +1,64 @@ +describe.skip('Animations', () => { + beforeEach(() => { + cy.visit('/'); + cy.createStubs(); + }); + + it('Tasks collapse/expand (long)', () => { + cy.visit('/household/groceries'); + + cy.press('Completed'); + cy.wait(1000); + + cy.press('Completed'); + cy.wait(1000); + + cy.contains('li', 'Bananas').within(() => cy.get('input[type="checkbox"]').click()); + cy.contains('li', 'Orange juice').within(() => cy.get('input[type="checkbox"]').click()); + cy.wait(1000); + + cy.press('Completed'); + cy.wait(1000); + + cy.press('Completed'); + cy.wait(1000); + }); + + it('Tasks collapse/expand (short)', () => { + cy.visit('/household'); + + cy.press('Completed'); + cy.wait(1000); + + cy.press('Completed'); + cy.wait(1000); + + cy.contains('li', 'Clean room').within(() => cy.get('input[type="checkbox"]').click()); + cy.wait(1000); + + cy.press('Completed'); + cy.wait(1000); + + cy.press('Completed'); + cy.wait(1000); + }); + + it('Tasks toggle', () => { + cy.visit('/household/groceries'); + + cy.press('Completed'); + cy.wait(1000); + + cy.contains('li', 'Bananas').within(() => cy.get('input[type="checkbox"]').click()); + cy.wait(1000); + + cy.contains('li', 'Orange juice').within(() => cy.get('input[type="checkbox"]').click()); + cy.wait(1000); + + cy.press('Completed'); + cy.wait(1000); + + cy.contains('li', 'Orange juice').within(() => cy.get('input[type="checkbox"]').click()); + cy.wait(1000); + }); +}); diff --git a/cypress/e2e/cloud.cy.ts b/cypress/e2e/cloud.cy.ts index 8dbd224..a41ff67 100644 --- a/cypress/e2e/cloud.cy.ts +++ b/cypress/e2e/cloud.cy.ts @@ -186,7 +186,7 @@ describe('Cloud', () => { cy.get('@createMangaContainer.all').should('have.length', 1); cy.get('@createMainTask.all').should('have.length', 4); cy.get('@createHouseholdTask.all').should('have.length', 2); - cy.get('@createGroceriesTask.all').should('have.length', 3); + cy.get('@createGroceriesTask.all').should('have.length', 30); cy.get('@createRecipesTask.all').should('have.length', 3); cy.get('@createJapaneseTask.all').should('have.length', 3); cy.get('@createMangaTask.all').should('have.length', 2); diff --git a/cypress/e2e/navigation.cy.ts b/cypress/e2e/navigation.cy.ts index f435d3e..89d89c2 100644 --- a/cypress/e2e/navigation.cy.ts +++ b/cypress/e2e/navigation.cy.ts @@ -11,9 +11,8 @@ describe('Navigation', () => { cy.press('Groceries'); cy.seeActiveWorkspace('Household'); cy.seeActiveList('Groceries'); - cy.see('Nuts'); - cy.see('Chickpeas'); - cy.see('Tomatoes'); + cy.see('Bananas'); + cy.see('Orange juice'); cy.press('Recipes'); cy.seeActiveWorkspace('Household'); diff --git a/cypress/support/commands/stubs.ts b/cypress/support/commands/stubs.ts index 9be334f..896a8cf 100644 --- a/cypress/support/commands/stubs.ts +++ b/cypress/support/commands/stubs.ts @@ -16,9 +16,38 @@ export function createStubs(open: boolean = true): void { await household.relatedTasks.create({ name: 'Clean room' }); await household.relatedTasks.create({ name: 'Clean Neko\'s litter box' }).then((task) => task.toggle()); - await groceries.relatedTasks.create({ name: 'Chickpeas' }); - await groceries.relatedTasks.create({ name: 'Tomatoes' }); - await groceries.relatedTasks.create({ name: 'Nuts' }); + await groceries.relatedTasks.create({ name: 'Bananas' }); + await groceries.relatedTasks.create({ name: 'Orange juice' }); + await groceries.relatedTasks.create({ name: 'Whole grain bread' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Fresh apples and pears' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Baby carrots' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Red and green grapes' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Peanut butter' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Cucumber' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Fresh strawberries' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Almond milk' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Romaine lettuce' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Mixed berries (frozen)' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Broccoli florets' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Whole grain pasta' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Fresh tomatoes' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Rolled oats' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Bell peppers (variety pack)' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Fresh basil leaves' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Canned black beans' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Brown rice' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Fresh oranges and lemons' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Canned tomatoes' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Baby spinach' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Mixed nuts' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Fresh garlic and onions' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Avocado' }).then((task) => task.toggle()); + await groceries.relatedTasks + .create({ name: 'Fresh blueberries and raspberries' }) + .then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Zucchini and squash' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Whole wheat tortillas' }).then((task) => task.toggle()); + await groceries.relatedTasks.create({ name: 'Applesauce (unsweetened)' }).then((task) => task.toggle()); await recipes.relatedTasks.create({ name: 'Ramen' }); await recipes.relatedTasks.create({ name: 'Pizza' }).then((task) => task.toggle()); diff --git a/package-lock.json b/package-lock.json index 82806ae..d57956c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.0", "hasInstallScript": true, "dependencies": { - "@aerogel/core": "0.0.0-next.2d501d5a5dfe5863218d65b11ac78ea023da9127", + "@aerogel/core": "0.0.0-next.6c539d8e63b397d4bb6c3d61a7f20a4d108b1cdd", "@aerogel/plugin-i18n": "0.0.0-next.ee913f442ea0f8f8c64301001984e83797c6b7e3", "@aerogel/plugin-offline-first": "0.0.0-next.6c02970f90d4e979a72530f1fa0c650785d6538f", "@aerogel/plugin-routing": "0.0.0-next.6c02970f90d4e979a72530f1fa0c650785d6538f", @@ -18,7 +18,7 @@ "@headlessui/vue": "^1.7.22", "@intlify/unplugin-vue-i18n": "^0.12.2", "@noeldemartin/solid-utils": "0.4.0-next.30aceafb9d58f505d02a146d8e81f2e3a041b92f", - "@noeldemartin/utils": "0.5.1-next.369ec16c5fa546a6a2e7512501add6b0e5b1a3ac", + "@noeldemartin/utils": "0.5.1-next.82707467527729a9fe7c2155959beb4b5ea30b81", "@popperjs/core": "^2.11.8", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.9", @@ -93,9 +93,9 @@ } }, "node_modules/@aerogel/core": { - "version": "0.0.0-next.2d501d5a5dfe5863218d65b11ac78ea023da9127", - "resolved": "https://registry.npmjs.org/@aerogel/core/-/core-0.0.0-next.2d501d5a5dfe5863218d65b11ac78ea023da9127.tgz", - "integrity": "sha512-N8YBJWVfORHW4sGBkOrID1h15we2B47pWHCkqo+6nhHdfDszQBKe+rJcxiAj5rP6x6glqUDuSnjGI4n9kSBv0g==", + "version": "0.0.0-next.6c539d8e63b397d4bb6c3d61a7f20a4d108b1cdd", + "resolved": "https://registry.npmjs.org/@aerogel/core/-/core-0.0.0-next.6c539d8e63b397d4bb6c3d61a7f20a4d108b1cdd.tgz", + "integrity": "sha512-UzXPI3exrnjVu5sGeRuFkJgSw7o4F8gRnDRFD164JoakFqIAt38O4ARtjA7XnnU7DuJO9A1v0gk9RlNWfrZE6A==", "dependencies": { "@headlessui/vue": "^1.7.14", "@noeldemartin/utils": "0.5.1-next.4fd89de2cbde6c7e1cfa4d5f9bdac234e9cd3d98", @@ -6589,9 +6589,9 @@ } }, "node_modules/@noeldemartin/utils": { - "version": "0.5.1-next.369ec16c5fa546a6a2e7512501add6b0e5b1a3ac", - "resolved": "https://registry.npmjs.org/@noeldemartin/utils/-/utils-0.5.1-next.369ec16c5fa546a6a2e7512501add6b0e5b1a3ac.tgz", - "integrity": "sha512-g6AeBtt18C0zXmxtXy6ZXuDPHeZ+p306+fN+kYxp+DM1z+wgjmozYUCt0Sra3qUBCOAb+ss1+ZGLpN82RfshtA==", + "version": "0.5.1-next.82707467527729a9fe7c2155959beb4b5ea30b81", + "resolved": "https://registry.npmjs.org/@noeldemartin/utils/-/utils-0.5.1-next.82707467527729a9fe7c2155959beb4b5ea30b81.tgz", + "integrity": "sha512-FdupMZXCGBaLOA9nUWqfncJQ0cOEjjuqw8LHmpwdkt0ki0vSHFnEFj/uNoyosXNKH1bKdHQgDeC7eAhAokeCUw==", "dependencies": { "@babel/runtime": "^7.12.18", "core-js": "^3.9.0" diff --git a/package.json b/package.json index 6ab4602..36aceb7 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:serve-pod": "community-solid-server -l warn" }, "dependencies": { - "@aerogel/core": "0.0.0-next.2d501d5a5dfe5863218d65b11ac78ea023da9127", + "@aerogel/core": "0.0.0-next.6c539d8e63b397d4bb6c3d61a7f20a4d108b1cdd", "@aerogel/plugin-i18n": "0.0.0-next.ee913f442ea0f8f8c64301001984e83797c6b7e3", "@aerogel/plugin-offline-first": "0.0.0-next.6c02970f90d4e979a72530f1fa0c650785d6538f", "@aerogel/plugin-routing": "0.0.0-next.6c02970f90d4e979a72530f1fa0c650785d6538f", @@ -31,7 +31,7 @@ "@headlessui/vue": "^1.7.22", "@intlify/unplugin-vue-i18n": "^0.12.2", "@noeldemartin/solid-utils": "0.4.0-next.30aceafb9d58f505d02a146d8e81f2e3a041b92f", - "@noeldemartin/utils": "0.5.1-next.369ec16c5fa546a6a2e7512501add6b0e5b1a3ac", + "@noeldemartin/utils": "0.5.1-next.82707467527729a9fe7c2155959beb4b5ea30b81", "@popperjs/core": "^2.11.8", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.9", diff --git a/src/components/AppFooter.vue b/src/components/AppFooter.vue index c1da32f..fe01e12 100644 --- a/src/components/AppFooter.vue +++ b/src/components/AppFooter.vue @@ -3,7 +3,13 @@
-
+ {{ $t('footer.about') }} @@ -15,7 +21,7 @@ {{ $app.versionName }} -
+
diff --git a/src/main.ts b/src/main.ts index 3e328c0..9b07093 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import offlineFirst from '@aerogel/plugin-offline-first'; import routing from '@aerogel/plugin-routing'; import solid from '@aerogel/plugin-solid'; import soukai from '@aerogel/plugin-soukai'; +import vivant from '@/vivant/aerogel'; import { bootstrap } from '@aerogel/core'; import './assets/css/styles.css'; @@ -20,6 +21,7 @@ bootstrap(App, { routing({ routes, bindings }), solid(), offlineFirst(), + vivant(), ], install(app) { Object.assign(app.config.globalProperties, globals); diff --git a/src/pages/workspace/WorkspaceContentBody.vue b/src/pages/workspace/WorkspaceContentBody.vue index 53b6bf7..528a970 100644 --- a/src/pages/workspace/WorkspaceContentBody.vue +++ b/src/pages/workspace/WorkspaceContentBody.vue @@ -1,52 +1,78 @@ diff --git a/src/pages/workspace/WorkspaceSidebar.vue b/src/pages/workspace/WorkspaceSidebar.vue index 8182399..e802ebe 100644 --- a/src/pages/workspace/WorkspaceSidebar.vue +++ b/src/pages/workspace/WorkspaceSidebar.vue @@ -35,7 +35,7 @@ import Focus from '@/services/Focus'; import Workspaces from '@/services/Workspaces'; import { watchKeyboardShortcut } from '@/utils/composables'; -import { PanelAnimator } from './animations'; +import PanelAnimator from './animations/PanelAnimator'; const $panel = ref(); const $filler = ref(); diff --git a/src/pages/workspace/WorkspaceTask.vue b/src/pages/workspace/WorkspaceTask.vue index 989c15b..ee2d980 100644 --- a/src/pages/workspace/WorkspaceTask.vue +++ b/src/pages/workspace/WorkspaceTask.vue @@ -220,7 +220,7 @@ import Focus from '@/services/Focus'; import Task from '@/models/Task'; import Workspaces from '@/services/Workspaces'; -import { PanelAnimator } from './animations'; +import PanelAnimator from './animations/PanelAnimator'; const $panel = ref(); const $filler = ref(); diff --git a/src/pages/workspace/animations.ts b/src/pages/workspace/animations/PanelAnimator.ts similarity index 98% rename from src/pages/workspace/animations.ts rename to src/pages/workspace/animations/PanelAnimator.ts index 24089d0..fce33e6 100644 --- a/src/pages/workspace/animations.ts +++ b/src/pages/workspace/animations/PanelAnimator.ts @@ -1,7 +1,7 @@ import { element } from '@/utils/animations'; import type { ElementRef } from '@/utils/animations'; -export class PanelAnimator { +export default class PanelAnimator { private showing: boolean = false; private hiddenTransform: string; diff --git a/src/pages/workspace/animations/tasks.ts b/src/pages/workspace/animations/tasks.ts new file mode 100644 index 0000000..af97348 --- /dev/null +++ b/src/pages/workspace/animations/tasks.ts @@ -0,0 +1,94 @@ +import { animateStyles, requireElementBounds } from '@/vivant/core'; +import { isInstanceOf, required } from '@noeldemartin/utils'; + +function getFooterTopDelta(): number { + const $footer = required(document.querySelector('footer')); + const [first, last] = requireElementBounds(required($footer)); + + return Math.min(first.height, Math.abs(first.top - last.top)); +} + +function footerMoved(): boolean { + return getFooterTopDelta() !== 0; +} + +export async function toggleFooter($footer: Element): Promise { + const [first, last] = requireElementBounds($footer); + + if (!footerMoved() || !isInstanceOf($footer, HTMLElement)) { + return; + } + + const isAppearing = last.top > first.top; + const translateY = getFooterTopDelta(); + + await animateStyles($footer, { + initial: { + position: 'fixed', + bottom: `${-translateY}px`, + background: 'white', + willChange: 'transform', + + // Add 1px virtual padding to avoid appearing on top of side panels borders. + left: `${last.left + 1}px`, + width: `${last.width - 2}px`, + }, + onUpdate: (progress) => ({ + transform: `translateY(${(isAppearing ? 1 - progress : progress) * -translateY}px)`, + }), + }); +} + +export async function toggleCompletedTasks($wrapper: Element): Promise { + const [first, last] = requireElementBounds($wrapper); + + if (!first || !last || !isInstanceOf($wrapper, HTMLElement)) { + return; + } + + const isGrowing = last.height > first.height; + const bottomTranslateY = footerMoved() ? required(document.querySelector('footer')).clientHeight : 0; + const $completedWrapper = required($wrapper.parentElement); + const $list = required($wrapper.firstElementChild as HTMLElement); + const listHeight = Math.abs(first.top - last.top) + bottomTranslateY - 1; + const listMarginTop = parseInt(getComputedStyle($list).marginTop.slice(0, -2)); + const listTranslateYOffset = listMarginTop - 1; + const wrapperScaleYOffset = 1 + bottomTranslateY; + const wrapperScaleY = listHeight - 1 - bottomTranslateY; + const listTranslateY = -(listHeight - bottomTranslateY - 1); + + await animateStyles( + { $completedWrapper, $wrapper, $list }, + { + initial: { + $completedWrapper: { paddingBottom: `${bottomTranslateY}px` }, + $wrapper: { + position: 'absolute', + width: `${Math.max(first.width, last.width)}px`, + height: '1px', + bottom: '0', + transformOrigin: 'bottom', + willChange: 'transform', + }, + $list: { + top: '0', + marginTop: '0', + transformOrigin: 'top', + willChange: 'transform', + }, + }, + onUpdate(progress) { + progress = isGrowing ? 1 - progress : progress; + + const wrapperScale = (1 - progress) * wrapperScaleY + wrapperScaleYOffset; + const listScale = 1 / wrapperScale; + const listTranslate = progress * listTranslateY + listTranslateYOffset; + + return { + $wrapper: { transform: `scaleY(${wrapperScale})` }, + $list: { transform: `scaleY(${listScale}) translateY(${listTranslate}px)` }, + }; + }, + }, + ); +} diff --git a/src/pages/workspace/components/tasks/TasksList.vue b/src/pages/workspace/components/tasks/TasksList.vue index f9e6d68..acf9ee3 100644 --- a/src/pages/workspace/components/tasks/TasksList.vue +++ b/src/pages/workspace/components/tasks/TasksList.vue @@ -1,13 +1,13 @@ diff --git a/src/services/Focus.state.ts b/src/services/Focus.state.ts index 73fc436..e085e97 100644 --- a/src/services/Focus.state.ts +++ b/src/services/Focus.state.ts @@ -1,4 +1,12 @@ import { defineServiceState } from '@aerogel/core'; +import type { AnimatedGroup } from '@/vivant/core'; +import type { AnimationHook } from '@/vivant/vue'; + +export interface FooterAnimation { + group: AnimatedGroup; + animation?: string; + animate?: AnimationHook; +} export default defineServiceState({ name: 'focus', @@ -8,5 +16,6 @@ export default defineServiceState({ showCompleted: false, footerRightPadding: null as number | null, footerLeftPadding: null as number | null, + footerAnimation: null as FooterAnimation | null, }, }); diff --git a/src/services/Focus.ts b/src/services/Focus.ts index b8a997f..e116c1a 100644 --- a/src/services/Focus.ts +++ b/src/services/Focus.ts @@ -1,6 +1,8 @@ import { facade } from '@noeldemartin/utils'; +import { markRaw } from 'vue'; import Service from './Focus.state'; +import type { FooterAnimation } from './Focus.state'; export class FocusService extends Service { @@ -8,6 +10,10 @@ export class FocusService extends Service { this.showCompleted = !this.showCompleted; } + public setFooterAnimation(animation: FooterAnimation | null): void { + this.footerAnimation = animation === null ? null : markRaw(animation); + } + protected async boot(): Promise { this.visits++; } diff --git a/src/vivant/aerogel/index.ts b/src/vivant/aerogel/index.ts new file mode 100644 index 0000000..5ae290c --- /dev/null +++ b/src/vivant/aerogel/index.ts @@ -0,0 +1,12 @@ +import { directives } from '@/vivant/vue'; +import type { Plugin } from '@aerogel/core'; + +export default function(): Plugin { + return { + install(app) { + for (const [name, directive] of Object.entries(directives)) { + app.directive(name, directive); + } + }, + }; +} diff --git a/src/vivant/core/AnimatedElement.ts b/src/vivant/core/AnimatedElement.ts new file mode 100644 index 0000000..aaa9eb2 --- /dev/null +++ b/src/vivant/core/AnimatedElement.ts @@ -0,0 +1,48 @@ +import { registerElement } from '@/vivant/core/registry'; +import type Animation from '@/vivant/core/animations/Animation'; + +export default class AnimatedElement { + + public readonly nativeElement: Element; + private animations: Animation[]; + private previousBounds?: DOMRect; + private currentBounds?: DOMRect; + private registryCleanup?: Function; + + constructor(nativeElement: Element) { + this.nativeElement = nativeElement; + this.animations = []; + } + + public getPreviousBounds(): DOMRect | undefined { + return this.previousBounds; + } + + public getCurrentBounds(): DOMRect | undefined { + return this.currentBounds; + } + + public useAnimation(animation: Animation): void { + this.animations.push(animation); + } + + public attach(): void { + this.registryCleanup = registerElement(this.nativeElement, this); + } + + public detach(): void { + this.registryCleanup?.(); + + delete this.registryCleanup; + } + + public measure(): void { + this.previousBounds = this.currentBounds; + this.currentBounds = this.nativeElement.getBoundingClientRect(); + } + + public async animate(): Promise { + await Promise.all(this.animations.map((animation) => animation.run(this))); + } + +} diff --git a/src/vivant/core/AnimatedGroup.ts b/src/vivant/core/AnimatedGroup.ts new file mode 100644 index 0000000..87123c8 --- /dev/null +++ b/src/vivant/core/AnimatedGroup.ts @@ -0,0 +1,11 @@ +import type { AnimationConfig } from '@/vivant/core/animations/config'; + +export default class AnimatedGroup { + + constructor(public duration: number = 500) {} + + public config(): AnimationConfig { + return { duration: this.duration }; + } + +} diff --git a/src/vivant/core/animations/Animation.ts b/src/vivant/core/animations/Animation.ts new file mode 100644 index 0000000..c6d557a --- /dev/null +++ b/src/vivant/core/animations/Animation.ts @@ -0,0 +1,7 @@ +import type AnimatedElement from '@/vivant/core/AnimatedElement'; + +export default abstract class Animation { + + public abstract run(element: AnimatedElement): Promise; + +} diff --git a/src/vivant/core/animations/classes/FreezeAnimation.ts b/src/vivant/core/animations/classes/FreezeAnimation.ts new file mode 100644 index 0000000..8eb1b5a --- /dev/null +++ b/src/vivant/core/animations/classes/FreezeAnimation.ts @@ -0,0 +1,11 @@ +import Animation from '@/vivant/core/animations/Animation'; +import freeze from '@/vivant/core/animations/helpers/freeze'; +import type AnimatedElement from '@/vivant/core/AnimatedElement'; + +export default class FreezeAnimation extends Animation { + + public async run(element: AnimatedElement): Promise { + await freeze(element.nativeElement); + } + +} diff --git a/src/vivant/core/animations/classes/LayoutAnimation.ts b/src/vivant/core/animations/classes/LayoutAnimation.ts new file mode 100644 index 0000000..cc2753e --- /dev/null +++ b/src/vivant/core/animations/classes/LayoutAnimation.ts @@ -0,0 +1,23 @@ +import { isInstanceOf } from '@noeldemartin/utils'; + +import animateStyles from '@/vivant/core/helpers/animate-styles'; +import Animation from '@/vivant/core/animations/Animation'; +import type AnimatedElement from '@/vivant/core/AnimatedElement'; + +export default class LayoutAnimation extends Animation { + + public async run(element: AnimatedElement): Promise { + const first = element.getPreviousBounds(); + const last = element.getCurrentBounds(); + + if (!isInstanceOf(element.nativeElement, HTMLElement) || !first || !last || first.y === last.y) { + return; + } + + await animateStyles(element.nativeElement, { + initial: { willChange: 'transform' }, + onUpdate: (progress) => ({ transform: `translateY(${(1 - progress) * (first.y - last.y)}px)` }), + }); + } + +} diff --git a/src/vivant/core/animations/classes/SlideDownAnimation.ts b/src/vivant/core/animations/classes/SlideDownAnimation.ts new file mode 100644 index 0000000..b1ce63b --- /dev/null +++ b/src/vivant/core/animations/classes/SlideDownAnimation.ts @@ -0,0 +1,12 @@ +import type AnimatedElement from '@/vivant/core/AnimatedElement'; + +import Animation from '@/vivant/core/animations/Animation'; +import slideDown from '@/vivant/core/animations/helpers/slide-down'; + +export default class SlideDownAnimation extends Animation { + + public async run(element: AnimatedElement): Promise { + await slideDown(element.nativeElement); + } + +} diff --git a/src/vivant/core/animations/classes/SlideUpAnimation.ts b/src/vivant/core/animations/classes/SlideUpAnimation.ts new file mode 100644 index 0000000..7bc77b0 --- /dev/null +++ b/src/vivant/core/animations/classes/SlideUpAnimation.ts @@ -0,0 +1,12 @@ +import type AnimatedElement from '@/vivant/core/AnimatedElement'; + +import Animation from '@/vivant/core/animations/Animation'; +import slideUp from '@/vivant/core/animations/helpers/slide-up'; + +export default class SlideUpAnimation extends Animation { + + public async run(element: AnimatedElement): Promise { + await slideUp(element.nativeElement); + } + +} diff --git a/src/vivant/core/animations/config.ts b/src/vivant/core/animations/config.ts new file mode 100644 index 0000000..7fcc109 --- /dev/null +++ b/src/vivant/core/animations/config.ts @@ -0,0 +1,3 @@ +export interface AnimationConfig { + duration: number; +} diff --git a/src/vivant/core/animations/helpers/freeze.ts b/src/vivant/core/animations/helpers/freeze.ts new file mode 100644 index 0000000..90bdd3f --- /dev/null +++ b/src/vivant/core/animations/helpers/freeze.ts @@ -0,0 +1,19 @@ +import { isInstanceOf } from '@noeldemartin/utils'; + +import animateStyles from '@/vivant/core/helpers/animate-styles'; +import { requireElementBounds } from '@/vivant/core/helpers/bounds'; + +export default async function freeze(element: Element): Promise { + const [bounds] = requireElementBounds(element); + + if (!isInstanceOf(element, HTMLElement)) { + return; + } + + await animateStyles(element, { + onUpdate: () => ({ + width: `${bounds.width}px`, + height: `${bounds.height}px`, + }), + }); +} diff --git a/src/vivant/core/animations/helpers/slide-down.ts b/src/vivant/core/animations/helpers/slide-down.ts new file mode 100644 index 0000000..988008c --- /dev/null +++ b/src/vivant/core/animations/helpers/slide-down.ts @@ -0,0 +1,14 @@ +import { isInstanceOf } from '@noeldemartin/utils'; + +import animateStyles from '@/vivant/core/helpers/animate-styles'; + +export default async function slideDown(element: Element): Promise { + if (!isInstanceOf(element, HTMLElement)) { + return; + } + + await animateStyles(element, { + initial: { willChange: 'transform' }, + onUpdate: (progress) => ({ transform: `translateY(${(1 - progress) * -100}%)` }), + }); +} diff --git a/src/vivant/core/animations/helpers/slide-up.ts b/src/vivant/core/animations/helpers/slide-up.ts new file mode 100644 index 0000000..18ebbac --- /dev/null +++ b/src/vivant/core/animations/helpers/slide-up.ts @@ -0,0 +1,14 @@ +import { isInstanceOf } from '@noeldemartin/utils'; + +import animateStyles from '@/vivant/core/helpers/animate-styles'; + +export default async function slideUp(element: Element): Promise { + if (!isInstanceOf(element, HTMLElement)) { + return; + } + + await animateStyles(element, { + initial: { willChange: 'transform' }, + onUpdate: (progress) => ({ transform: `translateY(${progress * -100}%)` }), + }); +} diff --git a/src/vivant/core/animations/index.ts b/src/vivant/core/animations/index.ts new file mode 100644 index 0000000..c2b04d3 --- /dev/null +++ b/src/vivant/core/animations/index.ts @@ -0,0 +1,5 @@ +export * from './config'; +export { default as Animation } from './Animation'; +export { default as freeze } from './helpers/freeze'; +export { default as slideDown } from './helpers/slide-down'; +export { default as slideUp } from './helpers/slide-up'; diff --git a/src/vivant/core/helpers/animate-motion.ts b/src/vivant/core/helpers/animate-motion.ts new file mode 100644 index 0000000..e85fb72 --- /dev/null +++ b/src/vivant/core/helpers/animate-motion.ts @@ -0,0 +1,10 @@ +import { animate } from 'popmotion'; +import type { AnimationOptions } from 'popmotion'; + +export default function animateMotion(options: AnimationOptions): Promise { + return new Promise((resolve) => + animate({ + ...options, + onComplete: () => (options.onComplete?.(), resolve()), + })); +} diff --git a/src/vivant/core/helpers/animate-styles.ts b/src/vivant/core/helpers/animate-styles.ts new file mode 100644 index 0000000..55b5e6a --- /dev/null +++ b/src/vivant/core/helpers/animate-styles.ts @@ -0,0 +1,96 @@ +import { isInstanceOf } from '@noeldemartin/utils'; + +import animateMotion from '@/vivant/core/helpers/animate-motion'; +import { resolveAnimatedGroup } from '@/vivant/core/registry'; +import type { AnimationConfig } from '@/vivant/core/animations/config'; + +function resolveElementsConfig(elements: Record): AnimationConfig | null { + for (const element of Object.values(elements)) { + const group = resolveAnimatedGroup(element); + + if (!group) { + continue; + } + + return group.config(); + } + + return null; +} + +function applyStyles>( + elements: T, + styles: Partial>>, +): void { + for (const [elementName, elementStyles] of Object.entries(styles)) { + if (!elementStyles) { + continue; + } + + for (const [styleProperty, styleValue] of Object.entries(elementStyles)) { + (elements[elementName] as unknown as { style: Record }).style[styleProperty] = styleValue; + } + } +} + +export interface ElementStylesAnimationOptions { + config?: AnimationConfig; + initial?: Partial; + onUpdate?(progress: number): Partial; +} + +export type ElementsStylesAnimationOptions> = { + config?: AnimationConfig; + initial?: Partial>>; + onUpdate?(progress: number): Partial>>; +}; + +export default function animateStyles>( + elements: T, + options: ElementsStylesAnimationOptions +): Promise; +export default function animateStyles(element: HTMLElement, options: ElementStylesAnimationOptions): Promise; +export default async function animateStyles>( + elementOrElements: HTMLElement | T, + defaultOptions: ElementStylesAnimationOptions | ElementsStylesAnimationOptions, +): Promise { + const elements = isInstanceOf(elementOrElements, HTMLElement) + ? ({ $el: elementOrElements } as Record) + : elementOrElements; + const options = ( + isInstanceOf(elementOrElements, HTMLElement) + ? { + config: defaultOptions.config, + initial: defaultOptions.initial && { $el: defaultOptions.initial }, + onUpdate: defaultOptions.onUpdate && ((progress) => ({ $el: defaultOptions.onUpdate?.(progress) })), + } + : defaultOptions + ) as ElementsStylesAnimationOptions>; + const config = options.config ?? resolveElementsConfig(elements); + const initialStyles = Object.entries(elements).reduce((styles, [name, element]) => { + styles[name] = element.getAttribute('style'); + + return styles; + }, {} as Record); + + options.initial && applyStyles(elements, options.initial); + + await animateMotion({ + duration: config?.duration ?? 500, + onUpdate: (progress) => { + const styles = options.onUpdate?.(progress); + + styles && applyStyles(elements, styles); + }, + }); + + for (const [name, initial] of Object.entries(initialStyles)) { + if (!initial) { + elements[name]?.removeAttribute('style'); + + continue; + } + + elements[name]?.setAttribute('style', initial); + } +} diff --git a/src/vivant/core/helpers/bounds.ts b/src/vivant/core/helpers/bounds.ts new file mode 100644 index 0000000..e00002b --- /dev/null +++ b/src/vivant/core/helpers/bounds.ts @@ -0,0 +1,14 @@ +import { required } from '@noeldemartin/utils'; +import { resolveAnimatedElement } from '@/vivant/core/registry'; + +export function getElementBounds(element: Element): [DOMRect?, DOMRect?] { + const animatedElement = resolveAnimatedElement(element); + + return [animatedElement?.getPreviousBounds(), animatedElement?.getCurrentBounds()]; +} + +export function requireElementBounds(element: Element): [DOMRect, DOMRect] { + const [first, last] = getElementBounds(element); + + return [required(first), required(last)]; +} diff --git a/src/vivant/core/helpers/index.ts b/src/vivant/core/helpers/index.ts new file mode 100644 index 0000000..e982203 --- /dev/null +++ b/src/vivant/core/helpers/index.ts @@ -0,0 +1,4 @@ +export { default as animateMotion } from './animate-motion'; +export { default as animateStyles } from './animate-styles'; +export { default as prefersReducedMotion } from './prefers-reduced-motion'; +export * from './bounds'; diff --git a/src/vivant/core/helpers/prefers-reduced-motion.ts b/src/vivant/core/helpers/prefers-reduced-motion.ts new file mode 100644 index 0000000..f6d5fd9 --- /dev/null +++ b/src/vivant/core/helpers/prefers-reduced-motion.ts @@ -0,0 +1,5 @@ +import { memo } from '@noeldemartin/utils'; + +export default function prefersReducedMotion(): boolean { + return memo('prefers-reduced-motion', () => window.matchMedia('(prefers-reduced-motion)').matches); +} diff --git a/src/vivant/core/index.ts b/src/vivant/core/index.ts new file mode 100644 index 0000000..ad8e071 --- /dev/null +++ b/src/vivant/core/index.ts @@ -0,0 +1,5 @@ +export * from './animations'; +export * from './helpers'; +export * from './registry'; +export { default as AnimatedElement } from './AnimatedElement'; +export { default as AnimatedGroup } from './AnimatedGroup'; diff --git a/src/vivant/core/registry.ts b/src/vivant/core/registry.ts new file mode 100644 index 0000000..632e22a --- /dev/null +++ b/src/vivant/core/registry.ts @@ -0,0 +1,79 @@ +import type { Constructor } from '@noeldemartin/utils'; + +import FreezeAnimation from '@/vivant/core/animations/classes/FreezeAnimation'; +import LayoutAnimation from '@/vivant/core/animations/classes/LayoutAnimation'; +import SlideDownAnimation from '@/vivant/core/animations/classes/SlideDownAnimation'; +import SlideUpAnimation from '@/vivant/core/animations/classes/SlideUpAnimation'; +import type AnimatedElement from '@/vivant/core/AnimatedElement'; +import type AnimatedGroup from '@/vivant/core/AnimatedGroup'; +import type Animation from '@/vivant/core/animations/Animation'; + +const animations: Record> = { + 'freeze': FreezeAnimation, + 'layout': LayoutAnimation, + 'slide-down': SlideDownAnimation, + 'slide-up': SlideUpAnimation, +}; +const elements = new WeakMap(); +const groups = new Map(); +const groupElements = new WeakMap>(); +const elementsGroup = new WeakMap(); + +export function registerElement(element: Element, animatedElement: AnimatedElement): Function { + elements.set(element, animatedElement); + + return () => elements.delete(element); +} + +export function registerGroup(element: Element, group: AnimatedGroup): Function { + groups.set(element, group); + + return () => groups.delete(element); +} + +export function registerGroupElement(group: AnimatedGroup, element: AnimatedElement): Function { + const registerElements = groupElements.get(group) ?? new Set(); + + registerElements.add(element); + groupElements.set(group, registerElements); + elementsGroup.set(element, group); + + return () => { + const cleanUpElements = groupElements.get(group) ?? new Set(); + + cleanUpElements.delete(element); + elementsGroup.delete(element); + + if (cleanUpElements.size === 0) { + groupElements.delete(group); + } + }; +} + +export function resolveAnimation(name: string): Constructor | undefined { + return animations[name]; +} + +export function resolveAnimatedElement(element: Element): AnimatedElement | undefined { + return elements.get(element); +} + +export function resolveAnimatedGroup(element: Element): AnimatedGroup | undefined { + const animatedElement = elements.get(element); + + if (animatedElement && elementsGroup.has(animatedElement)) { + return elementsGroup.get(animatedElement); + } + + for (const groupElement of groups.keys()) { + if (!groupElement.contains(element)) { + continue; + } + + return groups.get(groupElement); + } +} + +export function resolveGroupElements(group: AnimatedGroup): Set { + return groupElements.get(group) ?? new Set(); +} diff --git a/src/vivant/vue/animations/HookAnimation.ts b/src/vivant/vue/animations/HookAnimation.ts new file mode 100644 index 0000000..578b8ac --- /dev/null +++ b/src/vivant/vue/animations/HookAnimation.ts @@ -0,0 +1,21 @@ +import { PromisedValue } from '@noeldemartin/utils'; + +import { Animation } from '@/vivant/core'; +import type { AnimatedElement } from '@/vivant/core'; + +export type AnimationHook = (element: Element, done: Function) => void | Promise; + +export default class HookAnimation extends Animation { + + constructor(private implementation: AnimationHook) { + super(); + } + + public run(element: AnimatedElement): Promise { + const finished = new PromisedValue(); + const result = this.implementation(element.nativeElement, () => finished.resolve()); + + return result instanceof Promise ? result : finished; + } + +} diff --git a/src/vivant/vue/animations/index.ts b/src/vivant/vue/animations/index.ts new file mode 100644 index 0000000..88eb3aa --- /dev/null +++ b/src/vivant/vue/animations/index.ts @@ -0,0 +1 @@ +export * from './HookAnimation'; diff --git a/src/vivant/vue/components/AnimatedElement.vue b/src/vivant/vue/components/AnimatedElement.vue new file mode 100644 index 0000000..42a2fd2 --- /dev/null +++ b/src/vivant/vue/components/AnimatedElement.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/vivant/vue/components/AnimatedGroup.ts b/src/vivant/vue/components/AnimatedGroup.ts new file mode 100644 index 0000000..3e227e0 --- /dev/null +++ b/src/vivant/vue/components/AnimatedGroup.ts @@ -0,0 +1,5 @@ +import type AnimatedGroup from '@/vivant/core/AnimatedGroup'; + +export interface IAnimatedGroup { + group: AnimatedGroup; +} diff --git a/src/vivant/vue/components/AnimatedGroup.vue b/src/vivant/vue/components/AnimatedGroup.vue new file mode 100644 index 0000000..0ce90a5 --- /dev/null +++ b/src/vivant/vue/components/AnimatedGroup.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/vivant/vue/components/AnimatedTransition.vue b/src/vivant/vue/components/AnimatedTransition.vue new file mode 100644 index 0000000..a32eb57 --- /dev/null +++ b/src/vivant/vue/components/AnimatedTransition.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/vivant/vue/components/index.ts b/src/vivant/vue/components/index.ts new file mode 100644 index 0000000..ed39108 --- /dev/null +++ b/src/vivant/vue/components/index.ts @@ -0,0 +1 @@ +export * from './AnimatedGroup'; diff --git a/src/vivant/vue/components/utils.ts b/src/vivant/vue/components/utils.ts new file mode 100644 index 0000000..b51797c --- /dev/null +++ b/src/vivant/vue/components/utils.ts @@ -0,0 +1,8 @@ +import { objectWithout } from '@noeldemartin/utils'; +import type { Obj, ObjectWithout } from '@noeldemartin/utils'; + +export type Falsish = null | undefined | false; + +export function objectWithoutFalsish(obj: T): ObjectWithout { + return objectWithout(obj, (value): value is Falsish => value === null || value === undefined || value === false); +} diff --git a/src/vivant/vue/directives/animate-layout.ts b/src/vivant/vue/directives/animate-layout.ts new file mode 100644 index 0000000..6d83965 --- /dev/null +++ b/src/vivant/vue/directives/animate-layout.ts @@ -0,0 +1,34 @@ +import { defineDirective } from '@aerogel/core'; +import { nextTick } from 'vue'; +import { resolveAnimatedGroup } from '@/vivant/core'; + +import { setupAnimatedElement } from '@/vivant/vue/helpers/element-animations'; + +const cleanup = new WeakMap(); + +export default defineDirective({ + mounted(element) { + nextTick(() => { + const group = resolveAnimatedGroup(element); + + if (!group) { + // eslint-disable-next-line no-console + console.warn('v-animate-layout only works inside components'); + + return; + } + + cleanup.set( + element, + setupAnimatedElement(element, { + group, + animation: 'layout', + }), + ); + }); + }, + unmounted(element) { + cleanup.get(element)?.(); + cleanup.delete(element); + }, +}); diff --git a/src/vivant/vue/directives/index.ts b/src/vivant/vue/directives/index.ts new file mode 100644 index 0000000..878453b --- /dev/null +++ b/src/vivant/vue/directives/index.ts @@ -0,0 +1,14 @@ +import { required, stringMatch } from '@noeldemartin/utils'; +import type { Directive } from 'vue'; + +const directivesGlob = import.meta.glob(['./*.ts'], { eager: true }) as Record; + +const directives: Record = {}; + +for (const [fileName, moduleExports] of Object.entries(directivesGlob)) { + const name = required(stringMatch<3>(fileName, /^(.*\/)?(.+)\.ts$/))[2]; + + directives[name] = moduleExports.default; +} + +export default directives; diff --git a/src/vivant/vue/helpers/element-animations.ts b/src/vivant/vue/helpers/element-animations.ts new file mode 100644 index 0000000..a2c4040 --- /dev/null +++ b/src/vivant/vue/helpers/element-animations.ts @@ -0,0 +1,27 @@ +import { AnimatedElement, registerGroupElement, resolveAnimation } from '@/vivant/core'; +import type { AnimatedGroup } from '@/vivant/core'; + +import HookAnimation from '@/vivant/vue/animations/HookAnimation'; +import type { AnimationHook } from '@/vivant/vue/animations/HookAnimation'; + +export interface AnimatedElementOptions { + group: AnimatedGroup; + animation?: string; + animate?: AnimationHook; +} + +export function setupAnimatedElement(element: Element, options: AnimatedElementOptions): Function { + const animatedElement = new AnimatedElement(element); + const groupCleanup = registerGroupElement(options.group, animatedElement); + const Animation = options.animation && resolveAnimation(options.animation); + + options.animate && animatedElement.useAnimation(new HookAnimation(options.animate)); + Animation && animatedElement.useAnimation(new Animation()); + + animatedElement.attach(); + + return () => { + animatedElement.detach(); + groupCleanup(); + }; +} diff --git a/src/vivant/vue/helpers/group-animations.ts b/src/vivant/vue/helpers/group-animations.ts new file mode 100644 index 0000000..e7e3919 --- /dev/null +++ b/src/vivant/vue/helpers/group-animations.ts @@ -0,0 +1,87 @@ +import { AnimatedElement, resolveAnimation, resolveGroupElements } from '@/vivant/core'; +import { nextTick } from 'vue'; +import { PromisedValue } from '@noeldemartin/utils'; +import type { AnimatedGroup } from '@/vivant/core'; + +import HookAnimation from '@/vivant/vue/animations/HookAnimation'; +import type { AnimatedElementOptions } from '@/vivant/vue/helpers/element-animations'; + +interface ActiveGroupAnimation { + done: PromisedValue; + classes: WeakMap; + elements: AnimatedElement[]; +} + +const activeGroupAnimations = new WeakMap(); + +async function runGroupAnimation(group: AnimatedGroup, animation: ActiveGroupAnimation): Promise { + // Measure group elements. + const groupElements = resolveGroupElements(group); + + for (const element of groupElements) { + element.measure(); + } + + // Update from and to classes. + // We need to do this manually because Vue's implementation takes two frames to update, and this causes + // a flicker in the UI when the implementation relies on JavaScript updates rather than CSS transitions. + // See https://github.com/vuejs/core/blob/v3.4.0/packages/runtime-dom/src/components/Transition.ts#L316..L320 + for (const element of animation.elements) { + const elementClasses = animation.classes.get(element); + elementClasses?.from?.split(' ').forEach((className) => element.nativeElement.classList.remove(className)); + elementClasses?.to?.split(' ').forEach((className) => element.nativeElement.classList.add(className)); + } + + // Measure again and run animations. + const elements = [...animation.elements, ...groupElements]; + + elements.map((element) => element.measure()); + + await Promise.all(elements.map((element) => element.animate())); + + // Remove classes. + for (const element of animation.elements) { + const elementClasses = animation.classes.get(element); + elementClasses?.to?.split(' ').forEach((className) => element.nativeElement.classList.remove(className)); + } + + // Complete group animation. + animation.done.resolve(); + animation.elements.forEach((element) => element.detach()); + activeGroupAnimations.delete(group); +} + +function initializeGroupAnimation(group: AnimatedGroup): ActiveGroupAnimation { + const animation: ActiveGroupAnimation = { + done: new PromisedValue(), + classes: new WeakMap(), + elements: [], + }; + + nextTick(() => runGroupAnimation(group, animation)); + + activeGroupAnimations.set(group, animation); + + return animation; +} + +export interface GroupAnimationOptions extends AnimatedElementOptions { + fromClass?: string; + toClass?: string; +} + +export async function startGroupAnimation(element: Element, options: GroupAnimationOptions): Promise { + const activeAnimation = activeGroupAnimations.get(options.group) ?? initializeGroupAnimation(options.group); + const animatedElement = new AnimatedElement(element); + const Animation = options.animation && resolveAnimation(options.animation); + + options.animate && animatedElement.useAnimation(new HookAnimation(options.animate)); + Animation && animatedElement.useAnimation(new Animation()); + animatedElement.measure(); + animatedElement.attach(); + + activeAnimation.elements.push(animatedElement); + activeAnimation.classes.set(animatedElement, { from: options.fromClass, to: options.toClass }); + + await activeAnimation.done; +} diff --git a/src/vivant/vue/helpers/index.ts b/src/vivant/vue/helpers/index.ts new file mode 100644 index 0000000..3dbe376 --- /dev/null +++ b/src/vivant/vue/helpers/index.ts @@ -0,0 +1,2 @@ +export * from './element-animations'; +export * from './group-animations'; diff --git a/src/vivant/vue/index.ts b/src/vivant/vue/index.ts new file mode 100644 index 0000000..f5b0dfa --- /dev/null +++ b/src/vivant/vue/index.ts @@ -0,0 +1,4 @@ +export * from './animations'; +export * from './components'; +export * from './helpers'; +export { default as directives } from './directives'; diff --git a/vite.config.ts b/vite.config.ts index 76449d7..eb35d80 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ deep: true, dts: false, resolvers: [HeadlessUiResolver(), AerogelResolver(), IconsResolver({ customCollections: ['app'] })], - dirs: ['src/components', 'src/pages'], + dirs: ['src/components', 'src/pages', 'src/vivant/vue/components'], }), I18n({ strictMessage: false, include: resolve(__dirname, './src/lang/**/*.yaml') }), Icons({