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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@
import Placeholder from "components/app/Placeholder.svelte"
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
import ComponentPlaceholder from "components/app/ComponentPlaceholder.svelte"
import Skeleton from "components/app/Skeleton.svelte"
export let instance = {}
export let isLayout = false
@ -38,6 +39,7 @@
// Get parent contexts
const context = getContext("context")
const loading = getContext("loading")
const insideScreenslot = !!getContext("screenslot")
// Create component context
@ -470,9 +472,21 @@
componentStore.actions.unregisterInstance(id)
}
})
$: showSkeleton =
$loading &&
definition.name !== "Screenslot" &&
children.length === 0 &&
!instance._blockElementHasChildren &&
definition.skeleton !== false
</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) -->
<!-- and the performance matters for the selection indicators -->
<div

View File

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

View File

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

View File

@ -12,22 +12,25 @@
const { Provider } = getContext("sdk")
const component = getContext("component")
const loading = getContext("loading")
$: rows = dataProvider?.rows ?? []
$: loaded = dataProvider?.loaded ?? true
// 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.
$: rows = $loading
? new Array(dataProvider.limit > 20 ? 20 : dataProvider.limit).fill({})
: dataProvider?.rows
</script>
<Container {direction} {hAlign} {vAlign} {gap} wrap>
{#if $component.empty}
<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}
<Provider data={{ ...row, index }}>
<slot />
</Provider>
{/each}
{:else if loaded && noRowsMessage}
<div class="noRows"><i class="ri-list-check-2" />{noRowsMessage}</div>
{/if}
</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
defaultValue={[]}
>
{#if fieldState}
<CoreDropzone
value={fieldState.value}
disabled={fieldState.disabled}
error={fieldState.error}
on:change={handleChange}
{processFiles}
{deleteAttachments}
{handleFileTooLarge}
{handleTooManyFiles}
{maximum}
{extensions}
/>
{/if}
<div class="minHeightWrapper">
{#if fieldState}
<CoreDropzone
value={fieldState.value}
disabled={fieldState.disabled}
error={fieldState.error}
on:change={handleChange}
{processFiles}
{deleteAttachments}
{handleFileTooLarge}
{handleTooManyFiles}
{maximum}
{extensions}
/>
{/if}
</div>
</Field>
<style>
.minHeightWrapper {
min-height: 220px;
}
</style>

View File

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

View File

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