| 1 | import { type Accessor, createMemo, For, type JSX, type Resource, Show } from "solid-js"; |
| 2 | import Unplug from "lucide-solid/icons/unplug"; |
| 3 | import { reason, unconnected } from "../api.ts"; |
| 4 | |
| 5 | /** The latest value that loaded, kept through later failures; reading an errored resource directly throws. */ |
| 6 | export function lastGood<T>(resource: Resource<T>): Accessor<T | undefined> { |
| 7 | return createMemo<T | undefined>((previous) => (resource.state === "errored" ? previous : resource.latest)); |
| 8 | } |
| 9 | |
| 10 | /** Placeholder rows of staggered widths, for a list or table. */ |
| 11 | export function SkeletonRows(props: { count: number }) { |
| 12 | return <For each={Array(props.count)}>{(_, i) => <div class="skeleton row-skeleton" style={{ width: `${35 + ((i() * 37) % 50)}%` }} />}</For>; |
| 13 | } |
| 14 | |
| 15 | /** |
| 16 | * A skeleton, then `children` with the latest value, which stays up through a refetch and, with a note and a retry |
| 17 | * above it, through a failed one. Only a failure before anything loaded replaces the content with the error, and a |
| 18 | * source that isn't connected yet replaces it with the server's note instead, since retrying can't help. |
| 19 | */ |
| 20 | export function Loaded<T>(props: { |
| 21 | data: Resource<T>; |
| 22 | /** What failed to load, e.g. "pool status", read as "Couldn't load pool status." */ |
| 23 | what: string; |
| 24 | retry: () => void; |
| 25 | /** Placeholder shaped like the content, built from `.skeleton` blocks; defaults to one block. */ |
| 26 | skeleton?: JSX.Element; |
| 27 | children: (value: Accessor<NonNullable<T>>) => JSX.Element; |
| 28 | }) { |
| 29 | const value = lastGood(props.data); |
| 30 | /** The last failure, kept through a refetch until one succeeds, so polling doesn't flash the skeleton. */ |
| 31 | const error = createMemo<unknown>((previous) => |
| 32 | props.data.state === "errored" ? props.data.error : props.data.state === "ready" ? undefined : previous); |
| 33 | const failure = () => error() !== undefined && reason(error()); |
| 34 | return ( |
| 35 | <Show when={value()} fallback={ |
| 36 | <Show when={failure()} fallback={ |
| 37 | <> |
| 38 | <span class="sr-only">Loading {props.what}…</span> |
| 39 | {props.skeleton ?? <div class="skeleton" style={{ height: "120px" }} />} |
| 40 | </> |
| 41 | }> |
| 42 | {(text) => unconnected(error()) ? ( |
| 43 | <div class="empty unconnected" role="status"> |
| 44 | <Unplug aria-hidden="true" /> |
| 45 | <p>{text()}</p> |
| 46 | </div> |
| 47 | ) : ( |
| 48 | <div class="empty failed" role="alert"> |
| 49 | <p><span class="error">Couldn't load {props.what}.</span> {text()}</p> |
| 50 | <button class="button" onClick={props.retry}>retry</button> |
| 51 | </div> |
| 52 | )} |
| 53 | </Show> |
| 54 | }> |
| 55 | {(latest) => ( |
| 56 | <> |
| 57 | <Show when={failure()}> |
| 58 | {(text) => ( |
| 59 | <p class="stale-note" role="status"> |
| 60 | <span class="error">Couldn't refresh {props.what}.</span> {text()} |
| 61 | <button class="button small" onClick={props.retry}>retry</button> |
| 62 | </p> |
| 63 | )} |
| 64 | </Show> |
| 65 | {props.children(latest)} |
| 66 | </> |
| 67 | )} |
| 68 | </Show> |
| 69 | ); |
| 70 | } |