Skip to content

Commit 85af6c3

Browse files
feat: avatar
1 parent 6587886 commit 85af6c3

File tree

10 files changed

+279
-0
lines changed

10 files changed

+279
-0
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Ref } from "vue";
2+
import type { PrimitiveProps } from "../primitive/index.ts";
3+
import { createContext } from "../hooks/createContext.ts";
4+
5+
export interface AvatarProps extends PrimitiveProps { }
6+
7+
export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
8+
9+
export interface AvatarContext {
10+
imageLoadingStatus: Ref<ImageLoadingStatus>;
11+
onImageLoadingStatusChange(status: ImageLoadingStatus): void;
12+
};
13+
14+
export const [provideAvatarContext, useAvatarContext] = createContext<AvatarContext>('Avatar');
15+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue';
3+
import { Primitive } from '../primitive/index.ts'
4+
import { provideAvatarContext, type AvatarProps, type ImageLoadingStatus } from './Avatar.ts'
5+
6+
defineOptions({
7+
name: 'Avatar',
8+
})
9+
10+
withDefaults(defineProps<AvatarProps>(), {
11+
as: 'span',
12+
})
13+
14+
const imageLoadingStatus = shallowRef<ImageLoadingStatus>('idle')
15+
16+
provideAvatarContext({
17+
imageLoadingStatus,
18+
onImageLoadingStatusChange(newStatus) {
19+
imageLoadingStatus.value = newStatus
20+
}
21+
})
22+
</script>
23+
24+
<template>
25+
<Primitive
26+
:as="as"
27+
:as-child="asChild"
28+
>
29+
<slot />
30+
</Primitive>
31+
</template>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { PrimitiveProps } from "../primitive/index.ts";
2+
3+
export interface AvatarFallbackProps extends PrimitiveProps {
4+
delayMs?: number;
5+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script setup lang="ts">
2+
import { shallowRef, watchEffect } from 'vue';
3+
import { Primitive } from '../primitive/index.ts'
4+
import type { AvatarFallbackProps } from './AvatarFallback.ts';
5+
import { useAvatarContext } from './Avatar.ts';
6+
import { isClient } from '@vueuse/core';
7+
8+
defineOptions({
9+
name: 'AvatarFallback',
10+
})
11+
12+
const props = withDefaults(defineProps<AvatarFallbackProps>(), {
13+
as: 'span',
14+
})
15+
16+
const context = useAvatarContext()
17+
const canRender = shallowRef(props.delayMs === undefined)
18+
19+
watchEffect((onCleanup) => {
20+
if (!isClient) return
21+
22+
if (props.delayMs !== undefined) {
23+
const timerId = window.setTimeout(() => canRender.value = true, props.delayMs)
24+
onCleanup(() => {
25+
window.clearTimeout(timerId)
26+
})
27+
}
28+
})
29+
</script>
30+
31+
<template>
32+
<Primitive v-if="canRender && context.imageLoadingStatus.value !== 'loaded'" :as="as" :as-child="asChild">
33+
<slot />
34+
</Primitive>
35+
</template>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { PrimitiveProps } from "../primitive";
2+
import type { ImageLoadingStatus } from "./Avatar";
3+
4+
export interface AvatarImageProps extends PrimitiveProps {
5+
src?: string
6+
}
7+
8+
// eslint-disable-next-line ts/consistent-type-definitions
9+
export type AvatarImageEmits = {
10+
loadingStatusChange: [status: ImageLoadingStatus]
11+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script setup lang="ts">
2+
import { shallowRef, watchEffect, watchSyncEffect } from 'vue';
3+
import { Primitive } from '../primitive/index.ts'
4+
import type { AvatarImageEmits, AvatarImageProps } from './AvatarImage.ts';
5+
import { useAvatarContext, type ImageLoadingStatus } from './Avatar.ts';
6+
import { useImageLoadingStatus } from './utils.ts';
7+
8+
defineOptions({
9+
name: 'AvatarImage',
10+
})
11+
12+
const props = withDefaults(defineProps<AvatarImageProps>(), {
13+
as: 'img',
14+
})
15+
16+
const emit = defineEmits<AvatarImageEmits>()
17+
18+
const context = useAvatarContext()
19+
const imageLoadingStatus = useImageLoadingStatus(() => props.src)
20+
21+
function handleLoadingStatusChange(status: ImageLoadingStatus) {
22+
emit('loadingStatusChange', status)
23+
context.onImageLoadingStatusChange(status);
24+
}
25+
26+
watchEffect(() => {
27+
console.error(imageLoadingStatus.value)
28+
if (imageLoadingStatus.value !== 'idle') {
29+
handleLoadingStatusChange(imageLoadingStatus.value);
30+
}
31+
})
32+
</script>
33+
34+
<template>
35+
<Primitive v-if="imageLoadingStatus === 'loaded'" :as="as" :as-child="asChild" :src="src">
36+
<slot />
37+
</Primitive>
38+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as Avatar } from './Avatar.vue'
2+
export { default as AvatarFallback } from './AvatarFallback.vue'
3+
export { default as AvatarImage } from './AvatarImage.vue'
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Avatar, AvatarImage, AvatarFallback } from "../index.ts";
2+
import './styles.css'
3+
4+
export default { title: 'Components/Avatar' };
5+
6+
const src = 'https://avatars.githubusercontent.com/u/62594983?v=4';
7+
const srcBroken = 'https://broken.link.com/broken-pic.jpg';
8+
9+
export const Styled = () => (
10+
<>
11+
<h1>Without image & with fallback</h1>
12+
<Avatar class={'avatar_rootClass'}>
13+
<AvatarFallback class={'avatar_fallbackClass'}>JS</AvatarFallback>
14+
</Avatar>
15+
16+
<h1>With image & with fallback</h1>
17+
<Avatar class={'avatar_rootClass'}>
18+
<AvatarImage class={'avatar_imageClass'} alt="John Smith" src={src} />
19+
<AvatarFallback delayMs={300} class={'avatar_fallbackClass'}>
20+
JS
21+
</AvatarFallback>
22+
</Avatar>
23+
24+
<h1>With image & with fallback (but broken src)</h1>
25+
<Avatar class={'avatar_rootClass'}>
26+
<AvatarImage
27+
class={'avatar_imageClass'}
28+
alt="John Smith"
29+
src={srcBroken}
30+
onLoadingStatusChange={console.log}
31+
/>
32+
<AvatarFallback class={'avatar_fallbackClass'}>
33+
<AvatarIcon />
34+
</AvatarFallback>
35+
</Avatar>
36+
</>
37+
);
38+
39+
export const Chromatic = () => (
40+
<>
41+
<h1>Without image & with fallback</h1>
42+
<Avatar class={'avatar_rootClass'}>
43+
<AvatarFallback class={'avatar_fallbackClass'}>JS</AvatarFallback>
44+
</Avatar>
45+
46+
<h1>With image & with fallback</h1>
47+
<Avatar class={'avatar_rootClass'}>
48+
<AvatarImage class={'avatar_imageClass'} alt="John Smith" src={src} />
49+
<AvatarFallback delayMs={300} class={'avatar_fallbackClass'}>
50+
JS
51+
</AvatarFallback>
52+
</Avatar>
53+
54+
<h1>With image & with fallback (but broken src)</h1>
55+
<Avatar class={'avatar_rootClass'}>
56+
<AvatarImage class={'avatar_imageClass'} alt="John Smith" src={srcBroken} />
57+
<AvatarFallback class={'avatar_fallbackClass'}>
58+
<AvatarIcon />
59+
</AvatarFallback>
60+
</Avatar>
61+
</>
62+
);
63+
Chromatic.parameters = { chromatic: { disable: false, delay: 1000 } };
64+
65+
66+
const AvatarIcon = () => (
67+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="42" height="42">
68+
<path
69+
d="M50 51.7a22.1 22.1 0 100-44.2 22.1 22.1 0 000 44.2zM87.9 69.3a27.8 27.8 0 00-21.2-16.1 4 4 0 00-2.8.7 23.5 23.5 0 01-27.6 0 4 4 0 00-2.8-.7 27.5 27.5 0 00-21.2 16.1c-.3.6-.2 1.3.1 1.8a52.8 52.8 0 007 8.9 43.4 43.4 0 0056.9 3.8 56.3 56.3 0 008.9-8.8c.9-1.2 1.8-2.5 2.6-3.9.3-.6.3-1.2.1-1.8z"
70+
fill="currentColor"
71+
/>
72+
</svg>
73+
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.avatar_rootClass {
2+
display: inline-flex;
3+
align-items: center;
4+
justify-content: center;
5+
vertical-align: middle;
6+
overflow: hidden;
7+
user-select: none;
8+
9+
border-radius: 9999px;
10+
width: 48px;
11+
height: 48px;
12+
}
13+
14+
.avatar_imageClass {
15+
width: 100%;
16+
height: 100%;
17+
object-fit: cover;
18+
}
19+
20+
.avatar_fallbackClass {
21+
width: 100%;
22+
height: 100%;
23+
display: flex;
24+
align-items: center;
25+
justify-content: center;
26+
27+
background-color: black;
28+
color: white;
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { shallowRef, watch, type Ref } from "vue";
2+
import type { ImageLoadingStatus } from "./Avatar";
3+
import type { AvatarImageProps } from "./AvatarImage";
4+
import { isClient } from "@vueuse/core";
5+
6+
export function useImageLoadingStatus(src: Ref<AvatarImageProps['src']> | (() => AvatarImageProps['src'])) {
7+
const loadingStatus = shallowRef<ImageLoadingStatus>('idle');
8+
9+
watch(src, (value, _, onCleanup) => {
10+
if (!isClient) return
11+
12+
if (!value) {
13+
loadingStatus.value = 'error'
14+
return;
15+
}
16+
17+
let isMounted = true;
18+
const image = new window.Image();
19+
20+
const updateStatus = (status: ImageLoadingStatus) => () => {
21+
if (!isMounted) return;
22+
loadingStatus.value = status
23+
};
24+
25+
loadingStatus.value = 'loaded'
26+
// TODO: fix onload
27+
image.onload = updateStatus('loaded');
28+
image.onerror = updateStatus('error');
29+
image.src = value;
30+
31+
onCleanup(() => {
32+
isMounted = false;
33+
})
34+
}, {
35+
immediate: true
36+
});
37+
38+
return loadingStatus;
39+
}

0 commit comments

Comments
 (0)