/** * Planner — a month of days, each carrying what falls on it. * * ```tsx * const [month, setMonth] = useState(new Date()); * * * * * * * * * * * {(date, entries) => {entries.length} on {date.toDateString()}} * * * ``` * * ## How it differs from Calendar * * `Calendar` picks a date and answers with one. This shows what is already on * the days and answers with the day you asked about — the selection exists to * open something, not to be submitted. * * ## Why it draws its own Frame * * A month at a glance is a widget: a boundary, a strip along the top carrying * the month and the way through it, and a footer that holds still while the * middle changes. That is `Frame`, so the root renders one instead of leaving * every caller to assemble the same shell. Pass `frame={false}` to drop it, * for a planner in a sheet or a card that already draws its own edge. * * ## Why the grid is always six weeks * * A month can span five weeks or six. Drawn at its natural height the panel * jumps as you page through the year and the days appear to move under your * thumb, so the grid is always six rows and the last one is sometimes all * next month. `Calendar` fixes its height for the same reason. * * ## Why a day says more than its date * * The marker on a day is a coloured dot, and colour is a signal that does not * reach everyone looking at it. So the legend prints its label beside every * swatch, and a day is spoken as its date, how many entries it carries and * which categories they belong to. Neither is decoration: between them they * are the whole content of the grid for somebody who cannot see it. */ import { Children, createContext, forwardRef, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { AccessibilityInfo, AppState, FlatList, Platform, Pressable, View, type ViewToken, type ViewProps, } from 'react-native'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { ChevronLeftIcon, ChevronRightIcon } from '../../icons'; import { Text, type TextProps } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { addCalendarMonths, calendarDayNumber, calendarLongDate, calendarMonthLabel, isSameCalendarMonth, isSameDay, localeWeekStart, monthGrid, normalizeWeekStart, resolveCalendar, startOfCalendarMonth, startOfDay, weekdayNames, type CalendarSystem, type DateLocale, } from '../../utils/date'; import { Dialog } from '../dialog'; import { Frame } from '../frame'; import { bucketByDay, dayAccessibilityLabel, entriesOn, summariseMonth, visibleEntries, type PlannerCountedCategory, } from './planner-entries'; import { usePlannerMonthAnnouncement } from './planner-announcement'; import { plannerGridTarget } from './planner-grid-navigation'; import { weekAnchor, weekDays, weekIndex, weekRange } from './planner-weeks'; import { usePlannerMonthLifecycle, usePlannerSelectionLifecycle, } from './planner-lifecycle'; import { millisecondsUntilNextLocalDay } from './planner-today'; /** * How many of a day's entries a cell draws before it stops and counts. * * `calendar` gets one more because a named entry is a row, and a cell tall * enough to name one is tall enough for three; `tiles` draws a single icon at * the size of the tile, so the limit there is the look rather than a number. */ const DEFAULT_ENTRY_LIMIT: Record = { default: 2, tiles: 1, calendar: 3, }; /** The palette a category takes its dot from when it does not name a colour. */ const PALETTE_SIZE = 5; /** * How strongly a `tiles` day takes its category's colour. * * Low enough that the date in the corner and the icon over it both stay * legible, high enough that a brand colour arrives as that brand rather than as * a grey tile with a suggestion of one. */ const TILE_TINT_OPACITY = 0.32; /** * The height a `tiles` grid reserves, and what it is made of. * * Six weeks' worth, always — but only the weeks the month actually spans are * drawn, and they share the reserved height out between them. A five-week * month therefore has slightly taller tiles than a six-week one and no band of * empty space under the last row, while the panel itself never changes size as * you page through the year. Reserving the height and leaving the sixth row * blank, or dropping the row and letting the panel shrink, each fix one of * those at the cost of the other. */ const TILE_ROW_HEIGHT = 64; const TILE_ROW_GAP = 4; const TILE_GRID_HEIGHT = TILE_ROW_HEIGHT * 6 + TILE_ROW_GAP * 5; /** Stable wrapper keeps the native method's receiver and the hook dependency steady. */ const announceMonth = (label: string) => { AccessibilityInfo.announceForAccessibility(label); }; const plannerVariants = tv({ slots: { grid: 'gap-1 px-3 pb-3 pt-1', week: 'flex-row', heading: 'flex-1 text-center', legend: 'flex-row flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3', swatch: 'h-2 w-2 rounded-full', }, variants: { /* * The weeks share out the height instead of standing at their own. The * weekday row keeps its natural height — it is a label, and stretching it * only moves the letters away from the column they name. */ fill: { true: { grid: 'flex-1', week: 'flex-1' } }, }, }); const dayVariants = tv({ slots: { /* * A fixed height, and a square-ish tile rather than a circle. * * Fixed because the cell has to be the same size whether the day carries * anything or not — sized to its contents, a row with one icon in it is * taller than the five around it and the grid stops being a grid. * * Square-ish because a day here holds a number, a marker and sometimes an * icon, and a circle wastes the corners it needs for them. */ /* * The transparent border is load-bearing: today and the open day both draw * one, and a cell that only grows a border when it is picked shifts its * contents by a point at the moment you look at it. Every cell reserves the * point; the variants below only colour it. */ cell: 'mx-0.5 h-14 flex-1 rounded-xl border border-transparent px-1.5 pt-1.5', /* * `leading-none` is safe here and only here: these slots hold a day * number, `•••` or `+3`, and none of those has anything below the * baseline for Android to clip. Anywhere the caller's own words can * appear it has to be `leading-tight` — a line height equal to the font * size loses the tail of every `g` and `y` on that platform. */ number: 'text-xs leading-none', marker: 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full', body: 'flex-1 flex-row items-center justify-center gap-0.5 pb-1', /** The colour a day's entries give it, drawn under everything else. */ tint: 'absolute inset-0 rounded-xl', /* * The corners of a tile. These are Views rather than classes on the Text * itself: a Text takes its typography from a class but not its position, * so a number told to sit in a corner stayed where the layout put it — * dead centre, under the icon. */ corner: 'absolute bottom-1 right-1.5', counter: 'absolute left-1.5 top-1', /** One entry, named, in a cell with the height to name it. */ chip: 'w-full overflow-hidden rounded-sm bg-muted px-1 py-0.5', overflow: 'leading-none', }, variants: { /* * Cells stretch to their container rather than standing at a fixed height. * The grid is still six weeks, so what changes is how tall a week is, not * how many there are. */ fill: { true: { cell: 'h-auto min-h-0 flex-1' } }, /** Inside the month being shown, as opposed to the days either side. */ inMonth: { true: { cell: 'bg-muted/40', number: 'text-foreground' }, false: { cell: 'bg-muted/15', number: 'text-muted-foreground/40' }, }, /* * Today rings the tile; the open day fills it. Two rings would be the * problem — two channels are not, and the ring is the one that has to * survive being read at a glance across forty-two tiles. */ today: { true: { cell: 'border-foreground', number: 'font-semibold' } }, selected: { true: { cell: 'border-primary/60 bg-primary/10' } }, disabled: { true: { cell: 'opacity-40' } }, /* * What a cell draws. One axis, because these are three answers to the same * question and a cell can only give one of them. * * Last, so a look can undo what the states above it set. `calendar` has no * tile to ring or fill, so it has to be able to take the background and the * border back off a cell that today or the open day just gave one to. */ variant: { default: {}, /* * A tile per day, carrying the one thing on it at the size of a mark * rather than a marker. The date moves out of the way into the corner: * it is how you find the day you want, not what the cell is showing. */ tiles: { cell: 'h-full items-center justify-center px-0 pt-0', overflow: 'leading-none', }, /* * Named entries stacked down an open cell. No tile, because a tile per * day and a block per entry are two boxes saying the same thing and the * entries are the ones carrying words — the weeks are ruled off instead, * which separates them without enclosing anything. */ calendar: { cell: 'mx-0 h-full min-h-0 items-stretch gap-px rounded-none border-transparent bg-transparent px-1 pb-1 pt-1.5', number: 'text-center', body: 'flex-col items-stretch justify-start gap-px pb-0', overflow: 'text-center leading-none', }, }, }, compoundVariants: [ /* * Today, and open. Declaration order would otherwise hand the border to * `selected` and lose the one mark that says which day it actually is — * so the ring is restated over the fill. */ { today: true, selected: true, class: { cell: 'border-foreground bg-primary/10' } }, ], defaultVariants: { inMonth: true, variant: 'default' }, }); /* ------------------------------------------------------------------ * * Data * ------------------------------------------------------------------ */ /** * What a day cell draws. * * `default` is a number, a marker and small icons. `tiles` gives the day over * to one large icon tinted with its category's colour, with the date in the * corner. `calendar` names each entry in a block under a centred date, on an * open grid ruled off by week. */ export type PlannerVariant = 'default' | 'tiles' | 'calendar'; /** One thing that falls on a day. */ export interface PlannerEntry { /** Stable across renders; it keys the cell's contents. */ id: string; /** When it falls. The time of day is kept but not read by the grid. */ date: Date; /** Named in the dialog and, through its category, in the day's spoken label. */ label: string; /** Matches a `PlannerCategory` id. Without one the entry is still counted. */ category?: string; /** Drawn in the cell — a brand mark, an avatar, a glyph. */ icon?: ReactNode; /** * This entry's own colour, for a mark that belongs to the entry rather than * to a group of them — a brand. It wins over the category's colour, and * under `tiles` it is what tints the day. */ color?: string; } /** A group of entries: the key to a colour, and a line in the legend. */ export interface PlannerCategory { id: string; /** Printed beside the swatch, and spoken as part of a day that carries it. */ label: string; /** * Which `--color-chart-*` token the dot takes, 1 to 5. Categories without * one are numbered in the order they are declared. */ colorIndex?: number; /** An explicit colour, for a brand that is not the theme's to choose. */ color?: string; } interface PlannerContextValue { month: Date; setMonth: (month: Date) => void; /** Moves the month without announcing it. For scrolling, which is continuous. */ syncMonth: (month: Date) => void; today: Date; selected: Date | null; select: (date: Date | null) => void; days: Map; categories: PlannerCategory[]; categoryLabels: Map; colorOf: (category: string | undefined) => string | undefined; summary: { total: number; categories: PlannerCountedCategory[] }; entryLimit: number; variant: PlannerVariant; fill: boolean; weekStartsOn: number; grid: Date[][]; locale: DateLocale; system: 'gregory' | 'islamic'; isInMonth: (date: Date) => boolean; renderDay?: PlannerDayRenderer; onDayPress?: (date: Date, entries: PlannerEntry[]) => void; } const PlannerContext = createContext(null); function usePlanner(component: string): PlannerContextValue { const context = useContext(PlannerContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** Today, recomputed at local midnight and whenever the app comes back. */ function useToday(): Date { const [today, setToday] = useState(() => startOfDay(new Date())); useEffect(() => { let timer: ReturnType | null = null; const clearTimer = () => { if (timer === null) return; clearTimeout(timer); timer = null; }; const refreshAndSchedule = () => { const current = new Date(); const now = startOfDay(current); // A new object every resume would invalidate every memo below it for a // date that has not changed. setToday((previous) => (previous.getTime() === now.getTime() ? previous : now)); clearTimer(); timer = setTimeout(refreshAndSchedule, millisecondsUntilNextLocalDay(current)); }; refreshAndSchedule(); const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') refreshAndSchedule(); else clearTimer(); }); return () => { clearTimer(); subscription.remove(); }; }, []); return today; } /** * The five series colours, read once. * * A fixed number of reads rather than one per category: a hook cannot run in a * loop whose length is a prop. */ function usePalette(): (string | undefined)[] { const one = useCSSVariable('--color-chart-1'); const two = useCSSVariable('--color-chart-2'); const three = useCSSVariable('--color-chart-3'); const four = useCSSVariable('--color-chart-4'); const five = useCSSVariable('--color-chart-5'); return useMemo( () => [one, two, three, four, five].map((v) => (typeof v === 'string' ? v : undefined)), [one, two, three, four, five] ); } /* ------------------------------------------------------------------ * * Root * ------------------------------------------------------------------ */ export interface PlannerProps extends Omit { className?: string; /** The month on show. Leave it out for an uncontrolled planner. */ month?: Date; defaultMonth?: Date; onMonthChange?: (month: Date) => void; /** Everything the planner knows about, in any order and any month. */ entries?: PlannerEntry[]; /** The colour key. Declaration order is legend order and palette order. */ categories?: PlannerCategory[]; /** The open day. `null` is none. Leave it out for an uncontrolled planner. */ selected?: Date | null; defaultSelected?: Date | null; onSelectedChange?: (date: Date | null) => void; /** Runs before the selection moves, whether or not `Details` is present. */ onDayPress?: (date: Date, entries: PlannerEntry[]) => void; /** * What each day draws. `tiles` is one large icon per day, tinted by its * category; `calendar` names every entry and needs the height to do it. */ variant?: PlannerVariant; /** * Stretch the grid to its container instead of standing at its own height. * For a planner that owns a screen — the six weeks share out whatever is * left after the header, the legend and anything below them. */ fill?: boolean; /** * How many entries a cell draws before it counts the rest. Default `2`, or * `3` under `calendar`, which has the room; `tiles` draws one whatever you * pass. */ entryLimit?: number; /** First day of the week, 0 is Sunday. Defaults to the locale's. */ weekStartsOn?: number | 'auto'; locale?: DateLocale; calendar?: CalendarSystem; /** Draw the surrounding `Frame`. Off for a planner in a sheet or a card. */ frame?: boolean; children?: ReactNode; } const PlannerRoot = forwardRef( ( { className, month: monthProp, defaultMonth, onMonthChange, entries = [], categories = [], selected: selectedProp, defaultSelected = null, onSelectedChange, onDayPress, variant = 'default', fill = false, entryLimit, weekStartsOn = 'auto', locale, calendar = 'gregory', frame = true, children, ...props }, ref ) => { const resolvedEntryLimit = entryLimit ?? DEFAULT_ENTRY_LIMIT[variant]; const system = resolveCalendar(calendar, locale); const palette = usePalette(); const today = useToday(); const settleMonth = useCallback( (date: Date) => startOfCalendarMonth(date, system, locale), [system, locale] ); const [month, requestMonth] = usePlannerMonthLifecycle({ month: monthProp, defaultMonth, settleMonth, onMonthChange, }); /* * The announcement is registered against the month a press asks for, and * spoken only once a commit arrives carrying it. That is what keeps a * controlled parent's rejection silent, so it wraps the lifecycle's setter * rather than living inside it — the hook owns which month is current, and * this owns whether the change was the user's to hear about. */ const expectMonthAnnouncement = usePlannerMonthAnnouncement({ monthKey: month.getTime(), monthLabel: calendarMonthLabel(month, system, locale), announce: announceMonth, }); /* * Scrolling changes the month continuously, and announcing each one as it * goes would talk over somebody reading the weeks. The announcement belongs * to a deliberate move — the arrows, the Today pill — so a scroll reports * the change and stays quiet about it. */ const syncMonth = useCallback( (next: Date) => { requestMonth(next); }, [requestMonth] ); const setMonth = useCallback( (next: Date) => { expectMonthAnnouncement(settleMonth(next)); requestMonth(next); }, [expectMonthAnnouncement, requestMonth, settleMonth] ); const [selected, select] = usePlannerSelectionLifecycle({ selected: selectedProp, defaultSelected, onSelectedChange, }); const days = useMemo(() => bucketByDay(entries), [entries]); const normalizedWeekStart = weekStartsOn === 'auto' ? localeWeekStart(locale) : normalizeWeekStart(weekStartsOn); const grid = useMemo( () => monthGrid(month, normalizedWeekStart, system, locale), [month, normalizedWeekStart, system, locale] ); const isInMonth = useCallback( (date: Date) => isSameCalendarMonth(date, month, system, locale), [month, system, locale] ); const categoryLabels = useMemo( () => new Map(categories.map((category) => [category.id, category.label])), [categories] ); const categoryColors = useMemo( () => { const colors = new Map(); categories.forEach((category, index) => { // `findIndex` used to make the first duplicate id authoritative. if (colors.has(category.id)) return; const slot = (category.colorIndex ?? index + 1) - 1; colors.set( category.id, category.color ?? palette[((slot % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE] ); }); return colors; }, [categories, palette] ); const colorOf = useCallback( (id: string | undefined) => id ? categoryColors.get(id) : undefined, [categoryColors] ); const summary = useMemo( () => summariseMonth(days, grid, categories, isInMonth), [days, grid, categories, isInMonth] ); const context = useMemo( () => ({ month, setMonth, syncMonth, today, selected, select, days, categories, categoryLabels, colorOf, summary, entryLimit: resolvedEntryLimit, variant, fill, weekStartsOn: normalizedWeekStart, grid, locale, system, isInMonth, onDayPress, }), [ month, setMonth, syncMonth, today, selected, select, days, categories, categoryLabels, colorOf, summary, resolvedEntryLimit, variant, fill, normalizedWeekStart, grid, locale, system, isInMonth, onDayPress, ] ); /* * The header belongs in the Frame's strip and everything else in its * panel, so the root sorts its children rather than asking the caller to * nest them in two places to get one widget. */ const parts = Children.toArray(children); const header = parts.find( (child) => isValidElement(child) && child.type === PlannerHeader ); const body = parts.filter((child) => child !== header); return ( {frame ? ( // Full width by default: the grid is seven equal columns, and a // container that sizes to its content collapses them to the width of // a two-digit number. Under `fill` the panel has to flex too, or the // grid inside it stretches against a container that does not. {header} {body} ) : ( {header} {body} )} ); } ); PlannerRoot.displayName = 'Planner'; /* ------------------------------------------------------------------ * * Header strip * ------------------------------------------------------------------ */ export interface PlannerHeaderProps { children?: ReactNode; } /** * The strip along the top of the frame. Put `Title`, `Today` and `Nav` in it. * * The strip has two ends rather than an even spread. What the month *is* — * its name, and the way back to today — reads from the leading edge; what * *moves* it sits at the trailing edge, under the thumb that reaches for it. * Spaced evenly across a full-width strip they read as three unrelated * controls instead of a label and a pair of buttons. */ function PlannerHeader({ children }: PlannerHeaderProps) { const parts = Children.toArray(children); const trails = (child: ReactNode) => isValidElement(child) && (child.type === PlannerNav || child.type === PlannerAction); const trailing = parts.filter(trails); const lead = parts.filter((child) => !trails(child)); return ( {lead} {/* `ml-auto` rather than a flexible lead. The nav belongs against the right edge whatever is to its left — one short title, a title and a pill, or nothing at all — and pushing it there with a margin says that directly instead of leaving it to whether the thing beside it happened to grow. */} {trailing.length > 0 ? ( {trailing} ) : null} ); } PlannerHeader.displayName = 'Planner.Header'; export interface PlannerTitleProps extends Omit { /** Replaces the month name, for a title that says something else. */ children?: ReactNode; } /** The month on show, in the calendar system and locale the grid uses. */ function PlannerTitle({ children, className, ...props }: PlannerTitleProps) { const { month, system, locale } = usePlanner('Planner.Title'); return ( {children ?? calendarMonthLabel(month, system, locale)} ); } PlannerTitle.displayName = 'Planner.Title'; export interface PlannerTodayProps { /** Replaces the word on the pill. */ children?: ReactNode; } /** Jumps back to the month today is in, and selects nothing. */ function PlannerToday({ children = 'Today' }: PlannerTodayProps) { const { today, setMonth, month, system, locale } = usePlanner('Planner.Today'); const alreadyHere = isSameCalendarMonth(today, month, system, locale); return ( setMonth(today)} disabled={alreadyHere} hitSlop={6} accessibilityRole="button" accessibilityLabel="Go to this month" accessibilityState={{ disabled: alreadyHere }} className={cn( 'rounded-full border border-border px-3 py-1', alreadyHere ? 'opacity-40' : 'active:bg-accent' )} > {children} ); } PlannerToday.displayName = 'Planner.Today'; export interface PlannerNavProps { className?: string; } /** Back and forward a month. */ function PlannerNav({ className }: PlannerNavProps) { const { month, setMonth, system, locale } = usePlanner('Planner.Nav'); return ( setMonth(addCalendarMonths(month, -1, system, locale))} /> setMonth(addCalendarMonths(month, 1, system, locale))} /> ); } PlannerNav.displayName = 'Planner.Nav'; function PlannerArrow({ direction, onPress, }: { direction: 'previous' | 'next'; onPress: () => void; }) { return ( {direction === 'next' ? : } ); } export interface PlannerActionProps { children?: ReactNode; } /** The trailing end of the header strip, for a button of the caller's. */ function PlannerAction({ children }: PlannerActionProps) { return {children}; } PlannerAction.displayName = 'Planner.Action'; /* ------------------------------------------------------------------ * * Grid * ------------------------------------------------------------------ */ /** What a custom cell is handed. Everything the default cell draws from. */ export interface PlannerDayState { date: Date; entries: PlannerEntry[]; isToday: boolean; isSelected: boolean; isInMonth: boolean; } export type PlannerDayRenderer = (state: PlannerDayState) => ReactNode; export interface PlannerGridProps { className?: string; /** Draws a cell yourself. It is handed the day and what falls on it. */ renderDay?: PlannerDayRenderer; } interface PlannerGridKeyDownEvent { nativeEvent: { key?: string }; preventDefault: () => void; } interface PlannerGridNavigationContextValue { activeIndex: number; indexByDay: ReadonlyMap; register: (index: number, node: View | null) => void; focus: (index: number, event: PlannerGridKeyDownEvent) => void; makeActive: (index: number) => void; } const PlannerGridNavigationContext = createContext(null); /** * The weekday row and the six weeks below it. * * React Native has no per-cell grid vocabulary — no `gridcell`, no `row` — so * a screen reader never hears "row three, column five" and cannot fall back on * position for context. Every day therefore carries its own full date, and the * weekday headings are hidden rather than read out 42 times over. */ function PlannerGrid({ className, renderDay }: PlannerGridProps) { const { grid: weeks, weekStartsOn, locale, selected, today, isInMonth, variant, fill } = usePlanner('Planner.Grid'); const { grid, week: weekRow, heading } = plannerVariants({ fill }); /* * `tiles` leaves the days either side of the month blank, so its columns are * told apart by position alone and a single letter is enough to head them. */ const headings = useMemo( () => weekdayNames(locale, weekStartsOn, variant === 'tiles' ? 'narrow' : 'short'), [locale, weekStartsOn, variant] ); /* * `tiles` draws nothing for the days either side of the month, so a week made * only of those is a band of empty space rather than a week. It is dropped, * and the grid keeps its height by letting the weeks that remain stretch. */ const shownWeeks = useMemo( () => (variant === 'tiles' ? weeks.filter((week) => week.some(isInMonth)) : weeks), [weeks, variant, isInMonth] ); const dates = useMemo(() => shownWeeks.flat(), [shownWeeks]); const indexByDay = useMemo( () => new Map(dates.map((date, index) => [date.getTime(), index])), [dates] ); /* * A blank cell has nothing to focus, so arrow keys step over it rather than * landing on a day that is not drawn and losing focus altogether. */ const navigable = useMemo( () => variant === 'tiles' ? (index: number) => isInMonth(dates[index] ?? today) : undefined, [variant, dates, isInMonth, today] ); const initialDay = selected ?? today; const [activeDay, setActiveDay] = useState(initialDay.getTime()); const storedIndex = indexByDay.get(activeDay); const preferredIndex = (selected ? indexByDay.get(startOfDay(selected).getTime()) : undefined) ?? indexByDay.get(today.getTime()) ?? dates.findIndex(isInMonth); const activeIndex = storedIndex ?? Math.max(0, preferredIndex); const refs = useRef(new Map()); const register = useCallback((index: number, node: View | null) => { if (node) refs.current.set(index, node); else refs.current.delete(index); }, []); const makeActive = useCallback( (index: number) => { const date = dates[index]; if (date) setActiveDay(date.getTime()); }, [dates] ); const focus = useCallback( (index: number, event: PlannerGridKeyDownEvent) => { const target = plannerGridTarget( event.nativeEvent.key ?? '', index, dates.length, 7, navigable ); if (target === null) return; event.preventDefault(); makeActive(target); refs.current.get(target)?.focus(); }, [dates.length, makeActive, navigable] ); const navigation = useMemo( () => ({ activeIndex, indexByDay, register, focus, makeActive }), [activeIndex, indexByDay, register, focus, makeActive] ); return ( {/* Keyed by position rather than by the word: narrow names repeat in plenty of locales — English has T twice and S twice — and a column is identified by where it is, not by the letter over it. */} {headings.map((label, position) => ( {label.toUpperCase()} ))} {shownWeeks.map((week, index) => ( {week.map((date) => ( ))} ))} ); } PlannerGrid.displayName = 'Planner.Grid'; export interface PlannerDayProps { date: Date; renderDay?: PlannerDayRenderer; } /** One cell. Pressing it selects the day and opens whatever is bound to it. */ function PlannerDay({ date, renderDay }: PlannerDayProps) { const { days, today, selected, select, colorOf, categoryLabels, entryLimit, variant, fill, locale, system, isInMonth, onDayPress, } = usePlanner('Planner.Day'); const navigation = useContext(PlannerGridNavigationContext); const registerGridCell = navigation?.register; const focusGridCell = navigation?.focus; const makeGridCellActive = navigation?.makeActive; const entries = entriesOn(days, date); const inMonth = isInMonth(date); const isToday = isSameDay(date, today); const isSelected = selected ? isSameDay(date, selected) : false; const { shown, overflow } = visibleEntries(entries, entryLimit); const drawable = shown.filter((entry) => entry.icon); const styles = dayVariants({ variant, fill, inMonth, today: isToday, selected: isSelected }); /* * The colour the day's entries give it. The first entry naming a category the * planner knows wins, and an uncategorised day gets nothing — the tint is a * signal about what is on the day, so a day with no answer stays plain rather * than being coloured for the sake of it. */ const tint = markerColor(entries, colorOf); const label = dayAccessibilityLabel( calendarLongDate(date, system, locale), entries, categoryLabels ); /* * Pressing the open day again closes it. Without this the mark is a one-way * door: a planner with no `Details` bound has nothing to dismiss, so the day * you pressed stays ringed until you press a different one and there is no * way back to none selected. */ const press = () => { onDayPress?.(date, entries); select(isSelected ? null : date); }; const gridIndex = navigation?.indexByDay.get(date.getTime()); const setRef = useCallback( (node: View | null) => { if (gridIndex !== undefined) registerGridCell?.(gridIndex, node); }, [gridIndex, registerGridCell] ); const onFocus = useCallback(() => { if (gridIndex !== undefined) makeGridCellActive?.(gridIndex); }, [gridIndex, makeGridCellActive]); const onKeyDown = useCallback( (event: PlannerGridKeyDownEvent) => { if (gridIndex !== undefined) focusGridCell?.(gridIndex, event); }, [gridIndex, focusGridCell] ); const webGridProps = Platform.OS === 'web' && gridIndex !== undefined && navigation ? { ref: setRef, tabIndex: navigation.activeIndex === gridIndex ? (0 as const) : (-1 as const), onFocus, onKeyDown, } : {}; if (renderDay) { return ( {renderDay({ date, entries, isToday, isSelected, isInMonth: inMonth })} ); } /* * `tiles` leaves the days either side of the month out altogether. The row * still holds their column, so the grid is six weeks whatever month it is * showing, but there is no tile, no number and nothing to press or to read * out: a blank is the answer, and drawing a faded one only invites the press * it is going to ignore. */ if (variant === 'tiles' && !inMonth) { return ( ); } return ( 0 ? 'Opens what is on this day' : undefined} accessibilityState={{ selected: isSelected }} className={styles.cell()} > {/* The tint sits under the content rather than on the cell itself: the colour arrives as a runtime string from the theme, so it cannot go through a class with an opacity on it, and painting the cell at full strength would take the number down with it. */} {variant === 'tiles' && tint ? ( ) : null} {variant === 'tiles' ? ( {calendarDayNumber(date, system, locale)} ) : variant === 'calendar' ? ( /* * A disc around the date rather than a ring around the cell. There is * no tile here to ring, and the date is the only thing in the cell that * is always present — so it is the only place a mark can go and be * found in the same spot on every day of the month. */ {calendarDayNumber(date, system, locale)} ) : ( {calendarDayNumber(date, system, locale)} )} {variant === 'default' && entries.length > 0 ? ( ) : null} {variant === 'calendar' ? ( {shown.map((entry) => ( ))} {/* Three dots, not a count. The cell has already run out of room for the entries themselves, and "+2 more" spends a whole row of that room on a number nobody acts on — the dots say there is more and leave the row for an entry. */} {overflow > 0 ? ( ••• ) : null} ) : null} {/* Only entries that brought an icon are drawn, and the overflow count goes with them. A day whose entries have no icons is already saying "something is here" with its marker; adding a bare "+3" under an empty row says it twice, in a way that looks like a stray number. */} {variant === 'tiles' && overflow > 0 ? ( +{overflow} ) : null} {variant !== 'calendar' && drawable.length > 0 ? ( {drawable.map((entry) => ( {entry.icon} ))} {variant !== 'tiles' && overflow > 0 ? ( +{overflow} ) : null} ) : null} ); } /** * One named entry inside a `calendar` cell. * * A solid block of the entry's colour with light text on it. That is a real * constraint rather than a free choice: the colour is the caller's, so a pale * one will not carry white well — but a wash with the label in the foreground * colour makes every entry the same weight and loses the one thing the colour * was for, which is telling them apart down a column at a glance. */ function PlannerChip({ entry, color }: { entry: PlannerEntry; color?: string }) { const { chip } = dayVariants(); return ( {entry.label} ); } /** * The dot's colour: the first entry that names a category the planner knows. * * One dot rather than one per entry. A cell this size fits a row of dots or a * day number, and the count is already spoken — so the dot answers "is there * anything here, and roughly what kind", which is all it has room to answer. */ function markerColor( entries: readonly PlannerEntry[], colorOf: (category: string | undefined) => string | undefined ): string | undefined { for (const entry of entries) { // The entry's own colour first: a brand belongs to the thing, not to the // group it was filed under. const color = entry.color ?? colorOf(entry.category); if (color) return color; } return undefined; } /* ------------------------------------------------------------------ * * Legend and summary * ------------------------------------------------------------------ */ export interface PlannerLegendProps { className?: string; /** Print each category's count for the month beside its label. */ counts?: boolean; /** Sits at the trailing end — a total, a currency, whatever the month adds to. */ children?: ReactNode; } /** * The key to the dots. * * It prints the label beside every swatch, because a column of coloured dots * with nothing to read them against is a quiz. */ function PlannerLegend({ className, counts = false, children }: PlannerLegendProps) { const { summary, colorOf } = usePlanner('Planner.Legend'); const { legend, swatch } = plannerVariants(); return ( {summary.categories.map((category) => ( {counts ? `${category.label} ${category.count}` : category.label} ))} {children} ); } PlannerLegend.displayName = 'Planner.Legend'; export interface PlannerSummaryProps extends Omit { /** Replaces the count, for a total that is money rather than entries. */ children?: ReactNode; } /** What the month adds up to. Counts this month only, never the days either side. */ function PlannerSummary({ children, ...props }: PlannerSummaryProps) { const { summary } = usePlanner('Planner.Summary'); return ( {children ?? `${summary.total} ${summary.total === 1 ? 'entry' : 'entries'}`} ); } PlannerSummary.displayName = 'Planner.Summary'; export interface PlannerFooterProps { className?: string; children?: ReactNode; } /** The strip along the bottom, for tools that act on the month. */ function PlannerFooter({ className, children }: PlannerFooterProps) { return ( {children} ); } PlannerFooter.displayName = 'Planner.Footer'; /* ------------------------------------------------------------------ * * Details * ------------------------------------------------------------------ */ export interface PlannerDetailsProps { className?: string; /** Title above the children. Defaults to the day's full date. */ title?: ReactNode; /** * The line under the title. Defaults to how many entries the day carries, * so the dialog answers "how much of this is there" before it is read. * Pass `null` to drop it. */ description?: ReactNode; /** Given the open day and what falls on it. */ children: (date: Date, entries: PlannerEntry[]) => ReactNode; } /** * A dialog bound to the open day. * * The planner owns the grid and the binding; what the dialog says is the * application's, because the contents of a day are its data and not the * component's. Leave it out and `onDayPress` is still called — a planner that * pushes a screen instead of opening a dialog wants that and nothing else. */ function PlannerDetails({ className, title, description, children }: PlannerDetailsProps) { const { selected, select, days, system, locale } = usePlanner('Planner.Details'); const entries = selected ? entriesOn(days, selected) : []; const count = entries.length === 1 ? '1 entry' : `${entries.length} entries`; return ( !open && select(null)}> {title ?? (selected ? calendarLongDate(selected, system, locale) : '')} {description === null ? null : ( {description ?? (entries.length === 0 ? 'Nothing planned' : count)} )} {selected ? children(selected, entries) : null} ); } PlannerDetails.displayName = 'Planner.Details'; /* ------------------------------------------------------------------ * * Scroller * ------------------------------------------------------------------ */ /** How many weeks either side of the opening month a scroller reaches by default. */ const DEFAULT_SCROLLER_WEEKS = 53; /** The height of one week row, and enough of it for a date and three entries. */ const DEFAULT_WEEK_HEIGHT = 96; export interface PlannerScrollerProps { className?: string; /** * How many weeks either side of the opening month can be reached. Default * `53`, about a year each way. */ weeks?: number; /** The height of one week row. Default `96`. */ rowHeight?: number; /** Draws a cell yourself. It is handed the day and what falls on it. */ renderDay?: PlannerDayRenderer; } /** * The weeks of the year, scrolled through rather than paged. * * A month grid answers "what does this month look like"; a scroller answers * "what is coming", which does not stop at the end of a month. The week * straddling the boundary is drawn once, in one piece, instead of appearing cut * in half at the bottom of one page and again at the top of the next. * * The range is bounded rather than endless. A scroller has to know its own * height to place a scrollbar and to jump to a month without rendering its way * there, and neither is possible over a list with no end. * * The month in the header follows the scroll: whichever month holds most of the * first week on screen is the one named, and the days either side of it grey * out. `Planner.Nav` and `Planner.Today` still work — they scroll the list * rather than replacing what is in it. */ function PlannerScroller({ className, weeks: span = DEFAULT_SCROLLER_WEEKS, rowHeight = DEFAULT_WEEK_HEIGHT, renderDay, }: PlannerScrollerProps) { const { month, syncMonth, weekStartsOn, locale, variant, system } = usePlanner('Planner.Scroller'); const { week: weekRow, heading } = plannerVariants(); const list = useRef>(null); /* * The range is anchored where the planner opened and never rebuilt. Rebuilding * it around the current month would move every row's index under the scroll * position each time the month changed, which is the one thing a list being * scrolled must not do. */ const [anchor] = useState(() => month); const range = useMemo( () => weekRange(anchor, weekStartsOn, span, span), [anchor, weekStartsOn, span] ); const headings = useMemo( () => weekdayNames(locale, weekStartsOn, 'narrow'), [locale, weekStartsOn] ); /* * Which month the scroll is showing, reported up but not acted on here. The * ref is what stops a scroll that stays inside one month from setting it over * and over, and what stops the scroll this component performs in answer to a * month change from reporting that same month straight back. */ const reported = useRef(month.getTime()); const onViewable = useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => { const first = viewableItems[0]?.item as Date | undefined; if (!first) return; const next = startOfCalendarMonth(weekAnchor(first), system, locale); if (next.getTime() === reported.current) return; reported.current = next.getTime(); syncMonth(next); }); /* * A month arriving from outside — the arrows, the Today pill, a controlled * prop — scrolls the list to it. A month the scroll itself just reported is * already there, and scrolling to it again would fight the finger. */ useEffect(() => { if (month.getTime() === reported.current) return; reported.current = month.getTime(); const index = weekIndex(range, month, weekStartsOn); if (index >= 0) list.current?.scrollToIndex({ index, animated: true }); }, [month, range, weekStartsOn]); const initialIndex = Math.max(0, weekIndex(range, month, weekStartsOn)); return ( {headings.map((label, position) => ( {label.toUpperCase()} ))} String(weekStart.getTime())} initialScrollIndex={initialIndex} // Every row is the same height, so the list can place any of them // without measuring — which is what makes jumping to a month cheap. getItemLayout={(_unused, index) => ({ length: rowHeight, offset: rowHeight * index, index, })} onViewableItemsChanged={onViewable.current} viewabilityConfig={VIEWABILITY} showsVerticalScrollIndicator={false} renderItem={({ item }) => ( {weekDays(item).map((date) => ( ))} )} /> ); } PlannerScroller.displayName = 'Planner.Scroller'; /* * The first row more than half on screen is the one that names the month. A * lower threshold hands the month over while the week is still mostly below the * fold, which reads as the header changing early. */ const VIEWABILITY = { itemVisiblePercentThreshold: 60 }; export const Planner = Object.assign(PlannerRoot, { Header: PlannerHeader, Title: PlannerTitle, Today: PlannerToday, Nav: PlannerNav, Action: PlannerAction, Grid: PlannerGrid, Scroller: PlannerScroller, Day: PlannerDay, Legend: PlannerLegend, Summary: PlannerSummary, Footer: PlannerFooter, Details: PlannerDetails, }); export type { PlannerCountedCategory };