Skip to content

Commit

Permalink
refactor(avatar): test and core code (#322)
Browse files Browse the repository at this point in the history
* refactor: add test

* chore: update avatar

---------

Co-authored-by: productdevbook <hi@productdevbook.com>
  • Loading branch information
Cr0zy07 and productdevbook authored Sep 1, 2023
1 parent 4fe3a05 commit 85a0206
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 42 deletions.
151 changes: 151 additions & 0 deletions packages/components/avatar/src/avatar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import type { VueWrapper } from '@vue/test-utils'
import { axe } from 'vitest-axe'
import { OkuAvatar, OkuAvatarFallback, OkuAvatarImage } from './'

const AVATAR_TEST_ID = 'avatar-test'
const FALLBACK_TEXT = 'AB'
const IMAGE_ALT_TEXT = 'Fake Avatar'
const DELAY = 300

enableAutoUnmount(afterEach)

describe('given an Avatar with fallback and no image', () => {
let wrapper: VueWrapper

beforeEach(() => {
wrapper = mount({
components: {
OkuAvatar,
OkuAvatarFallback,
},
setup() {
return {
AVATAR_TEST_ID,
FALLBACK_TEXT,
}
},
template: `
<OkuAvatar :data-testid="AVATAR_TEST_ID">
<OkuAvatarFallback :delayMs="600">{{ FALLBACK_TEXT }}</OkuAvatarFallback>
</OkuAvatar>
`,
})
})

/**
* @vitest-environment jsdom
*/

it('should have no accessibility violations', async () => {
// https://github.com/capricorn86/happy-dom/issues/978
// TODO:77 https://github.com/chaance/vitest-axe/issues/7
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
expect(await axe(wrapper.element)).toHaveNoViolations()
})
})

describe('given an Avatar with fallback and a working image', () => {
let wrapper: VueWrapper
const originalGlobalImage = window.Image

beforeAll(() => {
(window.Image as any) = class MockImage {
onload: () => void = () => {}
src: string = ''
constructor() {
setTimeout(() => {
this.onload()
}, DELAY)
return this
}
}
})

afterAll(() => {
window.Image = originalGlobalImage
})

beforeEach(() => {
wrapper = mount({
components: {
OkuAvatar,
OkuAvatarFallback,
OkuAvatarImage,
},
setup() {
return {
AVATAR_TEST_ID,
FALLBACK_TEXT,
IMAGE_ALT_TEXT,
}
},
template: `
<OkuAvatar :data-testid="AVATAR_TEST_ID">
<OkuAvatarFallback>{{ FALLBACK_TEXT }}</OkuAvatarFallback>
<OkuAvatarImage src="https://picsum.photos/id/1005/400/400" :alt="IMAGE_ALT_TEXT" />
</OkuAvatar>
`,
})
})

it('should render the fallback initially', () => {
expect(wrapper.text()).toContain(FALLBACK_TEXT)
})

it('should not render the image initially', () => {
expect(wrapper.find('img').exists()).toBe(false)
})

it('should render the image after it has loaded', async () => {
await new Promise(resolve => setTimeout(resolve, DELAY))

expect(wrapper.find('img').exists()).toBe(true)
})

it('should have alt text on the image', async () => {
await new Promise(resolve => setTimeout(resolve, DELAY))

expect(wrapper.find('img').attributes('alt')).toBe(IMAGE_ALT_TEXT)
})
})

describe('given an Avatar with fallback and delayed render', () => {
let wrapper: VueWrapper

beforeEach(() => {
wrapper = mount({
components: {
OkuAvatar,
OkuAvatarFallback,
},
setup() {
return {
AVATAR_TEST_ID,
FALLBACK_TEXT,
IMAGE_ALT_TEXT,
DELAY,
}
},
template: `
<OkuAvatar :data-testid="AVATAR_TEST_ID">
<OkuAvatarFallback :delayMs="DELAY">{{ FALLBACK_TEXT }}</OkuAvatarFallback>
</OkuAvatar>
`,
})
})

it('should not render a fallback immediately', () => {
expect(wrapper.text()).not.toContain(FALLBACK_TEXT)
})

it('should render a fallback after the delay', async () => {
expect(wrapper.text()).not.toContain(FALLBACK_TEXT)

await new Promise(resolve => setTimeout(resolve, DELAY))

expect(wrapper.text()).toContain(FALLBACK_TEXT)
})
})
12 changes: 5 additions & 7 deletions packages/components/avatar/src/avatar.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Ref } from 'vue'
import { defineComponent, h, ref } from 'vue'
import type { ElementType, PrimitiveProps } from '@oku-ui/primitive'
import { Primitive, primitiveProps } from '@oku-ui/primitive'
import { createProvideScope } from '@oku-ui/provide'
import { useForwardRef } from '@oku-ui/use-composable'
import type { ScopeAvatar } from './utils'
import { scopeAvatarProps } from './utils'

