-
Notifications
You must be signed in to change notification settings - Fork 63
VLogoLoader #448
VLogoLoader #448
Changes from all commits
49ffdc1
bdb85ad
f41a60a
15c0160
4b151a2
1db788a
dc02695
861e866
2929fb2
afa7731
3e62fcf
f380dc1
65036fd
a5eb8db
e060388
ae0201d
9808f3f
12cedb4
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 |
---|---|---|
@@ -0,0 +1,9 @@ | ||
export const propTypes = { | ||
status: { | ||
type: /** @type {'loading'|'idle'} */ (String), | ||
default: 'idle', | ||
required: false, | ||
}, | ||
} | ||
|
||
/** @typedef {import('@nuxtjs/composition-api').ExtractPropTypes<typeof propTypes>} Props */ |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
<template> | ||
<svg | ||
viewBox="0 0 34 32" | ||
xmlns="http://www.w3.org/2000/svg" | ||
:class="{ | ||
[$style.loading]: status === 'loading' && !prefersReducedMotion, | ||
}" | ||
aria-hidden="true" | ||
:data-prefers-reduced-motion="prefersReducedMotion" | ||
data-testid="logo-loader" | ||
class="hover:bg-yellow w-16 h-16 p-4 rounded inline-flex justify-center items-center" | ||
> | ||
<path | ||
data-logo-part-1 | ||
d="M0 6.962c0 3.831 3.09 6.962 6.916 6.962V0C3.09 0 0 3.111 0 6.962Z" | ||
/> | ||
<path | ||
data-logo-part-2 | ||
d="M10.084 6.962c0 3.831 3.091 6.962 6.916 6.962V0c-3.806 0-6.916 3.111-6.916 6.962Z" | ||
/> | ||
<path | ||
data-logo-part-3 | ||
d="M27.084 13.924c3.82 0 6.916-3.117 6.916-6.962C34 3.117 30.904 0 27.084 0c-3.82 0-6.916 3.117-6.916 6.962 0 3.845 3.097 6.962 6.916 6.962Z" | ||
/> | ||
<path | ||
data-logo-part-4 | ||
d="M0 24.153c0 3.85 3.09 6.962 6.916 6.962V17.21C3.09 17.21 0 20.322 0 24.153Z" | ||
/> | ||
<path | ||
data-logo-part-5 | ||
d="M10.084 24.095c0 3.83 3.091 6.962 6.916 6.962V17.152c-3.806 0-6.916 3.112-6.916 6.943Z" | ||
/> | ||
<path | ||
data-logo-part-6 | ||
d="M27.084 31.057c3.82 0 6.916-3.117 6.916-6.962 0-3.845-3.096-6.962-6.916-6.962-3.82 0-6.916 3.117-6.916 6.962 0 3.845 3.097 6.962 6.916 6.962Z" | ||
/> | ||
</svg> | ||
</template> | ||
|
||
<script> | ||
import { defineComponent } from '@nuxtjs/composition-api' | ||
import { useReducedMotion } from '~/composables/use-media-query' | ||
|
||
import { propTypes } from './VLogoLoader.types' | ||
|
||
export default defineComponent({ | ||
name: 'VLogoLoader', | ||
props: propTypes, | ||
/** | ||
* @param {import('./VLogoLoader.types').Props} props | ||
* @param {import('@nuxtjs/composition-api').SetupContext} context | ||
*/ | ||
setup() { | ||
const defaultWindow = typeof window !== 'undefined' ? window : undefined | ||
const prefersReducedMotion = useReducedMotion({ window: defaultWindow }) | ||
|
||
return { prefersReducedMotion } | ||
}, | ||
}) | ||
</script> | ||
|
||
<style module> | ||
/** | ||
Openverse Logo Loader | ||
|
||
┌───┐ ┌───┐ ┌──────┐ | ||
│ 1 │ │ 2 │ │ 3 │ | ||
└───┘ └───┘ └──────┘ | ||
┌───┐ ┌───┐ ┌──────┐ | ||
│ 4 │ │ 5 │ │ 6 │ | ||
└───┘ └───┘ └──────┘ | ||
|
||
The logo, in goofy ascii! | ||
|
||
The loader keyframe animation steps (the defined percentage blocks within each animation) | ||
are tightly coupled to the cubic bezier easing curve of the full circles (parts 3 and 6), | ||
as they move from right to left and back to their starting positions. | ||
|
||
This easing curve determines at which point in the animation timeline the sphere will be | ||
in a particular position. Changing it or the keyframe step percentages will break the animation. | ||
|
||
Things you *can* change are: | ||
|
||
1. The duration of the animation | ||
2. The easing of parts 1,2,4, and 5 (this is the animation of the half circles shifting in from | ||
the right after the circles 'sweep' by) | ||
3. The size of the logo (has no bearing on the animation despite the 'magic' pixel values in our | ||
CSS vars) | ||
|
||
Things you can't change, without making several additional changes: | ||
|
||
1. The --halfcircle-shift and --circle-shift variable values | ||
2. The easing curve of the end-shift animation on parts 3 and 6 | ||
3. The keyframe percentage steps (think of these as points on a timeline of the animation, with 0% | ||
being the start and 100% being the end) | ||
*/ | ||
|
||
.loading { | ||
--halfcircle-shift: 7px; | ||
--circle-shift: -20.1px; | ||
} | ||
|
||
.loading > [data-logo-part-1], | ||
.loading > [data-logo-part-4] { | ||
animation: start-shift 2s infinite ease-in-out; | ||
} | ||
|
||
.loading > [data-logo-part-2], | ||
.loading > [data-logo-part-5] { | ||
animation: middle-shift 2s infinite ease-in-out; | ||
} | ||
|
||
.loading > [data-logo-part-3], | ||
.loading > [data-logo-part-6] { | ||
/** | ||
Changing this cubic-bezier will break the animation and require changes to the | ||
start-shift and middle-shift keyframe steps. | ||
*/ | ||
animation: end-shift 2s infinite cubic-bezier(0.79, 0.14, 0.15, 0.86); | ||
} | ||
|
||
/* Stagger the second row so it animates slightly after the first */ | ||
.loading > [data-logo-part-4], | ||
.loading > [data-logo-part-5], | ||
.loading > [data-logo-part-6] { | ||
animation-delay: 0.35s; | ||
} | ||
|
||
@keyframes start-shift { | ||
0% { | ||
visibility: visible; | ||
transform: translateX(0); | ||
} | ||
50% { | ||
visibility: hidden; | ||
transform: translateX(0); | ||
} | ||
73% { | ||
visibility: hidden; | ||
transform: translateX(var(--halfcircle-shift)); | ||
} | ||
79% { | ||
visibility: visible; | ||
transform: translateX(0); | ||
} | ||
} | ||
|
||
@keyframes middle-shift { | ||
0% { | ||
visibility: visible; | ||
transform: translateX(0); | ||
} | ||
30% { | ||
visibility: hidden; | ||
transform: translateX(0); | ||
} | ||
78.6% { | ||
visibility: hidden; | ||
transform: translateX(var(--halfcircle-shift)); | ||
} | ||
86% { | ||
visibility: visible; | ||
transform: translateX(0); | ||
} | ||
} | ||
|
||
/** | ||
The stops at 20% and 40% here are used to simulate a 'pause' whenever the spheres | ||
reach the start or end of the logo. CSS's built in animation-delay property only | ||
works on the first cycle of an animation, not between subsequent runs. | ||
*/ | ||
@keyframes end-shift { | ||
0%, | ||
20% { | ||
transform: translateX(0); | ||
} | ||
40%, | ||
50% { | ||
transform: translateX(var(--circle-shift)); | ||
} | ||
} | ||
</style> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import VLogoLoader from '~/components/VLogoLoader/VLogoLoader' | ||
|
||
export default { | ||
component: VLogoLoader, | ||
title: 'Components/VLogoLoader', | ||
argTypes: { | ||
status: { | ||
default: 'idle', | ||
options: ['loading', 'idle'], | ||
control: { type: 'radio' }, | ||
}, | ||
}, | ||
} | ||
|
||
const SimpleLoaderStory = (_, { argTypes }) => ({ | ||
props: Object.keys(argTypes), | ||
template: ` | ||
<div> | ||
<VLogoLoader v-bind="$props" /> | ||
<p>Remember to test with the <code class="inline p-0"><pre class="inline bg-light-gray p-0 m-0 leading-normal">prefers-reduced-motion</pre></code> media query. You can find instructions for doing so in Firefox <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion#user_preferences">here.</a></p> | ||
</div> | ||
`, | ||
components: { VLogoLoader }, | ||
setup() {}, | ||
}) | ||
|
||
export const Default = SimpleLoaderStory.bind({}) | ||
Default.args = { | ||
status: 'idle', | ||
} | ||
|
||
export const Loading = SimpleLoaderStory.bind({}) | ||
Loading.args = { | ||
status: 'loading', | ||
loadingLabel: 'Loading images', | ||
} | ||
|
||
const LinkWrappedLoaderStory = (_, { argTypes }) => ({ | ||
props: Object.keys(argTypes), | ||
template: ` | ||
<a class="focus:outline-none focus:ring focus:ring-offset-2 focus:ring-pink inline-block" href='https://wordpress.org/openverse'> | ||
<VLogoLoader v-bind="$props" /> | ||
</a> | ||
`, | ||
components: { VLogoLoader }, | ||
setup() {}, | ||
}) | ||
|
||
export const LinkWrapped = LinkWrappedLoaderStory.bind({}) | ||
LinkWrappedLoaderStory.args = { | ||
status: 'idle', | ||
loadingLabel: 'Loading images', | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,33 @@ | ||||||||||||||||||
import { | ||||||||||||||||||
isRef, | ||||||||||||||||||
watch, | ||||||||||||||||||
onMounted, | ||||||||||||||||||
onBeforeUnmount, | ||||||||||||||||||
unref, | ||||||||||||||||||
} from '@nuxtjs/composition-api' | ||||||||||||||||||
|
||||||||||||||||||
/** | ||||||||||||||||||
* Use an event listener. Shamelessly stolen from https://logaretm.com/blog/my-favorite-5-vuejs-composables/#useeventlistener | ||||||||||||||||||
zackkrida marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
* | ||||||||||||||||||
* @param {import('@nuxtjs/composition-api').Ref<EventTarget | null> | EventTarget} target The target can be a reactive ref which adds flexibility | ||||||||||||||||||
* @param {string} event | ||||||||||||||||||
* @param {(e: Event) => void} handler | ||||||||||||||||||
*/ | ||||||||||||||||||
export function useEventListener(target, event, handler) { | ||||||||||||||||||
// if its a reactive ref, use a watcher | ||||||||||||||||||
if (isRef(target)) { | ||||||||||||||||||
watch(target, (value, oldValue) => { | ||||||||||||||||||
oldValue?.removeEventListener(event, handler) | ||||||||||||||||||
value?.addEventListener(event, handler) | ||||||||||||||||||
}) | ||||||||||||||||||
Comment on lines
+19
to
+22
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. Would it be better to use
Suggested change
I'm not 100% sure there is a difference, I'll do some fiddling to test this out, but i think the watcher may never run on component dismount to remove the old listener but invalidate will run. 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. I do like your take on it! 😄 |
||||||||||||||||||
} else { | ||||||||||||||||||
// otherwise use the mounted hook | ||||||||||||||||||
onMounted(() => { | ||||||||||||||||||
target.addEventListener(event, handler) | ||||||||||||||||||
}) | ||||||||||||||||||
} | ||||||||||||||||||
// clean it up | ||||||||||||||||||
onBeforeUnmount(() => { | ||||||||||||||||||
unref(target)?.removeEventListener(event, handler) | ||||||||||||||||||
}) | ||||||||||||||||||
Comment on lines
+30
to
+32
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. Oh wait, I see, I guess |
||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import { render, screen } from '@testing-library/vue' | ||
import VLogoLoader from '~/components/VLogoLoader/VLogoLoader.vue' | ||
import { useReducedMotion } from '~/composables/use-media-query' | ||
|
||
jest.mock('~/utils/warn', () => ({ | ||
warn: jest.fn(), | ||
})) | ||
|
||
jest.mock('~/composables/use-media-query', () => ({ | ||
useReducedMotion: jest.fn(), | ||
})) | ||
|
||
describe('VLogoLoader', () => { | ||
it('should render the logo', () => { | ||
render(VLogoLoader) | ||
const element = screen.getByTestId('logo-loader') | ||
expect(element).toBeInTheDocument() | ||
}) | ||
|
||
describe('accessibility', () => { | ||
it('should render differently when the user prefers reduced motion', () => { | ||
useReducedMotion.mockImplementation(() => true) | ||
|
||
render(VLogoLoader, { | ||
props: { status: 'loading' }, | ||
}) | ||
const element = screen.getByTestId('logo-loader') | ||
expect(element).toHaveAttribute('data-prefers-reduced-motion', 'true') | ||
}) | ||
it('should show the default loading style when no motion preference is set', () => { | ||
useReducedMotion.mockImplementation(() => false) | ||
|
||
render(VLogoLoader, { | ||
props: { status: 'loading' }, | ||
}) | ||
const element = screen.getByTestId('logo-loader') | ||
expect(element).not.toHaveAttribute('data-prefers-reduced-motion') | ||
}) | ||
}) | ||
}) |
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.
The default window is being used in several places now to ensure that composables work correctly with SSR. Is there a way to extract this code somehow?
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.
I was thinking it could be the default value in the hook, at least. Not sure if it'll work, I'll test
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.
I don't think you can use a default value as it'll try to evaluate
window
even when it's undefined and throw an error.