Skip to content

Instantly share code, notes, and snippets.

@Jamiewarb
Last active June 10, 2022 14:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Jamiewarb/8f00b9181d29768e3f5405a2bb56601d to your computer and use it in GitHub Desktop.
Save Jamiewarb/8f00b9181d29768e3f5405a2bb56601d to your computer and use it in GitHub Desktop.
Skeleton Loader - Vue Component
// 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>
<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>
<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