// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Calendar.tsx * @input Uses React useState, useMemo, useCallback, hooks * @output Exports Calendar component and related types * @position Core implementation; forwards DOM ref and exposes navigation via * handleRef * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Calendar/Calendar.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Calendar/getInitialFocusDate.ts (which month the calendar opens on) * - /packages/core/src/Calendar/Calendar.test.tsx (tests for new/changed behavior) * - /packages/core/src/Calendar/index.ts (exports if types change) * - /apps/storybook/stories/Calendar.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Calendar/ (showcase blocks) */ import { useState, useMemo, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; import type { BaseProps } from "../BaseProps.js"; import * as stylex from "../style-runtime.js"; import { Button } from "../Button/index.js"; import { Icon } from "../Icon/index.js"; import { useAnnounce, useGridFocus } from "../hooks/index.js"; import { useCalendarDays, useCalendarConstraints, type CalendarDay } from "./hooks/index.js"; import { calendarStyles, monthGridStyles, dayCellStyles, dayCellTheme } from "./styles.js"; import { type PlainDate, plainDateFromISO, plainDateToISO, plainDateToDate, plainDateToday, plainDateSetFirstOfMonth, plainDateAddMonths, plainDateAddDays, plainDateIsBefore, plainDateIsEqual, plainDateGetWeekNumber, plainDateFormat, DATE_FORMAT_WITH_WEEKDAY, DATE_FORMAT_MONTH_YEAR } from "../utils/plainDate.js"; import { mergeProps, composeEventHandlers, rtlStyles } from "../utils/index.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { getInitialFocusDate } from "./getInitialFocusDate.js"; import { computeDayCellState, computeRangeRounding, computePreviewRounding, computeDayNeighborContinuity, isEndpoint, type DayNeighborContinuity } from "./dayCellUtils.js"; // ============================================================================= // Types // ============================================================================= export type { ISODateString, DayOfWeek, DayOfWeekName, DateRange } from "../utils/dateTypes.js"; import type { ISODateString, DayOfWeek, DayOfWeekName, DateRange } from "../utils/dateTypes.js"; import { normalizeDayOfWeek } from "../utils/dateTypes.js"; import { themeProps } from "../utils/themeProps.js"; import { useLocale, useTranslator } from "../i18n/index.js"; /** Imperative handle for Calendar handleRef */ export interface CalendarHandle { /** Navigate the calendar to show the month containing the given date */ navigateTo: (date: ISODateString) => void; } // ─── Base Props (shared across all modes) ───────────────────── interface CalendarBaseProps extends Omit, 'onChange' | 'defaultValue'> { /** Ref forwarded to the calendar root element. */ ref?: React.Ref; /** Imperative handle ref for calendar navigation. */ handleRef?: React.Ref; /** Number of months to display (default: 1) */ numberOfMonths?: 1 | 2; /** Minimum selectable date in ISO format */ min?: ISODateString; /** Maximum selectable date in ISO format */ max?: ISODateString; /** * Custom date constraint functions. Date is disabled if ANY function returns false. * Use for complex rules like "weekdays only" or "no holidays". */ dateConstraints?: ReadonlyArray<(date: Date) => boolean>; /** * Range mode only. Maximum number of days a selected range may span, * counting both endpoints — `maxRangeSpan={7}` allows a 7-day window * (start + 6 days). Once a start date is picked, days beyond this distance * from it are disabled in either direction; before a start is picked every * otherwise-valid day stays selectable. Use for rolling windows like "at * most a week from the chosen day". For fixed calendar bounds use min/max. */ maxRangeSpan?: number; /** * Range mode only. Minimum number of days a selected range must span, * counting both endpoints — `minRangeSpan={2}` forbids a single-day range. * Once a start date is picked, days closer than this to it are disabled — * except the start itself, which stays selectable. Clicking the start again * commits a one-day range when the minimum allows it; otherwise it cancels * the in-progress selection so the start can be moved. Defaults to 1 (a * same-day start and end is allowed). */ minRangeSpan?: number; /** * Controlled focus date (which month is visible). * If not provided, defaults to the selected date, else today clamped into * the `min`/`max` window (so a window that excludes today opens on the * bound nearest to it, not on an all-disabled month). */ focusDate?: ISODateString; /** Callback when visible month changes via navigation */ onFocusDateChange?: (focusDate: ISODateString) => void; /** * Show days from adjacent months (grayed out). * Default: true */ hasOutsideDays?: boolean; /** * Show ISO week numbers in a side column. * Default: false */ hasWeekNumbers?: boolean; /** * Use variable rows per month vs. fixed 6-row grid. * Default: false (fixed 6 rows for consistent height) */ hasVariableRowCount?: boolean; /** * First day of week. Accepts a number (0 = Sunday … 6 = Saturday) or a * three-letter day name ('sun'–'sat', case-insensitive) for readability. * Default: 0 (Sunday) */ weekStartsOn?: DayOfWeek | DayOfWeekName; } // ─── Mode-specific Props (discriminated union) ──────────────── interface CalendarSingleProps extends CalendarBaseProps { /** Selection mode */ mode?: 'single'; /** Selected date in ISO format (YYYY-MM-DD) */ value?: ISODateString; /** Default value for uncontrolled mode */ defaultValue?: ISODateString; /** Callback when date is selected */ onChange?: (value: ISODateString, valueAsDate: Date) => void; } interface CalendarRangeProps extends CalendarBaseProps { /** Selection mode */ mode: 'range'; /** Selected date range */ value?: DateRange; /** Default value for uncontrolled mode */ defaultValue?: DateRange; /** Callback when range is selected */ onChange?: (value: DateRange) => void; } export type CalendarProps = CalendarSingleProps | CalendarRangeProps; // ============================================================================= // Main Component // ============================================================================= /** * A calendar component for selecting dates or date ranges. * * @example * ``` * * ``` */ export function Calendar({ ref, ...props }: CalendarProps) { const t = useTranslator(); const locale = useLocale(); const { handleRef, mode = 'single', value, defaultValue, onChange, numberOfMonths: numberOfMonthsProp = 1, min, max, dateConstraints, maxRangeSpan, minRangeSpan, focusDate: focusDateProp, onFocusDateChange, hasOutsideDays = true, hasWeekNumbers = false, hasVariableRowCount = false, weekStartsOn: weekStartsOnProp = 0, xstyle, className, style, onKeyDown, ...rest } = props; // Normalize `weekStartsOn` (number or three-letter day name) to a numeric // DayOfWeek so all downstream date math keeps working with an index. const weekStartsOn = normalizeDayOfWeek(weekStartsOnProp); // `numberOfMonths` is typed `1 | 2`; defensively clamp anything else that // slips through at runtime to 1 so `Array.from({length})` can't render an // absurd number of month grids (e.g. `numberOfMonths={1000}`). const numberOfMonths = numberOfMonthsProp === 2 ? 2 : 1; // Today's date (memoized) const today = useMemo(() => plainDateToday(), []); // Internal state for uncontrolled mode const [internalValue, setInternalValue] = useState(defaultValue); // Range selection in progress (first click made, waiting for second) const [rangeSelectionStart, setRangeSelectionStart] = useState(null); // Hovered date for range preview const [hoveredDate, setHoveredDate] = useState(null); // Pending focus target after month navigation const [pendingFocus, setPendingFocus] = useState(null); // Determine effective value const effectiveValue = value !== undefined ? value : internalValue; // Focus date state (which month is visible). Falls back to today, clamped // into the min/max window so a window that doesn't contain today doesn't // open on an all-disabled month. const [internalFocusDate, setInternalFocusDate] = useState(() => getInitialFocusDate({ focusDate: focusDateProp, value: effectiveValue, min, max, numberOfMonths, today })); // Use controlled focusDate if callback is provided, otherwise use internal state const isControlledFocus = focusDateProp !== undefined && onFocusDateChange !== undefined; const focusDate = isControlledFocus ? plainDateFromISO(focusDateProp) : internalFocusDate; // Expose imperative handle for external navigation useImperativeHandle(handleRef, () => ({ navigateTo: (date: ISODateString) => { if (isControlledFocus) { onFocusDateChange?.(date); } else { setInternalFocusDate(plainDateFromISO(date)); } } }), [isControlledFocus, onFocusDateChange]); // Base month (first day of focus month) const baseMonth = useMemo(() => { return plainDateSetFirstOfMonth(focusDate); }, [focusDate]); // Generate visible months const visibleMonths = useMemo(() => { return Array.from({ length: numberOfMonths }, (_, i) => { return plainDateAddMonths(baseMonth, i); }); }, [baseMonth, numberOfMonths]); // Format month header const monthYearLabel = useMemo(() => { if (numberOfMonths === 1) { return plainDateFormat(visibleMonths[0], DATE_FORMAT_MONTH_YEAR, locale); } return visibleMonths.map(m => plainDateFormat(m, DATE_FORMAT_MONTH_YEAR, locale)).join(' – '); }, [visibleMonths, numberOfMonths, locale]); // Announce the newly visible month to screen readers whenever it changes. // The visible month label (``) carries no live semantics, so paging the // grid — via the header prev/next buttons, keyboard grid paging (arrow keys // across a month boundary, PageUp/PageDown), the `navigateTo` handle, or a // controlled `focusDate` change — otherwise updates the grid silently. Keying // off `monthYearLabel` reuses the existing single-/multi-month formatting and // only fires when the visible month actually changes (so selecting a date, // which does not move the grid, stays silent). The first-render guard avoids // announcing the initial month on mount. const announce = useAnnounce(); const isInitialRenderRef = useRef(true); useEffect(() => { if (isInitialRenderRef.current) { isInitialRenderRef.current = false; return; } announce(monthYearLabel); }, [monthYearLabel, announce]); // Determine if prev/next navigation is possible based on min/max const canNavigatePrevious = useMemo(() => { if (!min) { return true; } const minDate = plainDateFromISO(min); // Can't go back if min is in the current focus month return minDate.year < baseMonth.year || minDate.year === baseMonth.year && minDate.month < baseMonth.month; }, [min, baseMonth]); const canNavigateNext = useMemo(() => { if (!max) { return true; } const maxDate = plainDateFromISO(max); // Check against the last visible month, not just baseMonth const lastVisibleMonth = plainDateAddMonths(baseMonth, numberOfMonths - 1); return maxDate.year > lastVisibleMonth.year || maxDate.year === lastVisibleMonth.year && maxDate.month > lastVisibleMonth.month; }, [max, baseMonth, numberOfMonths]); // Navigation handlers const navigateMonth = useCallback((delta: number, focusedDate?: ISODateString, offset?: number) => { const newPd = plainDateAddMonths(baseMonth, delta); const newISO = plainDateToISO(newPd); // Calculate target focus date if a focused date was provided if (focusedDate) { const currentPd = plainDateFromISO(focusedDate); const daysToMove = offset ?? 7; const targetPd = plainDateAddDays(currentPd, delta * daysToMove); setPendingFocus(plainDateToISO(targetPd)); } if (onFocusDateChange) { onFocusDateChange(newISO); } else { setInternalFocusDate(newPd); } }, [baseMonth, onFocusDateChange]); // Escape key handler to cancel range selection const handleCalendarKeyDown = useCallback((e: React.KeyboardEvent) => { if (mode === 'range' && rangeSelectionStart !== null && e.key === 'Escape') { setRangeSelectionStart(null); e.preventDefault(); e.stopPropagation(); } }, [mode, rangeSelectionStart]); // Day click handler const handleDayClick = useCallback((date: PlainDate) => { const iso = plainDateToISO(date); if (mode === 'single') { setInternalValue(iso); (onChange as CalendarSingleProps['onChange'])?.(iso, plainDateToDate(date)); } else { // Range mode if (rangeSelectionStart === null) { // First click - start the range. Nothing else about this pick is // perceivable non-visually (WCAG 1.3.1) — the grid doesn't move, so // the month-change announcement stays silent — so speak the range // progress through the same polite live region. setRangeSelectionStart(iso); announce(t('@astryx.calendar.rangeStartAnnounce', { date: plainDateFormat(date, DATE_FORMAT_WITH_WEEKDAY, locale) })); } else { // Second click - complete the range const startPd = plainDateFromISO(rangeSelectionStart); // Clicking the anchor again commits a one-day range when the minimum // span allows it. For longer minimum spans, the repeated click clears // the in-progress start instead: the anchor is the only nearby day // that remains enabled, so this preserves an escape hatch for moving // the start without violating the configured minimum. if (plainDateIsEqual(date, startPd) && (minRangeSpan ?? 1) > 1) { setRangeSelectionStart(null); announce(t('@astryx.calendar.rangeClearedAnnounce', { date: plainDateFormat(date, DATE_FORMAT_WITH_WEEKDAY, locale) })); return; } let start: ISODateString; let end: ISODateString; // Ensure start <= end if (plainDateIsBefore(date, startPd)) { start = iso; end = rangeSelectionStart; } else { start = rangeSelectionStart; end = iso; } const range: DateRange = { start, end }; setInternalValue(range); setRangeSelectionStart(null); (onChange as CalendarRangeProps['onChange'])?.(range); // Completed-range announcement, in chronological order (matches the // swapped {start, end} above even for a reverse pick). announce(t('@astryx.calendar.rangeCompleteAnnounce', { start: plainDateFormat(plainDateFromISO(start), DATE_FORMAT_WITH_WEEKDAY, locale), end: plainDateFormat(plainDateFromISO(end), DATE_FORMAT_WITH_WEEKDAY, locale) })); } } }, [mode, onChange, rangeSelectionStart, minRangeSpan, announce, t, locale]); return
{/* Header with navigation */}
{/* Month grids */}
{visibleMonths.map(month => setHoveredDate(date ? plainDateToISO(date) : null)} today={today} onNavigatePrevious={(focusedDate, offset) => navigateMonth(-1, focusedDate, offset)} onNavigateNext={(focusedDate, offset) => navigateMonth(1, focusedDate, offset)} pendingFocus={pendingFocus} onPendingFocusHandled={() => setPendingFocus(null)} />)}
; } Calendar.displayName = 'Calendar'; // ============================================================================= // MonthGrid (Private) // ============================================================================= interface MonthGridProps { month: PlainDate; value: ISODateString | DateRange | undefined; mode: 'single' | 'range'; rangeSelectionStart: ISODateString | null; hoveredDate: ISODateString | null; min?: ISODateString; max?: ISODateString; dateConstraints?: ReadonlyArray<(date: Date) => boolean>; maxRangeSpan?: number; minRangeSpan?: number; hasOutsideDays: boolean; hasWeekNumbers: boolean; hasVariableRowCount: boolean; weekStartsOn: DayOfWeek; onDayClick: (date: PlainDate) => void; onDayHover: (date: PlainDate | null) => void; today: PlainDate; onNavigatePrevious?: (focusedDate: ISODateString, offset: number) => void; onNavigateNext?: (focusedDate: ISODateString, offset: number) => void; pendingFocus?: ISODateString | null; onPendingFocusHandled?: () => void; } function MonthGrid({ month, value, mode, rangeSelectionStart, hoveredDate, min, max, dateConstraints, maxRangeSpan, minRangeSpan, hasOutsideDays, hasWeekNumbers, hasVariableRowCount, weekStartsOn, onDayClick, onDayHover, today, onNavigatePrevious, onNavigateNext, pendingFocus, onPendingFocusHandled }: MonthGridProps) { const locale = useLocale(); const year = month.year; // Use hooks for days generation and constraints const { days, weeks, dayNames } = useCalendarDays({ year, month: month.month, weekStartsOn, hasVariableRowCount }); const dayNameHeaders = useMemo(() => dayNames.map((name, offset) => ({ dayOfWeek: (weekStartsOn + offset) % 7 as DayOfWeek, name })), [dayNames, weekStartsOn]); const rangeAnchor = useMemo(() => mode === 'range' && rangeSelectionStart ? plainDateFromISO(rangeSelectionStart) : null, [mode, rangeSelectionStart]); const { isDateDisabled } = useCalendarConstraints({ min, max, dateConstraints, maxRangeSpan, minRangeSpan, rangeAnchor }); // Parse selected date for roving tabindex priority const selectedDateForTabindex = useMemo(() => { if (mode === 'single' && value && typeof value === 'string') { return plainDateFromISO(value); } return null; }, [mode, value]); // Seed the initial roving tab stop for this month. useGridFocus owns the // live tab stop (see `hasRovingTabIndex` below) — it honors an existing // `tabindex="0"` and repairs/moves it thereafter — so this only decides // which day button starts tabbable. Priority: selected date (if visible and // enabled) > today (if visible and enabled) > first enabled in-month day. const seedTabbableIso = useMemo((): ISODateString | null => { if (selectedDateForTabindex) { const isSelectedInMonth = selectedDateForTabindex.year === year && selectedDateForTabindex.month === month.month; if (isSelectedInMonth && !isDateDisabled(selectedDateForTabindex)) { return plainDateToISO(selectedDateForTabindex); } } const isTodayInMonth = today.year === year && today.month === month.month; if (isTodayInMonth && !isDateDisabled(today)) { return plainDateToISO(today); } for (const day of days) { if (!day.isOutside && !isDateDisabled(day.date)) { return day.iso; } } return null; }, [days, today, year, month.month, isDateDisabled, selectedDateForTabindex]); // Helper to get the focused date from the currently focused element. // Reads the machine-readable `data-date` (ISO) attribute rather than parsing // the human-readable `aria-label` with `new Date()`, which is locale/format // dependent and returns Invalid Date in non-English locales (e.g. fr-FR, // ja-JP), silently swallowing month-boundary arrow navigation (complex-4). const getFocusedDate = useCallback((): ISODateString | null => { const activeElement = document.activeElement as HTMLElement | null; if (!activeElement) { return null; } const iso = activeElement.getAttribute('data-date'); if (!iso) { return null; } return iso as ISODateString; }, []); // Handle navigation to previous month const handleNavigatePrevious = useCallback((_column: number, offset: number) => { const focusedDate = getFocusedDate(); if (focusedDate) { onNavigatePrevious?.(focusedDate, offset); } }, [getFocusedDate, onNavigatePrevious]); // Handle navigation to next month const handleNavigateNext = useCallback((_column: number, offset: number) => { const focusedDate = getFocusedDate(); if (focusedDate) { onNavigateNext?.(focusedDate, offset); } }, [getFocusedDate, onNavigateNext]); // Handle PageUp/PageDown const handlePageUp = useCallback(() => { const focusedDate = getFocusedDate(); if (focusedDate) { onNavigatePrevious?.(focusedDate, 7); } }, [getFocusedDate, onNavigatePrevious]); const handlePageDown = useCallback(() => { const focusedDate = getFocusedDate(); if (focusedDate) { onNavigateNext?.(focusedDate, 7); } }, [getFocusedDate, onNavigateNext]); // Grid focus navigation. // // The hook enumerates ALL grid cells (every `role="gridcell"`, including // disabled days and empty placeholder cells) so the true 7-column geometry is // preserved. `isCellFocusable` / `getFocusTarget` tell the hook which cells // can take focus (those containing an enabled day button) and where to send // focus (the day button inside the cell). Arrow keys move to the target // row/column and, if that cell is disabled, continue in the same direction to // the next enabled cell. const { gridRef, handleKeyDown: handleGridKeyDown, handleFocus: handleGridFocus } = useGridFocus({ columns: 7, cellSelector: '[role="gridcell"]', isCellFocusable: cell => cell.querySelector('button:not([disabled])') !== null, getFocusTarget: cell => cell.querySelector('button'), hasRovingTabIndex: true, onNavigateBefore: handleNavigatePrevious, onNavigateAfter: handleNavigateNext, onPageUp: handlePageUp, onPageDown: handlePageDown }); // Handle pending focus after month navigation useEffect(() => { if (!pendingFocus || !gridRef.current) { return; } const buttons = gridRef.current.querySelectorAll('button:not([disabled])'); const targetPd = plainDateFromISO(pendingFocus); const targetIso = plainDateToISO(targetPd); let targetButton: HTMLElement | null = null; for (const button of buttons) { if (button.getAttribute('data-date') === targetIso) { targetButton = button; break; } } if (!targetButton && buttons.length > 0) { targetButton = buttons[0]; } targetButton?.focus(); onPendingFocusHandled?.(); }, [pendingFocus, gridRef, onPendingFocusHandled]); // Parse selection let selectedDate: PlainDate | null = null; let rangeStart: PlainDate | null = null; let rangeEnd: PlainDate | null = null; if (mode === 'single' && value && typeof value === 'string') { selectedDate = plainDateFromISO(value); } else if (mode === 'range' && value && typeof value === 'object') { const range = value; rangeStart = plainDateFromISO(range.start); rangeEnd = plainDateFromISO(range.end); } // Handle in-progress range selection if (rangeSelectionStart) { rangeStart = plainDateFromISO(rangeSelectionStart); rangeEnd = rangeStart; } // Calculate preview range when hovering during range selection let previewStart: PlainDate | null = null; let previewEnd: PlainDate | null = null; if (mode === 'range' && rangeSelectionStart && hoveredDate) { const startPd = plainDateFromISO(rangeSelectionStart); const hoverPd = plainDateFromISO(hoveredDate); if (!plainDateIsEqual(startPd, hoverPd)) { if (plainDateIsBefore(hoverPd, startPd)) { previewStart = hoverPd; previewEnd = startPd; } else { previewStart = startPd; previewEnd = hoverPd; } } } // Month label for announcements const monthLabel = useMemo(() => { return plainDateFormat(month, DATE_FORMAT_MONTH_YEAR, locale); }, [month, locale]); return
{/* Days grid (APG grid: header row of columnheaders + week rows) */}
{/* Day names header row (columnheaders live inside the grid). Uses the same display:contents row so its cells align to the grid columns. */}
{hasWeekNumbers &&
} {dayNameHeaders.map(({ dayOfWeek, name }) =>
{name}
)}
{weeks.map(week => { const weekDate = week.find(d => !d.isOutside)?.date || week[0].date; const weekNum = plainDateGetWeekNumber(weekDate); return
{hasWeekNumbers &&
{weekNum}
} {week.map((day, dayIndex) => { // Whether the previous/next day in this week row continues the // highlighted run (range and preview). A disabled or // adjacent-month neighbour breaks continuity, so this day gets // an end cap on that side (#2715). const neighbors = computeDayNeighborContinuity({ week, dayIndex, mode, rangeStart, rangeEnd, previewStart, previewEnd, isDisabled: isDateDisabled }); return ; })}
; })}
; } // ============================================================================= // DayCell (Private) // ============================================================================= interface DayCellProps { day: CalendarDay; dayIndex: number; mode: 'single' | 'range'; selectedDate: PlainDate | null; rangeStart: PlainDate | null; rangeEnd: PlainDate | null; previewStart: PlainDate | null; previewEnd: PlainDate | null; today: PlainDate; hasOutsideDays: boolean; isDisabled: boolean; /** * Whether the previous/next day in the same week continues the highlighted * run (range and preview). When a neighbour is disabled or outside the month * it breaks the run, so this day gets an end cap on that side (#2715). */ neighbors: DayNeighborContinuity; /** * Whether this day seeds the initial roving tab stop. useGridFocus * (`hasRovingTabIndex`) owns the live tab stop thereafter — it honors an * existing `tabindex="0"` and repairs/moves it on navigation and focus. */ isTabbable: boolean; /** * Whether a range selection is mid-flight (first pick made, awaiting the * second). Disambiguates the accessible name of the picked day: mid-flight * it is only the "range start", while a completed one-day range is both * "range start and range end" (rangeStart === rangeEnd in both cases). */ isRangeSelectionInProgress: boolean; onDayClick: (date: PlainDate) => void; onDayHover: (date: PlainDate | null) => void; } function DayCell({ day, dayIndex, mode, selectedDate, rangeStart, rangeEnd, previewStart, previewEnd, today, hasOutsideDays, isDisabled, neighbors, isTabbable: isTabbableDay, isRangeSelectionInProgress, onDayClick, onDayHover }: DayCellProps) { const t = useTranslator(); const locale = useLocale(); const { date, isOutside, dayNumber } = day; if (isOutside && !hasOutsideDays) { // Empty placeholder cell — still a gridcell so the grid geometry stays a // clean 7-per-row set for keyboard navigation. return
; } const state = computeDayCellState({ date, dayIndex, mode, selectedDate, rangeStart, rangeEnd, previewStart, previewEnd, today, isDisabled, isOutside }); const endpoint = isEndpoint(state); // The day's focus-ring treatment, derived once so the reflected `marker` // theme state and the StyleX ring styles below share a single source of // truth. `state.isSelected` is single-select only, so a range endpoint that // is today still qualifies for the today-in-range ring (unchanged prior // behavior). const showsTodayRing = state.isToday && !state.isSelected && !state.isInRange; const showsTodayInRangeRing = state.isToday && !state.isSelected && state.isInRange; const markerState: 'today-only' | 'today-in-range' | null = showsTodayRing ? 'today-only' : showsTodayInRangeRing ? 'today-in-range' : null; // Accessible name for the day button. Roving focus lands on the
; }