Refine updates pages styling and pagination
Generalize the cursor Prev/Next pagination hook into a reusable useCursorPagination in lib/relay, taking the page size as a parameter, and keep the updates page size (25) as a feature constant. Move the list card surface and its loading-dim state into tv variants behind an UpdatesList component, and lift the detail article layout and its gold metadata styling into shared variants, so the pages carry only placement classes. Skeletons reuse the same variants. Relocate the generic pager labels to the app-root namespace and expose Intl.DateTimeFormat options on the formatDate helper. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -38,5 +38,9 @@
|
||||
},
|
||||
"footer": {
|
||||
"poweredBy": "Powered by"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Previous page",
|
||||
"next": "Next page"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,5 +38,9 @@
|
||||
},
|
||||
"footer": {
|
||||
"poweredBy": "Propulsé par"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Page précédente",
|
||||
"next": "Page suivante"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,23 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
// Locale-aware long date formatting via Intl.DateTimeFormat, e.g.
|
||||
// "August 6, 2026". The locale must be the active i18next language so the output
|
||||
// follows the UI.
|
||||
export function formatDate(date: Date | string | number, locale: string): string {
|
||||
// Long-date defaults, e.g. "August 6, 2026". Callers can override or extend any
|
||||
// of these through the `options` argument.
|
||||
const DEFAULT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
// Locale-aware date formatting via Intl.DateTimeFormat. The locale must be the
|
||||
// active i18next language so the output follows the UI. `options` are merged
|
||||
// over the long-date defaults, exposing the full Intl.DateTimeFormat surface.
|
||||
export function formatDate(
|
||||
date: Date | string | number,
|
||||
locale: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(target);
|
||||
return new Intl.DateTimeFormat(locale, { ...DEFAULT_OPTIONS, ...options }).format(target);
|
||||
}
|
||||
|
||||
@@ -14,33 +14,36 @@
|
||||
|
||||
import { useCallback, useTransition } from "react";
|
||||
|
||||
// Page size for the cursor-paginated updates list. Matches the Figma list frame.
|
||||
export const UPDATES_PAGE_SIZE = 10;
|
||||
|
||||
export interface UpdatesPageInfo {
|
||||
// The `pageInfo` shape a Relay connection exposes for bidirectional cursor
|
||||
// pagination. Structurally compatible with generated connection page info.
|
||||
export interface CursorPageInfo {
|
||||
hasPreviousPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
startCursor: string | null | undefined;
|
||||
endCursor: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface UpdatesPaginationVariables {
|
||||
// The connection pagination arguments passed to a refetch.
|
||||
export interface CursorPaginationVariables {
|
||||
first?: number | null;
|
||||
after?: string | null;
|
||||
last?: number | null;
|
||||
before?: string | null;
|
||||
}
|
||||
|
||||
type RefetchUpdates = (variables: UpdatesPaginationVariables) => void;
|
||||
type CursorRefetch = (variables: CursorPaginationVariables) => void;
|
||||
|
||||
// Cursor-based Prev/Next pagination for the updates list. Drives the connection
|
||||
// refetch inside a transition so the current page stays mounted (dimmed) while
|
||||
// the next one loads. Enabled/disabled state comes from the server `pageInfo`,
|
||||
// which stays correct however the page is reached. No page-number counter:
|
||||
// cursor pagination encodes a position, not an ordinal, so a reliable page
|
||||
// index (deep-linkable or refresh-safe) would need offset + totalCount, which
|
||||
// this API does not expose.
|
||||
export function useUpdatesPagination(refetch: RefetchUpdates, pageInfo: UpdatesPageInfo) {
|
||||
// Prev/Next pagination for a Relay cursor connection. Drives the refetch inside
|
||||
// a transition so the current page stays mounted (dimmed) while the next one
|
||||
// loads; Prev/Next availability comes from the server `pageInfo`, so it stays
|
||||
// correct however the page is reached. There is no page-number counter: cursor
|
||||
// pagination encodes a position, not an ordinal, so a reliable page index
|
||||
// (deep-linkable or refresh-safe) would need offset + totalCount.
|
||||
export function useCursorPagination(
|
||||
refetch: CursorRefetch,
|
||||
pageInfo: CursorPageInfo,
|
||||
pageSize: number,
|
||||
) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
@@ -48,18 +51,18 @@ export function useUpdatesPagination(refetch: RefetchUpdates, pageInfo: UpdatesP
|
||||
return;
|
||||
}
|
||||
startTransition(() => {
|
||||
refetch({ first: UPDATES_PAGE_SIZE, after: pageInfo.endCursor, last: null, before: null });
|
||||
refetch({ first: pageSize, after: pageInfo.endCursor, last: null, before: null });
|
||||
});
|
||||
}, [refetch, pageInfo.hasNextPage, pageInfo.endCursor]);
|
||||
}, [refetch, pageSize, pageInfo.hasNextPage, pageInfo.endCursor]);
|
||||
|
||||
const goPrevious = useCallback(() => {
|
||||
if (!pageInfo.hasPreviousPage || pageInfo.startCursor == null) {
|
||||
return;
|
||||
}
|
||||
startTransition(() => {
|
||||
refetch({ first: null, after: null, last: UPDATES_PAGE_SIZE, before: pageInfo.startCursor });
|
||||
refetch({ first: null, after: null, last: pageSize, before: pageInfo.startCursor });
|
||||
});
|
||||
}, [refetch, pageInfo.hasPreviousPage, pageInfo.startCursor]);
|
||||
}, [refetch, pageSize, pageInfo.hasPreviousPage, pageInfo.startCursor]);
|
||||
|
||||
return { isPending, goPrevious, goNext };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { formatDate } from "#/lib/datetime/formatDate";
|
||||
|
||||
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
|
||||
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
|
||||
import { updateArticle } from "./_components/variants";
|
||||
|
||||
export const updateDetailPageQuery = graphql`
|
||||
query UpdateDetailPageQuery($updateId: ID!) {
|
||||
@@ -52,20 +53,22 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
||||
}
|
||||
const update = data.node;
|
||||
|
||||
const { toolbar, content, article, meta, metaIcon, body } = updateArticle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderBand>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className={toolbar()}>
|
||||
<Link to="/updates" variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
||||
{t("backToUpdates")}
|
||||
</Link>
|
||||
<UpdatesSubscribeButton />
|
||||
</div>
|
||||
</HeaderBand>
|
||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||
<article className="flex w-full max-w-2xl flex-col gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NewspaperIcon weight="light" className="size-4 text-gold-9" />
|
||||
<div className={content()}>
|
||||
<article className={article()}>
|
||||
<div className={meta()}>
|
||||
<NewspaperIcon weight="light" className={metaIcon()} />
|
||||
<Text size={1} color="gold">
|
||||
{formatDate(update.updatedAt, i18n.language)}
|
||||
</Text>
|
||||
@@ -73,7 +76,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
||||
<Heading level={1} size={7} weight="medium" highContrast>
|
||||
{update.title}
|
||||
</Heading>
|
||||
<Text size={3} className="block whitespace-pre-wrap">
|
||||
<Text size={3} className={body()}>
|
||||
{update.body}
|
||||
</Text>
|
||||
</article>
|
||||
|
||||
@@ -18,24 +18,28 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
|
||||
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
|
||||
const BODY_PLACEHOLDERS = ["a", "b", "c", "d", "e"];
|
||||
import { updateArticle } from "./_components/variants";
|
||||
|
||||
const BODY_LINE_COUNT = 5;
|
||||
|
||||
export function UpdateDetailPageSkeleton() {
|
||||
const { toolbar, content, article } = updateArticle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderBand>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className={toolbar()}>
|
||||
<ButtonSkeleton size={2} />
|
||||
<ButtonSkeleton size={2} />
|
||||
</div>
|
||||
</HeaderBand>
|
||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||
<div className="flex w-full max-w-2xl flex-col gap-4" aria-hidden>
|
||||
<div className={content()}>
|
||||
<div className={article()} aria-hidden>
|
||||
<TextSkeleton size={1} className="w-28" />
|
||||
<HeadingSkeleton size={7} className="w-96" />
|
||||
<div className="flex flex-col gap-2">
|
||||
{BODY_PLACEHOLDERS.map(placeholder => (
|
||||
<TextSkeleton key={placeholder} size={3} className="w-full" />
|
||||
{Array.from({ length: BODY_LINE_COUNT }, (_, index) => (
|
||||
<TextSkeleton key={index} size={3} className="w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,14 +20,16 @@ import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"
|
||||
|
||||
import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
|
||||
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
||||
import type { CursorPaginationVariables } from "#/lib/relay/useCursorPagination";
|
||||
import { useCursorPagination } from "#/lib/relay/useCursorPagination";
|
||||
|
||||
import type { UpdatesPage_query$key } from "./__generated__/UpdatesPage_query.graphql";
|
||||
import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
|
||||
import type { UpdatesPageRefetchQuery } from "./__generated__/UpdatesPageRefetchQuery.graphql";
|
||||
import { UpdatesEmpty } from "./_components/UpdatesEmpty";
|
||||
import { UpdatesList } from "./_components/UpdatesList";
|
||||
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
|
||||
import type { UpdatesPaginationVariables } from "./_lib/useUpdatesPagination";
|
||||
import { useUpdatesPagination } from "./_lib/useUpdatesPagination";
|
||||
import { UPDATES_PAGE_SIZE } from "./_lib/constants";
|
||||
|
||||
export const updatesPageQuery = graphql`
|
||||
query UpdatesPageQuery($first: Int, $after: CursorKey, $last: Int, $before: CursorKey) {
|
||||
@@ -69,19 +71,20 @@ interface UpdatesPageProps {
|
||||
|
||||
export function UpdatesPage({ queryRef }: UpdatesPageProps) {
|
||||
const { t } = useTranslation("updates");
|
||||
const { t: tCommon } = useTranslation();
|
||||
const root = usePreloadedQuery<UpdatesPageQuery>(updatesPageQuery, queryRef);
|
||||
const [data, refetch] = useRefetchableFragment<UpdatesPageRefetchQuery, UpdatesPage_query$key>(
|
||||
updatesPageFragment,
|
||||
root,
|
||||
);
|
||||
|
||||
const refetchUpdates = useCallback((variables: UpdatesPaginationVariables) => {
|
||||
const refetchUpdates = useCallback((variables: CursorPaginationVariables) => {
|
||||
refetch(variables, { fetchPolicy: "store-or-network" });
|
||||
}, [refetch]);
|
||||
|
||||
const { updates } = data.currentTrustCenter;
|
||||
const { pageInfo } = updates;
|
||||
const { isPending, goPrevious, goNext } = useUpdatesPagination(refetchUpdates, pageInfo);
|
||||
const { isPending, goPrevious, goNext } = useCursorPagination(refetchUpdates, pageInfo, UPDATES_PAGE_SIZE);
|
||||
|
||||
const nodes = updates.edges.map(edge => edge.node);
|
||||
const isEmpty = nodes.length === 0;
|
||||
@@ -95,21 +98,16 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) {
|
||||
? <UpdatesEmpty />
|
||||
: (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div
|
||||
aria-busy={isPending}
|
||||
className={`overflow-hidden rounded-5 border border-sand-3 bg-sand-1 transition-opacity duration-150 ${isPending ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="divide-y divide-sand-a2">
|
||||
{nodes.map(node => (
|
||||
<MailingListUpdateListItem key={node.id} updateKey={node} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<UpdatesList busy={isPending}>
|
||||
{nodes.map(node => (
|
||||
<MailingListUpdateListItem key={node.id} updateKey={node} />
|
||||
))}
|
||||
</UpdatesList>
|
||||
<Pagination
|
||||
hasPrevious={pageInfo.hasPreviousPage}
|
||||
hasNext={pageInfo.hasNextPage}
|
||||
previousLabel={t("pagination.previous")}
|
||||
nextLabel={t("pagination.next")}
|
||||
previousLabel={tCommon("pagination.previous")}
|
||||
nextLabel={tCommon("pagination.next")}
|
||||
onPrevious={goPrevious}
|
||||
onNext={goNext}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
|
||||
import { UPDATES_PAGE_SIZE } from "./_lib/useUpdatesPagination";
|
||||
import { UPDATES_PAGE_SIZE } from "./_lib/constants";
|
||||
import { UpdatesPage, updatesPageQuery } from "./UpdatesPage";
|
||||
import { UpdatesPageSkeleton } from "./UpdatesPageSkeleton";
|
||||
|
||||
|
||||
@@ -18,9 +18,13 @@ import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
|
||||
import { ComplianceArticleItemSkeleton } from "#/components/ComplianceArticleItem/ComplianceArticleItemSkeleton";
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
|
||||
const ROW_PLACEHOLDERS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
|
||||
import { updatesList } from "./_components/variants";
|
||||
|
||||
const ROW_COUNT = 10;
|
||||
|
||||
export function UpdatesPageSkeleton() {
|
||||
const { card, rows } = updatesList();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderBand>
|
||||
@@ -30,10 +34,10 @@ export function UpdatesPageSkeleton() {
|
||||
</HeaderBand>
|
||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||
<div className="flex w-full max-w-5xl flex-col gap-8">
|
||||
<div className="overflow-hidden rounded-5 border border-sand-3 bg-sand-1" aria-hidden>
|
||||
<div className="divide-y divide-sand-a2">
|
||||
{ROW_PLACEHOLDERS.map(placeholder => (
|
||||
<ComplianceArticleItemSkeleton key={placeholder} />
|
||||
<div className={card()} aria-hidden>
|
||||
<div className={rows()}>
|
||||
{Array.from({ length: ROW_COUNT }, (_, index) => (
|
||||
<ComplianceArticleItemSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { updatesList } from "./variants";
|
||||
|
||||
interface UpdatesListProps {
|
||||
// Dims the list while a page change is loading.
|
||||
busy?: boolean;
|
||||
// The rendered update rows.
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// White card surface holding divider-separated update rows.
|
||||
export function UpdatesList({ busy = false, children }: UpdatesListProps) {
|
||||
const { card, rows } = updatesList();
|
||||
|
||||
return (
|
||||
<div className={card({ busy })} aria-busy={busy || undefined}>
|
||||
<div className={rows()}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { tv } from "tailwind-variants/lite";
|
||||
|
||||
// Updates list surface: a white card holding divider-separated rows. The card
|
||||
// dims while a page change is loading. Slots are shared by the list and its
|
||||
// skeleton so the loading placeholder matches the real layout.
|
||||
export const updatesList = tv({
|
||||
slots: {
|
||||
card: "overflow-hidden rounded-5 border border-sand-3 bg-sand-1 transition-opacity duration-150",
|
||||
rows: "divide-y divide-sand-a2",
|
||||
},
|
||||
variants: {
|
||||
busy: {
|
||||
true: { card: "opacity-60" },
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
busy: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Update detail article layout: the toolbar row (back link + subscribe), the
|
||||
// centered content column, and the article body with its gold metadata row.
|
||||
// Slots are shared by the detail page and its skeleton.
|
||||
export const updateArticle = tv({
|
||||
slots: {
|
||||
toolbar: "flex w-full items-center justify-between gap-4",
|
||||
content: "flex w-full flex-col items-center px-8 py-8",
|
||||
article: "flex w-full max-w-2xl flex-col gap-4",
|
||||
meta: "flex items-center gap-1.5",
|
||||
metaIcon: "size-4 text-gold-9",
|
||||
body: "block whitespace-pre-wrap",
|
||||
},
|
||||
});
|
||||
16
apps/compliance-portal/src/pages/updates/_lib/constants.ts
Normal file
16
apps/compliance-portal/src/pages/updates/_lib/constants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
// Page size for the cursor-paginated updates list.
|
||||
export const UPDATES_PAGE_SIZE = 25;
|
||||
@@ -5,9 +5,5 @@
|
||||
"empty": {
|
||||
"title": "No updates yet.",
|
||||
"description": "Subscribe to get notified when new updates are published."
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Previous page",
|
||||
"next": "Next page"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,5 @@
|
||||
"empty": {
|
||||
"title": "Aucune mise à jour pour le moment.",
|
||||
"description": "Abonnez-vous pour être informé lors de la publication de nouvelles mises à jour."
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Page précédente",
|
||||
"next": "Page suivante"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user