Skip to content

Commit 2eeb656

Browse files
committed
Refactor of all animation-related components and mobile navigation to use a central store for eventing instead of an ad hoc events systems. Implements pause / resume when the mobile splash screen opens, maintains state and checks prefers-reduced motion to pause animations when the user does not want them.
1 parent 46832d1 commit 2eeb656

19 files changed

Lines changed: 882 additions & 458 deletions

File tree

‎eslint.config.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable */
12
import path from 'node:path'
23
import { fileURLToPath } from 'node:url'
34
import eslint from '@eslint/js'
@@ -13,6 +14,7 @@ import noHtmlElementAssertionsRule from './test/eslint/no-html-element-assertion
1314
import noQuerySelectorOutsideSelectorsRule from './test/eslint/no-query-selector-outside-selectors-rule'
1415

1516
const level = 'error'
17+
const astroParser = (astroPlugin as unknown as { parser: typeof tsPlugin.parser }).parser
1618
const tsconfigRootDir = path.dirname(fileURLToPath(import.meta.url))
1719

1820
const errorDefinitionIgnores = [
@@ -239,6 +241,7 @@ export default [
239241
{
240242
files: [
241243
'integrations/**/*',
244+
'src/integrations/**/*',
242245
'scripts/**/*.ts',
243246
'src/components/**/server/**/*',
244247
'src/layouts/**/server/**/*',
@@ -272,6 +275,16 @@ export default [
272275
'no-restricted-syntax': createRestrictedSyntaxRule({ allowTestError: true }),
273276
},
274277
},
278+
{
279+
files: [
280+
'src/integrations/**/*.spec.ts',
281+
'src/integrations/**/__tests__/**/*.ts',
282+
],
283+
ignores: errorDefinitionIgnores,
284+
rules: {
285+
'no-restricted-syntax': createRestrictedSyntaxRule({ allowTestError: true }),
286+
},
287+
},
275288
/**
276289
* =================================================================================================
277290
*
@@ -304,7 +317,7 @@ export default [
304317
'src/pages/**/*.astro',
305318
],
306319
languageOptions: {
307-
parser: astroPlugin.parser,
320+
parser: astroParser,
308321
parserOptions: {
309322
parser: tsPlugin.parser,
310323
project: './tsconfig.json',
@@ -667,6 +680,8 @@ export default [
667680
},
668681
{
669682
files: [
683+
/** Test case for the utility is an exception to the restricted paths rule */
684+
'src/components/scripts/utils/__tests__/siteUrlClient.spec.ts',
670685
/** Environment file in src/pages/api is an exception to the restricted paths rule */
671686
'src/pages/api/_environment/environmentApi.ts',
672687
'src/pages/api/_logger/index.ts',

‎src/components/Animations/Computers/client/__tests__/index.spec.ts‎

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@ const getComputersModule = () => {
5050

5151
const addScriptBreadcrumbMock = vi.hoisted(() => vi.fn())
5252
const handleScriptErrorMock = vi.hoisted(() => vi.fn())
53-
const onAnimationEventMock = vi.hoisted(() => vi.fn())
54-
55-
const lifecycleEvents = vi.hoisted(() => ({
56-
OVERLAY_OPENED: 'overlay-opened',
57-
OVERLAY_CLOSED: 'overlay-closed',
58-
}))
53+
const createAnimationControllerMock = vi.hoisted(() =>
54+
vi.fn(() => ({
55+
requestPlay: vi.fn(),
56+
requestPause: vi.fn(),
57+
clearUserPreference: vi.fn(),
58+
destroy: vi.fn(),
59+
}))
60+
)
5961

6062
vi.mock('@components/scripts/errors', () => ({
6163
addScriptBreadcrumb: addScriptBreadcrumbMock,
@@ -65,9 +67,8 @@ vi.mock('@components/scripts/errors/handler', () => ({
6567
handleScriptError: handleScriptErrorMock,
6668
}))
6769

68-
vi.mock('@components/scripts/events', () => ({
69-
AnimationLifecycleEvent: lifecycleEvents,
70-
onAnimationEvent: onAnimationEventMock,
70+
vi.mock('@components/scripts/store', () => ({
71+
createAnimationController: createAnimationControllerMock,
7172
}))
7273

7374
vi.mock('gsap', () => ({
@@ -141,8 +142,6 @@ describe('ComputersAnimation web component module', () => {
141142

142143
describe('ComputersAnimationElement', () => {
143144
it('initializes the GSAP timeline when hero markup exists', async () => {
144-
const { overlayOpenedCleanup, overlayClosedCleanup } = mockOverlayLifecycle()
145-
146145
await renderComputersAnimation(async ({ element }) => {
147146
element.initialize()
148147

@@ -152,22 +151,25 @@ describe('ComputersAnimationElement', () => {
152151
'.monitorBottom',
153152
expect.objectContaining({ transformOrigin: '50% 100%' }),
154153
)
155-
expect(onAnimationEventMock).toHaveBeenNthCalledWith(1, lifecycleEvents.OVERLAY_OPENED, expect.any(Function))
156-
expect(onAnimationEventMock).toHaveBeenNthCalledWith(2, lifecycleEvents.OVERLAY_CLOSED, expect.any(Function))
154+
expect(createAnimationControllerMock).toHaveBeenCalledWith(
155+
expect.objectContaining({
156+
animationId: 'computers-animation',
157+
debugLabel: 'ComputersAnimationElement',
158+
onPause: expect.any(Function),
159+
onPlay: expect.any(Function),
160+
}),
161+
)
157162
expect(getBreadcrumbOperations()).toEqual(expect.arrayContaining(['initialize', 'startAnimation']))
158-
expect(overlayOpenedCleanup).not.toHaveBeenCalled()
159-
expect(overlayClosedCleanup).not.toHaveBeenCalled()
160163
})
161164
})
162165

163-
it('pauses and resumes when overlay lifecycle events fire', async () => {
164-
mockOverlayLifecycle()
165-
166+
it('pauses and resumes when lifecycle controller callbacks run', async () => {
166167
await renderComputersAnimation(async ({ element }) => {
167168
element.initialize()
168169

169-
const pauseHandler = onAnimationEventMock.mock.calls[0]?.[1] as (() => void) | undefined
170-
const resumeHandler = onAnimationEventMock.mock.calls[1]?.[1] as (() => void) | undefined
170+
const controllerArgs = createAnimationControllerMock.mock.calls[0]?.[0]
171+
const pauseHandler = controllerArgs?.onPause
172+
const resumeHandler = controllerArgs?.onPlay
171173

172174
pauseHandler?.()
173175
resumeHandler?.()
@@ -179,14 +181,12 @@ describe('ComputersAnimationElement', () => {
179181
})
180182

181183
it('tears down resources on disconnect', async () => {
182-
const { overlayOpenedCleanup, overlayClosedCleanup } = mockOverlayLifecycle()
183-
184184
await renderComputersAnimation(async ({ element }) => {
185185
element.initialize()
186186
element.disconnectedCallback()
187187

188-
expect(overlayOpenedCleanup).toHaveBeenCalled()
189-
expect(overlayClosedCleanup).toHaveBeenCalled()
188+
const controllerHandle = getLastControllerHandle()
189+
expect(controllerHandle?.destroy).toHaveBeenCalled()
190190
expect(timelineMock.kill).toHaveBeenCalled()
191191
expect(getBreadcrumbOperations()).toContain('teardown')
192192
})
@@ -274,13 +274,6 @@ const renderComputersAnimation = async (
274274
})
275275
}
276276

277-
const mockOverlayLifecycle = () => {
278-
const overlayOpenedCleanup = vi.fn()
279-
const overlayClosedCleanup = vi.fn()
280-
onAnimationEventMock.mockReturnValueOnce(overlayOpenedCleanup).mockReturnValueOnce(overlayClosedCleanup)
281-
return { overlayOpenedCleanup, overlayClosedCleanup }
282-
}
283-
284277
const getBreadcrumbOperations = (): string[] => {
285278
return addScriptBreadcrumbMock.mock.calls
286279
.map(([context]) => context?.operation)
@@ -323,3 +316,8 @@ function createTimelineMock() {
323316
}
324317

325318
const generateUniqueTagName = (): string => `computers-animation-${Math.random().toString(36).slice(2)}`
319+
320+
const getLastControllerHandle = () =>
321+
createAnimationControllerMock.mock.results.at(-1)?.value as
322+
| ReturnType<typeof createAnimationControllerMock>
323+
| undefined

‎src/components/Animations/Computers/client/index.ts‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { LitElement } from 'lit'
22
import { gsap } from 'gsap'
33
import { addScriptBreadcrumb } from '@components/scripts/errors'
44
import { handleScriptError } from '@components/scripts/errors/handler'
5-
import { AnimationLifecycleEvent, onAnimationEvent } from '@components/scripts/events'
5+
import {
6+
createAnimationController,
7+
type AnimationControllerHandle,
8+
} from '@components/scripts/store'
69
import { defineCustomElement } from '@components/scripts/utils'
710
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
811

@@ -36,9 +39,8 @@ const Anticipate = {
3639

3740
export class ComputersAnimationElement extends LitElement {
3841
private timeline: Timeline | null = null
39-
private overlayOpenedCleanup: (() => void) | undefined
40-
private overlayClosedCleanup: (() => void) | undefined
4142
private initialized = false
43+
private animationController: AnimationControllerHandle | undefined
4244
private readonly domReadyHandler = () => {
4345
document.removeEventListener('DOMContentLoaded', this.domReadyHandler)
4446
this.initialize()
@@ -76,11 +78,15 @@ export class ComputersAnimationElement extends LitElement {
7678

7779
try {
7880
this.startAnimation()
79-
this.overlayOpenedCleanup = onAnimationEvent(AnimationLifecycleEvent.OVERLAY_OPENED, () => {
80-
this.pause()
81-
})
82-
this.overlayClosedCleanup = onAnimationEvent(AnimationLifecycleEvent.OVERLAY_CLOSED, () => {
83-
this.resume()
81+
this.animationController = createAnimationController({
82+
animationId: 'computers-animation',
83+
debugLabel: SCRIPT_NAME,
84+
onPause: () => {
85+
this.pause()
86+
},
87+
onPlay: () => {
88+
this.resume()
89+
},
8490
})
8591
this.initialized = true
8692
} catch (error) {
@@ -117,10 +123,8 @@ export class ComputersAnimationElement extends LitElement {
117123
addScriptBreadcrumb(context)
118124

119125
try {
120-
this.overlayOpenedCleanup?.()
121-
this.overlayClosedCleanup?.()
122-
this.overlayOpenedCleanup = undefined
123-
this.overlayClosedCleanup = undefined
126+
this.animationController?.destroy()
127+
this.animationController = undefined
124128

125129
if (this.timeline) {
126130
this.timeline.kill()

‎src/components/Carousel/client/__tests__/index.spec.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ import type { WebComponentModule } from '@components/scripts/@types/webComponent
99
import { executeRender } from '@test/unit/helpers/litRuntime'
1010
import sampleCollection from '@components/Carousel/client/__fixtures__/collection.fixture'
1111

12+
const createAnimationControllerMock = vi.fn(() => ({
13+
requestPlay: vi.fn(),
14+
requestPause: vi.fn(),
15+
clearUserPreference: vi.fn(),
16+
destroy: vi.fn(),
17+
}))
18+
19+
vi.mock('@components/scripts/store', () => ({
20+
createAnimationController: createAnimationControllerMock,
21+
}))
22+
1223
type CarouselComponentModule = WebComponentModule<CarouselElement>
1324
type ConcreteCarouselProps = Required<CarouselProps<'articles'>>
1425

@@ -79,6 +90,7 @@ const renderCarousel = async (
7990
describe('Carousel component (server output)', () => {
8091
beforeEach(() => {
8192
vi.clearAllMocks()
93+
createAnimationControllerMock.mockClear()
8294
})
8395

8496
it('renders the provided title and respects the requested limit', async () => {
@@ -130,4 +142,10 @@ describe('Carousel component (server output)', () => {
130142
expect(new Set(cardTitles)).toEqual(new Set(['Article Four', 'Article Three', 'Article One']))
131143
}, { variant: 'random', limit: 3 })
132144
})
145+
146+
it('registers an animation lifecycle controller', async () => {
147+
await renderCarousel(() => {
148+
expect(createAnimationControllerMock).toHaveBeenCalled()
149+
})
150+
})
133151
})

‎src/components/Carousel/client/index.ts‎

Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { addButtonEventListeners } from '@components/scripts/elementListeners'
44
import { addScriptBreadcrumb, ClientScriptError } from '@components/scripts/errors'
55
import { handleScriptError } from '@components/scripts/errors/handler'
66
import { defineCustomElement } from '@components/scripts/utils'
7+
import {
8+
createAnimationController,
9+
type AnimationControllerHandle,
10+
} from '@components/scripts/store'
711
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
812

913
const SCRIPT_NAME = 'CarouselElement'
@@ -32,15 +36,22 @@ export class CarouselElement extends HTMLElement {
3236
private prevBtn: HTMLButtonElement | null = null
3337
private nextBtn: HTMLButtonElement | null = null
3438
private initialized = false
39+
private animationController: AnimationControllerHandle | undefined
40+
private readonly animationInstanceId: string
3541
private readonly domReadyHandler = () => {
3642
document.removeEventListener('DOMContentLoaded', this.domReadyHandler)
3743
this.initialize()
3844
}
3945
private readonly autoplayPlayHandler = () => this.setAutoplayState('playing')
4046
private readonly autoplayStopHandler = () => this.setAutoplayState('paused')
4147

42-
private static readonly instances = new Set<CarouselElement>()
43-
private static visibilityHandler: (() => void) | undefined
48+
private static instanceCounter = 0
49+
50+
constructor() {
51+
super()
52+
CarouselElement.instanceCounter += 1
53+
this.animationInstanceId = `carousel-${CarouselElement.instanceCounter}`
54+
}
4455

4556
connectedCallback(): void {
4657
if (typeof document === 'undefined') return
@@ -49,7 +60,6 @@ export class CarouselElement extends HTMLElement {
4960
addScriptBreadcrumb(context)
5061

5162
try {
52-
CarouselElement.registerInstance(this)
5363
if (document.readyState === 'loading') {
5464
document.addEventListener('DOMContentLoaded', this.domReadyHandler, { once: true })
5565
return
@@ -64,7 +74,6 @@ export class CarouselElement extends HTMLElement {
6474
if (typeof document === 'undefined') return
6575
document.removeEventListener('DOMContentLoaded', this.domReadyHandler)
6676
this.teardown()
67-
CarouselElement.unregisterInstance(this)
6877
}
6978

7079
private initialize(): void {
@@ -101,6 +110,7 @@ export class CarouselElement extends HTMLElement {
101110

102111
this.initialized = true
103112
this.setAttribute('data-carousel-ready', 'true')
113+
this.registerAnimationLifecycle()
104114
} catch (error) {
105115
this.teardown()
106116
handleScriptError(error, context)
@@ -126,6 +136,8 @@ export class CarouselElement extends HTMLElement {
126136
this.initialized = false
127137
this.removeAttribute('data-carousel-ready')
128138
this.removeAttribute('data-carousel-autoplay')
139+
this.animationController?.destroy()
140+
this.animationController = undefined
129141

130142
if (this.dotsContainer) {
131143
this.dotsContainer.innerHTML = ''
@@ -257,39 +269,19 @@ export class CarouselElement extends HTMLElement {
257269
this.setAttribute('data-carousel-autoplay', state)
258270
}
259271

260-
private static registerInstance(instance: CarouselElement): void {
261-
CarouselElement.instances.add(instance)
262-
CarouselElement.ensureVisibilityHandler()
263-
}
264-
265-
private static unregisterInstance(instance: CarouselElement): void {
266-
CarouselElement.instances.delete(instance)
267-
if (CarouselElement.instances.size === 0) {
268-
CarouselElement.cleanupVisibilityHandler()
269-
}
270-
}
271-
272-
private static ensureVisibilityHandler(): void {
273-
if (typeof document === 'undefined') return
274-
if (CarouselElement.visibilityHandler) return
275-
276-
CarouselElement.visibilityHandler = () => {
277-
if (document.hidden) {
278-
CarouselElement.instances.forEach(instance => instance.pause())
279-
} else {
280-
CarouselElement.instances.forEach(instance => instance.resume())
281-
}
282-
}
283-
284-
document.addEventListener('visibilitychange', CarouselElement.visibilityHandler)
285-
}
286-
287-
private static cleanupVisibilityHandler(): void {
288-
if (typeof document === 'undefined') return
289-
if (!CarouselElement.visibilityHandler) return
290-
291-
document.removeEventListener('visibilitychange', CarouselElement.visibilityHandler)
292-
CarouselElement.visibilityHandler = undefined
272+
private registerAnimationLifecycle(): void {
273+
if (this.animationController) return
274+
this.animationController = createAnimationController({
275+
animationId: 'carousel',
276+
instanceId: this.animationInstanceId,
277+
debugLabel: SCRIPT_NAME,
278+
onPlay: () => {
279+
this.resume()
280+
},
281+
onPause: () => {
282+
this.pause()
283+
},
284+
})
293285
}
294286
}
295287

0 commit comments

Comments
 (0)