const AVATAR_NAME = 'OkuAvatar'
Expand All @@ -12,7 +12,7 @@ export const [createAvatarProvide, createAvatarScope] = createProvideScope(AVATA
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'

type AvatarProvideValue = {
imageLoadingStatus: ImageLoadingStatus
imageLoadingStatus: Ref<ImageLoadingStatus>
onImageLoadingStatusChange(status: ImageLoadingStatus): void
}

Expand All @@ -39,23 +39,21 @@ const avatar = defineComponent({
...primitiveProps,
},
setup(props, { attrs, slots }) {
const { ...avatarProps } = attrs as AvatarIntrinsicElement

const forwardedRef = useForwardRef()

const imageLoadingStatus = ref<ImageLoadingStatus>('idle')

avatarProvider({
scope: props.scopeOkuAvatar,
imageLoadingStatus: imageLoadingStatus.value,
imageLoadingStatus,
onImageLoadingStatusChange: (status: ImageLoadingStatus) => {
imageLoadingStatus.value = status
},
})

const originalReturn = () => h(
Primitive.span, {
...avatarProps,
...attrs,
ref: forwardedRef,
asChild: props.asChild,
},
Expand All @@ -70,5 +68,5 @@ const avatar = defineComponent({
// TODO: https://github.com/vuejs/core/pull/7444 after delete
export const OkuAvatar = avatar as typeof avatar &
(new () => {
$props: ScopeAvatar<Partial<AvatarElement>>
$props: Partial<AvatarElement>
})
54 changes: 30 additions & 24 deletions packages/components/avatar/src/avatarFallback.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineComponent, h, onMounted, ref, toRef, watchEffect } from 'vue'
import type { PropType } from 'vue'
import { defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue'
import type { ElementType, PrimitiveProps } from '@oku-ui/primitive'
import { Primitive, primitiveProps } from '@oku-ui/primitive'
import { useForwardRef } from '@oku-ui/use-composable'
Expand All @@ -18,7 +19,8 @@ export interface AvatarFallbackProps extends PrimitiveProps {
export const avatarFallbackProps = {
props: {
delayMs: {
type: Number,
type: Number as PropType<number | undefined>,
default: undefined,
required: false,
},
},
Expand All @@ -33,44 +35,48 @@ const avatarFallback = defineComponent({
...primitiveProps,
},
setup(props, { attrs, slots }) {
const delayMs = toRef(props, 'delayMs')
const { ...fallbackAttrs } = attrs as AvatarFallbackIntrinsicElement
const provide = useAvatarInject(FALLBACK_NAME, props.scopeOkuAvatar)
const canRender = ref(delayMs.value === undefined)
const canRender = ref(props.delayMs === undefined)

const forwardedRef = useForwardRef()

onMounted(() => {
if (delayMs.value === undefined)
canRender.value = true
else
canRender.value = false
})
let timerId: number | null = null

const clearTimer = () => {
if (timerId !== null) {
clearTimeout(timerId)
timerId = null
}
}

const setupTimer = () => {
clearTimer()
if (props.delayMs !== undefined) {
timerId = window.setTimeout(() => {
canRender.value = true
}, props.delayMs)
}
}

onMounted(() => setupTimer())

onMounted(() => {
watchEffect(() => {
if (delayMs.value !== undefined) {
const timerID = window.setTimeout(() => {
canRender.value = true
}, delayMs.value)
return () => window.clearTimeout(timerID)
}
})
onBeforeUnmount(() => {
clearTimer()
})

const originalReturn = () => {
return (canRender.value && (provide.imageLoadingStatus !== 'loaded'))
return (canRender.value && (provide.imageLoadingStatus.value !== 'loaded'))
? h(
Primitive.span, {
...fallbackAttrs,
...attrs,
ref: forwardedRef,
asChild: props.asChild,
asChild: false,
},
{
default: () => slots.default?.(),
},
)
: canRender.value
: null
}

return originalReturn
Expand Down
16 changes: 5 additions & 11 deletions packages/components/avatar/src/avatarImage.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { defineComponent, h, onMounted, toRef, watch } from 'vue'
import { defineComponent, h, toRef, watchEffect } from 'vue'
import type { ElementType, PrimitiveProps } from '@oku-ui/primitive'
import { Primitive, primitiveProps } from '@oku-ui/primitive'
import type { Scope } from '@oku-ui/provide'
import { useCallbackRef, useForwardRef } from '@oku-ui/use-composable'
import type { ImageLoadingStatus, ScopeAvatar } from './utils'
import type { ImageLoadingStatus } from './utils'
import { scopeAvatarProps, useImageLoadingStatus } from './utils'
import { useAvatarInject } from './avatar'

Expand Down Expand Up @@ -44,7 +44,6 @@ const avatarImage = defineComponent({
emits: avatarImageProps.emits,
setup(props, { attrs, slots, emit }) {
const src = toRef(props, 'src')
const { ...imageAttrs } = attrs as AvatarImageIntrinsicElement
const inject = useAvatarInject(IMAGE_NAME, props.scopeOkuAvatar)

const forwardedRef = useForwardRef()
Expand All @@ -56,21 +55,16 @@ const avatarImage = defineComponent({
inject.onImageLoadingStatusChange(status)
})

onMounted(() => {
watchEffect(() => {
if (imageLoadingStatus.value !== 'idle')
handleLoadingStatusChange(imageLoadingStatus.value)
})

watch(imageLoadingStatus, (newValue) => {
if (newValue !== 'idle')
handleLoadingStatusChange(newValue)
})

const originalReturn = () => imageLoadingStatus.value === 'loaded'
? h(
Primitive.img, {
asChild: props.asChild,
...imageAttrs,
...attrs,
src: src.value,
ref: forwardedRef,
},
Expand All @@ -87,5 +81,5 @@ const avatarImage = defineComponent({
// TODO: https://github.com/vuejs/core/pull/7444 after delete
export const OkuAvatarImage = avatarImage as typeof avatarImage &
(new () => {
$props: ScopeAvatar<Partial<AvatarImageElement>>
$props: Partial<AvatarImageElement>
})

0 comments on commit 85a0206

Please sign in to comment.