Skeleton Loading States (#8719)

* Loading Skeletons

* PR Feedback
This commit is contained in:
Gerard Burns 2022-11-25 10:02:43 +00:00 committed by GitHub
parent c79f7506c1
commit 21b2706cea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 207 additions and 61 deletions

View File

@ -0,0 +1,56 @@
<div class="skeleton">
<div class="children">
<slot />
</div>
</div>
<style>
.skeleton {
height: 100%;
width: 100%;
opacity: 0;
background-color: var(--spectrum-global-color-gray-300) !important;
border-radius: 7px;
overflow: hidden;
position: relative;
animation: fadeIn 130ms ease 0s 1 normal forwards;
}
.children {
pointer-events: none;
opacity: 0;
}
.skeleton::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
content: "";
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>

View File

@ -71,7 +71,8 @@
visibleRowCount, visibleRowCount,
rowCount, rowCount,
totalRowCount, totalRowCount,
rowHeight rowHeight,
loading
) )
$: sortedRows = sortRows(rows, sortColumn, sortOrder) $: sortedRows = sortRows(rows, sortColumn, sortOrder)
$: gridStyle = getGridStyle(fields, schema, showEditColumn) $: gridStyle = getGridStyle(fields, schema, showEditColumn)
@ -120,8 +121,12 @@
visibleRowCount, visibleRowCount,
rowCount, rowCount,
totalRowCount, totalRowCount,
rowHeight rowHeight,
loading
) => { ) => {
if (loading) {
return `height: ${headerHeight + visibleRowCount * rowHeight}px;`
}
if (!rowCount || !visibleRowCount || totalRowCount <= rowCount) { if (!rowCount || !visibleRowCount || totalRowCount <= rowCount) {
return "" return ""
} }
@ -277,9 +282,11 @@
bind:offsetHeight={height} bind:offsetHeight={height}
style={`--row-height: ${rowHeight}px; --header-height: ${headerHeight}px;`} style={`--row-height: ${rowHeight}px; --header-height: ${headerHeight}px;`}
> >
{#if !loaded} {#if loading}
<div class="loading" style={heightStyle}> <div class="loading" style={heightStyle}>
<ProgressCircle /> <slot name="loadingIndicator">
<ProgressCircle />
</slot>
</div> </div>
{:else} {:else}
<div class="spectrum-Table" style={`${heightStyle}${gridStyle}`}> <div class="spectrum-Table" style={`${heightStyle}${gridStyle}`}>
@ -438,9 +445,10 @@
/* Loading */ /* Loading */
.loading { .loading {
display: grid; display: flex;
place-items: center; align-items: center;
min-height: 100px; min-height: 100px;
justify-content: center;
} }
/* Table */ /* Table */

View File

@ -4,6 +4,7 @@ import "./bbui.css"
import "@spectrum-css/icon/dist/index-vars.css" import "@spectrum-css/icon/dist/index-vars.css"
// Components // Components
export { default as Skeleton } from "./Skeleton/Skeleton.svelte"
export { default as Input } from "./Form/Input.svelte" export { default as Input } from "./Form/Input.svelte"
export { default as Stepper } from "./Form/Stepper.svelte" export { default as Stepper } from "./Form/Stepper.svelte"
export { default as TextArea } from "./Form/TextArea.svelte" export { default as TextArea } from "./Form/TextArea.svelte"

View File

@ -284,7 +284,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 105, "width": 105,
"height": 35 "height": 32
}, },
"settings": [ "settings": [
{ {
@ -683,7 +683,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 30 "height": 24
}, },
"settings": [ "settings": [
{ {
@ -808,7 +808,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 40 "height": 32
}, },
"settings": [ "settings": [
{ {
@ -2447,6 +2447,7 @@
] ]
}, },
"stringfield": { "stringfield": {
"skeleton": false,
"name": "Text Field", "name": "Text Field",
"icon": "Text", "icon": "Text",
"styles": [ "styles": [
@ -2455,7 +2456,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 32
}, },
"settings": [ "settings": [
{ {
@ -2538,6 +2539,7 @@
] ]
}, },
"numberfield": { "numberfield": {
"skeleton": false,
"name": "Number Field", "name": "Number Field",
"icon": "123", "icon": "123",
"styles": [ "styles": [
@ -2652,6 +2654,7 @@
] ]
}, },
"optionsfield": { "optionsfield": {
"skeleton": false,
"name": "Options Picker", "name": "Options Picker",
"icon": "Menu", "icon": "Menu",
"styles": [ "styles": [
@ -2820,6 +2823,7 @@
] ]
}, },
"multifieldselect": { "multifieldselect": {
"skeleton": false,
"name": "Multi-select Picker", "name": "Multi-select Picker",
"icon": "ViewList", "icon": "ViewList",
"styles": [ "styles": [
@ -2982,12 +2986,13 @@
] ]
}, },
"booleanfield": { "booleanfield": {
"skeleton": false,
"name": "Checkbox", "name": "Checkbox",
"icon": "SelectBox", "icon": "SelectBox",
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 20,
"height": 50 "height": 20
}, },
"settings": [ "settings": [
{ {
@ -3139,6 +3144,7 @@
] ]
}, },
"datetimefield": { "datetimefield": {
"skeleton": false,
"name": "Date Picker", "name": "Date Picker",
"icon": "Date", "icon": "Date",
"styles": [ "styles": [
@ -3220,6 +3226,7 @@
] ]
}, },
"codescanner": { "codescanner": {
"skeleton": false,
"name": "Barcode/QR Scanner", "name": "Barcode/QR Scanner",
"icon": "Camera", "icon": "Camera",
"styles": [ "styles": [
@ -3385,6 +3392,7 @@
] ]
}, },
"attachmentfield": { "attachmentfield": {
"skeleton": false,
"name": "Attachment", "name": "Attachment",
"icon": "Attach", "icon": "Attach",
"styles": [ "styles": [
@ -3443,6 +3451,7 @@
] ]
}, },
"relationshipfield": { "relationshipfield": {
"skeleton": false,
"name": "Relationship Picker", "name": "Relationship Picker",
"icon": "TaskList", "icon": "TaskList",
"styles": [ "styles": [
@ -3506,6 +3515,7 @@
] ]
}, },
"jsonfield": { "jsonfield": {
"skeleton": false,
"name": "JSON Field", "name": "JSON Field",
"icon": "Brackets", "icon": "Brackets",
"styles": [ "styles": [
@ -3707,6 +3717,7 @@
} }
}, },
"table": { "table": {
"skeleton": false,
"name": "Table", "name": "Table",
"icon": "Table", "icon": "Table",
"illegalChildren": [ "illegalChildren": [

View File

@ -23,6 +23,7 @@
// to render this part of the block, taking advantage of binding enrichment // to render this part of the block, taking advantage of binding enrichment
$: id = `${block.id}-${context ?? rand}` $: id = `${block.id}-${context ?? rand}`
$: instance = { $: instance = {
_blockElementHasChildren: $$slots?.default ?? false,
_component: `@budibase/standard-components/${type}`, _component: `@budibase/standard-components/${type}`,
_id: id, _id: id,
_instanceName: type[0].toUpperCase() + type.slice(1), _instanceName: type[0].toUpperCase() + type.slice(1),

View File

@ -29,6 +29,7 @@
import Placeholder from "components/app/Placeholder.svelte" import Placeholder from "components/app/Placeholder.svelte"
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte" import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
import ComponentPlaceholder from "components/app/ComponentPlaceholder.svelte" import ComponentPlaceholder from "components/app/ComponentPlaceholder.svelte"
import Skeleton from "components/app/Skeleton.svelte"
export let instance = {} export let instance = {}
export let isLayout = false export let isLayout = false
@ -38,6 +39,7 @@
// Get parent contexts // Get parent contexts
const context = getContext("context") const context = getContext("context")
const loading = getContext("loading")
const insideScreenslot = !!getContext("screenslot") const insideScreenslot = !!getContext("screenslot")
// Create component context // Create component context
@ -470,9 +472,21 @@
componentStore.actions.unregisterInstance(id) componentStore.actions.unregisterInstance(id)
} }
}) })
$: showSkeleton =
$loading &&
definition.name !== "Screenslot" &&
children.length === 0 &&
!instance._blockElementHasChildren &&
definition.skeleton !== false
</script> </script>
{#if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden} {#if showSkeleton}
<Skeleton
height={initialSettings?.height || definition?.size?.height || 0}
width={initialSettings?.width || definition?.size?.width || 0}
/>
{:else if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden}
<!-- The ID is used as a class because getElementsByClassName is O(1) --> <!-- The ID is used as a class because getElementsByClassName is O(1) -->
<!-- and the performance matters for the selection indicators --> <!-- and the performance matters for the selection indicators -->
<div <div

View File

@ -1,4 +1,5 @@
<script> <script>
import { writable } from "svelte/store"
import { setContext, getContext, onMount } from "svelte" import { setContext, getContext, onMount } from "svelte"
import Router, { querystring } from "svelte-spa-router" import Router, { querystring } from "svelte-spa-router"
import { routeStore, stateStore } from "stores" import { routeStore, stateStore } from "stores"
@ -9,6 +10,9 @@
const component = getContext("component") const component = getContext("component")
setContext("screenslot", true) setContext("screenslot", true)
const loading = writable(false)
setContext("loading", loading)
// Only wrap this as an array to take advantage of svelte keying, // Only wrap this as an array to take advantage of svelte keying,
// to ensure the svelte-spa-router is fully remounted when route config // to ensure the svelte-spa-router is fully remounted when route config
// changes // changes

View File

@ -1,6 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { writable } from "svelte/store"
import { ProgressCircle, Pagination } from "@budibase/bbui" import { setContext, getContext } from "svelte"
import { Pagination } from "@budibase/bbui"
import { fetchData, LuceneUtils } from "@budibase/frontend-core" import { fetchData, LuceneUtils } from "@budibase/frontend-core"
export let dataSource export let dataSource
@ -10,6 +11,8 @@
export let limit export let limit
export let paginate export let paginate
const loading = writable(false)
const { styleable, Provider, ActionTypes, API } = getContext("sdk") const { styleable, Provider, ActionTypes, API } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
@ -77,9 +80,13 @@
sortColumn: $fetch.sortColumn, sortColumn: $fetch.sortColumn,
sortOrder: $fetch.sortOrder, sortOrder: $fetch.sortOrder,
}, },
loaded: $fetch.loaded, limit: limit,
} }
const parentLoading = getContext("loading")
setContext("loading", loading)
$: loading.set($parentLoading || !$fetch.loaded)
const createFetch = datasource => { const createFetch = datasource => {
return fetchData({ return fetchData({
API, API,
@ -127,23 +134,17 @@
<div use:styleable={$component.styles} class="container"> <div use:styleable={$component.styles} class="container">
<Provider {actions} data={dataContext}> <Provider {actions} data={dataContext}>
{#if !$fetch.loaded} <slot />
<div class="loading"> {#if paginate && $fetch.supportsPagination}
<ProgressCircle /> <div class="pagination">
<Pagination
page={$fetch.pageNumber + 1}
hasPrevPage={$fetch.hasPrevPage}
hasNextPage={$fetch.hasNextPage}
goToPrevPage={fetch.prevPage}
goToNextPage={fetch.nextPage}
/>
</div> </div>
{:else}
<slot />
{#if paginate && $fetch.supportsPagination}
<div class="pagination">
<Pagination
page={$fetch.pageNumber + 1}
hasPrevPage={$fetch.hasPrevPage}
hasNextPage={$fetch.hasNextPage}
goToPrevPage={fetch.prevPage}
goToNextPage={fetch.nextPage}
/>
</div>
{/if}
{/if} {/if}
</Provider> </Provider>
</div> </div>
@ -155,13 +156,6 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
} }
.loading {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 100px;
}
.pagination { .pagination {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -12,22 +12,25 @@
const { Provider } = getContext("sdk") const { Provider } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
const loading = getContext("loading")
$: rows = dataProvider?.rows ?? [] // If the parent DataProvider is loading, fill the rows array with a number of empty objects corresponding to the DataProvider's page size; this allows skeleton loader components to be rendered further down the tree.
$: loaded = dataProvider?.loaded ?? true $: rows = $loading
? new Array(dataProvider.limit > 20 ? 20 : dataProvider.limit).fill({})
: dataProvider?.rows
</script> </script>
<Container {direction} {hAlign} {vAlign} {gap} wrap> <Container {direction} {hAlign} {vAlign} {gap} wrap>
{#if $component.empty} {#if $component.empty}
<Placeholder /> <Placeholder />
{:else if rows.length > 0} {:else if !$loading && rows.length === 0}
<div class="noRows"><i class="ri-list-check-2" />{noRowsMessage}</div>
{:else}
{#each rows as row, index} {#each rows as row, index}
<Provider data={{ ...row, index }}> <Provider data={{ ...row, index }}>
<slot /> <slot />
</Provider> </Provider>
{/each} {/each}
{:else if loaded && noRowsMessage}
<div class="noRows"><i class="ri-list-check-2" />{noRowsMessage}</div>
{/if} {/if}
</Container> </Container>

View File

@ -0,0 +1,31 @@
<script>
import { getContext } from "svelte"
import { Skeleton } from "@budibase/bbui"
const { styleable } = getContext("sdk")
const component = getContext("component")
export let height
export let width
let styles
$: {
styles = JSON.parse(JSON.stringify($component.styles))
if (!styles.normal.height && height) {
// The height and width props provided to this component can either be numbers or strings set by users (ex. '100%', '100px', '100'). A string of '100' wouldn't be a valid CSS property, but some of our components respect that input, so we need to handle it here also, hence the `!isNaN` check.
styles.normal.height = !isNaN(height) ? `${height}px` : height
}
if (!styles.normal.width && width) {
styles.normal.width = !isNaN(width) ? `${width}px` : width
}
}
</script>
<div use:styleable={styles}>
<Skeleton>
<slot />
</Skeleton>
</div>

View File

@ -76,18 +76,26 @@
bind:fieldApi bind:fieldApi
defaultValue={[]} defaultValue={[]}
> >
{#if fieldState} <div class="minHeightWrapper">
<CoreDropzone {#if fieldState}
value={fieldState.value} <CoreDropzone
disabled={fieldState.disabled} value={fieldState.value}
error={fieldState.error} disabled={fieldState.disabled}
on:change={handleChange} error={fieldState.error}
{processFiles} on:change={handleChange}
{deleteAttachments} {processFiles}
{handleFileTooLarge} {deleteAttachments}
{handleTooManyFiles} {handleFileTooLarge}
{maximum} {handleTooManyFiles}
{extensions} {maximum}
/> {extensions}
{/if} />
{/if}
</div>
</Field> </Field>
<style>
.minHeightWrapper {
min-height: 220px;
}
</style>

View File

@ -1,6 +1,7 @@
<script> <script>
import Placeholder from "../Placeholder.svelte" import Placeholder from "../Placeholder.svelte"
import FieldGroupFallback from "./FieldGroupFallback.svelte" import FieldGroupFallback from "./FieldGroupFallback.svelte"
import Skeleton from "../Skeleton.svelte"
import { getContext, onDestroy } from "svelte" import { getContext, onDestroy } from "svelte"
export let label export let label
@ -53,6 +54,8 @@
builderStore.actions.updateProp("label", e.target.textContent) builderStore.actions.updateProp("label", e.target.textContent)
} }
const loading = getContext("loading")
onDestroy(() => { onDestroy(() => {
fieldApi?.deregister() fieldApi?.deregister()
unsubscribe?.() unsubscribe?.()
@ -76,6 +79,10 @@
<div class="spectrum-Form-itemField"> <div class="spectrum-Form-itemField">
{#if !formContext} {#if !formContext}
<Placeholder text="Form components need to be wrapped in a form" /> <Placeholder text="Form components need to be wrapped in a form" />
{:else if $loading}
<Skeleton>
<slot />
</Skeleton>
{:else if !fieldState} {:else if !fieldState}
<Placeholder /> <Placeholder />
{:else if schemaType && schemaType !== type && type !== "options"} {:else if schemaType && schemaType !== type && type !== "options"}

View File

@ -1,6 +1,6 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { Table } from "@budibase/bbui" import { Table, Skeleton } from "@budibase/bbui"
import SlotRenderer from "./SlotRenderer.svelte" import SlotRenderer from "./SlotRenderer.svelte"
import { UnsortableTypes } from "../../../constants" import { UnsortableTypes } from "../../../constants"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
@ -18,6 +18,7 @@
export let allowSelectRows export let allowSelectRows
export let compact export let compact
const loading = getContext("loading")
const component = getContext("component") const component = getContext("component")
const { styleable, getAction, ActionTypes, routeStore, rowSelectionStore } = const { styleable, getAction, ActionTypes, routeStore, rowSelectionStore } =
getContext("sdk") getContext("sdk")
@ -30,7 +31,6 @@
] ]
let selectedRows = [] let selectedRows = []
$: hasChildren = $component.children $: hasChildren = $component.children
$: loading = dataProvider?.loading ?? false
$: data = dataProvider?.rows || [] $: data = dataProvider?.rows || []
$: fullSchema = dataProvider?.schema ?? {} $: fullSchema = dataProvider?.schema ?? {}
$: fields = getFields(fullSchema, columns, showAutoColumns) $: fields = getFields(fullSchema, columns, showAutoColumns)
@ -140,7 +140,7 @@
<Table <Table
{data} {data}
{schema} {schema}
{loading} loading={$loading}
{rowCount} {rowCount}
{quiet} {quiet}
{compact} {compact}
@ -155,6 +155,9 @@
on:sort={onSort} on:sort={onSort}
on:click={onClick} on:click={onClick}
> >
<div class="skeleton" slot="loadingIndicator">
<Skeleton />
</div>
<slot /> <slot />
</Table> </Table>
{#if allowSelectRows && selectedRows.length} {#if allowSelectRows && selectedRows.length}
@ -169,6 +172,11 @@
background-color: var(--spectrum-alias-background-color-secondary); background-color: var(--spectrum-alias-background-color-secondary);
} }
.skeleton {
height: 100%;
width: 100%;
}
.row-count { .row-count {
margin-top: var(--spacing-l); margin-top: var(--spacing-l);
} }