Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 107 additions & 15 deletions packages/design-system/src/composables/useIsVisible/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ const mockIntersectionObserver = () => {
observeMock,
unobserveMock,
disconnectMock,
callback: (args: unknown[], fastForward = 0) => {
const observerMock = window.IntersectionObserver as any
observerMock.mock.calls[0][0](args, observerMock.mock.instances[0])
instanceCount: () => (window.IntersectionObserver as any).mock.calls.length,
callback: (entries: unknown[], fastForward = 0) => {
;(window.IntersectionObserver as any).mock.calls.at(-1)[0](entries)
vi.advanceTimersByTime(fastForward)
}
}
Expand All @@ -34,8 +34,10 @@ const mockIntersectionObserver = () => {
return { enable, disable }
}

const createWrapper = (options = {}) =>
mount(
const wrappers: Array<{ unmount: () => void }> = []

const createWrapper = (options = {}) => {
const wrapper = mount(
{
template: `<div ref="target">{{ isVisible }}</div>`,
setup: () => {
Expand All @@ -51,6 +53,39 @@ const createWrapper = (options = {}) =>
},
{ attachTo: document.body }
)
wrappers.push(wrapper)
return wrapper
}

const createListWrapper = (itemCount: number, options = {}) => {
const wrapper = mount(
{
template: `
<div>
<div v-for="item in items" :key="item" :ref="(el) => setTarget(item, el)" data-item>
{{ visibilities[item] }}
</div>
</div>`,
setup: () => {
const root = ref(document.createElement('div'))
const items = Array.from({ length: itemCount }, (_, index) => index)
const targets = items.map(() => ref<HTMLElement>())
const visibilities = items.map(
(item) => useIsVisible({ root, ...options, target: targets[item] }).isVisible
)

return {
items,
visibilities,
setTarget: (item: number, el: HTMLElement) => (targets[item].value = el)
}
}
},
{ attachTo: document.body }
)
wrappers.push(wrapper)
return wrapper
}

const observerOptions = (call = 0) => (window.IntersectionObserver as any).mock.calls[call][1]

Expand All @@ -60,6 +95,8 @@ describe('useIsVisible', () => {
})

afterEach(() => {
// unmounting releases the targets from the shared observer
wrappers.splice(0).forEach((wrapper) => wrapper.unmount())
vi.useRealTimers()
vi.restoreAllMocks()
document.body.innerHTML = ''
Expand Down Expand Up @@ -114,7 +151,7 @@ describe('useIsVisible', () => {
expect(observerOptions().root).toBeNull()
})

it('re-creates the observer and unobserves the old target if the root changes', async () => {
it('moves to another observer and unobserves the old target if the root changes', async () => {
const { unobserveMock, observeMock } = enableIntersectionObserver()
const firstRoot = document.createElement('div')
const secondRoot = document.createElement('div')
Expand All @@ -131,16 +168,49 @@ describe('useIsVisible', () => {
expect(unobserveMock).toHaveBeenCalledWith(wrapper.vm.$refs.target)
})

it('uses one shared observer for all targets of a list', async () => {
const { observeMock, instanceCount } = enableIntersectionObserver()
createListWrapper(10)
await nextTick()

expect(observeMock).toHaveBeenCalledTimes(10)
expect(instanceCount()).toBe(1)
})

it('creates one observer per rootMargin', async () => {
const { instanceCount } = enableIntersectionObserver()
const root = ref(document.createElement('div'))
createWrapper({ root, rootMargin: '0px' })
createWrapper({ root, rootMargin: '100px' })
await nextTick()

expect(instanceCount()).toBe(2)
})

it('only notifies the target the entry belongs to', async () => {
const { callback: observerCallback } = enableIntersectionObserver()
const wrapper = createListWrapper(2)
await nextTick()

const [first, second] = wrapper.findAll('[data-item]')
observerCallback([{ isIntersecting: true, target: first.element }])
await nextTick()

expect(first.text()).toBe('true')
expect(second.text()).toBe('false')
})

it('only shows once and then gets unobserved if the the composable is in the default show mode', async () => {
const { unobserveMock, callback: observerCallback } = enableIntersectionObserver()
const wrapper = createWrapper()

await nextTick()
expect((wrapper.vm.$refs.target as any).innerHTML).toBe('false')
const target = wrapper.vm.$refs.target as HTMLElement
expect(target.innerHTML).toBe('false')

observerCallback([{ isIntersecting: true, target: wrapper.vm.$refs.target }])
observerCallback([{ isIntersecting: true, target }])
await nextTick()
expect((wrapper.vm.$refs.target as any).innerHTML).toBe('true')
expect(target.innerHTML).toBe('true')
expect(unobserveMock).toHaveBeenCalledTimes(1)
})

Expand All @@ -149,21 +219,43 @@ describe('useIsVisible', () => {
const wrapper = createWrapper({ mode: 'showHide' })

await nextTick()
expect((wrapper.vm.$refs.target as any).innerHTML).toBe('false')
const target = wrapper.vm.$refs.target as HTMLElement
expect(target.innerHTML).toBe('false')

observerCallback([{ isIntersecting: true, target: wrapper.vm.$refs.target }])
observerCallback([{ isIntersecting: true, target }])
await nextTick()
expect((wrapper.vm.$refs.target as any).innerHTML).toBe('true')
expect(target.innerHTML).toBe('true')
expect(unobserveMock).toHaveBeenCalledTimes(0)

observerCallback([{ isIntersecting: false, target }])
await nextTick()
expect(target.innerHTML).toBe('false')
})

it('disconnects the observer before component gets unmounted', async () => {
const { disconnectMock } = enableIntersectionObserver()
it('only takes the last entry per target into account', async () => {
const { callback: observerCallback } = enableIntersectionObserver()
const wrapper = createWrapper()

expect(disconnectMock).toHaveBeenCalledTimes(0)
await nextTick()
const target = wrapper.vm.$refs.target as HTMLElement

observerCallback([
{ isIntersecting: false, target },
{ isIntersecting: true, target }
])
await nextTick()
expect(target.innerHTML).toBe('true')
})

it('unobserves the target before the component gets unmounted', async () => {
const { unobserveMock, disconnectMock } = enableIntersectionObserver()
const wrapper = createWrapper({ mode: 'showHide' })
await nextTick()

expect(unobserveMock).toHaveBeenCalledTimes(0)
wrapper.unmount()
expect(unobserveMock).toHaveBeenCalledTimes(1)
// the shared observer is disconnected once its last target is gone
expect(disconnectMock).toHaveBeenCalledTimes(1)
})
})
115 changes: 51 additions & 64 deletions packages/design-system/src/composables/useIsVisible/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Ref, onBeforeUnmount, ref, watch } from 'vue'
import { getObserver } from './intersectionObserver'

export const useIsVisible = ({
target,
Expand Down Expand Up @@ -31,90 +32,76 @@ export const useIsVisible = ({
const isSupported = window && 'IntersectionObserver' in window
if (!isSupported) {
return {
isVisible: ref(true)
isVisible: ref(true),
unobserve: (): void => undefined
}
}

const isVisible = ref(false)
let hasBeenVisible = false
let observer: IntersectionObserver
let observedRoot: Element | null
let observedTarget: Element = null
let observer: ReturnType<typeof getObserver> = null

const createObserver = (rootElement: Element | null) =>
new IntersectionObserver(
(
intersectionObserverEntries: IntersectionObserverEntry[],
intersectionObserver: IntersectionObserver
) => {
/**
* In some edge cases intersectionObserverEntries contains 2 entries with the first one having wrong rootBounds.
* This happens for some reason when the table is being re-sorted immediately after being rendered.
* Therefore we always check the last entry for isIntersecting.
*/
const entry = intersectionObserverEntries.at(-1)
const isIntersecting = entry.isIntersecting
function unobserve() {
if (!observedTarget) {
return
}
observer.unobserve(observedTarget, onIntersect)
observedTarget = null
observer = null
}

/**
* In mode `show` the target stays visible once it has been visible.
*/
if (mode === 'showHide' || !hasBeenVisible) {
isVisible.value = isIntersecting
}
function onIntersect(isIntersecting: boolean) {
/**
* In mode `show` the target stays visible once it has been visible.
*/
if (mode === 'showHide' || !hasBeenVisible) {
isVisible.value = isIntersecting
}

if (isIntersecting) {
hasBeenVisible = true
onVisibleCallback?.()
} else if (hasBeenVisible) {
onHiddenCallback?.()
}
if (isIntersecting) {
hasBeenVisible = true
onVisibleCallback?.()
} else if (hasBeenVisible) {
onHiddenCallback?.()
}

/**
* if given mode is `showHide` we need to keep the observation alive.
* the same applies if the caller wants to be notified about targets leaving the viewport.
*/
if (mode === 'showHide' || onHiddenCallback) {
return
}
/**
* if the mode is `show` which is the default, the implementation needs to unsubscribe the target from the observer
*/
if (!isIntersecting) {
return
}
/**
* if given mode is `showHide` we need to keep the observation alive.
* the same applies if the caller wants to be notified about targets leaving the viewport.
*/
if (mode === 'showHide' || onHiddenCallback) {
return
}
/**
* if the mode is `show` which is the default, the implementation needs to unsubscribe the target from the observer
*/
if (!isIntersecting) {
return
}

intersectionObserver.unobserve(entry.target)
},
{
root: rootElement,
rootMargin
}
)
unobserve()
}

/**
* The observer is created lazily, so it picks up the root and the target as soon
* as both are available, and gets re-created whenever the root changes.
* The observation starts lazily, so it picks up the root and the target as soon
* as both are available, and moves to another observer whenever the root changes.
*/
watch([target, root], ([targetElement, rootElement], [previousTargetElement]) => {
if (observer && previousTargetElement) {
observer.unobserve(previousTargetElement)
}

watch([target, root], ([targetElement, rootElement]) => {
unobserve()
if (!targetElement) {
return
}

if (!observer || rootElement !== observedRoot) {
observer?.disconnect()
observer = createObserver(rootElement ?? null)
observedRoot = rootElement
}

observer.observe(targetElement)
observedTarget = targetElement
observer = getObserver(rootElement ?? null, rootMargin)
observer.observe(targetElement, onIntersect)
})

onBeforeUnmount(() => observer?.disconnect())
onBeforeUnmount(unobserve)

return {
isVisible
isVisible,
unobserve
}
}
Loading