An audit of all twelve draft files against the build turned up a set of mismatches, most of them in the tier-2 token layer rather than in any one component. Tier 1 is untouched throughout - only the meanings moved. SHAPE. The drafts draw three corners, not five: 2px for a tag, thumbnail, flag or checkbox, 4px for anything with a box, and fully round for a filter chip or status pill. Nothing in any draft is 8px or 12px. --radius-card and --radius-surface therefore come down to radius/sm, a --radius-chip is added for the 2px tier, and --radius-control-lg - a quirk of the Figma Button frame's ghost cells - is retired along with its four consumers. ELEVATION. A shadow is a fraction of the ground it falls on, and the ramp was tuned for a white page: on the drafts' near-black ground a 12-28% black is invisible. Tier 1 gains the two deep steps the drafts actually draw (--shadow-dark-lg/xl), and --elevation-card/-popover/-modal move into the per-surface blocks so the dark surface can take them while the light one keeps the soft ramp. A resting card now has no shadow at all - the drafts separate with a hairline - and the black is spent on what floats. CARDS. The hover was a white-24% hairline and a faint glow; the drafts turn the hairline accent green, lift the card 6px and land a deep black under it. The lift is pointer-only and is dropped under prefers-reduced-motion. GHOST IS A TEXT LINK. The drafts never fill a ghost control - every "All updates ->" is plain text that turns green while the label-to-arrow gap opens. The tokens carry no fill in any state and the variant opts out of the 3px lift, which also retires the WCAG workaround the old Figma hover forced (green label on a green fill, 1.40:1). NAVIGATION. A nav label rests in text/secondary and brightens to primary; the current section stays primary and is marked by the accent rule, not by green text. HERO STRIPE. The middle block was painted near-black against a comment claiming the redesign had dropped white; the drafts paint it #F8F9FA. Back to background/inverse, and to the drafts' 390 : 495 : 363 split. styles/README.md is updated to match, including a contrast table that still described --surface-raised as the retired #2e2e2c. Not changed, because each is a decision rather than a defect: the drafts set every eyebrow, tag and meta line in IBM Plex Mono at 10-11px with 1.2-1.4px tracking (a third typeface, and no Arabic counterpart); draft headings are weight 800, which needs a font file we do not have; and the EU badge statement stays at --text-primary, since the visibility lockup is contractual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
653 lines
21 KiB
TypeScript
653 lines
21 KiB
TypeScript
/* ============================================================================
|
||
EVENTS CALENDAR - /{locale}/events-calendar
|
||
----------------------------------------------------------------------------
|
||
Draft section 4, laid out to the 2026 redesign draft ("Events-Calendar"):
|
||
|
||
filter row All · Upcoming · Ongoing · Completed as pills, a region
|
||
select beside them, and a List / Month view switch
|
||
list view one wide row per event: a date chip, the title, where and
|
||
when, the registration state, and the call to action
|
||
month view a seven-column grid with the events of one month in it
|
||
|
||
Every control is a real URL (?status=®ion=&view=&month=&page=), so a
|
||
filtered calendar is shareable, survives the back button and is reachable by
|
||
a crawler that only follows links. There is no client-side filtering to fall
|
||
out of step with the server, and the page works with scripting off.
|
||
|
||
Upcoming / Ongoing / Completed are DERIVED from the dates rather than stored
|
||
(see ./_events-opportunities/events.ts), so nothing goes stale the morning
|
||
after an event.
|
||
|
||
NOT ONE DATE ON THIS PAGE IS WRITTEN HERE. The draft's five sample events -
|
||
the Tripoli SUQ dialogue on 4 March, the Benghazi network meeting, "March
|
||
2026" - were mockup content. The grid and the list are built from Strapi
|
||
entries; with an empty CMS the localised empty state renders instead, and if
|
||
Strapi cannot be reached at all the page still renders, with a notice,
|
||
rather than a 500.
|
||
|
||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||
* the "You're registered." confirmation panel - registration happens on
|
||
the event's own page, which already renders that approved confirmation;
|
||
putting it on the listing would mean faking a submission;
|
||
* "Add to calendar · iCal / Google" on the month header - the .ics route
|
||
is per event (events-calendar.$slug.ics.ts) and there is no whole-
|
||
calendar feed to offer, so the action lives on each event instead;
|
||
* the event-type kicker ("SUQ Dialogue", "Training"): `event.eventType`
|
||
exists in Strapi but is not carried on the card view model, which lives
|
||
in a module this change does not own.
|
||
========================================================================= */
|
||
|
||
import { Link, useSearchParams } from "react-router";
|
||
|
||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||
import { CardLink, CardShell } from "~/components/ui/Card";
|
||
import {
|
||
intlLocale,
|
||
localizedPath,
|
||
sectionPath,
|
||
toIsoDate,
|
||
translate,
|
||
useTranslation,
|
||
type Locale,
|
||
} from "~/i18n";
|
||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||
|
||
import type { Route } from "./+types/events-calendar";
|
||
import {
|
||
collectRegions,
|
||
eventActionKey,
|
||
eventRegistrationLabel,
|
||
eventTimeStatusKey,
|
||
EVENT_STATUS_FILTERS,
|
||
EVENT_TIME_ZONE,
|
||
filterEvents,
|
||
formatEventWhen,
|
||
sortEvents,
|
||
type EventCardData,
|
||
} from "./_events-opportunities/events";
|
||
import { fetchEvents } from "./_events-opportunities/events.server";
|
||
import { getRouteCopy } from "./_events-opportunities/copy";
|
||
import { readRootSeo } from "./_events-opportunities/root-seo";
|
||
import { Notice } from "./_events-opportunities/ui";
|
||
import {
|
||
buildCalendarMonth,
|
||
calendarDayIn,
|
||
monthKeyOf,
|
||
parseMonthKey,
|
||
weekdayLabel,
|
||
weekStartFor,
|
||
type CalendarDay,
|
||
type CalendarMonth,
|
||
} from "./_shared/calendar";
|
||
import {
|
||
buildPagination,
|
||
PAGE_SIZE,
|
||
parseEnum,
|
||
parsePage,
|
||
requireLocale,
|
||
withParam,
|
||
} from "./_shared/listing";
|
||
import {
|
||
EmptyState,
|
||
FilterPills,
|
||
ListingHero,
|
||
MetaLine,
|
||
PageShell,
|
||
Pagination,
|
||
PillSelect,
|
||
ResultSummary,
|
||
} from "./_shared/listing-ui";
|
||
import { getListingChrome } from "./_shared/page-copy";
|
||
|
||
import "./_page-kit/motion.css";
|
||
|
||
/** Longest region value we will echo back into a filter. */
|
||
const MAX_REGION_LENGTH = 80;
|
||
|
||
/** The two ways the calendar can be read. */
|
||
const VIEWS = ["list", "month"] as const;
|
||
type CalendarView = (typeof VIEWS)[number];
|
||
|
||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||
const locale = requireLocale(params.lang);
|
||
const url = new URL(request.url);
|
||
const now = new Date();
|
||
|
||
const status = parseEnum(
|
||
url.searchParams,
|
||
"status",
|
||
EVENT_STATUS_FILTERS,
|
||
"all",
|
||
);
|
||
const view = parseEnum(url.searchParams, "view", VIEWS, "list");
|
||
|
||
const { events, available } = await fetchEvents(locale, now);
|
||
|
||
/* Region options come from the events themselves, never from a hard-coded
|
||
list: an empty CMS offers no regions, and a region only appears once
|
||
something is actually happening in it. A requested region that no event
|
||
carries is dropped rather than rendering an empty page. */
|
||
const regions = collectRegions(events, locale);
|
||
const requestedRegion = url.searchParams
|
||
.get("region")
|
||
?.trim()
|
||
.slice(0, MAX_REGION_LENGTH);
|
||
const region =
|
||
requestedRegion && regions.includes(requestedRegion) ? requestedRegion : null;
|
||
|
||
const matching = sortEvents(filterEvents(events, { status, region }));
|
||
const pagination = buildPagination(
|
||
parsePage(url.searchParams),
|
||
PAGE_SIZE,
|
||
matching.length,
|
||
);
|
||
const start = (pagination.page - 1) * PAGE_SIZE;
|
||
|
||
/* Which month the grid opens on: the one the reader asked for, else the
|
||
month of the next event in the filtered set, else the current month.
|
||
Derived, never a literal - the draft's "March 2026" was sample content. */
|
||
const requestedMonth = parseMonthKey(url.searchParams.get("month"));
|
||
const firstDay = matching[0]
|
||
? calendarDayIn(matching[0].start, EVENT_TIME_ZONE)
|
||
: undefined;
|
||
const today = calendarDayIn(now, EVENT_TIME_ZONE);
|
||
const month =
|
||
requestedMonth ??
|
||
(firstDay ? monthKeyOf(firstDay) : monthKeyOf(today ?? "1970-01-01"));
|
||
|
||
const calendar = buildCalendarMonth({
|
||
month,
|
||
entries: matching.map((event) => ({
|
||
id: event.slug,
|
||
title: event.title,
|
||
href: `${sectionPath("events", locale)}/${encodeURIComponent(event.slug)}`,
|
||
start: event.start,
|
||
...(event.end ? { end: event.end } : {}),
|
||
allDay: event.allDay,
|
||
})),
|
||
timeZone: EVENT_TIME_ZONE,
|
||
weekStartsOn: weekStartFor(locale),
|
||
now,
|
||
});
|
||
|
||
return {
|
||
locale,
|
||
events: matching.slice(start, start + PAGE_SIZE),
|
||
pagination,
|
||
status,
|
||
region,
|
||
regions,
|
||
view,
|
||
calendar,
|
||
available,
|
||
/** Distinguishes "the CMS is empty" from "these filters match nothing". */
|
||
hasEvents: events.length > 0,
|
||
};
|
||
}
|
||
|
||
export function meta({ location, matches }: Route.MetaArgs) {
|
||
const root = readRootSeo(matches);
|
||
const copy = getRouteCopy(root.locale);
|
||
const search = new URLSearchParams(location.search);
|
||
|
||
return buildMeta({
|
||
locale: root.locale,
|
||
pathname: root.pathname,
|
||
siteUrl: root.siteUrl,
|
||
title: translate(root.locale, "nav.sections.events"),
|
||
description: copy.events.metaDescription,
|
||
// A filtered, paginated or re-viewed calendar is the same content in
|
||
// another order.
|
||
noIndex:
|
||
search.has("status") ||
|
||
search.has("region") ||
|
||
search.has("page") ||
|
||
search.has("view") ||
|
||
search.has("month"),
|
||
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
||
{
|
||
name: translate(root.locale, "nav.sections.home"),
|
||
pathname: localizedPath("/", root.locale),
|
||
},
|
||
{
|
||
name: translate(root.locale, "nav.sections.events"),
|
||
pathname: sectionPath("events", root.locale),
|
||
},
|
||
]),
|
||
});
|
||
}
|
||
|
||
export default function EventsCalendar({ loaderData }: Route.ComponentProps) {
|
||
const {
|
||
events,
|
||
pagination,
|
||
status,
|
||
region,
|
||
regions,
|
||
view,
|
||
calendar,
|
||
available,
|
||
hasEvents,
|
||
} = loaderData;
|
||
const { locale, t } = useTranslation();
|
||
const copy = getRouteCopy(locale);
|
||
const chrome = getListingChrome(locale).events;
|
||
const [searchParams] = useSearchParams();
|
||
|
||
const pathname = sectionPath("events", locale);
|
||
const href = (key: string, value: string | undefined) =>
|
||
`${pathname}${withParam(searchParams, key, value)}`;
|
||
|
||
const statusPills = EVENT_STATUS_FILTERS.map((value) => ({
|
||
id: value,
|
||
label: value === "all" ? t("filters.all") : t(eventTimeStatusKey(value)),
|
||
href: href("status", value === "all" ? undefined : value),
|
||
isCurrent: status === value,
|
||
}));
|
||
|
||
/* Paging and the month on show are properties of one view, so switching
|
||
view keeps the filters and starts the new view at its own beginning. */
|
||
const viewParams = new URLSearchParams();
|
||
if (status !== "all") viewParams.set("status", status);
|
||
if (region) viewParams.set("region", region);
|
||
|
||
const viewPills = VIEWS.map((value) => ({
|
||
id: value,
|
||
label: value === "list" ? chrome.viewList : chrome.viewMonth,
|
||
href: `${pathname}${withParam(
|
||
viewParams,
|
||
"view",
|
||
value === "list" ? undefined : value,
|
||
)}`,
|
||
isCurrent: view === value,
|
||
}));
|
||
|
||
/* What the region form has to carry across a GET submit - the query string
|
||
is replaced wholesale, so anything not in the form is lost. */
|
||
const keep: (readonly [string, string])[] = [
|
||
...(status === "all" ? [] : [["status", status] as const]),
|
||
...(view === "list" ? [] : [["view", view] as const]),
|
||
];
|
||
|
||
const isFiltered = status !== "all" || region !== null;
|
||
|
||
return (
|
||
<>
|
||
<ListingHero
|
||
eyebrow={chrome.eyebrow}
|
||
heading={copy.events.heading}
|
||
intro={copy.events.intro}
|
||
ambient={[{ top: -250, from: "end", offset: "4%", width: 720, height: 560, durationSeconds: 20 }]}
|
||
/>
|
||
|
||
<PageShell>
|
||
{available ? null : (
|
||
<Notice tone="info" role="status">
|
||
{copy.events.unavailable}
|
||
</Notice>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-lg">
|
||
<div className="flex flex-wrap items-center justify-between gap-lg border-b border-border-subtle pb-lg">
|
||
<FilterPills
|
||
label={copy.events.statusFilterLabel}
|
||
pills={statusPills}
|
||
>
|
||
{regions.length > 0 ? (
|
||
<PillSelect
|
||
name="region"
|
||
label={t("filters.region")}
|
||
value={region ?? ""}
|
||
options={regions.map((value) => ({ value, label: value }))}
|
||
allLabel={t("filters.all")}
|
||
applyLabel={t("filters.apply")}
|
||
action={pathname}
|
||
keep={keep}
|
||
/>
|
||
) : null}
|
||
</FilterPills>
|
||
|
||
{/* The draft draws the List / Month switch as a pair of square
|
||
controls with no caption, beside the rounded filter pills. */}
|
||
<FilterPills
|
||
label={chrome.viewLabel}
|
||
pills={viewPills}
|
||
shape="control"
|
||
hideLabel
|
||
/>
|
||
</div>
|
||
|
||
{view === "list" ? <ResultSummary pagination={pagination} t={t} /> : null}
|
||
</div>
|
||
|
||
{view === "month" ? (
|
||
<MonthView
|
||
calendar={calendar}
|
||
locale={locale}
|
||
emptyMessage={chrome.monthEmpty}
|
||
label={chrome.calendarLabel}
|
||
previousLabel={chrome.previousMonth}
|
||
nextLabel={chrome.nextMonth}
|
||
hrefFor={(month) => href("month", month)}
|
||
/>
|
||
) : events.length > 0 ? (
|
||
<>
|
||
{/* The list is the page's content, so it gets a heading in the
|
||
outline even though the hero already names the page. */}
|
||
<h2 className="sr-only">{copy.events.listLabel}</h2>
|
||
<ul
|
||
aria-label={copy.events.listLabel}
|
||
className="flex flex-col gap-md"
|
||
>
|
||
{events.map((event) => (
|
||
<EventRow
|
||
key={event.slug}
|
||
event={event}
|
||
href={`${pathname}/${encodeURIComponent(event.slug)}`}
|
||
/>
|
||
))}
|
||
</ul>
|
||
|
||
<Pagination
|
||
model={pagination}
|
||
pathname={pathname}
|
||
params={searchParams}
|
||
t={t}
|
||
/>
|
||
</>
|
||
) : hasEvents && isFiltered ? (
|
||
<EmptyState
|
||
title={t("filters.noResults")}
|
||
body={t("filters.noResultsHint")}
|
||
>
|
||
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
||
{t("filters.clear")}
|
||
</Button>
|
||
</EmptyState>
|
||
) : (
|
||
<EmptyState title={t("events.noEvents")} body={copy.events.emptyHint} />
|
||
)}
|
||
</PageShell>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
ONE EVENT, AS THE DRAFT DRAWS IT
|
||
------------------------------------------------------------------------ */
|
||
|
||
/** "10:00–14:00" in Libya time, or nothing for an all-day event. */
|
||
function formatClock(event: EventCardData, locale: Locale): string | undefined {
|
||
if (event.allDay) return undefined;
|
||
|
||
const options: Intl.DateTimeFormatOptions = {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
timeZone: EVENT_TIME_ZONE,
|
||
};
|
||
const formatter = new Intl.DateTimeFormat(intlLocale(locale), options);
|
||
|
||
const start = new Date(event.start);
|
||
if (Number.isNaN(start.getTime())) return undefined;
|
||
|
||
const end = event.end ? new Date(event.end) : null;
|
||
if (end && !Number.isNaN(end.getTime()) && end.getTime() !== start.getTime()) {
|
||
return formatter.formatRange(start, end);
|
||
}
|
||
return formatter.format(start);
|
||
}
|
||
|
||
/** The date block on the inline-start edge: month over the day number. */
|
||
function DateChip({ event, locale }: { event: EventCardData; locale: Locale }) {
|
||
const timeZone = event.allDay ? "UTC" : EVENT_TIME_ZONE;
|
||
const date = new Date(event.start);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
|
||
const part = (options: Intl.DateTimeFormatOptions) =>
|
||
new Intl.DateTimeFormat(intlLocale(locale), { ...options, timeZone }).format(
|
||
date,
|
||
);
|
||
|
||
return (
|
||
/* The draft draws a 76x82 block holding the month over the day and
|
||
nothing else - the full date, year included, is on the line under the
|
||
title, so repeating it here only crowds the block. */
|
||
<div className="flex h-[5.125rem] w-[4.75rem] shrink-0 flex-col items-center justify-center gap-2xs rounded-card border border-border-subtle bg-surface-sunken">
|
||
<span className="label-md uppercase text-text-accent">
|
||
{part({ month: "short" })}
|
||
</span>
|
||
<span className="heading-h2 text-text-primary">
|
||
{part({ day: "numeric" })}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EventRow({ event, href }: { event: EventCardData; href: string }) {
|
||
const { locale, t } = useTranslation();
|
||
const copy = getRouteCopy(locale);
|
||
|
||
const isPast = event.timeStatus === "completed";
|
||
const isLive = !isPast && event.registrationStatus === "open";
|
||
|
||
const formatLabel =
|
||
event.format === "online"
|
||
? t("events.online")
|
||
: event.format === "hybrid"
|
||
? copy.events.formatHybrid
|
||
: event.format === "in-person"
|
||
? t("events.inPerson")
|
||
: undefined;
|
||
|
||
const clock = formatClock(event, locale);
|
||
|
||
/* The draft's kicker above the title. Empty for an event the CMS left
|
||
unclassified ("other"), in which case only the time status shows. */
|
||
const typeLabel = event.eventType
|
||
? copy.events.eventTypes[event.eventType]
|
||
: undefined;
|
||
|
||
return (
|
||
<CardShell
|
||
as="li"
|
||
linked
|
||
className="sh-reveal sh-reveal--in flex-col gap-lg p-lg md:flex-row md:items-center"
|
||
>
|
||
<div className="flex min-w-0 flex-1 items-center gap-lg">
|
||
<DateChip event={event} locale={locale} />
|
||
|
||
<div className="flex min-w-0 flex-col gap-xs">
|
||
<p className="label-md uppercase text-text-tertiary">
|
||
{typeLabel ? (
|
||
<>
|
||
<span className="text-text-accent">{typeLabel}</span>
|
||
<span aria-hidden="true"> · </span>
|
||
</>
|
||
) : null}
|
||
{t(eventTimeStatusKey(event.timeStatus))}
|
||
</p>
|
||
|
||
<h3 className="sh-card-title body-lg-bold text-text-primary">
|
||
<CardLink href={href} className="sh-card-link">
|
||
{event.title}
|
||
</CardLink>
|
||
</h3>
|
||
|
||
<p className="body-sm-bold text-text-secondary">
|
||
<time dateTime={toIsoDate(event.start)}>
|
||
{formatEventWhen(event, locale)}
|
||
</time>
|
||
</p>
|
||
|
||
<MetaLine
|
||
items={[formatLabel, event.place, event.region, clock].filter(
|
||
(item): item is string => Boolean(item),
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-md md:justify-end">
|
||
{/* A finished event's registration state is history, not news. */}
|
||
{isPast ? null : (
|
||
<span className="inline-flex items-center gap-xs rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
|
||
{isLive ? (
|
||
<span
|
||
aria-hidden="true"
|
||
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
||
/>
|
||
) : null}
|
||
{eventRegistrationLabel(event.registrationStatus, locale)}
|
||
</span>
|
||
)}
|
||
|
||
{/* The draft ends every row with the filled accent action - Register,
|
||
Apply, Join waitlist, Read recap - not a text hint. `sh-card-above`
|
||
lifts it over the stretched title link so it stays its own target,
|
||
and the label comes from the event's own registration state. */}
|
||
<Button
|
||
as={Link}
|
||
to={href}
|
||
prefetch="intent"
|
||
variant="primary"
|
||
size="md"
|
||
iconEnd={<ArrowIcon />}
|
||
className="sh-card-above shrink-0"
|
||
>
|
||
{t(eventActionKey(event))}
|
||
</Button>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
THE MONTH GRID
|
||
------------------------------------------------------------------------ */
|
||
|
||
function MonthView({
|
||
calendar,
|
||
locale,
|
||
label,
|
||
emptyMessage,
|
||
previousLabel,
|
||
nextLabel,
|
||
hrefFor,
|
||
}: {
|
||
calendar: CalendarMonth;
|
||
locale: Locale;
|
||
label: string;
|
||
emptyMessage: string;
|
||
previousLabel: string;
|
||
nextLabel: string;
|
||
hrefFor: (month: string) => string;
|
||
}) {
|
||
const monthName = new Intl.DateTimeFormat(intlLocale(locale), {
|
||
month: "long",
|
||
year: "numeric",
|
||
timeZone: "UTC",
|
||
}).format(new Date(`${calendar.firstDay}T00:00:00Z`));
|
||
|
||
return (
|
||
<section aria-label={label} className="flex flex-col gap-lg">
|
||
<div className="flex flex-wrap items-center justify-between gap-md">
|
||
<h2 className="heading-h3 text-text-primary">{monthName}</h2>
|
||
|
||
<div className="flex items-center gap-xs">
|
||
<Button
|
||
as={Link}
|
||
to={hrefFor(calendar.previous)}
|
||
prefetch="intent"
|
||
variant="ghost"
|
||
size="sm"
|
||
iconStart={<ArrowIcon direction="backward" />}
|
||
>
|
||
{previousLabel}
|
||
</Button>
|
||
<Button
|
||
as={Link}
|
||
to={hrefFor(calendar.next)}
|
||
prefetch="intent"
|
||
variant="ghost"
|
||
size="sm"
|
||
iconEnd={<ArrowIcon />}
|
||
>
|
||
{nextLabel}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* The hairlines are the grid's own gap showing through, which is how the
|
||
draft draws it - one border round the whole block, one pixel between
|
||
the cells. */}
|
||
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-card border border-border-subtle bg-[var(--border-subtle)]">
|
||
{calendar.weekdays.map((weekday) => (
|
||
<div
|
||
key={weekday}
|
||
className="bg-surface-raised p-sm text-center label-md uppercase text-text-tertiary"
|
||
>
|
||
<span aria-hidden="true">
|
||
{weekdayLabel(weekday, intlLocale(locale), "narrow")}
|
||
</span>
|
||
<span className="sr-only">
|
||
{weekdayLabel(weekday, intlLocale(locale), "long")}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{calendar.days.map((day) => (
|
||
<MonthCell key={day.key} day={day} locale={locale} />
|
||
))}
|
||
</div>
|
||
|
||
{calendar.entryCount === 0 ? (
|
||
<p className="body-md text-text-secondary" role="status">
|
||
{emptyMessage}
|
||
</p>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function MonthCell({ day, locale }: { day: CalendarDay; locale: Locale }) {
|
||
const dayNumber = new Intl.NumberFormat(intlLocale(locale), {
|
||
useGrouping: false,
|
||
}).format(day.dayNumber);
|
||
|
||
return (
|
||
<div
|
||
className={[
|
||
"sh-lift flex min-h-[6rem] flex-col gap-2xs p-xs",
|
||
day.inMonth ? "bg-[var(--surface-page)]" : "bg-surface-sunken",
|
||
"hover:bg-surface-muted",
|
||
].join(" ")}
|
||
>
|
||
<p
|
||
className={[
|
||
"label-md",
|
||
day.isToday ? "text-text-accent" : "text-text-tertiary",
|
||
day.inMonth ? "" : "opacity-60",
|
||
].join(" ")}
|
||
>
|
||
<time dateTime={day.key}>{dayNumber}</time>
|
||
</p>
|
||
|
||
{day.entries.map((entry) => (
|
||
<Link
|
||
key={`${day.key}-${entry.id}`}
|
||
to={entry.href}
|
||
prefetch="intent"
|
||
className={[
|
||
"rounded-chip border-s-2 border-s-[var(--border-accent)]",
|
||
"bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary",
|
||
"transition-colors duration-[var(--motion-duration-fast)]",
|
||
"ease-[var(--motion-ease-standard)] hover:text-text-accent",
|
||
].join(" ")}
|
||
>
|
||
{entry.title}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|