Last active
June 10, 2022 14:50
-
-
Save Jamiewarb/8f00b9181d29768e3f5405a2bb56601d to your computer and use it in GitHub Desktop.
Skeleton Loader - Vue Component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Show 8 trader cards inside a grid. Show the 'trader-card' skeleton loader when it's loading the data | |
<template> | |
<UiSkeletonLoader v-show="loading" v-for="i in 8" :key="`trader-card-${i}`" type="trader-card" tile /> | |
<div v-show="!loading" class="trader-card"> | |
Content Loaded | |
</div> | |
</template> | |
// Another example | |
<template> | |
<UiSkeletonLoader v-show="loading" type="article" /> | |
<div v-show="!loading"> | |
Article Loaded | |
</div> | |
</template> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<component | |
:is="transition ? 'transition' : 'div'" | |
:name="transition" | |
@afterEnter="resetStyles" | |
@beforeEnter="onBeforeEnter" | |
@beforeLeave="onBeforeLeave" | |
@leaveCancelled="resetStyles" | |
> | |
<div v-show="isLoading || boilerplate" :class="classes" v-bind="attributes"> | |
<UiSkeletonLoaderBone | |
v-for="(bone, index) in skeleton" | |
:key="index" | |
:name="bone.name" | |
:children="bone.children" | |
/> | |
</div> | |
<slot v-show="!(isLoading || boilerplate)" /> | |
</component> | |
</template> | |
<script lang="ts"> | |
import { computed, defineComponent, ref } from '@nuxtjs/composition-api'; | |
const availableTypes: Record<string, string> = { | |
button: 'button', | |
text: 'text', | |
paragraph: 'text@3', | |
sentences: 'text@2', | |
image: 'image', | |
heading: 'heading', | |
subheading: 'text', | |
article: 'heading, paragraph', | |
'trader-card-body': 'heading, subheading, sentences', | |
'trader-card': 'image, trader-card-body', | |
'category-card-body': 'heading, heading, sentences, heading, button', | |
'category-card': 'image, category-card-body', | |
}; | |
export type Bone = { | |
name: string; | |
children: Array<Bone>; | |
}; | |
export type Skeleton = Array<Bone>; | |
export interface HTMLSkeletonLoaderElement extends HTMLElement { | |
_initialStyle?: { | |
display: string | null; | |
transition: string; | |
}; | |
} | |
export default defineComponent({ | |
name: 'SkeletonLoader', | |
props: { | |
type: { type: String, required: true }, | |
boilerplate: { type: Boolean, default: false }, | |
loading: { type: Boolean, default: false }, | |
tile: { type: Boolean, default: false }, | |
transition: { type: String, default: undefined }, | |
}, | |
setup(props, { slots, attrs }) { | |
const isLoading = ref(props.loading || !slots.default); | |
const classes = computed(() => ({ | |
'v-skeleton-loader': true, | |
'v-skeleton-loader--boilerplate': props.boilerplate, | |
'v-skeleton-loader--is-loading': isLoading, | |
'v-skeleton-loader--tile': props.tile, | |
})); | |
const attributes = computed(() => { | |
if (!isLoading) return attrs; | |
return !props.boilerplate | |
? { | |
'aria-busy': true, | |
'aria-live': 'polite', | |
role: 'alert', | |
...attrs, | |
} | |
: {}; | |
}); | |
const skeleton = computed(() => generateSkeleton(props.type)); | |
function generateSkeleton(type: string = ''): Skeleton { | |
const skeletonOrBone = recursiveGenerateStructure(type); | |
if (!Array.isArray(skeletonOrBone)) { | |
return [skeletonOrBone]; | |
} | |
return skeletonOrBone; | |
} | |
function recursiveGenerateStructure(type: string = ''): Skeleton { | |
let children: Array<Bone> = []; | |
const bone = availableTypes[type] || ''; | |
// If type and bone are the same then we've found a root bone and can generate it | |
if (type !== bone) { | |
if (isList(type)) { | |
return trim(type) | |
.split(',') | |
.map(recursiveGenerateStructure) | |
.reduce((accSkeleton, skeleton) => [...accSkeleton, ...skeleton]); | |
} | |
if (isMultiple(type)) { | |
return generateBoneMultiple(type); | |
} | |
if (isList(bone)) { | |
children = trim(bone) | |
.split(',') | |
.map(recursiveGenerateStructure) | |
.reduce((accSkeleton, skeleton) => [...accSkeleton, ...skeleton]); | |
} else if (isMultiple(bone)) { | |
children = generateBoneMultiple(bone); | |
} else if (bone) { | |
// Single value that isn't a root bone - e.g. 'card-heading' | |
children = generateSkeleton(bone); | |
} | |
} | |
return [{ name: type, children }]; | |
} | |
// Type is an array of values - e.g. 'heading, paragraph, text@2' | |
// Or Bone is array of values - e.g. type 'card' which gives the bones 'image, card-heading' | |
const isList = (typeOrBone: string) => typeOrBone.includes(','); | |
// Type has a multiplier - e.g. 'paragraph@4' | |
// Or Bone has a multiplier - e.g. type 'actions' which gives the bones 'button@2' | |
const isMultiple = (typeOrBone: string) => typeOrBone.includes('@'); | |
// Remove spaces from string | |
const trim = (bones: string) => bones.replace(/\s/g, ''); | |
// e.g. 'text@3' to 3 'text' bone elements | |
function generateBoneMultiple(bone: string): Skeleton { | |
const [type, length] = bone.split('@') as [string, number]; | |
const boneFactoryFn = () => recursiveGenerateStructure(type); | |
return Array.from({ length }) | |
.map(boneFactoryFn) | |
.reduce((accSkeleton, skeleton) => [...accSkeleton, ...skeleton]); | |
} | |
function onBeforeEnter(el: HTMLSkeletonLoaderElement) { | |
resetStyles(el); | |
if (!isLoading) return; | |
el._initialStyle = { | |
display: el.style.display, | |
transition: el.style.transition, | |
}; | |
el.style.setProperty('transition', 'none', 'important'); | |
} | |
function onBeforeLeave(el: HTMLSkeletonLoaderElement) { | |
el.style.setProperty('display', 'none', 'important'); | |
} | |
function resetStyles(el: HTMLSkeletonLoaderElement) { | |
if (!el._initialStyle) return; | |
el.style.display = el._initialStyle.display || ''; | |
el.style.transition = el._initialStyle.transition; | |
delete el._initialStyle; | |
} | |
return { | |
skeleton, | |
classes, | |
attributes, | |
isLoading, | |
onBeforeEnter, | |
onBeforeLeave, | |
resetStyles, | |
}; | |
}, | |
}); | |
</script> | |
<style scoped lang="scss"> | |
.v-skeleton-loader { | |
border-radius: var(--skeleton-loader-border-radius, 4px); | |
position: relative; | |
vertical-align: top; | |
&--is-loading { | |
overflow: hidden; | |
} | |
&[aria-busy='true'] { | |
cursor: progress; | |
} | |
&--boilerplate::v-deep .v-skeleton-loader__bone::after { | |
display: none; | |
} | |
&--tile { | |
border-radius: 0; | |
} | |
} | |
</style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div :class="['v-skeleton-loader__bone', `v-skeleton-loader__bone--${name}`]"> | |
<UiSkeletonLoaderBone v-for="(bone, index) in children" :key="index" :name="bone.name" :children="bone.children" /> | |
</div> | |
</template> | |
<script lang="ts"> | |
import { defineComponent } from '@nuxtjs/composition-api'; | |
export default defineComponent({ | |
name: 'SkeletonLoaderBone', | |
props: { | |
name: { type: String, required: true }, | |
children: { type: Array, default: () => [] }, | |
}, | |
}); | |
</script> | |
<style scoped lang="scss"> | |
.v-skeleton-loader { | |
$loader: &; | |
#{$loader}__bone { | |
border-radius: inherit; | |
overflow: hidden; | |
position: relative; | |
&::after { | |
animation: var(--skeleton-loading-animation, loading) var(--skeleton-loader-speed, 1.5s) infinite; | |
background: linear-gradient(90deg, hsla(0deg, 0%, 100%, 0%), hsla(0deg, 0%, 100%, 30%), hsla(0deg, 0%, 100%, 0%)); | |
content: ''; | |
height: 100%; | |
left: 0; | |
position: absolute; | |
right: 0; | |
top: 0; | |
transform: translateX(-100%); | |
z-index: theme('zIndex.base'); | |
} | |
&--image, | |
&--text, | |
&--button, | |
&--heading { | |
background: var(--skeleton-loader-color, rgba(0, 0, 0, 12%)); | |
} | |
&--button { | |
border-radius: var(--skeleton-loader-button-border-radius, 0); | |
flex: 1 0 auto; | |
height: var(--skeleton-loader-button-height, theme('spacing.24')); | |
margin-bottom: theme('spacing.6'); | |
width: 40%; | |
} | |
&--text { | |
border-radius: var(--skeleton-loader-text-border-radius, 0.375rem); | |
flex: 1 0 auto; | |
height: var(--skeleton-loader-text-height, theme('spacing.12')); | |
margin-bottom: theme('spacing.6'); | |
} | |
&--image { | |
border-radius: 0; | |
aspect-ratio: 1/1; | |
} | |
&--heading { | |
border-radius: var(--skeleton-loader-heading-border-radius, 12px); | |
height: var(--skeleton-loader-heading-height, theme('spacing.24')); | |
width: 55%; | |
} | |
&--subheading { | |
border-radius: 12px; | |
height: theme('spacing.12'); | |
width: 40%; | |
} | |
&--paragraph, | |
&--sentences { | |
flex: 1 0 auto; | |
} | |
&--paragraph { | |
&:not(:last-child) { | |
margin-bottom: 6px; | |
} | |
#{$loader}__bone--text { | |
&:first-child { | |
max-width: 100%; | |
} | |
&:nth-child(2) { | |
max-width: 50%; | |
} | |
&:nth-child(3) { | |
max-width: 70%; | |
} | |
} | |
} | |
&--sentences { | |
#{$loader}__bone--text:nth-child(2) { | |
max-width: 70%; | |
} | |
&:not(:last-child) { | |
margin-bottom: 6px; | |
} | |
} | |
&--article { | |
background: var(--skeleton-loader-bone-background, theme('colors.cream')); | |
#{$loader}__bone--heading { | |
margin: theme('spacing.16') 0 theme('spacing.16') theme('spacing.16'); | |
} | |
#{$loader}__bone--paragraph { | |
padding: theme('spacing.16'); | |
} | |
} | |
&--trader-card-body, | |
&--category-card-body { | |
background: var(--skeleton-loader-bone-background, theme('colors.cream')); | |
#{$loader}__bone--heading { | |
margin: theme('spacing.12') 0 0; | |
&:first-child { | |
margin-top: theme('spacing.24'); | |
} | |
} | |
#{$loader}__bone--subheading { | |
margin: theme('spacing.8') 0 0; | |
} | |
#{$loader}__bone--paragraph { | |
padding: theme('spacing.16') 0; | |
} | |
#{$loader}__bone--sentences { | |
padding: theme('spacing.16') 0; | |
} | |
#{$loader}__bone--button { | |
margin-top: theme('spacing.16'); | |
} | |
} | |
} | |
} | |
</style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment