Skeleton Loaders Implementation (#2596)
* Skeleton Loaders * Remove Href * Fix Height Jumping around * Subtle Colors * feedback by ciaran Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
a0057911c1
commit
95a793dd5a
10 changed files with 199 additions and 16 deletions
|
@ -125,7 +125,7 @@ const Layout = ({
|
||||||
status,
|
status,
|
||||||
plan,
|
plan,
|
||||||
...props
|
...props
|
||||||
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan }) => {
|
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => {
|
||||||
const isEmbed = useIsEmbed();
|
const isEmbed = useIsEmbed();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
@ -342,7 +342,7 @@ const Layout = ({
|
||||||
"px-4 sm:px-6 md:px-8",
|
"px-4 sm:px-6 md:px-8",
|
||||||
props.flexChildrenContainer && "flex flex-1 flex-col"
|
props.flexChildrenContainer && "flex flex-1 flex-col"
|
||||||
)}>
|
)}>
|
||||||
{props.children}
|
{!props.isLoading ? props.children : props.customLoader}
|
||||||
</div>
|
</div>
|
||||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||||
{status === "authenticated" && (
|
{status === "authenticated" && (
|
||||||
|
@ -403,6 +403,7 @@ type LayoutProps = {
|
||||||
// use when content needs to expand with flex
|
// use when content needs to expand with flex
|
||||||
flexChildrenContainer?: boolean;
|
flexChildrenContainer?: boolean;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
customLoader?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Shell(props: LayoutProps) {
|
export default function Shell(props: LayoutProps) {
|
||||||
|
@ -423,8 +424,10 @@ export default function Shell(props: LayoutProps) {
|
||||||
const i18n = useViewerI18n();
|
const i18n = useViewerI18n();
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
|
|
||||||
if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) {
|
const isLoading =
|
||||||
// show spinner whilst i18n is loading to avoid language flicker
|
i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading;
|
||||||
|
|
||||||
|
if (isLoading && !props.customLoader) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
|
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
|
||||||
<Loader />
|
<Loader />
|
||||||
|
@ -437,7 +440,7 @@ export default function Shell(props: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||||
<MemoizedLayout plan={user?.plan} status={status} {...props} />
|
<MemoizedLayout plan={user?.plan} status={status} {...props} isLoading={isLoading} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
39
apps/web/components/apps/SkeletonLoader.tsx
Normal file
39
apps/web/components/apps/SkeletonLoader.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { ShellSubHeading } from "@components/Shell";
|
||||||
|
|
||||||
|
function SkeletonLoader() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100"></div>} />
|
||||||
|
<ul className="-mx-4 animate-pulse divide-y divide-neutral-200 rounded-sm border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkeletonLoader;
|
||||||
|
|
||||||
|
function SkeletonItem() {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between p-3">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div className="flex justify-start space-x-2">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-gray-100"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-32 rounded-md bg-gray-100"></div>
|
||||||
|
<div className="h-4 w-16 rounded-md bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||||
|
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||||
|
<div className="h-11 w-32 rounded-md bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
31
apps/web/components/availability/SkeletonLoader.tsx
Normal file
31
apps/web/components/availability/SkeletonLoader.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function SkeletonLoader() {
|
||||||
|
return (
|
||||||
|
<ul className="animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkeletonLoader;
|
||||||
|
|
||||||
|
function SkeletonItem() {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between px-2 py-[23px] sm:px-6">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="h-4 w-32 rounded-md bg-gray-100"></div>
|
||||||
|
<div className="h-2 w-32 rounded-md bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||||
|
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||||
|
<div className="h-6 w-12 rounded-md bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
37
apps/web/components/booking/SkeletonLoader.tsx
Normal file
37
apps/web/components/booking/SkeletonLoader.tsx
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import BookingsShell from "@components/BookingsShell";
|
||||||
|
|
||||||
|
function SkeletonLoader() {
|
||||||
|
return (
|
||||||
|
<ul className="mt-6 animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkeletonLoader;
|
||||||
|
|
||||||
|
function SkeletonItem() {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between px-2 py-4 sm:px-6">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="h-5 w-32 rounded-md bg-gray-100"></div>
|
||||||
|
<div className="h-4 w-16 rounded-md bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 h-5 w-24 rounded-md bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||||
|
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||||
|
<div className="h-6 w-16 rounded-md bg-gray-100"></div>
|
||||||
|
<div className="h-6 w-32 rounded-md bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
52
apps/web/components/eventtype/SkeletonLoader.tsx
Normal file
52
apps/web/components/eventtype/SkeletonLoader.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { LinkIcon } from "@heroicons/react/outline";
|
||||||
|
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon, UserIcon } from "@heroicons/react/solid";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function SkeletonLoader() {
|
||||||
|
return (
|
||||||
|
<ul className="animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
<SkeletonItem />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkeletonLoader;
|
||||||
|
|
||||||
|
function SkeletonItem() {
|
||||||
|
return (
|
||||||
|
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||||
|
<div className="flex-grow truncate text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="h-5 w-32 rounded-md bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
<div className="text-neutral-500 dark:text-white">
|
||||||
|
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||||
|
<li className="flex items-center whitespace-nowrap">
|
||||||
|
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></ClockIcon>
|
||||||
|
<div className="h-4 w-12 rounded-md bg-gray-100"></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center whitespace-nowrap">
|
||||||
|
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></UserIcon>
|
||||||
|
<div className="h-4 w-16 rounded-md bg-gray-100"></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
|
||||||
|
<div className="flex justify-between rtl:space-x-reverse">
|
||||||
|
<div className="btn-icon appearance-none">
|
||||||
|
<ExternalLinkIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="btn-icon appearance-none">
|
||||||
|
<LinkIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="btn-icon appearance-none">
|
||||||
|
<DotsHorizontalIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
QueryObserverIdleResult,
|
QueryObserverIdleResult,
|
||||||
QueryObserverLoadingErrorResult,
|
QueryObserverLoadingErrorResult,
|
||||||
|
@ -31,6 +32,7 @@ type JSXElementOrNull = JSX.Element | null;
|
||||||
|
|
||||||
interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
|
interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
|
||||||
query: UseQueryResult<TData, TError>;
|
query: UseQueryResult<TData, TError>;
|
||||||
|
customLoader?: ReactNode;
|
||||||
error?: (
|
error?: (
|
||||||
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
|
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
|
||||||
) => JSXElementOrNull;
|
) => JSXElementOrNull;
|
||||||
|
@ -77,10 +79,10 @@ export function QueryCell<TData, TError extends ErrorLike>(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (query.status === "loading") {
|
if (query.status === "loading") {
|
||||||
return opts.loading?.(query) ?? <Loader />;
|
return opts.loading?.(query) ?? opts.customLoader ? opts.customLoader : <Loader />;
|
||||||
}
|
}
|
||||||
if (query.status === "idle") {
|
if (query.status === "idle") {
|
||||||
return opts.idle?.(query) ?? <Loader />;
|
return opts.idle?.(query) ?? opts.customLoader ? opts.customLoader : <Loader />;
|
||||||
}
|
}
|
||||||
// impossible state
|
// impossible state
|
||||||
return null;
|
return null;
|
||||||
|
@ -108,6 +110,7 @@ const withQuery = <TPath extends keyof TQueryValues & string>(
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
const query = trpc.useQuery(pathAndInput, params);
|
const query = trpc.useQuery(pathAndInput, params);
|
||||||
|
|
||||||
return <QueryCell query={query} {...opts} />;
|
return <QueryCell query={query} {...opts} />;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,8 +18,8 @@ import { trpc } from "@lib/trpc";
|
||||||
import AppsShell from "@components/AppsShell";
|
import AppsShell from "@components/AppsShell";
|
||||||
import { ClientSuspense } from "@components/ClientSuspense";
|
import { ClientSuspense } from "@components/ClientSuspense";
|
||||||
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||||
import Loader from "@components/Loader";
|
|
||||||
import Shell, { ShellSubHeading } from "@components/Shell";
|
import Shell, { ShellSubHeading } from "@components/Shell";
|
||||||
|
import SkeletonLoader from "@components/apps/SkeletonLoader";
|
||||||
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
||||||
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
||||||
import IntegrationListItem from "@components/integrations/IntegrationListItem";
|
import IntegrationListItem from "@components/integrations/IntegrationListItem";
|
||||||
|
@ -332,9 +332,13 @@ export default function IntegrationsPage() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")} large>
|
<Shell
|
||||||
|
heading={t("installed_apps")}
|
||||||
|
subtitle={t("manage_your_connected_apps")}
|
||||||
|
large
|
||||||
|
customLoader={<SkeletonLoader />}>
|
||||||
<AppsShell>
|
<AppsShell>
|
||||||
<ClientSuspense fallback={<Loader />}>
|
<ClientSuspense fallback={<SkeletonLoader />}>
|
||||||
<IntegrationsContainer />
|
<IntegrationsContainer />
|
||||||
<CalendarListContainer />
|
<CalendarListContainer />
|
||||||
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
|
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
import EmptyScreen from "@components/EmptyScreen";
|
import EmptyScreen from "@components/EmptyScreen";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
|
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
|
||||||
|
import SkeletonLoader from "@components/availability/SkeletonLoader";
|
||||||
|
|
||||||
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
|
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
|
@ -105,8 +106,12 @@ export default function AvailabilityPage() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Shell heading={t("availability")} subtitle={t("configure_availability")} CTA={<NewScheduleButton />}>
|
<Shell
|
||||||
<WithQuery success={({ data }) => <AvailabilityList {...data} />} />
|
heading={t("availability")}
|
||||||
|
subtitle={t("configure_availability")}
|
||||||
|
CTA={<NewScheduleButton />}
|
||||||
|
customLoader={<SkeletonLoader />}>
|
||||||
|
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
|
||||||
</Shell>
|
</Shell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,9 +12,9 @@ import { inferQueryInput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import BookingsShell from "@components/BookingsShell";
|
import BookingsShell from "@components/BookingsShell";
|
||||||
import EmptyScreen from "@components/EmptyScreen";
|
import EmptyScreen from "@components/EmptyScreen";
|
||||||
import Loader from "@components/Loader";
|
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import BookingListItem from "@components/booking/BookingListItem";
|
import BookingListItem from "@components/booking/BookingListItem";
|
||||||
|
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||||
|
|
||||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||||
|
|
||||||
|
@ -45,7 +45,10 @@ export default function Bookings() {
|
||||||
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell heading={t("bookings")} subtitle={t("bookings_description")}>
|
<Shell
|
||||||
|
heading={t("bookings")}
|
||||||
|
subtitle={t("bookings_description")}
|
||||||
|
customLoader={<SkeletonLoader></SkeletonLoader>}>
|
||||||
<WipeMyCalActionButton trpc={trpc} bookingStatus={status} bookingsEmpty={isEmpty} />
|
<WipeMyCalActionButton trpc={trpc} bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||||
<BookingsShell>
|
<BookingsShell>
|
||||||
<div className="-mx-4 flex flex-col sm:mx-auto">
|
<div className="-mx-4 flex flex-col sm:mx-auto">
|
||||||
|
@ -54,7 +57,7 @@ export default function Bookings() {
|
||||||
{query.status === "error" && (
|
{query.status === "error" && (
|
||||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||||
)}
|
)}
|
||||||
{(query.status === "loading" || query.status === "idle") && <Loader />}
|
{(query.status === "loading" || query.status === "idle") && <SkeletonLoader />}
|
||||||
{query.status === "success" && !isEmpty && (
|
{query.status === "success" && !isEmpty && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-6 overflow-hidden rounded-sm border border-b border-gray-200">
|
<div className="mt-6 overflow-hidden rounded-sm border border-b border-gray-200">
|
||||||
|
|
|
@ -41,6 +41,7 @@ import { Tooltip } from "@components/Tooltip";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||||
|
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
|
@ -523,8 +524,13 @@ const EventTypesPage = () => {
|
||||||
<title>Home | Cal.com</title>
|
<title>Home | Cal.com</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<Shell heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")} CTA={<CTA />}>
|
<Shell
|
||||||
|
heading={t("event_types_page_title")}
|
||||||
|
subtitle={t("event_types_page_subtitle")}
|
||||||
|
CTA={<CTA />}
|
||||||
|
customLoader={<SkeletonLoader />}>
|
||||||
<WithQuery
|
<WithQuery
|
||||||
|
customLoader={<SkeletonLoader />}
|
||||||
success={({ data }) => (
|
success={({ data }) => (
|
||||||
<>
|
<>
|
||||||
{data.viewer.plan === "FREE" && !data.viewer.canAddEvents && (
|
{data.viewer.plan === "FREE" && !data.viewer.canAddEvents && (
|
||||||
|
|
Loading…
Reference in a new issue