diff --git a/docs/pages/kskeletonloader.vue b/docs/pages/kskeletonloader.vue new file mode 100644 index 000000000..918a0d1bf --- /dev/null +++ b/docs/pages/kskeletonloader.vue @@ -0,0 +1,286 @@ + + + + + + + \ No newline at end of file diff --git a/docs/tableOfContents.js b/docs/tableOfContents.js index 58877cc87..0953f60e1 100644 --- a/docs/tableOfContents.js +++ b/docs/tableOfContents.js @@ -448,6 +448,11 @@ export default [ title: 'KFocusTrap', isCode: true, }), + new Page({ + path: '/kskeletonloader', + title: 'KSkeletonLoader', + isCode: true, + }), ], }), ]; diff --git a/lib/KSkeletonLoader/SkeleletonGeneric.vue b/lib/KSkeletonLoader/SkeleletonGeneric.vue new file mode 100644 index 000000000..1109d26c1 --- /dev/null +++ b/lib/KSkeletonLoader/SkeleletonGeneric.vue @@ -0,0 +1,14 @@ + + + + diff --git a/lib/cards/SkeletonCard.vue b/lib/KSkeletonLoader/SkeletonCard.vue similarity index 100% rename from lib/cards/SkeletonCard.vue rename to lib/KSkeletonLoader/SkeletonCard.vue diff --git a/lib/KSkeletonLoader/index.vue b/lib/KSkeletonLoader/index.vue new file mode 100644 index 000000000..6077ede88 --- /dev/null +++ b/lib/KSkeletonLoader/index.vue @@ -0,0 +1,133 @@ + + + + + + + diff --git a/lib/KSkeletonLoader/useGridLoading.js b/lib/KSkeletonLoader/useGridLoading.js new file mode 100644 index 000000000..f2c21f79e --- /dev/null +++ b/lib/KSkeletonLoader/useGridLoading.js @@ -0,0 +1,59 @@ +import { ref, watch } from '@vue/composition-api'; + +import useGridLayout from '../cards/useGridLayout'; +import { getBreakpointConfig } from '../cards/utils'; + +const DEFAULT_SKELETON = { + count: undefined, // default determined by the grid layout and the current breakpoint + height: '200px', + orientation: 'horizontal', + thumbnailDisplay: 'none', + thumbnailAlign: 'left', +}; + +export default function useGridLoading(skeletonsConfig, layout, layoutOverride) { + const { currentBreakpointConfig, windowBreakpoint } = useGridLayout(layout, layoutOverride); + + const skeletonCount = ref(DEFAULT_SKELETON.count); + const skeletonHeight = ref(DEFAULT_SKELETON.height); + const skeletonOrientation = ref(DEFAULT_SKELETON.orientation); + const skeletonThumbnailDisplay = ref(DEFAULT_SKELETON.thumbnailDisplay); + const skeletonThumbnailAlign = ref(DEFAULT_SKELETON.thumbnailAlign); + + // Updates the loading skeleton configuration + //for the current breakpoint + watch( + [windowBreakpoint, skeletonsConfig, currentBreakpointConfig], + ([newBreakpoint]) => { + skeletonCount.value = currentBreakpointConfig.value.cardsPerRow; + + const breakpointSkeletonConfig = getBreakpointConfig(skeletonsConfig.value, newBreakpoint); + if (breakpointSkeletonConfig) { + if (breakpointSkeletonConfig.count) { + skeletonCount.value = breakpointSkeletonConfig.count; + } + if (breakpointSkeletonConfig.height) { + skeletonHeight.value = breakpointSkeletonConfig.height; + } + if (breakpointSkeletonConfig.orientation) { + skeletonOrientation.value = breakpointSkeletonConfig.orientation; + } + if (breakpointSkeletonConfig.thumbnailDisplay) { + skeletonThumbnailDisplay.value = breakpointSkeletonConfig.thumbnailDisplay; + } + if (breakpointSkeletonConfig.thumbnailAlign) { + skeletonThumbnailAlign.value = breakpointSkeletonConfig.thumbnailAlign; + } + } + }, + { immediate: true, deep: true } + ); + + return { + skeletonCount, + skeletonHeight, + skeletonOrientation, + skeletonThumbnailDisplay, + skeletonThumbnailAlign, + }; +} diff --git a/lib/KSkeletonLoader/useLoading.js b/lib/KSkeletonLoader/useLoading.js new file mode 100644 index 000000000..b333ff4db --- /dev/null +++ b/lib/KSkeletonLoader/useLoading.js @@ -0,0 +1,115 @@ +import Vue from 'vue'; +import { ref, watch, onMounted, toRefs, computed } from '@vue/composition-api'; + +import useGridLoading from './useGridLoading'; + +// The skeleton loaders will be displayed after `LOADING_DELAY` +// for a duration of `MIN_LOADING_TIME` +// (https://www.nngroup.com/articles/skeleton-screens/) +const LOADING_DELAY = 1000; +const MIN_LOADING_TIME = 1000; + +export default function useLoading(props) { + const { loading, appearance, config } = toRefs(props); + + let grid = {}; + + if (appearance.value === 'cardGrid') { + const skeletonsConfig = computed(() => config.value.skeletonsConfig); + const layout = computed(() => config.value.layout); + const layoutOverride = computed(() => config.value.layoutOverride); + + const { + skeletonCount, + skeletonHeight, + skeletonOrientation, + skeletonThumbnailDisplay, + skeletonThumbnailAlign, + } = useGridLoading(skeletonsConfig, layout, layoutOverride); + + grid = computed(() => { + return { + layout: layout.value, + layoutOverride: layoutOverride.value, + skeletonCount: skeletonCount.value, + skeletonHeight: skeletonHeight.value, + skeletonOrientation: skeletonOrientation.value, + skeletonThumbnailDisplay: skeletonThumbnailDisplay.value, + skeletonThumbnailAlign: skeletonThumbnailAlign.value, + }; + }); + } + + const isLoading = ref(false); + const finishedMounting = ref(false); + const isLoadingDelayActive = ref(false); + let loadingDelayTimeout = null; + let loadingStartTime = null; + let loadingElapsedTime = null; + let remainingLoadingTime = 0; + + watch( + loading, + (newLoading, oldLoading) => { + if (newLoading === oldLoading) { + return; + } + + // if loading started, delay it + if (newLoading) { + isLoadingDelayActive.value = true; + loadingDelayTimeout = setTimeout(() => { + loadingStartTime = Date.now(); + isLoading.value = true; + isLoadingDelayActive.value = false; + }, LOADING_DELAY); + } + + // if loading finished before the loading delay completed, + // cancel display of the loading state + if (!newLoading && !loadingStartTime) { + isLoadingDelayActive.value = false; + clearTimeout(loadingDelayTimeout); + } + + // if loading finished some time after the loading delay completed, + // ensure that the loading state is visible for at least `MIN_LOADING_TIME` + if (!newLoading && loadingStartTime) { + loadingElapsedTime = Date.now() - loadingStartTime; + if (loadingElapsedTime < MIN_LOADING_TIME) { + remainingLoadingTime = MIN_LOADING_TIME - loadingElapsedTime; + } else { + remainingLoadingTime = 0; + } + + setTimeout(() => { + isLoading.value = false; + + loadingStartTime = null; + loadingElapsedTime = null; + remainingLoadingTime = 0; + }, remainingLoadingTime); + } + }, + { immediate: true } + ); + + // Used by `KSkeletonLoader` to prevent jarring UX: + // - (1) prevents flashes of unstyled content during the mouning stage + // - (2) prevents uncomplete components from being displayed during the loading delay period + const show = computed(() => { + return finishedMounting.value && !isLoadingDelayActive.value; + }); + + onMounted(() => { + Vue.nextTick(() => { + finishedMounting.value = true; + }); + }); + + return { + show, + isLoading, + grid, + }; +} diff --git a/lib/KThemePlugin.js b/lib/KThemePlugin.js index affdb6b5b..cac211f11 100644 --- a/lib/KThemePlugin.js +++ b/lib/KThemePlugin.js @@ -28,6 +28,7 @@ import KOptionalText from './KOptionalText'; import KPageContainer from './KPageContainer'; import KRadioButton from './KRadioButton'; import KRouterLink from './buttons-and-links/KRouterLink'; +import KSkeletonLoader from './KSkeletonLoader'; import KSelect from './KSelect'; import KSwitch from './KSwitch'; import KTable from './KTable'; @@ -153,6 +154,7 @@ export default function KThemePlugin(Vue) { Vue.component('KRadioButton', KRadioButton); Vue.component('KRouterLink', KRouterLink); Vue.component('KSelect', KSelect); + Vue.component('KSkeletonLoader', KSkeletonLoader); Vue.component('KSwitch', KSwitch); Vue.component('KTable', KTable); Vue.component('KTabs', KTabs); diff --git a/lib/cards/KCardGrid.vue b/lib/cards/KCardGrid.vue index d7ef459bc..1a5f75b01 100644 --- a/lib/cards/KCardGrid.vue +++ b/lib/cards/KCardGrid.vue @@ -1,33 +1,10 @@