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:
Émile Ré
2026-07-09 18:02:16 -04:00
parent a238f78768
commit 886cb36f98
14 changed files with 188 additions and 69 deletions

View File

@@ -38,5 +38,9 @@
}, },
"footer": { "footer": {
"poweredBy": "Powered by" "poweredBy": "Powered by"
},
"pagination": {
"previous": "Previous page",
"next": "Next page"
} }
} }

View File

@@ -38,5 +38,9 @@
}, },
"footer": { "footer": {
"poweredBy": "Propulsé par" "poweredBy": "Propulsé par"
},
"pagination": {
"previous": "Page précédente",
"next": "Page suivante"
} }
} }

View File

@@ -12,15 +12,23 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
// Locale-aware long date formatting via Intl.DateTimeFormat, e.g. // Long-date defaults, e.g. "August 6, 2026". Callers can override or extend any
// "August 6, 2026". The locale must be the active i18next language so the output // of these through the `options` argument.
// follows the UI. const DEFAULT_OPTIONS: Intl.DateTimeFormatOptions = {
export function formatDate(date: Date | string | number, locale: string): string {
const target = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(locale, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(target); };
// 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, { ...DEFAULT_OPTIONS, ...options }).format(target);
} }

View File

@@ -14,33 +14,36 @@
import { useCallback, useTransition } from "react"; import { useCallback, useTransition } from "react";
// Page size for the cursor-paginated updates list. Matches the Figma list frame. // The `pageInfo` shape a Relay connection exposes for bidirectional cursor
export const UPDATES_PAGE_SIZE = 10; // pagination. Structurally compatible with generated connection page info.
export interface CursorPageInfo {
export interface UpdatesPageInfo {
hasPreviousPage: boolean; hasPreviousPage: boolean;
hasNextPage: boolean; hasNextPage: boolean;
startCursor: string | null | undefined; startCursor: string | null | undefined;
endCursor: string | null | undefined; endCursor: string | null | undefined;
} }
export interface UpdatesPaginationVariables { // The connection pagination arguments passed to a refetch.
export interface CursorPaginationVariables {
first?: number | null; first?: number | null;
after?: string | null; after?: string | null;
last?: number | null; last?: number | null;
before?: string | 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 // Prev/Next pagination for a Relay cursor connection. Drives the refetch inside
// refetch inside a transition so the current page stays mounted (dimmed) while // a transition so the current page stays mounted (dimmed) while the next one
// the next one loads. Enabled/disabled state comes from the server `pageInfo`, // loads; Prev/Next availability comes from the server `pageInfo`, so it stays
// which stays correct however the page is reached. No page-number counter: // correct however the page is reached. There is no page-number counter: cursor
// cursor pagination encodes a position, not an ordinal, so a reliable page // pagination encodes a position, not an ordinal, so a reliable page index
// index (deep-linkable or refresh-safe) would need offset + totalCount, which // (deep-linkable or refresh-safe) would need offset + totalCount.
// this API does not expose. export function useCursorPagination(
export function useUpdatesPagination(refetch: RefetchUpdates, pageInfo: UpdatesPageInfo) { refetch: CursorRefetch,
pageInfo: CursorPageInfo,
pageSize: number,
) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const goNext = useCallback(() => { const goNext = useCallback(() => {
@@ -48,18 +51,18 @@ export function useUpdatesPagination(refetch: RefetchUpdates, pageInfo: UpdatesP
return; return;
} }
startTransition(() => { 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(() => { const goPrevious = useCallback(() => {
if (!pageInfo.hasPreviousPage || pageInfo.startCursor == null) { if (!pageInfo.hasPreviousPage || pageInfo.startCursor == null) {
return; return;
} }
startTransition(() => { 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 }; return { isPending, goPrevious, goNext };
} }

View File

@@ -25,6 +25,7 @@ import { formatDate } from "#/lib/datetime/formatDate";
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql"; import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton"; import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
import { updateArticle } from "./_components/variants";
export const updateDetailPageQuery = graphql` export const updateDetailPageQuery = graphql`
query UpdateDetailPageQuery($updateId: ID!) { query UpdateDetailPageQuery($updateId: ID!) {
@@ -52,20 +53,22 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
} }
const update = data.node; const update = data.node;
const { toolbar, content, article, meta, metaIcon, body } = updateArticle();
return ( return (
<> <>
<HeaderBand> <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 />}> <Link to="/updates" variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
{t("backToUpdates")} {t("backToUpdates")}
</Link> </Link>
<UpdatesSubscribeButton /> <UpdatesSubscribeButton />
</div> </div>
</HeaderBand> </HeaderBand>
<div className="flex w-full flex-col items-center px-8 py-8"> <div className={content()}>
<article className="flex w-full max-w-2xl flex-col gap-4"> <article className={article()}>
<div className="flex items-center gap-1.5"> <div className={meta()}>
<NewspaperIcon weight="light" className="size-4 text-gold-9" /> <NewspaperIcon weight="light" className={metaIcon()} />
<Text size={1} color="gold"> <Text size={1} color="gold">
{formatDate(update.updatedAt, i18n.language)} {formatDate(update.updatedAt, i18n.language)}
</Text> </Text>
@@ -73,7 +76,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
<Heading level={1} size={7} weight="medium" highContrast> <Heading level={1} size={7} weight="medium" highContrast>
{update.title} {update.title}
</Heading> </Heading>
<Text size={3} className="block whitespace-pre-wrap"> <Text size={3} className={body()}>
{update.body} {update.body}
</Text> </Text>
</article> </article>

View File

@@ -18,24 +18,28 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; 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() { export function UpdateDetailPageSkeleton() {
const { toolbar, content, article } = updateArticle();
return ( return (
<> <>
<HeaderBand> <HeaderBand>
<div className="flex w-full items-center justify-between gap-4"> <div className={toolbar()}>
<ButtonSkeleton size={2} /> <ButtonSkeleton size={2} />
<ButtonSkeleton size={2} /> <ButtonSkeleton size={2} />
</div> </div>
</HeaderBand> </HeaderBand>
<div className="flex w-full flex-col items-center px-8 py-8"> <div className={content()}>
<div className="flex w-full max-w-2xl flex-col gap-4" aria-hidden> <div className={article()} aria-hidden>
<TextSkeleton size={1} className="w-28" /> <TextSkeleton size={1} className="w-28" />
<HeadingSkeleton size={7} className="w-96" /> <HeadingSkeleton size={7} className="w-96" />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{BODY_PLACEHOLDERS.map(placeholder => ( {Array.from({ length: BODY_LINE_COUNT }, (_, index) => (
<TextSkeleton key={placeholder} size={3} className="w-full" /> <TextSkeleton key={index} size={3} className="w-full" />
))} ))}
</div> </div>
</div> </div>

View File

@@ -20,14 +20,16 @@ import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"
import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem"; import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
import { PageHeader } from "#/components/PageHeader/PageHeader"; 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 { UpdatesPage_query$key } from "./__generated__/UpdatesPage_query.graphql";
import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql"; import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
import type { UpdatesPageRefetchQuery } from "./__generated__/UpdatesPageRefetchQuery.graphql"; import type { UpdatesPageRefetchQuery } from "./__generated__/UpdatesPageRefetchQuery.graphql";
import { UpdatesEmpty } from "./_components/UpdatesEmpty"; import { UpdatesEmpty } from "./_components/UpdatesEmpty";
import { UpdatesList } from "./_components/UpdatesList";
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton"; import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
import type { UpdatesPaginationVariables } from "./_lib/useUpdatesPagination"; import { UPDATES_PAGE_SIZE } from "./_lib/constants";
import { useUpdatesPagination } from "./_lib/useUpdatesPagination";
export const updatesPageQuery = graphql` export const updatesPageQuery = graphql`
query UpdatesPageQuery($first: Int, $after: CursorKey, $last: Int, $before: CursorKey) { query UpdatesPageQuery($first: Int, $after: CursorKey, $last: Int, $before: CursorKey) {
@@ -69,19 +71,20 @@ interface UpdatesPageProps {
export function UpdatesPage({ queryRef }: UpdatesPageProps) { export function UpdatesPage({ queryRef }: UpdatesPageProps) {
const { t } = useTranslation("updates"); const { t } = useTranslation("updates");
const { t: tCommon } = useTranslation();
const root = usePreloadedQuery<UpdatesPageQuery>(updatesPageQuery, queryRef); const root = usePreloadedQuery<UpdatesPageQuery>(updatesPageQuery, queryRef);
const [data, refetch] = useRefetchableFragment<UpdatesPageRefetchQuery, UpdatesPage_query$key>( const [data, refetch] = useRefetchableFragment<UpdatesPageRefetchQuery, UpdatesPage_query$key>(
updatesPageFragment, updatesPageFragment,
root, root,
); );
const refetchUpdates = useCallback((variables: UpdatesPaginationVariables) => { const refetchUpdates = useCallback((variables: CursorPaginationVariables) => {
refetch(variables, { fetchPolicy: "store-or-network" }); refetch(variables, { fetchPolicy: "store-or-network" });
}, [refetch]); }, [refetch]);
const { updates } = data.currentTrustCenter; const { updates } = data.currentTrustCenter;
const { pageInfo } = updates; 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 nodes = updates.edges.map(edge => edge.node);
const isEmpty = nodes.length === 0; const isEmpty = nodes.length === 0;
@@ -95,21 +98,16 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) {
? <UpdatesEmpty /> ? <UpdatesEmpty />
: ( : (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div <UpdatesList busy={isPending}>
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 => ( {nodes.map(node => (
<MailingListUpdateListItem key={node.id} updateKey={node} /> <MailingListUpdateListItem key={node.id} updateKey={node} />
))} ))}
</div> </UpdatesList>
</div>
<Pagination <Pagination
hasPrevious={pageInfo.hasPreviousPage} hasPrevious={pageInfo.hasPreviousPage}
hasNext={pageInfo.hasNextPage} hasNext={pageInfo.hasNextPage}
previousLabel={t("pagination.previous")} previousLabel={tCommon("pagination.previous")}
nextLabel={t("pagination.next")} nextLabel={tCommon("pagination.next")}
onPrevious={goPrevious} onPrevious={goPrevious}
onNext={goNext} onNext={goNext}
/> />

View File

@@ -16,7 +16,7 @@ import { useEffect } from "react";
import { useQueryLoader } from "react-relay"; import { useQueryLoader } from "react-relay";
import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql"; 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 { UpdatesPage, updatesPageQuery } from "./UpdatesPage";
import { UpdatesPageSkeleton } from "./UpdatesPageSkeleton"; import { UpdatesPageSkeleton } from "./UpdatesPageSkeleton";

View File

@@ -18,9 +18,13 @@ import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
import { ComplianceArticleItemSkeleton } from "#/components/ComplianceArticleItem/ComplianceArticleItemSkeleton"; import { ComplianceArticleItemSkeleton } from "#/components/ComplianceArticleItem/ComplianceArticleItemSkeleton";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; 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() { export function UpdatesPageSkeleton() {
const { card, rows } = updatesList();
return ( return (
<> <>
<HeaderBand> <HeaderBand>
@@ -30,10 +34,10 @@ export function UpdatesPageSkeleton() {
</HeaderBand> </HeaderBand>
<div className="flex w-full flex-col items-center px-8 py-8"> <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="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={card()} aria-hidden>
<div className="divide-y divide-sand-a2"> <div className={rows()}>
{ROW_PLACEHOLDERS.map(placeholder => ( {Array.from({ length: ROW_COUNT }, (_, index) => (
<ComplianceArticleItemSkeleton key={placeholder} /> <ComplianceArticleItemSkeleton key={index} />
))} ))}
</div> </div>
</div> </div>

View File

@@ -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>
);
}

View File

@@ -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",
},
});

View 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;

View File

@@ -5,9 +5,5 @@
"empty": { "empty": {
"title": "No updates yet.", "title": "No updates yet.",
"description": "Subscribe to get notified when new updates are published." "description": "Subscribe to get notified when new updates are published."
},
"pagination": {
"previous": "Previous page",
"next": "Next page"
} }
} }

View File

@@ -5,9 +5,5 @@
"empty": { "empty": {
"title": "Aucune mise à jour pour le moment.", "title": "Aucune mise à jour pour le moment.",
"description": "Abonnez-vous pour être informé lors de la publication de nouvelles mises à jour." "description": "Abonnez-vous pour être informé lors de la publication de nouvelles mises à jour."
},
"pagination": {
"previous": "Page précédente",
"next": "Page suivante"
} }
} }