From 6a63eb3b05b473f457423890dbc0704c90e29026 Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Fri, 29 May 2026 18:06:05 -0500 Subject: [PATCH 01/14] feat(antares): add Calendar and RangeCalendar components Co-Authored-By: Claude Opus 4.7 (1M context) --- .../antares/components/calendar/README.mdx | 121 +++++++++ .../components/calendar/calendar.stories.tsx | 35 +++ .../calendar/examples/default-range.tsx | 14 + .../components/calendar/examples/default.tsx | 6 + .../calendar/examples/out-of-month-days.tsx | 24 ++ .../calendar/examples/today-distinct.tsx | 12 + .../calendar/examples/with-duration.tsx | 13 + .../calendar/examples/with-indicators.tsx | 41 +++ .../calendar/examples/with-min-max.tsx | 13 + .../examples/with-unavailable-dates.tsx | 17 ++ .../calendar/src/calendar-header.tsx | 93 +++++++ .../components/calendar/src/calendar.tsx | 64 +++++ .../components/calendar/src/day-cell.tsx | 72 +++++ .../components/calendar/src/get-year-range.ts | 48 ++++ .../components/calendar/src/index.module.css | 137 ++++++++++ .../antares/components/calendar/src/index.tsx | 3 + .../calendar/src/range-calendar.tsx | 132 ++++++++++ .../antares/components/calendar/src/types.ts | 22 ++ .../__snapshots__/calendar.node.test.tsx.snap | 3 + .../calendar/test/calendar.browser.test.tsx | 246 ++++++++++++++++++ .../calendar/test/calendar.node.test.tsx | 94 +++++++ .../calendar/test/calendar.visual.test.tsx | 52 ++++ packages/@godaddy/antares/exports/Calendar.ts | 8 + packages/@godaddy/antares/index.ts | 9 + 24 files changed, 1279 insertions(+) create mode 100644 packages/@godaddy/antares/components/calendar/README.mdx create mode 100644 packages/@godaddy/antares/components/calendar/calendar.stories.tsx create mode 100644 packages/@godaddy/antares/components/calendar/examples/default-range.tsx create mode 100644 packages/@godaddy/antares/components/calendar/examples/default.tsx create mode 100644 packages/@godaddy/antares/components/calendar/examples/out-of-month-days.tsx create mode 100644 packages/@godaddy/antares/components/calendar/examples/today-distinct.tsx create mode 100644 packages/@godaddy/antares/components/calendar/examples/with-duration.tsx create mode 100644 packages/@godaddy/antares/components/calendar/examples/with-indicators.tsx create mode 100644 packages/@godaddy/antares/components/calendar/examples/with-min-max.tsx create mode 100644 packages/@godaddy/antares/components/calendar/examples/with-unavailable-dates.tsx create mode 100644 packages/@godaddy/antares/components/calendar/src/calendar-header.tsx create mode 100644 packages/@godaddy/antares/components/calendar/src/calendar.tsx create mode 100644 packages/@godaddy/antares/components/calendar/src/day-cell.tsx create mode 100644 packages/@godaddy/antares/components/calendar/src/get-year-range.ts create mode 100644 packages/@godaddy/antares/components/calendar/src/index.module.css create mode 100644 packages/@godaddy/antares/components/calendar/src/index.tsx create mode 100644 packages/@godaddy/antares/components/calendar/src/range-calendar.tsx create mode 100644 packages/@godaddy/antares/components/calendar/src/types.ts create mode 100644 packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap create mode 100644 packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx create mode 100644 packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx create mode 100644 packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx create mode 100644 packages/@godaddy/antares/exports/Calendar.ts diff --git a/packages/@godaddy/antares/components/calendar/README.mdx b/packages/@godaddy/antares/components/calendar/README.mdx new file mode 100644 index 000000000..ddd3cb048 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/README.mdx @@ -0,0 +1,121 @@ +--- +title: Calendar +description: Calendar and RangeCalendar are stand-alone date grids for picking a single date or a contiguous range. Use them when you need an always-visible calendar; use DatePicker / DateRangePicker when the calendar should appear in a popover. +--- + +import { ArgTypes, Meta, Source, Story } from '@storybook/addon-docs/blocks'; +import * as Stories from './calendar.stories.tsx'; + +import SourceDefault from './examples/default.tsx?raw'; +import SourceDefaultRange from './examples/default-range.tsx?raw'; +import SourceWithMinMax from './examples/with-min-max.tsx?raw'; +import SourceWithUnavailableDates from './examples/with-unavailable-dates.tsx?raw'; +import SourceWithIndicators from './examples/with-indicators.tsx?raw'; +import SourceTodayDistinct from './examples/today-distinct.tsx?raw'; +import SourceOutOfMonthDays from './examples/out-of-month-days.tsx?raw'; +import SourceWithDuration from './examples/with-duration.tsx?raw'; + + + +## Features + +- **Single and range**: `Calendar` for a single date, `RangeCalendar` for a contiguous range +- **Month + Year header**: replaces RAC's default heading with two `Select` dropdowns +- **Indicator dots**: optional `getDayIndicators` renders up to 3 colored dots per day cell, mapped to design-system feedback colors +- **Bounds**: `minValue` and `maxValue` constrain selectable dates and drive the year `Select`'s range +- **Duration constraints (range only)**: `minDuration` and `maxDuration` disable end-date candidates that violate during an in-progress pick +- **React Aria integration**: built on React Aria Calendar / RangeCalendar for accessibility, keyboard, and locale handling + +## Installation + +```bash +npm install --save @godaddy/antares +``` + +## Working with dates + +Both components are typed for `CalendarDate` from [`@internationalized/date`][int-date]. See the +canonical "Working with dates" section in the `DatePicker` README for the full setup. Quick +patterns: + +```tsx +import { parseDate, today, getLocalTimeZone } from '@internationalized/date'; +import { Calendar, RangeCalendar } from '@godaddy/antares'; + + + +``` + +## Props + +### Calendar + + + +### RangeCalendar + + + +## Examples + +### Default + +A single calendar with one month visible. + + + + +### Default range + +`RangeCalendar` shows two months side-by-side. + + + + +### Min and max + +`minValue` and `maxValue` disable out-of-range days and constrain the year `Select`. + + + + +### Unavailable dates + +Use `isDateUnavailable` to disable specific dates such as weekends or holidays. + + + + +### Indicators + +`getDayIndicators` renders up to 3 colored dots beneath the day number. Returning more +than 3 silently slices to 3 and emits a `console.warn` in development. + + + + +### Today inside a range + +The "today" visual state coexists with `selected` and `in-range`. + + + + +### Out-of-month days + +Single calendars dim out-of-month days at 40% opacity. `RangeCalendar` hides them entirely. + + + + +### Min and max duration + +`minDuration` and `maxDuration` constrain the length of the selected range during an +in-progress pick. + + + + +[int-date]: https://react-spectrum.adobe.com/internationalized/date/index.html diff --git a/packages/@godaddy/antares/components/calendar/calendar.stories.tsx b/packages/@godaddy/antares/components/calendar/calendar.stories.tsx new file mode 100644 index 000000000..285966880 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/calendar.stories.tsx @@ -0,0 +1,35 @@ +'use client'; +import { getComponentDocs, getMeta, getStory } from '@bento/storybook-addon-helpers'; +import { CalendarDefaultExample } from './examples/default.tsx'; +import { CalendarDefaultRangeExample } from './examples/default-range.tsx'; +import { CalendarOutOfMonthDaysExample } from './examples/out-of-month-days.tsx'; +import { CalendarTodayDistinctExample } from './examples/today-distinct.tsx'; +import { CalendarWithDurationExample } from './examples/with-duration.tsx'; +import { CalendarWithIndicatorsExample } from './examples/with-indicators.tsx'; +import { CalendarWithMinMaxExample } from './examples/with-min-max.tsx'; +import { CalendarWithUnavailableDatesExample } from './examples/with-unavailable-dates.tsx'; +import { Calendar } from './src/calendar.tsx'; +import { RangeCalendar } from './src/range-calendar.tsx'; + +export default getMeta({ + title: 'components/Calendar' +}); + +export const Props = getComponentDocs(Calendar); +export const RangeProps = getComponentDocs(RangeCalendar); + +export const Default = getStory(CalendarDefaultExample); + +export const DefaultRange = getStory(CalendarDefaultRangeExample); + +export const WithMinMax = getStory(CalendarWithMinMaxExample); + +export const WithUnavailableDates = getStory(CalendarWithUnavailableDatesExample); + +export const WithIndicators = getStory(CalendarWithIndicatorsExample); + +export const TodayDistinct = getStory(CalendarTodayDistinctExample); + +export const OutOfMonthDays = getStory(CalendarOutOfMonthDaysExample); + +export const WithDuration = getStory(CalendarWithDurationExample); diff --git a/packages/@godaddy/antares/components/calendar/examples/default-range.tsx b/packages/@godaddy/antares/components/calendar/examples/default-range.tsx new file mode 100644 index 000000000..9ff2507b1 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/examples/default-range.tsx @@ -0,0 +1,14 @@ +import { parseDate } from '@internationalized/date'; +import { RangeCalendar } from '@godaddy/antares'; + +export function CalendarDefaultRangeExample() { + return ( + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/examples/default.tsx b/packages/@godaddy/antares/components/calendar/examples/default.tsx new file mode 100644 index 000000000..f5403ffb2 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/examples/default.tsx @@ -0,0 +1,6 @@ +import { parseDate } from '@internationalized/date'; +import { Calendar } from '@godaddy/antares'; + +export function CalendarDefaultExample() { + return ; +} diff --git a/packages/@godaddy/antares/components/calendar/examples/out-of-month-days.tsx b/packages/@godaddy/antares/components/calendar/examples/out-of-month-days.tsx new file mode 100644 index 000000000..3279875f7 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/examples/out-of-month-days.tsx @@ -0,0 +1,24 @@ +import { parseDate } from '@internationalized/date'; +import { Calendar, Flex, RangeCalendar, Text } from '@godaddy/antares'; + +export function CalendarOutOfMonthDaysExample() { + return ( + + + + Single (40% opacity) + + + + + + Range (hidden) + + + + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/examples/today-distinct.tsx b/packages/@godaddy/antares/components/calendar/examples/today-distinct.tsx new file mode 100644 index 000000000..ffa3d5abb --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/examples/today-distinct.tsx @@ -0,0 +1,12 @@ +import { getLocalTimeZone, today } from '@internationalized/date'; +import { RangeCalendar } from '@godaddy/antares'; + +export function CalendarTodayDistinctExample() { + const now = today(getLocalTimeZone()); + return ( + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/examples/with-duration.tsx b/packages/@godaddy/antares/components/calendar/examples/with-duration.tsx new file mode 100644 index 000000000..f3486b26e --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/examples/with-duration.tsx @@ -0,0 +1,13 @@ +import { parseDate } from '@internationalized/date'; +import { RangeCalendar } from '@godaddy/antares'; + +export function CalendarWithDurationExample() { + return ( + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/examples/with-indicators.tsx b/packages/@godaddy/antares/components/calendar/examples/with-indicators.tsx new file mode 100644 index 000000000..64c813de4 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/examples/with-indicators.tsx @@ -0,0 +1,41 @@ +import { type CalendarDate, parseDate } from '@internationalized/date'; +import { Calendar, type DayIndicator } from '@godaddy/antares'; + +export function CalendarWithIndicatorsExample() { + function getDayIndicators(date: CalendarDate): DayIndicator[] { + if (date.month !== 3) return []; + if (date.day === 5) { + return [{ color: 'success', label: 'Available' }]; + } + if (date.day === 12) { + return [ + { color: 'warning', label: 'Limited' }, + { color: 'info', label: 'Promo' } + ]; + } + if (date.day === 18) { + return [ + { color: 'critical', label: 'Booked' }, + { color: 'highlight', label: 'Featured' }, + { color: 'passive', label: 'Returning' } + ]; + } + if (date.day === 22) { + return [ + { color: 'critical' }, + { color: 'highlight' }, + { color: 'info' }, + { color: 'success' } + ]; + } + return []; + } + + return ( + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/examples/with-min-max.tsx b/packages/@godaddy/antares/components/calendar/examples/with-min-max.tsx new file mode 100644 index 000000000..379bf4bbd --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/examples/with-min-max.tsx @@ -0,0 +1,13 @@ +import { parseDate } from '@internationalized/date'; +import { Calendar } from '@godaddy/antares'; + +export function CalendarWithMinMaxExample() { + return ( + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/examples/with-unavailable-dates.tsx b/packages/@godaddy/antares/components/calendar/examples/with-unavailable-dates.tsx new file mode 100644 index 000000000..111309837 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/examples/with-unavailable-dates.tsx @@ -0,0 +1,17 @@ +import { type DateValue, parseDate } from '@internationalized/date'; +import { Calendar } from '@godaddy/antares'; + +export function CalendarWithUnavailableDatesExample() { + function isWeekend(date: DateValue) { + const dayOfWeek = date.toDate('UTC').getUTCDay(); + return dayOfWeek === 0 || dayOfWeek === 6; + } + + return ( + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx new file mode 100644 index 000000000..78e2818be --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx @@ -0,0 +1,93 @@ +import { type CalendarDate, type DateValue, today as todayFn, getLocalTimeZone } from '@internationalized/date'; +import { useContext } from 'react'; +import { + CalendarStateContext, + RangeCalendarStateContext, + useLocale, + type Key as RACKey +} from 'react-aria-components'; +import { Flex } from '#components/layout/flex'; +import { Select, SelectItem } from '#components/select'; +import { getYearRange } from './get-year-range'; +import styles from './index.module.css'; + +interface CalendarHeaderProps { + /** Lower bound passed to the parent calendar. Used to derive the year `Select` range. */ + minValue?: DateValue | null; + /** Upper bound passed to the parent calendar. Used to derive the year `Select` range. */ + maxValue?: DateValue | null; +} + +/** + * Replaces RAC's default calendar heading with two `Select` dropdowns: Month and Year. + * Reads the active calendar state from `CalendarStateContext` (single) or + * `RangeCalendarStateContext` (range), whichever is non-null. + * + * Consumes locale via `useLocale` so the month names match the locale supplied by + * the host-app's ``. + */ +export function CalendarHeader({ minValue, maxValue }: CalendarHeaderProps) { + const calendarState = useContext(CalendarStateContext); + const rangeState = useContext(RangeCalendarStateContext); + const state = calendarState ?? rangeState; + const { locale } = useLocale(); + + if (!state) { + return null; + } + + const focusedDate = state.focusedDate as CalendarDate; + const today = todayFn(getLocalTimeZone()); + + const monthFormatter = new Intl.DateTimeFormat(locale, { month: 'long' }); + const months = Array.from({ length: 12 }, function buildMonth(_, index) { + const referenceDate = focusedDate.set({ month: index + 1, day: 1 }); + return { + value: index + 1, + label: monthFormatter.format(referenceDate.toDate(state.timeZone)) + }; + }); + + const years = getYearRange(minValue ?? null, maxValue ?? null, today); + + function handleMonthChange(key: RACKey | null) { + if (key == null) return; + const month = Number(key); + state?.setFocusedDate(focusedDate.set({ month })); + } + + function handleYearChange(key: RACKey | null) { + if (key == null) return; + const year = Number(key); + state?.setFocusedDate(focusedDate.set({ year })); + } + + return ( + + + + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/src/calendar.tsx b/packages/@godaddy/antares/components/calendar/src/calendar.tsx new file mode 100644 index 000000000..bc65ea0e9 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/calendar.tsx @@ -0,0 +1,64 @@ +import type { CalendarDate } from '@internationalized/date'; +import { + Calendar as RACCalendar, + CalendarGrid as RACCalendarGrid, + type CalendarProps as RACCalendarProps +} from 'react-aria-components'; +import { CalendarHeader } from './calendar-header'; +import { DayCell } from './day-cell'; +import type { GetDayIndicators } from './types'; +import styles from './index.module.css'; + +/** + * Props for the {@link Calendar} component. + * + * Wraps {@link RACCalendarProps} typed for `CalendarDate`. `visibleDuration` is fixed + * at one month — consumers needing a custom layout should compose RAC directly. + * + * @public + */ +export interface CalendarProps extends Omit, 'children' | 'visibleDuration'> { + /** + * Per-day indicator function. Returns up to 3 colored dots rendered beneath the + * day number. Called once per visible day cell. + * + * @example + * date.day === 15 ? [{ color: 'success' }] : []} /> + */ + getDayIndicators?: GetDayIndicators; +} + +/** + * Calendar is a single-month date grid for picking one date. It mirrors React Aria + * Calendar typed for `CalendarDate` (date-only, no time, no timezone) and adds a + * Month + Year `Select` header in place of RAC's default heading. + * + * @param props - {@link CalendarProps} + * + * @example + * ```tsx + * import { parseDate } from '@internationalized/date'; + * + * + * ``` + * + * @see https://react-spectrum.adobe.com/react-aria/Calendar.html + */ +export function Calendar(props: CalendarProps) { + const { getDayIndicators, minValue, maxValue, ...racProps } = props; + + return ( + + {...racProps} + minValue={minValue} + maxValue={maxValue} + visibleDuration={{ months: 1 }} + className={styles.calendar} + > + + + {(date) => } + + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/src/day-cell.tsx b/packages/@godaddy/antares/components/calendar/src/day-cell.tsx new file mode 100644 index 000000000..0a62647c9 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/day-cell.tsx @@ -0,0 +1,72 @@ +import type { CalendarDate } from '@internationalized/date'; +import { CalendarCell as RACCalendarCell } from 'react-aria-components'; +import { type DayIndicator, type GetDayIndicators, MAX_INDICATORS } from './types'; +import styles from './index.module.css'; + +interface DayCellProps { + date: CalendarDate; + /** Hide out-of-month days entirely (RangeCalendar). When false, dim them (Calendar). */ + hideOutsideMonth?: boolean; + getDayIndicators?: GetDayIndicators; +} + +/** + * A calendar day cell. Renders the day number, an optional row of up to 3 indicator + * dots, and surfaces indicator labels to assistive tech via a visually-hidden span. + * + * Out-of-month days are either dimmed (single Calendar) or hidden entirely (RangeCalendar) + * per the AC. + */ +export function DayCell({ date, hideOutsideMonth = false, getDayIndicators }: DayCellProps) { + const rawIndicators = getDayIndicators?.(date) ?? []; + const indicators = sliceIndicators(rawIndicators); + const indicatorLabel = indicators + .map((indicator) => indicator.label) + .filter((label): label is string => Boolean(label)) + .join(', '); + + return ( + + {function renderCell(renderProps) { + if (hideOutsideMonth && renderProps.isOutsideMonth) { + return + ); +} + +function sliceIndicators(indicators: DayIndicator[]): DayIndicator[] { + if (indicators.length <= MAX_INDICATORS) { + return indicators; + } + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn( + `[@godaddy/antares] getDayIndicators returned ${indicators.length} indicators; only the first ${MAX_INDICATORS} will be rendered.` + ); + } + + return indicators.slice(0, MAX_INDICATORS); +} diff --git a/packages/@godaddy/antares/components/calendar/src/get-year-range.ts b/packages/@godaddy/antares/components/calendar/src/get-year-range.ts new file mode 100644 index 000000000..8325607af --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/get-year-range.ts @@ -0,0 +1,48 @@ +import type { CalendarDate, DateValue } from '@internationalized/date'; + +/** + * Years either side of `today` to include in the year `Select` when no bounds are set. + */ +export const DEFAULT_YEAR_PADDING = 100; + +/** + * Derive the list of years to surface in the calendar's year `Select`. + * + * - If `minValue` and/or `maxValue` is provided, the range is the inclusive span + * between them — falling back to today's year on the unset side. + * - If neither is provided, returns `today.year ± DEFAULT_YEAR_PADDING`. + * - If `minValue.year > maxValue.year` (consumer error), returns `[today.year]` + * rather than throwing — the calendar still renders. + * + * @param minValue - lower bound from the consumer + * @param maxValue - upper bound from the consumer + * @param today - reference date (typically `today(getLocalTimeZone())`) + */ +export function getYearRange( + minValue: DateValue | undefined | null, + maxValue: DateValue | undefined | null, + today: CalendarDate +): number[] { + if (!minValue && !maxValue) { + const start = today.year - DEFAULT_YEAR_PADDING; + const end = today.year + DEFAULT_YEAR_PADDING; + return range(start, end); + } + + const startYear = minValue?.year ?? today.year; + const endYear = maxValue?.year ?? today.year; + + if (startYear > endYear) { + return [today.year]; + } + + return range(startYear, endYear); +} + +function range(start: number, end: number): number[] { + const out: number[] = []; + for (let year = start; year <= end; year += 1) { + out.push(year); + } + return out; +} diff --git a/packages/@godaddy/antares/components/calendar/src/index.module.css b/packages/@godaddy/antares/components/calendar/src/index.module.css new file mode 100644 index 000000000..604d63303 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/index.module.css @@ -0,0 +1,137 @@ +.calendar { + --calendar-cell-size: 2.25rem; + --calendar-cell-radius: 0.25rem; + display: inline-flex; + flex-direction: column; + gap: var(--sp-md); + padding: var(--sp-md); +} + +.header { + flex-wrap: nowrap; +} + +.headerSelect { + flex: 1; +} + +.rangeGrids { + flex-wrap: nowrap; +} + +.grid { + border-collapse: separate; + border-spacing: 0.125rem; + + & th { + font-weight: bolder; + font-size: var(--fs-sm); + color: var(--fg-secondary, color-mix(in oklch, currentColor, transparent 35%)); + padding-block: var(--sp-xs); + } +} + +.cell { + width: var(--calendar-cell-size); + height: var(--calendar-cell-size); + border-radius: var(--calendar-cell-radius); + cursor: pointer; + outline: none; + text-align: center; + vertical-align: middle; + user-select: none; + + &[data-outside-month] { + opacity: 0.4; + } + + &[data-disabled], + &[data-unavailable] { + cursor: not-allowed; + color: color-mix(in oklch, currentColor, transparent 50%); + } + + &[data-hovered]:not([data-disabled]):not([data-unavailable]) { + background: color-mix(in oklch, currentColor, transparent 92%); + } + + &[data-focus-visible] { + outline: 2px solid Highlight; + outline-offset: 1px; + } + + &[data-selected] { + background: var(--color-action-background-primary-default, #4169e1); + color: var(--color-action-text-primary-default, #fff); + } + + &[data-selected][data-selection-start] { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + + &[data-selected][data-selection-end] { + border-start-start-radius: 0; + border-end-start-radius: 0; + } + + &[data-selected][data-selection-start][data-selection-end] { + border-radius: var(--calendar-cell-radius); + } + + &[data-selected]:not([data-selection-start]):not([data-selection-end]) { + border-radius: 0; + background: color-mix(in oklch, var(--color-action-background-primary-default, #4169e1), transparent 75%); + color: inherit; + } + + &[data-today] { + box-shadow: inset 0 0 0 1px var(--color-feedback-info-strong, currentColor); + } +} + +.cellHidden { + display: inline-block; + width: var(--calendar-cell-size); + height: var(--calendar-cell-size); +} + +.cellInner { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + gap: 2px; +} + +.cellDate { + font-variant-numeric: tabular-nums; +} + +.indicators { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.indicator { + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/packages/@godaddy/antares/components/calendar/src/index.tsx b/packages/@godaddy/antares/components/calendar/src/index.tsx new file mode 100644 index 000000000..37e77d820 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/index.tsx @@ -0,0 +1,3 @@ +export { Calendar, type CalendarProps } from './calendar'; +export { RangeCalendar, type RangeCalendarProps } from './range-calendar'; +export type { DayIndicator, GetDayIndicators } from './types'; diff --git a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx new file mode 100644 index 000000000..65e597d27 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx @@ -0,0 +1,132 @@ +import type { CalendarDate, DateDuration, DateValue } from '@internationalized/date'; +import { useCallback } from 'react'; +import { + CalendarGrid as RACCalendarGrid, + RangeCalendar as RACRangeCalendar, + type RangeCalendarProps as RACRangeCalendarProps +} from 'react-aria-components'; +import { Flex } from '#components/layout/flex'; +import { CalendarHeader } from './calendar-header'; +import { DayCell } from './day-cell'; +import type { GetDayIndicators } from './types'; +import styles from './index.module.css'; + +/** + * RAC's RangeCalendar passes the in-progress anchor date as the second argument to + * `isDateUnavailable`. Wider than RAC's exported `(date: DateValue) => boolean` shape. + */ +type RangeIsDateUnavailable = (date: DateValue, anchorDate: CalendarDate | null) => boolean; + +/** + * Props for the {@link RangeCalendar} component. + * + * @public + */ +export interface RangeCalendarProps + extends Omit, 'children' | 'visibleDuration' | 'isDateUnavailable'> { + /** + * Per-day indicator function. Returns up to 3 colored dots rendered beneath the day number. + * + * @example + * date.day % 5 === 0 ? [{ color: 'info' }] : []} /> + */ + getDayIndicators?: GetDayIndicators; + + /** + * Predicate to disable specific dates. Receives the in-progress anchor date as the + * second argument so duration constraints can be expressed by the consumer if needed + * — `minDuration` and `maxDuration` already cover the common cases. + * + * @see https://react-spectrum.adobe.com/react-aria/RangeCalendar.html#unavailable-dates + */ + isDateUnavailable?: RangeIsDateUnavailable; + + /** + * Minimum duration of the selected range. Disables end-date candidates that would + * produce a shorter range during an in-progress pick. + * + * @example + * + */ + minDuration?: DateDuration; + + /** + * Maximum duration of the selected range. Disables end-date candidates that would + * produce a longer range during an in-progress pick. + * + * @example + * + */ + maxDuration?: DateDuration; +} + +/** + * RangeCalendar is a two-month side-by-side date grid for picking a contiguous range. + * Mirrors React Aria RangeCalendar typed for `CalendarDate`. Adds `minDuration` / + * `maxDuration` constraints (not in RAC) and a Month + Year `Select` header. + * + * Out-of-month days in either grid are NOT rendered (per AC) so the visible cells + * always belong to one of the two visible months. + * + * @param props - {@link RangeCalendarProps} + * + * @example + * ```tsx + * import { parseDate } from '@internationalized/date'; + * + * + * ``` + * + * @see https://react-spectrum.adobe.com/react-aria/RangeCalendar.html + */ +export function RangeCalendar(props: RangeCalendarProps) { + const { getDayIndicators, minValue, maxValue, minDuration, maxDuration, isDateUnavailable, ...racProps } = props; + + const wrappedIsDateUnavailable = useCallback( + function check(date, anchorDate) { + if (isDateUnavailable?.(date, anchorDate)) return true; + if (!anchorDate) return false; + + const candidate = date as CalendarDate; + if (candidate.compare(anchorDate) < 0) return false; + + if (minDuration) { + const minBoundary = anchorDate.add(minDuration); + if (candidate.compare(minBoundary) < 0) return true; + } + + if (maxDuration) { + const maxBoundary = anchorDate.add(maxDuration); + if (candidate.compare(maxBoundary) > 0) return true; + } + + return false; + }, + [isDateUnavailable, minDuration, maxDuration] + ); + + return ( + + {...racProps} + minValue={minValue} + maxValue={maxValue} + visibleDuration={{ months: 2 }} + isDateUnavailable={wrappedIsDateUnavailable as (date: DateValue) => boolean} + className={styles.calendar} + > + + + + {(date) => } + + + {(date) => } + + + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/src/types.ts b/packages/@godaddy/antares/components/calendar/src/types.ts new file mode 100644 index 000000000..2ad8e13ef --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/types.ts @@ -0,0 +1,22 @@ +import type { CalendarDate } from '@internationalized/date'; + +/** + * Indicator dot rendered beneath a day cell. Up to 3 per day. + */ +export interface DayIndicator { + /** Maps to the `--color-feedback--strong` design token. */ + color: 'critical' | 'highlight' | 'info' | 'passive' | 'success' | 'warning'; + /** Optional accessible label combined with the day's date label for screen readers. */ + label?: string; +} + +/** + * Per-day indicator function, called with the CalendarDate for each visible day cell. + * Return up to 3 indicators; more are sliced and produce a dev-only `console.warn`. + */ +export type GetDayIndicators = (date: CalendarDate) => DayIndicator[]; + +/** + * Maximum indicators rendered per day cell. AC is firm at 3. + */ +export const MAX_INDICATORS = 3; diff --git a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap new file mode 100644 index 000000000..a2b48044b --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; diff --git a/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx b/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx new file mode 100644 index 000000000..952776097 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx @@ -0,0 +1,246 @@ +import { parseDate } from '@internationalized/date'; +import assume from 'assume'; +import { beforeAll, describe, it, vi } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { page, userEvent } from 'vitest/browser'; +import { set } from '#components/icon'; +import { Calendar, type DayIndicator } from '@godaddy/antares'; +import { CalendarDefaultExample } from '../examples/default.tsx'; +import { CalendarDefaultRangeExample } from '../examples/default-range.tsx'; +import { CalendarOutOfMonthDaysExample } from '../examples/out-of-month-days.tsx'; +import { CalendarTodayDistinctExample } from '../examples/today-distinct.tsx'; +import { CalendarWithDurationExample } from '../examples/with-duration.tsx'; +import { CalendarWithIndicatorsExample } from '../examples/with-indicators.tsx'; +import { CalendarWithMinMaxExample } from '../examples/with-min-max.tsx'; +import { CalendarWithUnavailableDatesExample } from '../examples/with-unavailable-dates.tsx'; + +describe('@godaddy/antares', function antares() { + beforeAll(function setupIcons() { + set({ + 'chevron-down': ( + + + + ), + alert: ( + + + + ) + }); + }); + + describe('#Calendar', function calendar() { + describe('#default', function defaultTests() { + it('renders the default month with day cells', async function renders() { + const { container } = await render(); + const cells = container.querySelectorAll('[role="gridcell"]'); + assume(cells.length).is.at.least(28); + }); + }); + + describe('#header', function header() { + it('changes the visible month when the Month Select changes', async function changesMonth() { + await render(); + const monthButton = page.getByRole('button', { name: /month/i }); + + await userEvent.click(monthButton); + const juneOption = page.getByRole('option', { name: 'June' }); + await userEvent.click(juneOption); + + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const updated = page.getByRole('button', { name: /month/i }); + assume(updated.element().textContent ?? '').contains('June'); + }); + + it('changes the visible year when the Year Select changes', async function changesYear() { + await render(); + const yearButton = page.getByRole('button', { name: /year/i }); + + await userEvent.click(yearButton); + const yearOption = page.getByRole('option', { name: '2025' }); + await userEvent.click(yearOption); + + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const updated = page.getByRole('button', { name: /year/i }); + assume(updated.element().textContent ?? '').contains('2025'); + }); + + it('limits the Year Select options to the configured min/max range', async function limitedYears() { + await render(); + const yearButton = page.getByRole('button', { name: /year/i }); + + await userEvent.click(yearButton); + + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const options = document.querySelectorAll('[role="option"]'); + const labels = Array.from(options).map(function getLabel(option) { + return (option.textContent ?? '').trim(); + }); + + assume(labels.length).equals(1); + assume(labels[0]).equals('2024'); + }); + }); + + describe('#unavailableDates', function unavailableDates() { + it('marks weekends as data-unavailable', async function unavailable() { + const { container } = await render(); + const unavailableCells = container.querySelectorAll('[data-unavailable]'); + assume(unavailableCells.length).is.at.least(4); + }); + }); + + describe('#minMax', function minMax() { + it('disables days outside the [minValue, maxValue] range', async function disablesOutOfRange() { + const { container } = await render(); + const monthButton = page.getByRole('button', { name: /month/i }); + + await userEvent.click(monthButton); + const januaryOption = page.getByRole('option', { name: 'January' }); + await userEvent.click(januaryOption); + + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const disabled = container.querySelectorAll('[data-disabled]'); + assume(disabled.length).is.at.least(1); + }); + }); + + describe('#indicators', function indicators() { + it('renders 1, 2, and 3 dots for the example days', async function variousDots() { + const { container } = await render(); + const successDots = container.querySelectorAll('[style*="color-feedback-success-strong"]'); + const warningDots = container.querySelectorAll('[style*="color-feedback-warning-strong"]'); + const criticalDots = container.querySelectorAll('[style*="color-feedback-critical-strong"]'); + + // March-only: day 5 -> success x1; day 12 -> warning + info; day 18 -> critical + highlight + passive; + // day 22 has 4 returned, sliced to first 3 (critical, highlight, info) — success is dropped. + assume(successDots.length).equals(1); // day 5 only + assume(warningDots.length).equals(1); // day 12 + assume(criticalDots.length).equals(2); // day 18 + day 22 + }); + + it('slices to 3 indicators and warns in dev when the consumer returns more', async function slices() { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(function noop() { + // ignore + }); + + const { container } = await render(); + const cellsWithDots = Array.from(container.querySelectorAll('[role="gridcell"]')).filter( + function hasDots(cell) { + return cell.querySelectorAll('[class*="indicator_"]').length > 0; + } + ); + const maxDotsInCell = cellsWithDots.length + ? Math.max( + ...cellsWithDots.map(function count(cell) { + return cell.querySelectorAll('[class*="indicator_"]').length; + }) + ) + : 0; + assume(maxDotsInCell).is.at.most(3); + assume(consoleWarn.mock.calls.length).is.above(0); + + consoleWarn.mockRestore(); + }); + + it('exposes the indicator label to assistive tech via a hidden span', async function indicatorLabel() { + const { container } = await render(); + assume(container.textContent ?? '').contains('Available'); + assume(container.textContent ?? '').contains('Limited'); + }); + }); + }); + + describe('#RangeCalendar', function rangeCalendar() { + describe('#default', function defaultRange() { + it('renders two side-by-side grids', async function twoGrids() { + const { container } = await render(); + const grids = container.querySelectorAll('[role="grid"]'); + assume(grids.length).equals(2); + }); + + it('hides out-of-month days entirely (no day number rendered)', async function hidesOutOfMonth() { + const { container } = await render(); + const outsideMonth = container.querySelectorAll('[data-outside-month]'); + assume(outsideMonth.length).is.at.least(1); + for (const cell of Array.from(outsideMonth)) { + assume((cell.textContent ?? '').trim()).equals(''); + } + }); + }); + + describe('#duration', function duration() { + it('disables end candidates within minDuration and beyond maxDuration after a start click', async function constrains() { + const { container } = await render(); + + const day15 = container.querySelector('[role="gridcell"][data-today]') + ?? container.querySelectorAll('[role="gridcell"]:not([data-outside-month])')[14]; + assume(day15).exists(); + await userEvent.click(day15 as HTMLElement); + + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const unavailable = container.querySelectorAll('[data-unavailable]'); + assume(unavailable.length).is.at.least(2); + }); + }); + + describe('#todayDistinct', function todayDistinct() { + it('renders today + selected + in-range states together', async function coexists() { + const { container } = await render(); + const todayCell = container.querySelector('[data-today]'); + assume(todayCell).exists(); + + const isSelected = todayCell?.hasAttribute('data-selected') ?? false; + assume(isSelected).equals(true); + }); + }); + }); + + describe('#OutOfMonthRendering', function outOfMonthRendering() { + it('renders out-of-month cells in single Calendar (with content) and hides them in Range', async function rendering() { + const { container } = await render(); + const grids = container.querySelectorAll('[role="grid"]'); + // grids[0] = single, grids[1] and grids[2] = first/second month of range. + const singleOutside = grids[0].querySelectorAll('[data-outside-month]'); + const rangeOutside = Array.from(grids[1].querySelectorAll('[data-outside-month]')).concat( + Array.from(grids[2].querySelectorAll('[data-outside-month]')) + ); + + assume(singleOutside.length).is.at.least(1); + const singleHasContent = Array.from(singleOutside).some(function hasContent(cell) { + return (cell.textContent ?? '').trim().length > 0; + }); + assume(singleHasContent).equals(true); + + assume(rangeOutside.length).is.at.least(1); + const rangeAllEmpty = rangeOutside.every(function isEmpty(cell) { + return (cell.textContent ?? '').trim().length === 0; + }); + assume(rangeAllEmpty).equals(true); + }); + }); +}); + +function OverflowIndicatorsExample() { + function getDayIndicators(): DayIndicator[] { + return [{ color: 'critical' }, { color: 'highlight' }, { color: 'info' }, { color: 'success' }]; + } + + return ; +} diff --git a/packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx b/packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx new file mode 100644 index 000000000..1cbfbba65 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx @@ -0,0 +1,94 @@ +import { parseDate } from '@internationalized/date'; +import { describe, expect, it } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { CalendarDefaultExample } from '../examples/default.tsx'; +import { CalendarDefaultRangeExample } from '../examples/default-range.tsx'; +import { CalendarOutOfMonthDaysExample } from '../examples/out-of-month-days.tsx'; +import { CalendarTodayDistinctExample } from '../examples/today-distinct.tsx'; +import { CalendarWithDurationExample } from '../examples/with-duration.tsx'; +import { CalendarWithIndicatorsExample } from '../examples/with-indicators.tsx'; +import { CalendarWithMinMaxExample } from '../examples/with-min-max.tsx'; +import { CalendarWithUnavailableDatesExample } from '../examples/with-unavailable-dates.tsx'; +import { DEFAULT_YEAR_PADDING, getYearRange } from '../src/get-year-range'; + +describe('@godaddy/antares', function antares() { + describe('#Calendar', function calendar() { + describe('#examples', function examples() { + it('renders default example', function defaultExample() { + const result = renderToString(); + expect(result).toContain('2024'); + }); + + it('renders default range example', function rangeExample() { + const result = renderToString(); + expect(result).toContain('data-selected'); + }); + + it('renders min/max example', function minMax() { + const result = renderToString(); + expect(result).toMatchSnapshot(); + }); + + it('renders unavailable dates example', function unavailable() { + const result = renderToString(); + expect(result).toContain('data-unavailable'); + }); + + it('renders indicators example', function indicators() { + const result = renderToString(); + expect(result).toContain('color-feedback-success-strong'); + }); + + it('renders today-distinct example', function todayDistinct() { + const result = renderToString(); + expect(result).toContain('data-today'); + }); + + it('renders out-of-month-days example', function outOfMonth() { + const result = renderToString(); + expect(result).toContain('data-outside-month'); + }); + + it('renders with-duration example', function withDuration() { + const result = renderToString(); + expect(result).toContain('2024'); + }); + }); + }); + + describe('#getYearRange', function getYearRangeTests() { + const today = parseDate('2024-06-15'); + + it('returns today.year ± DEFAULT_YEAR_PADDING when no bounds set', function defaultRange() { + const years = getYearRange(undefined, undefined, today); + expect(years).toHaveLength(DEFAULT_YEAR_PADDING * 2 + 1); + expect(years[0]).toBe(today.year - DEFAULT_YEAR_PADDING); + expect(years[years.length - 1]).toBe(today.year + DEFAULT_YEAR_PADDING); + }); + + it('returns inclusive year span when both bounds set', function bothBounds() { + const years = getYearRange(parseDate('2020-01-01'), parseDate('2025-12-31'), today); + expect(years).toEqual([2020, 2021, 2022, 2023, 2024, 2025]); + }); + + it('uses today.year as upper bound when only minValue set', function onlyMin() { + const years = getYearRange(parseDate('2022-01-01'), undefined, today); + expect(years).toEqual([2022, 2023, 2024]); + }); + + it('uses today.year as lower bound when only maxValue set', function onlyMax() { + const years = getYearRange(undefined, parseDate('2026-12-31'), today); + expect(years).toEqual([2024, 2025, 2026]); + }); + + it('returns [today.year] when minValue.year > maxValue.year', function inverted() { + const years = getYearRange(parseDate('2030-01-01'), parseDate('2020-01-01'), today); + expect(years).toEqual([2024]); + }); + + it('treats null and undefined as unset', function nulls() { + const years = getYearRange(null, null, today); + expect(years).toHaveLength(DEFAULT_YEAR_PADDING * 2 + 1); + }); + }); +}); diff --git a/packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx b/packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx new file mode 100644 index 000000000..ec3d99bfe --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx @@ -0,0 +1,52 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { set } from '#components/icon'; +import { CalendarDefaultRangeExample } from '../examples/default-range.tsx'; +import { CalendarOutOfMonthDaysExample } from '../examples/out-of-month-days.tsx'; +import { CalendarTodayDistinctExample } from '../examples/today-distinct.tsx'; +import { CalendarWithDurationExample } from '../examples/with-duration.tsx'; +import { CalendarWithIndicatorsExample } from '../examples/with-indicators.tsx'; + +describe('@godaddy/antares', function antares() { + beforeAll(function setupIcons() { + set({ + 'chevron-down': ( + + + + ), + alert: ( + + + + ) + }); + }); + + describe('#Calendar', function calendarTests() { + it('with-indicators example', async function indicatorsRender() { + const { container } = await render(); + await expect(container).toMatchScreenshot('with-indicators'); + }); + + it('default-range example', async function rangeRender() { + const { container } = await render(); + await expect(container).toMatchScreenshot('default-range'); + }); + + it('today-distinct example', async function todayRender() { + const { container } = await render(); + await expect(container).toMatchScreenshot('today-distinct'); + }); + + it('out-of-month-days example', async function outOfMonthRender() { + const { container } = await render(); + await expect(container).toMatchScreenshot('out-of-month-days'); + }); + + it('with-duration example', async function durationRender() { + const { container } = await render(); + await expect(container).toMatchScreenshot('with-duration'); + }); + }); +}); diff --git a/packages/@godaddy/antares/exports/Calendar.ts b/packages/@godaddy/antares/exports/Calendar.ts new file mode 100644 index 000000000..ca7475321 --- /dev/null +++ b/packages/@godaddy/antares/exports/Calendar.ts @@ -0,0 +1,8 @@ +export { + Calendar, + RangeCalendar, + type CalendarProps, + type RangeCalendarProps, + type DayIndicator, + type GetDayIndicators +} from '#components/calendar'; diff --git a/packages/@godaddy/antares/index.ts b/packages/@godaddy/antares/index.ts index d2a337be4..f478e26f2 100644 --- a/packages/@godaddy/antares/index.ts +++ b/packages/@godaddy/antares/index.ts @@ -61,6 +61,15 @@ export { TextField, type TextFieldProps } from '#components/text-field'; export { NumberField, type NumberFieldProps } from '#components/number-field'; +export { + Calendar, + RangeCalendar, + type CalendarProps, + type RangeCalendarProps, + type DayIndicator, + type GetDayIndicators +} from '#components/calendar'; + export { Carousel, type CarouselProps, type CarouselRef } from '#components/carousel'; export { Pagination, type PaginationProps } from '#components/pagination'; From 605ca15d530575e02c2938e9d84c5f39d3483c98 Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Fri, 29 May 2026 18:26:09 -0500 Subject: [PATCH 02/14] chore: add changeset for Calendar and RangeCalendar Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/calendar-components.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/calendar-components.md diff --git a/.changeset/calendar-components.md b/.changeset/calendar-components.md new file mode 100644 index 000000000..0b5b6152b --- /dev/null +++ b/.changeset/calendar-components.md @@ -0,0 +1,5 @@ +--- +'@godaddy/antares': minor +--- + +feat(antares): add Calendar and RangeCalendar components From 4d175915398ec8c89549e412935ba614588d431e Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Fri, 29 May 2026 18:44:31 -0500 Subject: [PATCH 03/14] refactor(antares): use Flex for Calendar and RangeCalendar layout Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/calendar/src/calendar.tsx | 11 +++++++---- .../components/calendar/src/day-cell.tsx | 9 +++++---- .../components/calendar/src/index.module.css | 19 +------------------ .../calendar/src/range-calendar.tsx | 18 ++++++++++-------- .../__snapshots__/calendar.node.test.tsx.snap | 2 +- 5 files changed, 24 insertions(+), 35 deletions(-) diff --git a/packages/@godaddy/antares/components/calendar/src/calendar.tsx b/packages/@godaddy/antares/components/calendar/src/calendar.tsx index bc65ea0e9..5d9d3bcd6 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar.tsx @@ -4,6 +4,7 @@ import { CalendarGrid as RACCalendarGrid, type CalendarProps as RACCalendarProps } from 'react-aria-components'; +import { Flex } from '#components/layout/flex'; import { CalendarHeader } from './calendar-header'; import { DayCell } from './day-cell'; import type { GetDayIndicators } from './types'; @@ -55,10 +56,12 @@ export function Calendar(props: CalendarProps) { visibleDuration={{ months: 1 }} className={styles.calendar} > - - - {(date) => } - + + + + {(date) => } + + ); } diff --git a/packages/@godaddy/antares/components/calendar/src/day-cell.tsx b/packages/@godaddy/antares/components/calendar/src/day-cell.tsx index 0a62647c9..86d455453 100644 --- a/packages/@godaddy/antares/components/calendar/src/day-cell.tsx +++ b/packages/@godaddy/antares/components/calendar/src/day-cell.tsx @@ -1,5 +1,6 @@ import type { CalendarDate } from '@internationalized/date'; import { CalendarCell as RACCalendarCell } from 'react-aria-components'; +import { Flex } from '#components/layout/flex'; import { type DayIndicator, type GetDayIndicators, MAX_INDICATORS } from './types'; import styles from './index.module.css'; @@ -33,10 +34,10 @@ export function DayCell({ date, hideOutsideMonth = false, getDayIndicators }: Da } return ( -
+ {renderProps.formattedDate} {indicators.length > 0 && ( -
+ ); }} diff --git a/packages/@godaddy/antares/components/calendar/src/index.module.css b/packages/@godaddy/antares/components/calendar/src/index.module.css index 604d63303..f3477f24a 100644 --- a/packages/@godaddy/antares/components/calendar/src/index.module.css +++ b/packages/@godaddy/antares/components/calendar/src/index.module.css @@ -1,24 +1,13 @@ .calendar { --calendar-cell-size: 2.25rem; --calendar-cell-radius: 0.25rem; - display: inline-flex; - flex-direction: column; - gap: var(--sp-md); - padding: var(--sp-md); -} - -.header { - flex-wrap: nowrap; + display: inline-block; } .headerSelect { flex: 1; } -.rangeGrids { - flex-wrap: nowrap; -} - .grid { border-collapse: separate; border-spacing: 0.125rem; @@ -98,10 +87,6 @@ .cellInner { position: relative; - display: inline-flex; - flex-direction: column; - align-items: center; - justify-content: center; width: 100%; height: 100%; gap: 2px; @@ -112,8 +97,6 @@ } .indicators { - display: inline-flex; - align-items: center; gap: 2px; } diff --git a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx index 65e597d27..9421929bd 100644 --- a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx +++ b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx @@ -118,14 +118,16 @@ export function RangeCalendar(props: RangeCalendarProps) { isDateUnavailable={wrappedIsDateUnavailable as (date: DateValue) => boolean} className={styles.calendar} > - - - - {(date) => } - - - {(date) => } - + + + + + {(date) => } + + + {(date) => } + + ); diff --git a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap index a2b48044b..3cbc9304b 100644 --- a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap +++ b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; +exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; From 14be6c7de234619392d3bff91f1018a4959da2c1 Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Fri, 29 May 2026 21:48:58 -0500 Subject: [PATCH 04/14] refactor(antares): simplify Calendar with RAC 1.18 pickers and per-month range headers Co-Authored-By: Claude Opus 4.7 (1M context) --- .../antares/components/calendar/README.mdx | 39 +-- .../components/calendar/calendar.stories.tsx | 9 - .../calendar/examples/out-of-month-days.tsx | 24 -- .../calendar/examples/with-duration.tsx | 13 - .../calendar/examples/with-indicators.tsx | 41 --- .../calendar/src/calendar-header.tsx | 183 ++++++---- .../components/calendar/src/calendar.tsx | 34 +- .../components/calendar/src/day-cell.tsx | 73 ---- .../components/calendar/src/get-year-range.ts | 48 --- .../components/calendar/src/index.module.css | 68 ++-- .../antares/components/calendar/src/index.tsx | 1 - .../calendar/src/range-calendar.tsx | 108 ++---- .../antares/components/calendar/src/types.ts | 22 -- .../__snapshots__/calendar.node.test.tsx.snap | 2 +- .../calendar/test/calendar.browser.test.tsx | 119 +------ .../calendar/test/calendar.node.test.tsx | 56 --- .../calendar/test/calendar.visual.test.tsx | 28 +- packages/@godaddy/antares/exports/Calendar.ts | 9 +- packages/@godaddy/antares/index.ts | 9 +- plans/calendar-size-measurements.svg | 306 ++++++++++++++++ plans/single-double-month.svg | 326 ++++++++++++++++++ 21 files changed, 840 insertions(+), 678 deletions(-) delete mode 100644 packages/@godaddy/antares/components/calendar/examples/out-of-month-days.tsx delete mode 100644 packages/@godaddy/antares/components/calendar/examples/with-duration.tsx delete mode 100644 packages/@godaddy/antares/components/calendar/examples/with-indicators.tsx delete mode 100644 packages/@godaddy/antares/components/calendar/src/day-cell.tsx delete mode 100644 packages/@godaddy/antares/components/calendar/src/get-year-range.ts delete mode 100644 packages/@godaddy/antares/components/calendar/src/types.ts create mode 100644 plans/calendar-size-measurements.svg create mode 100644 plans/single-double-month.svg diff --git a/packages/@godaddy/antares/components/calendar/README.mdx b/packages/@godaddy/antares/components/calendar/README.mdx index ddd3cb048..7c69513d1 100644 --- a/packages/@godaddy/antares/components/calendar/README.mdx +++ b/packages/@godaddy/antares/components/calendar/README.mdx @@ -10,20 +10,15 @@ import SourceDefault from './examples/default.tsx?raw'; import SourceDefaultRange from './examples/default-range.tsx?raw'; import SourceWithMinMax from './examples/with-min-max.tsx?raw'; import SourceWithUnavailableDates from './examples/with-unavailable-dates.tsx?raw'; -import SourceWithIndicators from './examples/with-indicators.tsx?raw'; import SourceTodayDistinct from './examples/today-distinct.tsx?raw'; -import SourceOutOfMonthDays from './examples/out-of-month-days.tsx?raw'; -import SourceWithDuration from './examples/with-duration.tsx?raw'; ## Features - **Single and range**: `Calendar` for a single date, `RangeCalendar` for a contiguous range -- **Month + Year header**: replaces RAC's default heading with two `Select` dropdowns -- **Indicator dots**: optional `getDayIndicators` renders up to 3 colored dots per day cell, mapped to design-system feedback colors -- **Bounds**: `minValue` and `maxValue` constrain selectable dates and drive the year `Select`'s range -- **Duration constraints (range only)**: `minDuration` and `maxDuration` disable end-date candidates that violate during an in-progress pick +- **Built-in Month + Year pickers**: prev/next arrows alongside React Aria's `` / `` rendered into the antares `Select` +- **Bounds**: `minValue` and `maxValue` constrain selectable dates and clamp the year `Select`'s range automatically - **React Aria integration**: built on React Aria Calendar / RangeCalendar for accessibility, keyboard, and locale handling ## Installation @@ -69,14 +64,17 @@ A single calendar with one month visible. ### Default range -`RangeCalendar` shows two months side-by-side. +`RangeCalendar` shows two months side-by-side. Each visible month has its own header +(prev arrow + dropdowns on the left, dropdowns + next arrow on the right). Picking a +month or year on either side scrolls both grids together; the prev/next arrows page by +one month. ### Min and max -`minValue` and `maxValue` disable out-of-range days and constrain the year `Select`. +`minValue` and `maxValue` disable out-of-range days and clamp the year `Select`. @@ -88,14 +86,6 @@ Use `isDateUnavailable` to disable specific dates such as weekends or holidays. -### Indicators - -`getDayIndicators` renders up to 3 colored dots beneath the day number. Returning more -than 3 silently slices to 3 and emits a `console.warn` in development. - - - - ### Today inside a range The "today" visual state coexists with `selected` and `in-range`. @@ -103,19 +93,4 @@ The "today" visual state coexists with `selected` and `in-range`. -### Out-of-month days - -Single calendars dim out-of-month days at 40% opacity. `RangeCalendar` hides them entirely. - - - - -### Min and max duration - -`minDuration` and `maxDuration` constrain the length of the selected range during an -in-progress pick. - - - - [int-date]: https://react-spectrum.adobe.com/internationalized/date/index.html diff --git a/packages/@godaddy/antares/components/calendar/calendar.stories.tsx b/packages/@godaddy/antares/components/calendar/calendar.stories.tsx index 285966880..aa054106d 100644 --- a/packages/@godaddy/antares/components/calendar/calendar.stories.tsx +++ b/packages/@godaddy/antares/components/calendar/calendar.stories.tsx @@ -2,10 +2,7 @@ import { getComponentDocs, getMeta, getStory } from '@bento/storybook-addon-helpers'; import { CalendarDefaultExample } from './examples/default.tsx'; import { CalendarDefaultRangeExample } from './examples/default-range.tsx'; -import { CalendarOutOfMonthDaysExample } from './examples/out-of-month-days.tsx'; import { CalendarTodayDistinctExample } from './examples/today-distinct.tsx'; -import { CalendarWithDurationExample } from './examples/with-duration.tsx'; -import { CalendarWithIndicatorsExample } from './examples/with-indicators.tsx'; import { CalendarWithMinMaxExample } from './examples/with-min-max.tsx'; import { CalendarWithUnavailableDatesExample } from './examples/with-unavailable-dates.tsx'; import { Calendar } from './src/calendar.tsx'; @@ -26,10 +23,4 @@ export const WithMinMax = getStory(CalendarWithMinMaxExample); export const WithUnavailableDates = getStory(CalendarWithUnavailableDatesExample); -export const WithIndicators = getStory(CalendarWithIndicatorsExample); - export const TodayDistinct = getStory(CalendarTodayDistinctExample); - -export const OutOfMonthDays = getStory(CalendarOutOfMonthDaysExample); - -export const WithDuration = getStory(CalendarWithDurationExample); diff --git a/packages/@godaddy/antares/components/calendar/examples/out-of-month-days.tsx b/packages/@godaddy/antares/components/calendar/examples/out-of-month-days.tsx deleted file mode 100644 index 3279875f7..000000000 --- a/packages/@godaddy/antares/components/calendar/examples/out-of-month-days.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { parseDate } from '@internationalized/date'; -import { Calendar, Flex, RangeCalendar, Text } from '@godaddy/antares'; - -export function CalendarOutOfMonthDaysExample() { - return ( - - - - Single (40% opacity) - - - - - - Range (hidden) - - - - - ); -} diff --git a/packages/@godaddy/antares/components/calendar/examples/with-duration.tsx b/packages/@godaddy/antares/components/calendar/examples/with-duration.tsx deleted file mode 100644 index f3486b26e..000000000 --- a/packages/@godaddy/antares/components/calendar/examples/with-duration.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { parseDate } from '@internationalized/date'; -import { RangeCalendar } from '@godaddy/antares'; - -export function CalendarWithDurationExample() { - return ( - - ); -} diff --git a/packages/@godaddy/antares/components/calendar/examples/with-indicators.tsx b/packages/@godaddy/antares/components/calendar/examples/with-indicators.tsx deleted file mode 100644 index 64c813de4..000000000 --- a/packages/@godaddy/antares/components/calendar/examples/with-indicators.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { type CalendarDate, parseDate } from '@internationalized/date'; -import { Calendar, type DayIndicator } from '@godaddy/antares'; - -export function CalendarWithIndicatorsExample() { - function getDayIndicators(date: CalendarDate): DayIndicator[] { - if (date.month !== 3) return []; - if (date.day === 5) { - return [{ color: 'success', label: 'Available' }]; - } - if (date.day === 12) { - return [ - { color: 'warning', label: 'Limited' }, - { color: 'info', label: 'Promo' } - ]; - } - if (date.day === 18) { - return [ - { color: 'critical', label: 'Booked' }, - { color: 'highlight', label: 'Featured' }, - { color: 'passive', label: 'Returning' } - ]; - } - if (date.day === 22) { - return [ - { color: 'critical' }, - { color: 'highlight' }, - { color: 'info' }, - { color: 'success' } - ]; - } - return []; - } - - return ( - - ); -} diff --git a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx index 78e2818be..1452c8e40 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx @@ -1,93 +1,140 @@ -import { type CalendarDate, type DateValue, today as todayFn, getLocalTimeZone } from '@internationalized/date'; -import { useContext } from 'react'; +import type { CalendarDate } from '@internationalized/date'; +import { useContext, useMemo } from 'react'; import { + Button as RACButton, + CalendarMonthPicker as RACCalendarMonthPicker, CalendarStateContext, - RangeCalendarStateContext, - useLocale, - type Key as RACKey + CalendarYearPicker as RACCalendarYearPicker, + RangeCalendarStateContext } from 'react-aria-components'; import { Flex } from '#components/layout/flex'; +import { Icon } from '#components/icon'; import { Select, SelectItem } from '#components/select'; -import { getYearRange } from './get-year-range'; import styles from './index.module.css'; +/** + * Position of this header within its parent calendar. + * + * - `'single'` — both prev/next arrows. Used by single-month `Calendar`. + * - `'left'` — prev arrow only. Used by the left grid of `RangeCalendar`. + * - `'right'` — next arrow only, dropdowns reflect the second visible month. + * Used by the right grid of `RangeCalendar`. + */ +type CalendarHeaderPosition = 'single' | 'left' | 'right'; + interface CalendarHeaderProps { - /** Lower bound passed to the parent calendar. Used to derive the year `Select` range. */ - minValue?: DateValue | null; - /** Upper bound passed to the parent calendar. Used to derive the year `Select` range. */ - maxValue?: DateValue | null; + position?: CalendarHeaderPosition; } /** - * Replaces RAC's default calendar heading with two `Select` dropdowns: Month and Year. - * Reads the active calendar state from `CalendarStateContext` (single) or - * `RangeCalendarStateContext` (range), whichever is non-null. + * Renders a calendar header with prev/next nav and Month + Year `Select` dropdowns. * - * Consumes locale via `useLocale` so the month names match the locale supplied by - * the host-app's ``. + * The pickers are powered by RAC's `` / ``, + * which expose `{ aria-label, value, onChange, items }` via render props. We spread + * those into our `Select` so styling stays in the antares design system while RAC + * owns locale-aware month formatting and year-range derivation. + * + * For `position='right'` the picker components read a state proxy that shifts + * `focusedDate` forward by one month and translates `setFocusedDate` back, so the + * right-side dropdowns reflect the second visible month and selecting a value + * scrolls both grids together. */ -export function CalendarHeader({ minValue, maxValue }: CalendarHeaderProps) { +export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { const calendarState = useContext(CalendarStateContext); const rangeState = useContext(RangeCalendarStateContext); - const state = calendarState ?? rangeState; - const { locale } = useLocale(); + const baseState = calendarState ?? rangeState; + const monthOffset = position === 'right' ? 1 : 0; - if (!state) { - return null; - } + const shiftedState = useMemo( + function buildShiftedState() { + if (!baseState || monthOffset === 0) return baseState; + return new Proxy(baseState, { + get(target, prop, receiver) { + if (prop === 'focusedDate') return target.focusedDate.add({ months: monthOffset }); + if (prop === 'setFocusedDate') { + return function shiftedSetFocusedDate(date: CalendarDate) { + target.setFocusedDate(date.subtract({ months: monthOffset })); + }; + } + return Reflect.get(target, prop, receiver); + } + }); + }, + [baseState, monthOffset] + ); - const focusedDate = state.focusedDate as CalendarDate; - const today = todayFn(getLocalTimeZone()); + if (!baseState) return null; - const monthFormatter = new Intl.DateTimeFormat(locale, { month: 'long' }); - const months = Array.from({ length: 12 }, function buildMonth(_, index) { - const referenceDate = focusedDate.set({ month: index + 1, day: 1 }); - return { - value: index + 1, - label: monthFormatter.format(referenceDate.toDate(state.timeZone)) - }; - }); + const showPrev = position !== 'right'; + const showNext = position !== 'left'; - const years = getYearRange(minValue ?? null, maxValue ?? null, today); + const headerContent = ( + + {showPrev && ( + + + + )} + + {function renderMonthPicker(picker) { + return ( + + ); + }} + + + {function renderYearPicker(picker) { + return ( + + ); + }} + + {showNext && ( + + + + )} + + ); - function handleMonthChange(key: RACKey | null) { - if (key == null) return; - const month = Number(key); - state?.setFocusedDate(focusedDate.set({ month })); - } + if (monthOffset === 0) return headerContent; - function handleYearChange(key: RACKey | null) { - if (key == null) return; - const year = Number(key); - state?.setFocusedDate(focusedDate.set({ year })); + if (calendarState) { + return ( + + {headerContent} + + ); } - return ( - - - - + + {headerContent} + ); } diff --git a/packages/@godaddy/antares/components/calendar/src/calendar.tsx b/packages/@godaddy/antares/components/calendar/src/calendar.tsx index 5d9d3bcd6..da1362692 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar.tsx @@ -1,13 +1,12 @@ import type { CalendarDate } from '@internationalized/date'; import { Calendar as RACCalendar, + CalendarCell as RACCalendarCell, CalendarGrid as RACCalendarGrid, type CalendarProps as RACCalendarProps } from 'react-aria-components'; import { Flex } from '#components/layout/flex'; import { CalendarHeader } from './calendar-header'; -import { DayCell } from './day-cell'; -import type { GetDayIndicators } from './types'; import styles from './index.module.css'; /** @@ -18,21 +17,12 @@ import styles from './index.module.css'; * * @public */ -export interface CalendarProps extends Omit, 'children' | 'visibleDuration'> { - /** - * Per-day indicator function. Returns up to 3 colored dots rendered beneath the - * day number. Called once per visible day cell. - * - * @example - * date.day === 15 ? [{ color: 'success' }] : []} /> - */ - getDayIndicators?: GetDayIndicators; -} +export interface CalendarProps extends Omit, 'children' | 'visibleDuration'> {} /** * Calendar is a single-month date grid for picking one date. It mirrors React Aria - * Calendar typed for `CalendarDate` (date-only, no time, no timezone) and adds a - * Month + Year `Select` header in place of RAC's default heading. + * Calendar typed for `CalendarDate` (date-only, no time, no timezone) and replaces + * RAC's default heading with prev/next arrows + Month + Year `Select` dropdowns. * * @param props - {@link CalendarProps} * @@ -46,20 +36,14 @@ export interface CalendarProps extends Omit, 'chi * @see https://react-spectrum.adobe.com/react-aria/Calendar.html */ export function Calendar(props: CalendarProps) { - const { getDayIndicators, minValue, maxValue, ...racProps } = props; - return ( - - {...racProps} - minValue={minValue} - maxValue={maxValue} - visibleDuration={{ months: 1 }} - className={styles.calendar} - > + {...props} visibleDuration={{ months: 1 }} className={styles.calendar}> - + - {(date) => } + {function renderCell(date) { + return ; + }} diff --git a/packages/@godaddy/antares/components/calendar/src/day-cell.tsx b/packages/@godaddy/antares/components/calendar/src/day-cell.tsx deleted file mode 100644 index 86d455453..000000000 --- a/packages/@godaddy/antares/components/calendar/src/day-cell.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { CalendarDate } from '@internationalized/date'; -import { CalendarCell as RACCalendarCell } from 'react-aria-components'; -import { Flex } from '#components/layout/flex'; -import { type DayIndicator, type GetDayIndicators, MAX_INDICATORS } from './types'; -import styles from './index.module.css'; - -interface DayCellProps { - date: CalendarDate; - /** Hide out-of-month days entirely (RangeCalendar). When false, dim them (Calendar). */ - hideOutsideMonth?: boolean; - getDayIndicators?: GetDayIndicators; -} - -/** - * A calendar day cell. Renders the day number, an optional row of up to 3 indicator - * dots, and surfaces indicator labels to assistive tech via a visually-hidden span. - * - * Out-of-month days are either dimmed (single Calendar) or hidden entirely (RangeCalendar) - * per the AC. - */ -export function DayCell({ date, hideOutsideMonth = false, getDayIndicators }: DayCellProps) { - const rawIndicators = getDayIndicators?.(date) ?? []; - const indicators = sliceIndicators(rawIndicators); - const indicatorLabel = indicators - .map((indicator) => indicator.label) - .filter((label): label is string => Boolean(label)) - .join(', '); - - return ( - - {function renderCell(renderProps) { - if (hideOutsideMonth && renderProps.isOutsideMonth) { - return - ); -} - -function sliceIndicators(indicators: DayIndicator[]): DayIndicator[] { - if (indicators.length <= MAX_INDICATORS) { - return indicators; - } - - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.warn( - `[@godaddy/antares] getDayIndicators returned ${indicators.length} indicators; only the first ${MAX_INDICATORS} will be rendered.` - ); - } - - return indicators.slice(0, MAX_INDICATORS); -} diff --git a/packages/@godaddy/antares/components/calendar/src/get-year-range.ts b/packages/@godaddy/antares/components/calendar/src/get-year-range.ts deleted file mode 100644 index 8325607af..000000000 --- a/packages/@godaddy/antares/components/calendar/src/get-year-range.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { CalendarDate, DateValue } from '@internationalized/date'; - -/** - * Years either side of `today` to include in the year `Select` when no bounds are set. - */ -export const DEFAULT_YEAR_PADDING = 100; - -/** - * Derive the list of years to surface in the calendar's year `Select`. - * - * - If `minValue` and/or `maxValue` is provided, the range is the inclusive span - * between them — falling back to today's year on the unset side. - * - If neither is provided, returns `today.year ± DEFAULT_YEAR_PADDING`. - * - If `minValue.year > maxValue.year` (consumer error), returns `[today.year]` - * rather than throwing — the calendar still renders. - * - * @param minValue - lower bound from the consumer - * @param maxValue - upper bound from the consumer - * @param today - reference date (typically `today(getLocalTimeZone())`) - */ -export function getYearRange( - minValue: DateValue | undefined | null, - maxValue: DateValue | undefined | null, - today: CalendarDate -): number[] { - if (!minValue && !maxValue) { - const start = today.year - DEFAULT_YEAR_PADDING; - const end = today.year + DEFAULT_YEAR_PADDING; - return range(start, end); - } - - const startYear = minValue?.year ?? today.year; - const endYear = maxValue?.year ?? today.year; - - if (startYear > endYear) { - return [today.year]; - } - - return range(startYear, endYear); -} - -function range(start: number, end: number): number[] { - const out: number[] = []; - for (let year = start; year <= end; year += 1) { - out.push(year); - } - return out; -} diff --git a/packages/@godaddy/antares/components/calendar/src/index.module.css b/packages/@godaddy/antares/components/calendar/src/index.module.css index f3477f24a..85cdd9617 100644 --- a/packages/@godaddy/antares/components/calendar/src/index.module.css +++ b/packages/@godaddy/antares/components/calendar/src/index.module.css @@ -8,6 +8,33 @@ flex: 1; } +.navButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--calendar-cell-size); + height: var(--calendar-cell-size); + padding: 0; + border: none; + background: transparent; + cursor: pointer; + border-radius: var(--calendar-cell-radius); + + &[data-hovered] { + background: color-mix(in oklch, currentColor, transparent 92%); + } + + &[data-disabled] { + cursor: not-allowed; + opacity: 0.4; + } + + &[data-focus-visible] { + outline: 2px solid Highlight; + outline-offset: 1px; + } +} + .grid { border-collapse: separate; border-spacing: 0.125rem; @@ -29,6 +56,7 @@ text-align: center; vertical-align: middle; user-select: none; + font-variant-numeric: tabular-nums; &[data-outside-month] { opacity: 0.4; @@ -78,43 +106,3 @@ box-shadow: inset 0 0 0 1px var(--color-feedback-info-strong, currentColor); } } - -.cellHidden { - display: inline-block; - width: var(--calendar-cell-size); - height: var(--calendar-cell-size); -} - -.cellInner { - position: relative; - width: 100%; - height: 100%; - gap: 2px; -} - -.cellDate { - font-variant-numeric: tabular-nums; -} - -.indicators { - gap: 2px; -} - -.indicator { - display: inline-block; - width: 4px; - height: 4px; - border-radius: 50%; -} - -.srOnly { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} diff --git a/packages/@godaddy/antares/components/calendar/src/index.tsx b/packages/@godaddy/antares/components/calendar/src/index.tsx index 37e77d820..efe2cb750 100644 --- a/packages/@godaddy/antares/components/calendar/src/index.tsx +++ b/packages/@godaddy/antares/components/calendar/src/index.tsx @@ -1,3 +1,2 @@ export { Calendar, type CalendarProps } from './calendar'; export { RangeCalendar, type RangeCalendarProps } from './range-calendar'; -export type { DayIndicator, GetDayIndicators } from './types'; diff --git a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx index 9421929bd..66461c704 100644 --- a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx +++ b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx @@ -1,72 +1,28 @@ -import type { CalendarDate, DateDuration, DateValue } from '@internationalized/date'; -import { useCallback } from 'react'; +import type { CalendarDate } from '@internationalized/date'; import { + CalendarCell as RACCalendarCell, CalendarGrid as RACCalendarGrid, RangeCalendar as RACRangeCalendar, type RangeCalendarProps as RACRangeCalendarProps } from 'react-aria-components'; import { Flex } from '#components/layout/flex'; import { CalendarHeader } from './calendar-header'; -import { DayCell } from './day-cell'; -import type { GetDayIndicators } from './types'; import styles from './index.module.css'; -/** - * RAC's RangeCalendar passes the in-progress anchor date as the second argument to - * `isDateUnavailable`. Wider than RAC's exported `(date: DateValue) => boolean` shape. - */ -type RangeIsDateUnavailable = (date: DateValue, anchorDate: CalendarDate | null) => boolean; - /** * Props for the {@link RangeCalendar} component. * * @public */ export interface RangeCalendarProps - extends Omit, 'children' | 'visibleDuration' | 'isDateUnavailable'> { - /** - * Per-day indicator function. Returns up to 3 colored dots rendered beneath the day number. - * - * @example - * date.day % 5 === 0 ? [{ color: 'info' }] : []} /> - */ - getDayIndicators?: GetDayIndicators; - - /** - * Predicate to disable specific dates. Receives the in-progress anchor date as the - * second argument so duration constraints can be expressed by the consumer if needed - * — `minDuration` and `maxDuration` already cover the common cases. - * - * @see https://react-spectrum.adobe.com/react-aria/RangeCalendar.html#unavailable-dates - */ - isDateUnavailable?: RangeIsDateUnavailable; - - /** - * Minimum duration of the selected range. Disables end-date candidates that would - * produce a shorter range during an in-progress pick. - * - * @example - * - */ - minDuration?: DateDuration; - - /** - * Maximum duration of the selected range. Disables end-date candidates that would - * produce a longer range during an in-progress pick. - * - * @example - * - */ - maxDuration?: DateDuration; -} + extends Omit, 'children' | 'visibleDuration'> {} /** * RangeCalendar is a two-month side-by-side date grid for picking a contiguous range. - * Mirrors React Aria RangeCalendar typed for `CalendarDate`. Adds `minDuration` / - * `maxDuration` constraints (not in RAC) and a Month + Year `Select` header. - * - * Out-of-month days in either grid are NOT rendered (per AC) so the visible cells - * always belong to one of the two visible months. + * Mirrors React Aria RangeCalendar typed for `CalendarDate`. Each visible month has its + * own header — left has prev arrow + Month + Year dropdowns; right has Month + Year + * dropdowns + next arrow. Selecting a month/year on either side scrolls both grids + * together; the prev/next arrows page by one month thanks to `pageBehavior="single"`. * * @param props - {@link RangeCalendarProps} * @@ -76,56 +32,34 @@ export interface RangeCalendarProps * * * ``` * * @see https://react-spectrum.adobe.com/react-aria/RangeCalendar.html */ export function RangeCalendar(props: RangeCalendarProps) { - const { getDayIndicators, minValue, maxValue, minDuration, maxDuration, isDateUnavailable, ...racProps } = props; - - const wrappedIsDateUnavailable = useCallback( - function check(date, anchorDate) { - if (isDateUnavailable?.(date, anchorDate)) return true; - if (!anchorDate) return false; - - const candidate = date as CalendarDate; - if (candidate.compare(anchorDate) < 0) return false; - - if (minDuration) { - const minBoundary = anchorDate.add(minDuration); - if (candidate.compare(minBoundary) < 0) return true; - } - - if (maxDuration) { - const maxBoundary = anchorDate.add(maxDuration); - if (candidate.compare(maxBoundary) > 0) return true; - } - - return false; - }, - [isDateUnavailable, minDuration, maxDuration] - ); - return ( - {...racProps} - minValue={minValue} - maxValue={maxValue} + {...props} visibleDuration={{ months: 2 }} - isDateUnavailable={wrappedIsDateUnavailable as (date: DateValue) => boolean} + pageBehavior="single" className={styles.calendar} > - - - + + + - {(date) => } + {function renderLeftCell(date) { + return ; + }} + + + - {(date) => } + {function renderRightCell(date) { + return ; + }} diff --git a/packages/@godaddy/antares/components/calendar/src/types.ts b/packages/@godaddy/antares/components/calendar/src/types.ts deleted file mode 100644 index 2ad8e13ef..000000000 --- a/packages/@godaddy/antares/components/calendar/src/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { CalendarDate } from '@internationalized/date'; - -/** - * Indicator dot rendered beneath a day cell. Up to 3 per day. - */ -export interface DayIndicator { - /** Maps to the `--color-feedback--strong` design token. */ - color: 'critical' | 'highlight' | 'info' | 'passive' | 'success' | 'warning'; - /** Optional accessible label combined with the day's date label for screen readers. */ - label?: string; -} - -/** - * Per-day indicator function, called with the CalendarDate for each visible day cell. - * Return up to 3 indicators; more are sliced and produce a dev-only `console.warn`. - */ -export type GetDayIndicators = (date: CalendarDate) => DayIndicator[]; - -/** - * Maximum indicators rendered per day cell. AC is firm at 3. - */ -export const MAX_INDICATORS = 3; diff --git a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap index 3cbc9304b..b607b8992 100644 --- a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap +++ b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; +exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; diff --git a/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx b/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx index 952776097..f304152fe 100644 --- a/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx +++ b/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx @@ -1,16 +1,11 @@ -import { parseDate } from '@internationalized/date'; import assume from 'assume'; -import { beforeAll, describe, it, vi } from 'vitest'; +import { beforeAll, describe, it } from 'vitest'; import { render } from 'vitest-browser-react'; import { page, userEvent } from 'vitest/browser'; import { set } from '#components/icon'; -import { Calendar, type DayIndicator } from '@godaddy/antares'; import { CalendarDefaultExample } from '../examples/default.tsx'; import { CalendarDefaultRangeExample } from '../examples/default-range.tsx'; -import { CalendarOutOfMonthDaysExample } from '../examples/out-of-month-days.tsx'; import { CalendarTodayDistinctExample } from '../examples/today-distinct.tsx'; -import { CalendarWithDurationExample } from '../examples/with-duration.tsx'; -import { CalendarWithIndicatorsExample } from '../examples/with-indicators.tsx'; import { CalendarWithMinMaxExample } from '../examples/with-min-max.tsx'; import { CalendarWithUnavailableDatesExample } from '../examples/with-unavailable-dates.tsx'; @@ -22,6 +17,16 @@ describe('@godaddy/antares', function antares() { ), + 'chevron-left': ( + + + + ), + 'chevron-right': ( + + + + ), alert: ( @@ -117,51 +122,6 @@ describe('@godaddy/antares', function antares() { assume(disabled.length).is.at.least(1); }); }); - - describe('#indicators', function indicators() { - it('renders 1, 2, and 3 dots for the example days', async function variousDots() { - const { container } = await render(); - const successDots = container.querySelectorAll('[style*="color-feedback-success-strong"]'); - const warningDots = container.querySelectorAll('[style*="color-feedback-warning-strong"]'); - const criticalDots = container.querySelectorAll('[style*="color-feedback-critical-strong"]'); - - // March-only: day 5 -> success x1; day 12 -> warning + info; day 18 -> critical + highlight + passive; - // day 22 has 4 returned, sliced to first 3 (critical, highlight, info) — success is dropped. - assume(successDots.length).equals(1); // day 5 only - assume(warningDots.length).equals(1); // day 12 - assume(criticalDots.length).equals(2); // day 18 + day 22 - }); - - it('slices to 3 indicators and warns in dev when the consumer returns more', async function slices() { - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(function noop() { - // ignore - }); - - const { container } = await render(); - const cellsWithDots = Array.from(container.querySelectorAll('[role="gridcell"]')).filter( - function hasDots(cell) { - return cell.querySelectorAll('[class*="indicator_"]').length > 0; - } - ); - const maxDotsInCell = cellsWithDots.length - ? Math.max( - ...cellsWithDots.map(function count(cell) { - return cell.querySelectorAll('[class*="indicator_"]').length; - }) - ) - : 0; - assume(maxDotsInCell).is.at.most(3); - assume(consoleWarn.mock.calls.length).is.above(0); - - consoleWarn.mockRestore(); - }); - - it('exposes the indicator label to assistive tech via a hidden span', async function indicatorLabel() { - const { container } = await render(); - assume(container.textContent ?? '').contains('Available'); - assume(container.textContent ?? '').contains('Limited'); - }); - }); }); describe('#RangeCalendar', function rangeCalendar() { @@ -172,31 +132,14 @@ describe('@godaddy/antares', function antares() { assume(grids.length).equals(2); }); - it('hides out-of-month days entirely (no day number rendered)', async function hidesOutOfMonth() { + it('renders out-of-month days with day numbers in both grids', async function rendersOutsideMonth() { const { container } = await render(); const outsideMonth = container.querySelectorAll('[data-outside-month]'); assume(outsideMonth.length).is.at.least(1); - for (const cell of Array.from(outsideMonth)) { - assume((cell.textContent ?? '').trim()).equals(''); - } - }); - }); - - describe('#duration', function duration() { - it('disables end candidates within minDuration and beyond maxDuration after a start click', async function constrains() { - const { container } = await render(); - - const day15 = container.querySelector('[role="gridcell"][data-today]') - ?? container.querySelectorAll('[role="gridcell"]:not([data-outside-month])')[14]; - assume(day15).exists(); - await userEvent.click(day15 as HTMLElement); - - await new Promise(function wait(resolve) { - setTimeout(resolve, 100); + const someHaveContent = Array.from(outsideMonth).some(function hasContent(cell) { + return (cell.textContent ?? '').trim().length > 0; }); - - const unavailable = container.querySelectorAll('[data-unavailable]'); - assume(unavailable.length).is.at.least(2); + assume(someHaveContent).equals(true); }); }); @@ -211,36 +154,4 @@ describe('@godaddy/antares', function antares() { }); }); }); - - describe('#OutOfMonthRendering', function outOfMonthRendering() { - it('renders out-of-month cells in single Calendar (with content) and hides them in Range', async function rendering() { - const { container } = await render(); - const grids = container.querySelectorAll('[role="grid"]'); - // grids[0] = single, grids[1] and grids[2] = first/second month of range. - const singleOutside = grids[0].querySelectorAll('[data-outside-month]'); - const rangeOutside = Array.from(grids[1].querySelectorAll('[data-outside-month]')).concat( - Array.from(grids[2].querySelectorAll('[data-outside-month]')) - ); - - assume(singleOutside.length).is.at.least(1); - const singleHasContent = Array.from(singleOutside).some(function hasContent(cell) { - return (cell.textContent ?? '').trim().length > 0; - }); - assume(singleHasContent).equals(true); - - assume(rangeOutside.length).is.at.least(1); - const rangeAllEmpty = rangeOutside.every(function isEmpty(cell) { - return (cell.textContent ?? '').trim().length === 0; - }); - assume(rangeAllEmpty).equals(true); - }); - }); }); - -function OverflowIndicatorsExample() { - function getDayIndicators(): DayIndicator[] { - return [{ color: 'critical' }, { color: 'highlight' }, { color: 'info' }, { color: 'success' }]; - } - - return ; -} diff --git a/packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx b/packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx index 1cbfbba65..d3b8b2f72 100644 --- a/packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx +++ b/packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx @@ -1,15 +1,10 @@ -import { parseDate } from '@internationalized/date'; import { describe, expect, it } from 'vitest'; import { renderToString } from 'react-dom/server'; import { CalendarDefaultExample } from '../examples/default.tsx'; import { CalendarDefaultRangeExample } from '../examples/default-range.tsx'; -import { CalendarOutOfMonthDaysExample } from '../examples/out-of-month-days.tsx'; import { CalendarTodayDistinctExample } from '../examples/today-distinct.tsx'; -import { CalendarWithDurationExample } from '../examples/with-duration.tsx'; -import { CalendarWithIndicatorsExample } from '../examples/with-indicators.tsx'; import { CalendarWithMinMaxExample } from '../examples/with-min-max.tsx'; import { CalendarWithUnavailableDatesExample } from '../examples/with-unavailable-dates.tsx'; -import { DEFAULT_YEAR_PADDING, getYearRange } from '../src/get-year-range'; describe('@godaddy/antares', function antares() { describe('#Calendar', function calendar() { @@ -34,61 +29,10 @@ describe('@godaddy/antares', function antares() { expect(result).toContain('data-unavailable'); }); - it('renders indicators example', function indicators() { - const result = renderToString(); - expect(result).toContain('color-feedback-success-strong'); - }); - it('renders today-distinct example', function todayDistinct() { const result = renderToString(); expect(result).toContain('data-today'); }); - - it('renders out-of-month-days example', function outOfMonth() { - const result = renderToString(); - expect(result).toContain('data-outside-month'); - }); - - it('renders with-duration example', function withDuration() { - const result = renderToString(); - expect(result).toContain('2024'); - }); - }); - }); - - describe('#getYearRange', function getYearRangeTests() { - const today = parseDate('2024-06-15'); - - it('returns today.year ± DEFAULT_YEAR_PADDING when no bounds set', function defaultRange() { - const years = getYearRange(undefined, undefined, today); - expect(years).toHaveLength(DEFAULT_YEAR_PADDING * 2 + 1); - expect(years[0]).toBe(today.year - DEFAULT_YEAR_PADDING); - expect(years[years.length - 1]).toBe(today.year + DEFAULT_YEAR_PADDING); - }); - - it('returns inclusive year span when both bounds set', function bothBounds() { - const years = getYearRange(parseDate('2020-01-01'), parseDate('2025-12-31'), today); - expect(years).toEqual([2020, 2021, 2022, 2023, 2024, 2025]); - }); - - it('uses today.year as upper bound when only minValue set', function onlyMin() { - const years = getYearRange(parseDate('2022-01-01'), undefined, today); - expect(years).toEqual([2022, 2023, 2024]); - }); - - it('uses today.year as lower bound when only maxValue set', function onlyMax() { - const years = getYearRange(undefined, parseDate('2026-12-31'), today); - expect(years).toEqual([2024, 2025, 2026]); - }); - - it('returns [today.year] when minValue.year > maxValue.year', function inverted() { - const years = getYearRange(parseDate('2030-01-01'), parseDate('2020-01-01'), today); - expect(years).toEqual([2024]); - }); - - it('treats null and undefined as unset', function nulls() { - const years = getYearRange(null, null, today); - expect(years).toHaveLength(DEFAULT_YEAR_PADDING * 2 + 1); }); }); }); diff --git a/packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx b/packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx index ec3d99bfe..2a88242fd 100644 --- a/packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx +++ b/packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx @@ -2,10 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-react'; import { set } from '#components/icon'; import { CalendarDefaultRangeExample } from '../examples/default-range.tsx'; -import { CalendarOutOfMonthDaysExample } from '../examples/out-of-month-days.tsx'; import { CalendarTodayDistinctExample } from '../examples/today-distinct.tsx'; -import { CalendarWithDurationExample } from '../examples/with-duration.tsx'; -import { CalendarWithIndicatorsExample } from '../examples/with-indicators.tsx'; describe('@godaddy/antares', function antares() { beforeAll(function setupIcons() { @@ -15,6 +12,16 @@ describe('@godaddy/antares', function antares() { ), + 'chevron-left': ( + + + + ), + 'chevron-right': ( + + + + ), alert: ( @@ -24,11 +31,6 @@ describe('@godaddy/antares', function antares() { }); describe('#Calendar', function calendarTests() { - it('with-indicators example', async function indicatorsRender() { - const { container } = await render(); - await expect(container).toMatchScreenshot('with-indicators'); - }); - it('default-range example', async function rangeRender() { const { container } = await render(); await expect(container).toMatchScreenshot('default-range'); @@ -38,15 +40,5 @@ describe('@godaddy/antares', function antares() { const { container } = await render(); await expect(container).toMatchScreenshot('today-distinct'); }); - - it('out-of-month-days example', async function outOfMonthRender() { - const { container } = await render(); - await expect(container).toMatchScreenshot('out-of-month-days'); - }); - - it('with-duration example', async function durationRender() { - const { container } = await render(); - await expect(container).toMatchScreenshot('with-duration'); - }); }); }); diff --git a/packages/@godaddy/antares/exports/Calendar.ts b/packages/@godaddy/antares/exports/Calendar.ts index ca7475321..988870fb5 100644 --- a/packages/@godaddy/antares/exports/Calendar.ts +++ b/packages/@godaddy/antares/exports/Calendar.ts @@ -1,8 +1 @@ -export { - Calendar, - RangeCalendar, - type CalendarProps, - type RangeCalendarProps, - type DayIndicator, - type GetDayIndicators -} from '#components/calendar'; +export { Calendar, RangeCalendar, type CalendarProps, type RangeCalendarProps } from '#components/calendar'; diff --git a/packages/@godaddy/antares/index.ts b/packages/@godaddy/antares/index.ts index f478e26f2..ecb8c0355 100644 --- a/packages/@godaddy/antares/index.ts +++ b/packages/@godaddy/antares/index.ts @@ -61,14 +61,7 @@ export { TextField, type TextFieldProps } from '#components/text-field'; export { NumberField, type NumberFieldProps } from '#components/number-field'; -export { - Calendar, - RangeCalendar, - type CalendarProps, - type RangeCalendarProps, - type DayIndicator, - type GetDayIndicators -} from '#components/calendar'; +export { Calendar, RangeCalendar, type CalendarProps, type RangeCalendarProps } from '#components/calendar'; export { Carousel, type CarouselProps, type CarouselRef } from '#components/carousel'; diff --git a/plans/calendar-size-measurements.svg b/plans/calendar-size-measurements.svg new file mode 100644 index 000000000..d3252f342 --- /dev/null +++ b/plans/calendar-size-measurements.svg @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plans/single-double-month.svg b/plans/single-double-month.svg new file mode 100644 index 000000000..7e6812fa6 --- /dev/null +++ b/plans/single-double-month.svg @@ -0,0 +1,326 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From d654f7779ef84e38c5f301c0ac021a3d67056d8c Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Fri, 29 May 2026 22:49:10 -0500 Subject: [PATCH 05/14] fix(antares): pin RangeCalendar header dropdowns to visibleRange.start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RAC's / read `state.focusedDate.month` for their displayed value. In a 2-month RangeCalendar, the focused date moves when the user hovers a cell during an in-progress range pick (RAC's `highlightDate` calls `setFocusedDate` to drive the highlight preview), so the dropdown labels would jump even though the underlying grids stayed stable. Pin the picker state Proxy's `focusedDate` to `state.visibleRange.start` (plus the position offset) instead of `state.focusedDate`. This anchors the dropdown labels to the GRID month and is no longer position-conditional — both left and right pickers benefit from the stable label. Adds a regression browser test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../calendar/src/calendar-header.tsx | 26 +++++++++-------- .../calendar/test/calendar.browser.test.tsx | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx index 1452c8e40..50751947b 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx @@ -34,10 +34,14 @@ interface CalendarHeaderProps { * those into our `Select` so styling stays in the antares design system while RAC * owns locale-aware month formatting and year-range derivation. * - * For `position='right'` the picker components read a state proxy that shifts - * `focusedDate` forward by one month and translates `setFocusedDate` back, so the - * right-side dropdowns reflect the second visible month and selecting a value - * scrolls both grids together. + * RAC's pickers read `state.focusedDate.month` for their displayed value, but in a + * 2-month `RangeCalendar` the focused date jumps around — including when the user + * hovers a cell during an in-progress range pick (RAC's `highlightDate` calls + * `setFocusedDate` to drive the highlight preview). To keep the dropdown labels + * tied to the GRID month rather than the focus, we wrap the pickers in a state + * Proxy that pins `focusedDate` to `state.visibleRange.start` (plus a one-month + * offset for `position='right'`). `setFocusedDate` is translated back so picking + * a value still scrolls the grids together. */ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { const calendarState = useContext(CalendarStateContext); @@ -45,12 +49,12 @@ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { const baseState = calendarState ?? rangeState; const monthOffset = position === 'right' ? 1 : 0; - const shiftedState = useMemo( - function buildShiftedState() { - if (!baseState || monthOffset === 0) return baseState; + const pickerState = useMemo( + function buildPickerState() { + if (!baseState) return baseState; return new Proxy(baseState, { get(target, prop, receiver) { - if (prop === 'focusedDate') return target.focusedDate.add({ months: monthOffset }); + if (prop === 'focusedDate') return target.visibleRange.start.add({ months: monthOffset }); if (prop === 'setFocusedDate') { return function shiftedSetFocusedDate(date: CalendarDate) { target.setFocusedDate(date.subtract({ months: monthOffset })); @@ -123,17 +127,15 @@ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) {
); - if (monthOffset === 0) return headerContent; - if (calendarState) { return ( - + {headerContent} ); } return ( - + {headerContent} ); diff --git a/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx b/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx index f304152fe..8069cf807 100644 --- a/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx +++ b/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx @@ -141,6 +141,34 @@ describe('@godaddy/antares', function antares() { }); assume(someHaveContent).equals(true); }); + + it('keeps grids and header dropdowns stable when hovering across months during in-progress range pick', async function hoverStable() { + // Regression: RAC's pickers read state.focusedDate, which highlightDate moves on hover. + // Our header pins display to state.visibleRange.start so dropdowns track the GRID, not focus. + const { container } = await render(); + + const day14 = page.getByRole('button', { name: /Thursday, March 14/ }); + await userEvent.click(day14); + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const day20 = page.getByRole('button', { name: /Saturday, April 20/ }); + await userEvent.hover(day20); + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const grids = container.querySelectorAll('[role="grid"]'); + const leftGrid = grids[0]; + const rightGrid = grids[1]; + assume(!!leftGrid?.querySelector('[aria-label*="Friday, March 15"]')).equals(true); + assume(!!rightGrid?.querySelector('[aria-label*="Monday, April 15"]')).equals(true); + + const monthButtons = Array.from(container.querySelectorAll('button[aria-haspopup="listbox"]')); + assume(monthButtons[0]?.textContent ?? '').contains('March'); + assume(monthButtons[2]?.textContent ?? '').contains('April'); + }); }); describe('#todayDistinct', function todayDistinct() { From 4fedb801ddea3194fb6b04e521c6632a229b0259 Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Fri, 29 May 2026 23:12:01 -0500 Subject: [PATCH 06/14] refactor(antares): replace picker-state Proxy with shallow object spread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useCalendarState / useRangeCalendarState return plain object literals, so { ...baseState, focusedDate, setFocusedDate } gives the same result with no trap mechanics. Drops useMemo too — RAC's state hook returns a fresh object each render, so memoizing on baseState identity buys nothing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../calendar/src/calendar-header.tsx | 57 ++++++------------- 1 file changed, 16 insertions(+), 41 deletions(-) diff --git a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx index 50751947b..5ee602ce5 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx @@ -1,5 +1,5 @@ import type { CalendarDate } from '@internationalized/date'; -import { useContext, useMemo } from 'react'; +import { useContext } from 'react'; import { Button as RACButton, CalendarMonthPicker as RACCalendarMonthPicker, @@ -12,36 +12,21 @@ import { Icon } from '#components/icon'; import { Select, SelectItem } from '#components/select'; import styles from './index.module.css'; -/** - * Position of this header within its parent calendar. - * - * - `'single'` — both prev/next arrows. Used by single-month `Calendar`. - * - `'left'` — prev arrow only. Used by the left grid of `RangeCalendar`. - * - `'right'` — next arrow only, dropdowns reflect the second visible month. - * Used by the right grid of `RangeCalendar`. - */ type CalendarHeaderPosition = 'single' | 'left' | 'right'; interface CalendarHeaderProps { + /** `'left'` / `'right'` for the two grids of `RangeCalendar`; `'single'` for `Calendar`. */ position?: CalendarHeaderPosition; } /** - * Renders a calendar header with prev/next nav and Month + Year `Select` dropdowns. - * - * The pickers are powered by RAC's `` / ``, - * which expose `{ aria-label, value, onChange, items }` via render props. We spread - * those into our `Select` so styling stays in the antares design system while RAC - * owns locale-aware month formatting and year-range derivation. + * Calendar header: prev/next nav + Month + Year `Select` dropdowns powered by RAC's + * `` / `` rendered into the antares `Select`. * - * RAC's pickers read `state.focusedDate.month` for their displayed value, but in a - * 2-month `RangeCalendar` the focused date jumps around — including when the user - * hovers a cell during an in-progress range pick (RAC's `highlightDate` calls - * `setFocusedDate` to drive the highlight preview). To keep the dropdown labels - * tied to the GRID month rather than the focus, we wrap the pickers in a state - * Proxy that pins `focusedDate` to `state.visibleRange.start` (plus a one-month - * offset for `position='right'`). `setFocusedDate` is translated back so picking - * a value still scrolls the grids together. + * Pickers read state via context. We feed them a shallow copy of the RAC state with + * `focusedDate` pinned to `state.visibleRange.start` (plus a one-month offset for + * `position='right'`) so the dropdowns track the grid month, not the focus. + * `setFocusedDate` is translated back so picking a value scrolls both grids together. */ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { const calendarState = useContext(CalendarStateContext); @@ -49,26 +34,16 @@ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { const baseState = calendarState ?? rangeState; const monthOffset = position === 'right' ? 1 : 0; - const pickerState = useMemo( - function buildPickerState() { - if (!baseState) return baseState; - return new Proxy(baseState, { - get(target, prop, receiver) { - if (prop === 'focusedDate') return target.visibleRange.start.add({ months: monthOffset }); - if (prop === 'setFocusedDate') { - return function shiftedSetFocusedDate(date: CalendarDate) { - target.setFocusedDate(date.subtract({ months: monthOffset })); - }; - } - return Reflect.get(target, prop, receiver); - } - }); - }, - [baseState, monthOffset] - ); - if (!baseState) return null; + const pickerState = { + ...baseState, + focusedDate: baseState.visibleRange.start.add({ months: monthOffset }), + setFocusedDate(date: CalendarDate) { + baseState.setFocusedDate(date.subtract({ months: monthOffset })); + } + }; + const showPrev = position !== 'right'; const showNext = position !== 'left'; From 5cc716164b8012538cdf94266f60448bc99de221 Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Fri, 29 May 2026 23:49:18 -0500 Subject: [PATCH 07/14] refactor(antares): use antares Button in CalendarHeader and trim docs - Replace RACButton with antares Button (variant="minimal", size="sm") in CalendarHeader so prev/next inherit hover/focus/disabled styling from the design system. Drop the now-unused .navButton CSS. - Trim verbose JSDoc on Calendar, RangeCalendar, and CalendarHeader. --- .../calendar/src/calendar-header.tsx | 22 +++++++-------- .../components/calendar/src/calendar.tsx | 15 ++--------- .../components/calendar/src/index.module.css | 27 ------------------- .../calendar/src/range-calendar.tsx | 18 +++---------- 4 files changed, 17 insertions(+), 65 deletions(-) diff --git a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx index 5ee602ce5..0ce3054da 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx @@ -1,12 +1,12 @@ import type { CalendarDate } from '@internationalized/date'; import { useContext } from 'react'; import { - Button as RACButton, CalendarMonthPicker as RACCalendarMonthPicker, CalendarStateContext, CalendarYearPicker as RACCalendarYearPicker, RangeCalendarStateContext } from 'react-aria-components'; +import { Button } from '#components/button'; import { Flex } from '#components/layout/flex'; import { Icon } from '#components/icon'; import { Select, SelectItem } from '#components/select'; @@ -20,13 +20,13 @@ interface CalendarHeaderProps { } /** - * Calendar header: prev/next nav + Month + Year `Select` dropdowns powered by RAC's - * `` / `` rendered into the antares `Select`. + * Header with prev/next + Month/Year selects, powered by RAC's `` / + * `` rendered through the antares `Select`. * - * Pickers read state via context. We feed them a shallow copy of the RAC state with - * `focusedDate` pinned to `state.visibleRange.start` (plus a one-month offset for - * `position='right'`) so the dropdowns track the grid month, not the focus. - * `setFocusedDate` is translated back so picking a value scrolls both grids together. + * RAC's pickers read state from context and key off `focusedDate`. We pin `focusedDate` + * to `visibleRange.start` (plus a one-month offset for `position='right'`) and translate + * `setFocusedDate` back, so the dropdowns track the visible month and selecting on + * either side scrolls both grids together. */ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { const calendarState = useContext(CalendarStateContext); @@ -50,9 +50,9 @@ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { const headerContent = ( {showPrev && ( - + )} {function renderMonthPicker(picker) { @@ -95,9 +95,9 @@ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { }} {showNext && ( - + )} ); diff --git a/packages/@godaddy/antares/components/calendar/src/calendar.tsx b/packages/@godaddy/antares/components/calendar/src/calendar.tsx index da1362692..fc12bab3e 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar.tsx @@ -9,20 +9,11 @@ import { Flex } from '#components/layout/flex'; import { CalendarHeader } from './calendar-header'; import styles from './index.module.css'; -/** - * Props for the {@link Calendar} component. - * - * Wraps {@link RACCalendarProps} typed for `CalendarDate`. `visibleDuration` is fixed - * at one month — consumers needing a custom layout should compose RAC directly. - * - * @public - */ export interface CalendarProps extends Omit, 'children' | 'visibleDuration'> {} /** - * Calendar is a single-month date grid for picking one date. It mirrors React Aria - * Calendar typed for `CalendarDate` (date-only, no time, no timezone) and replaces - * RAC's default heading with prev/next arrows + Month + Year `Select` dropdowns. + * Single-month date grid typed for `CalendarDate` (date-only, no time, no timezone). + * Replaces RAC's default heading with prev/next arrows + Month + Year `Select` dropdowns. * * @param props - {@link CalendarProps} * @@ -32,8 +23,6 @@ export interface CalendarProps extends Omit, 'chi * * * ``` - * - * @see https://react-spectrum.adobe.com/react-aria/Calendar.html */ export function Calendar(props: CalendarProps) { return ( diff --git a/packages/@godaddy/antares/components/calendar/src/index.module.css b/packages/@godaddy/antares/components/calendar/src/index.module.css index 85cdd9617..dfdaad5b1 100644 --- a/packages/@godaddy/antares/components/calendar/src/index.module.css +++ b/packages/@godaddy/antares/components/calendar/src/index.module.css @@ -8,33 +8,6 @@ flex: 1; } -.navButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--calendar-cell-size); - height: var(--calendar-cell-size); - padding: 0; - border: none; - background: transparent; - cursor: pointer; - border-radius: var(--calendar-cell-radius); - - &[data-hovered] { - background: color-mix(in oklch, currentColor, transparent 92%); - } - - &[data-disabled] { - cursor: not-allowed; - opacity: 0.4; - } - - &[data-focus-visible] { - outline: 2px solid Highlight; - outline-offset: 1px; - } -} - .grid { border-collapse: separate; border-spacing: 0.125rem; diff --git a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx index 66461c704..67bb3f130 100644 --- a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx +++ b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx @@ -9,20 +9,12 @@ import { Flex } from '#components/layout/flex'; import { CalendarHeader } from './calendar-header'; import styles from './index.module.css'; -/** - * Props for the {@link RangeCalendar} component. - * - * @public - */ -export interface RangeCalendarProps - extends Omit, 'children' | 'visibleDuration'> {} +export interface RangeCalendarProps extends Omit, 'children' | 'visibleDuration'> {} /** - * RangeCalendar is a two-month side-by-side date grid for picking a contiguous range. - * Mirrors React Aria RangeCalendar typed for `CalendarDate`. Each visible month has its - * own header — left has prev arrow + Month + Year dropdowns; right has Month + Year - * dropdowns + next arrow. Selecting a month/year on either side scrolls both grids - * together; the prev/next arrows page by one month thanks to `pageBehavior="single"`. + * Two-month side-by-side date grid for picking a contiguous range, typed for `CalendarDate`. + * Each grid has its own header; selecting a month/year on either side scrolls both together, + * and `pageBehavior="single"` makes the arrows page one month at a time. * * @param props - {@link RangeCalendarProps} * @@ -34,8 +26,6 @@ export interface RangeCalendarProps * defaultValue={{ start: parseDate('2024-03-01'), end: parseDate('2024-03-07') }} * /> * ``` - * - * @see https://react-spectrum.adobe.com/react-aria/RangeCalendar.html */ export function RangeCalendar(props: RangeCalendarProps) { return ( From d9f239c99c3d370d06964402c2c9afc25a88f63e Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Sat, 30 May 2026 00:10:21 -0500 Subject: [PATCH 08/14] refactor(antares): rename CalendarHeader position to range and flip chevrons in RTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename internal `position?: 'single' | 'left' | 'right'` prop to `range?: 'start' | 'end'` (omit for single calendars). Names match the range value shape (`{ start, end }`) and are locale-direction-agnostic. - Read `useLocale()` and flip the prev/next chevron icons in RTL so they point opposite the reading direction (matches RAC's recommended pattern). - Update the calendar.node SSR snapshot — the prior Button-swap commit's markup change (.navButton → antares Button classes) was never captured. --- .../calendar/src/calendar-header.tsx | 31 ++++++++++++------- .../components/calendar/src/calendar.tsx | 2 +- .../calendar/src/range-calendar.tsx | 8 ++--- .../__snapshots__/calendar.node.test.tsx.snap | 2 +- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx index 0ce3054da..19a04b15a 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx @@ -4,7 +4,8 @@ import { CalendarMonthPicker as RACCalendarMonthPicker, CalendarStateContext, CalendarYearPicker as RACCalendarYearPicker, - RangeCalendarStateContext + RangeCalendarStateContext, + useLocale } from 'react-aria-components'; import { Button } from '#components/button'; import { Flex } from '#components/layout/flex'; @@ -12,11 +13,13 @@ import { Icon } from '#components/icon'; import { Select, SelectItem } from '#components/select'; import styles from './index.module.css'; -type CalendarHeaderPosition = 'single' | 'left' | 'right'; - interface CalendarHeaderProps { - /** `'left'` / `'right'` for the two grids of `RangeCalendar`; `'single'` for `Calendar`. */ - position?: CalendarHeaderPosition; + /** + * `'start'` / `'end'` for the two grids of `RangeCalendar`. Omit for the single + * grid of `Calendar`. Names match the range value (`{ start, end }`) and are + * locale-direction-agnostic — in RTL the start grid renders on the right. + */ + range?: 'start' | 'end'; } /** @@ -24,15 +27,18 @@ interface CalendarHeaderProps { * `` rendered through the antares `Select`. * * RAC's pickers read state from context and key off `focusedDate`. We pin `focusedDate` - * to `visibleRange.start` (plus a one-month offset for `position='right'`) and translate + * to `visibleRange.start` (plus a one-month offset for `range='end'`) and translate * `setFocusedDate` back, so the dropdowns track the visible month and selecting on * either side scrolls both grids together. + * + * Chevron icons flip in RTL so "previous" always points opposite the reading direction. */ -export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { +export function CalendarHeader({ range }: CalendarHeaderProps) { const calendarState = useContext(CalendarStateContext); const rangeState = useContext(RangeCalendarStateContext); const baseState = calendarState ?? rangeState; - const monthOffset = position === 'right' ? 1 : 0; + const { direction } = useLocale(); + const monthOffset = range === 'end' ? 1 : 0; if (!baseState) return null; @@ -44,14 +50,15 @@ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { } }; - const showPrev = position !== 'right'; - const showNext = position !== 'left'; + const showPrev = range !== 'end'; + const showNext = range !== 'start'; + const isRtl = direction === 'rtl'; const headerContent = ( {showPrev && ( )} @@ -96,7 +103,7 @@ export function CalendarHeader({ position = 'single' }: CalendarHeaderProps) { {showNext && ( )} diff --git a/packages/@godaddy/antares/components/calendar/src/calendar.tsx b/packages/@godaddy/antares/components/calendar/src/calendar.tsx index fc12bab3e..4fb08461f 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar.tsx @@ -28,7 +28,7 @@ export function Calendar(props: CalendarProps) { return ( {...props} visibleDuration={{ months: 1 }} className={styles.calendar}> - + {function renderCell(date) { return ; diff --git a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx index 67bb3f130..0225f9d5d 100644 --- a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx +++ b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx @@ -37,17 +37,17 @@ export function RangeCalendar(props: RangeCalendarProps) { > - + - {function renderLeftCell(date) { + {function renderStartCell(date) { return ; }} - + - {function renderRightCell(date) { + {function renderEndCell(date) { return ; }} diff --git a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap index b607b8992..8543b68d6 100644 --- a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap +++ b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; +exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; From 9d2d7aaad7b32ce82302ee3c58f98dc8193c5d59 Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Mon, 1 Jun 2026 14:28:39 -0500 Subject: [PATCH 09/14] docs(antares): point Calendar Working-with-dates to DateField --- .../antares/components/calendar/README.mdx | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/@godaddy/antares/components/calendar/README.mdx b/packages/@godaddy/antares/components/calendar/README.mdx index 7c69513d1..19ea913a5 100644 --- a/packages/@godaddy/antares/components/calendar/README.mdx +++ b/packages/@godaddy/antares/components/calendar/README.mdx @@ -29,19 +29,9 @@ npm install --save @godaddy/antares ## Working with dates -Both components are typed for `CalendarDate` from [`@internationalized/date`][int-date]. See the -canonical "Working with dates" section in the `DatePicker` README for the full setup. Quick -patterns: - -```tsx -import { parseDate, today, getLocalTimeZone } from '@internationalized/date'; -import { Calendar, RangeCalendar } from '@godaddy/antares'; - - - -``` +Both components are typed for `CalendarDate` from `@internationalized/date`. See the +canonical [Working with dates](?path=/docs/components-datefield--overview#working-with-dates) +section in the `DateField` docs for installation, locale handling, and common patterns. ## Props @@ -92,5 +82,3 @@ The "today" visual state coexists with `selected` and `in-range`. - -[int-date]: https://react-spectrum.adobe.com/internationalized/date/index.html From b74e7ed029a6061af77666ea62b23a5946d273cc Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Mon, 1 Jun 2026 14:54:19 -0500 Subject: [PATCH 10/14] docs(antares): rephrase Calendar Working-with-dates as a prose reference --- packages/@godaddy/antares/components/calendar/README.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/@godaddy/antares/components/calendar/README.mdx b/packages/@godaddy/antares/components/calendar/README.mdx index 19ea913a5..9edfd2799 100644 --- a/packages/@godaddy/antares/components/calendar/README.mdx +++ b/packages/@godaddy/antares/components/calendar/README.mdx @@ -30,8 +30,7 @@ npm install --save @godaddy/antares ## Working with dates Both components are typed for `CalendarDate` from `@internationalized/date`. See the -canonical [Working with dates](?path=/docs/components-datefield--overview#working-with-dates) -section in the `DateField` docs for installation, locale handling, and common patterns. +`DateField` component docs for installation, locale handling, and common patterns. ## Props From 1cbe7c63a258b753b84123f40c77e205a5e6995b Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Thu, 4 Jun 2026 09:42:42 -0500 Subject: [PATCH 11/14] chore(antares): add @internationalized/date dep, drop stray plan SVGs Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 1 + packages/@godaddy/antares/package.json | 1 + plans/calendar-size-measurements.svg | 306 ----------------------- plans/single-double-month.svg | 326 ------------------------- 4 files changed, 2 insertions(+), 632 deletions(-) delete mode 100644 plans/calendar-size-measurements.svg delete mode 100644 plans/single-double-month.svg diff --git a/package-lock.json b/package-lock.json index 8ae010de6..9571d3814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28128,6 +28128,7 @@ "@bento/icon": "^0.2.0", "@bento/svg-parser": "^0.1.1", "@bento/use-data-attributes": "^0.1.1", + "@internationalized/date": "^3.12.2", "@react-aria/focus": "^3.22.0", "@react-aria/utils": "^3.34.0", "@react-stately/utils": "^3.12.0", diff --git a/packages/@godaddy/antares/package.json b/packages/@godaddy/antares/package.json index 56c590492..63ba337d3 100644 --- a/packages/@godaddy/antares/package.json +++ b/packages/@godaddy/antares/package.json @@ -43,6 +43,7 @@ "@bento/icon": "^0.2.0", "@bento/svg-parser": "^0.1.1", "@bento/use-data-attributes": "^0.1.1", + "@internationalized/date": "^3.12.2", "@react-aria/focus": "^3.22.0", "@react-aria/utils": "^3.34.0", "@react-stately/utils": "^3.12.0", diff --git a/plans/calendar-size-measurements.svg b/plans/calendar-size-measurements.svg deleted file mode 100644 index d3252f342..000000000 --- a/plans/calendar-size-measurements.svg +++ /dev/null @@ -1,306 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/plans/single-double-month.svg b/plans/single-double-month.svg deleted file mode 100644 index 7e6812fa6..000000000 --- a/plans/single-double-month.svg +++ /dev/null @@ -1,326 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 825fb0fe6bdb41e6c7f8c30330c721a2a746dea4 Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Tue, 16 Jun 2026 22:14:02 -0500 Subject: [PATCH 12/14] refactor(select): streamline Select component and enhance documentation --- .gitignore | 3 +- .../field-frame/src/index.module.css | 8 +- .../calendar/src/calendar-header.tsx | 8 +- .../__snapshots__/calendar.node.test.tsx.snap | 2 +- .../components/popover/src/index.module.css | 1 + .../antares/components/select/README.mdx | 160 +++-------- .../examples/{select-static.tsx => basic.tsx} | 6 +- .../components/select/examples/controlled.tsx | 21 ++ .../{select-multiple.tsx => disabled.tsx} | 6 +- .../components/select/examples/form.tsx | 52 ++++ .../components/select/examples/invalid.tsx | 11 + .../components/select/examples/multiple.tsx | 27 ++ .../{select-playground.tsx => playground.tsx} | 12 +- .../select/examples/select-controlled.tsx | 26 -- .../select/examples/select-dynamic.tsx | 25 -- .../select/examples/select-label-styles.tsx | 17 -- .../select/examples/select-render-props.tsx | 17 -- .../select/examples/select-sections.tsx | 18 -- .../select/examples/select-sizes.tsx | 17 -- .../select/examples/select-validation.tsx | 17 -- .../components/select/select.stories.tsx | 38 +-- .../components/select/src/index.module.css | 124 ++------ .../antares/components/select/src/index.tsx | 266 +++++------------- .../__snapshots__/select.node.test.tsx.snap | 18 +- .../select/test/select.browser.test.tsx | 72 ++--- .../select/test/select.node.test.tsx | 94 +++---- packages/@godaddy/antares/exports/Select.ts | 11 +- packages/@godaddy/antares/index.ts | 11 +- 28 files changed, 349 insertions(+), 739 deletions(-) rename packages/@godaddy/antares/components/select/examples/{select-static.tsx => basic.tsx} (57%) create mode 100644 packages/@godaddy/antares/components/select/examples/controlled.tsx rename packages/@godaddy/antares/components/select/examples/{select-multiple.tsx => disabled.tsx} (54%) create mode 100644 packages/@godaddy/antares/components/select/examples/form.tsx create mode 100644 packages/@godaddy/antares/components/select/examples/invalid.tsx create mode 100644 packages/@godaddy/antares/components/select/examples/multiple.tsx rename packages/@godaddy/antares/components/select/examples/{select-playground.tsx => playground.tsx} (82%) delete mode 100644 packages/@godaddy/antares/components/select/examples/select-controlled.tsx delete mode 100644 packages/@godaddy/antares/components/select/examples/select-dynamic.tsx delete mode 100644 packages/@godaddy/antares/components/select/examples/select-label-styles.tsx delete mode 100644 packages/@godaddy/antares/components/select/examples/select-render-props.tsx delete mode 100644 packages/@godaddy/antares/components/select/examples/select-sections.tsx delete mode 100644 packages/@godaddy/antares/components/select/examples/select-sizes.tsx delete mode 100644 packages/@godaddy/antares/components/select/examples/select-validation.tsx diff --git a/.gitignore b/.gitignore index 9b3bdca80..f9d910269 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ packages/@godaddy/**/test/__screenshots__/**/*-darwin.png # Claude .claude/settings.local.json +.claude # Nx .nx/cache @@ -77,4 +78,4 @@ packages/@godaddy/**/test/__screenshots__/**/*-darwin.png .nx/polygraph -.claude/worktrees \ No newline at end of file +.claude/worktrees diff --git a/packages/@godaddy/antares/components/_internal/field-frame/src/index.module.css b/packages/@godaddy/antares/components/_internal/field-frame/src/index.module.css index 7bb14fda6..0a7a8d415 100644 --- a/packages/@godaddy/antares/components/_internal/field-frame/src/index.module.css +++ b/packages/@godaddy/antares/components/_internal/field-frame/src/index.module.css @@ -56,18 +56,20 @@ } &[data-invalid]:has(input[data-focused="true"]), - &[data-invalid]:has(textarea[data-focused="true"]) { + &[data-invalid]:has(textarea[data-focused="true"]), + &[data-invalid]:has(button[data-trigger][data-focused="true"]) { outline: 2px solid var(--field-frame-critical-color); } &:has(input[data-focused="true"]), - &:has(textarea[data-focused="true"]) { + &:has(textarea[data-focused="true"]), + &:has(button[data-trigger][data-focused="true"]) { outline: 2px solid Highlight; outline: 2px auto -webkit-focus-ring-color; outline-offset: -1px; } - & .button[data-focused="true"] { + & .button:not([data-trigger])[data-focused="true"] { outline: 2px solid Highlight; outline: 2px auto -webkit-focus-ring-color; } diff --git a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx index 19a04b15a..e03f8ddfd 100644 --- a/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx +++ b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx @@ -66,8 +66,8 @@ export function CalendarHeader({ range }: CalendarHeaderProps) { return ( {picker.items.map(function renderYearItem(item) { diff --git a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap index 8543b68d6..5e8da1a11 100644 --- a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap +++ b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; +exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; diff --git a/packages/@godaddy/antares/components/popover/src/index.module.css b/packages/@godaddy/antares/components/popover/src/index.module.css index 55b69a45f..cbbbeb7aa 100644 --- a/packages/@godaddy/antares/components/popover/src/index.module.css +++ b/packages/@godaddy/antares/components/popover/src/index.module.css @@ -6,6 +6,7 @@ --_content-offset: calc(var(--_arrow-height) + var(--_arrow-rounded-tip)); --_arrow-offset: calc(var(--_arrow-height) * -1); + overflow: auto; transition: transform 0.2s, opacity 0.2s; diff --git a/packages/@godaddy/antares/components/select/README.mdx b/packages/@godaddy/antares/components/select/README.mdx index 3a343d615..4bb133df9 100644 --- a/packages/@godaddy/antares/components/select/README.mdx +++ b/packages/@godaddy/antares/components/select/README.mdx @@ -1,16 +1,29 @@ --- title: Select -description: Accessible dropdown component supporting single and multiple selection with keyboard navigation and validation +description: Select is a dropdown for picking one or more values from a list of options. It renders a labeled trigger and a popover listbox, supports single and multiple selection, and submits naturally inside a form. --- -import { Meta, ArgTypes, Source, Story } from '@storybook/addon-docs/blocks'; -import SourceSelectStatic from './examples/select-static.tsx?raw'; -import SourceSelectControlled from './examples/select-controlled.tsx?raw'; -import SourceSelectDynamic from './examples/select-dynamic.tsx?raw'; -import SourceSelectMultiple from './examples/select-multiple.tsx?raw'; + +import { ArgTypes, Meta, Source, Story } from '@storybook/addon-docs/blocks'; import * as Stories from './select.stories.tsx'; +import SourceBasic from './examples/basic.tsx?raw'; +import SourceControlled from './examples/controlled.tsx?raw'; +import SourceDisabled from './examples/disabled.tsx?raw'; +import SourceForm from './examples/form.tsx?raw'; +import SourceInvalid from './examples/invalid.tsx?raw'; +import SourceMultiple from './examples/multiple.tsx?raw'; + +## Features + +- **Label, description, error**: Optional label, helper text, and error message with proper accessibility +- **Single or multiple selection**: Set `selectionMode="multiple"` to allow multiple values +- **Controlled or uncontrolled**: Use `value` and `onChange` for controlled state, or `defaultValue` for uncontrolled +- **Validation states**: Use `isInvalid` with `errorMessage` and `isDisabled` for validation and disabled state +- **Form integration**: Set `name` and the value submits as part of a native `
` (multiple values submit as repeated entries) +- **React Aria integration**: Built on React Aria Select for accessibility, keyboard navigation, and typeahead + ## Installation ```bash @@ -19,135 +32,50 @@ npm install --save @godaddy/antares ## Props +The Select component accepts the following props: + ## Examples -### Static +### Basic + +Minimal usage with a label and a placeholder. - - + + ### Controlled - - +Use `value` and `onChange` for controlled state. -### Dynamic Items + + - - +### Multiple -### Multiple Selection +Set `selectionMode="multiple"` to allow multiple values. `value` is an array of keys. + - - -## Customization - -### Data Attributes - -React Aria Components automatically adds data attributes for styling different states: - -**Select Container:** `data-invalid`, `data-disabled`, `data-required`, `data-focused` - -**Trigger Button:** `data-pressed`, `data-disabled`, `data-focused`, `aria-expanded` - -**Select Value:** `data-placeholder` - -**Popover:** `data-entering`, `data-exiting` - -**List Items:** `data-hovered`, `data-focused`, `data-pressed`, `data-selected`, `data-disabled`, `data-selection-mode` -```css -[data-selected] { - background-color: #eff6ff; - font-weight: 600; -} +### Invalid -[data-invalid] .button { - border-color: #dc2626; -} +Use `isInvalid` with `errorMessage` for validation feedback. -[aria-expanded="true"] { - border-color: #3b82f6; -} -``` - -### Component Customization - -Individual child components can be styled by passing `className` props: - -```jsx - -``` - -## Accessibility - -### Keyboard Navigation - -- **Space/Enter**: Opens the popover and focuses the selected item -- **Arrow Down/Up**: Navigate through options -- **Home/End**: Jump to first/last option -- **Escape**: Closes the popover -- **Tab**: Moves focus to the next focusable element -- **Type to select**: Type characters to jump to matching options -- **Space** (in multi-select): Toggle selection of focused item + + -## Troubleshooting +### Disabled -### Selection Not Updating +Use `isDisabled` to prevent interaction. -```jsx -// ❌ Wrong: Using both value and defaultValue - + + -// ✅ Controlled mode - +### Form -// ✅ Uncontrolled mode - -``` - -### Styling Overrides Not Applying - -```css -/* ❌ May not have enough specificity */ -.item { - background-color: red; -} - -/* ✅ Use data attributes for higher specificity */ -.my-select [data-selected] { - background-color: red; -} -``` - -### Keyboard Navigation Not Working +Set `name` to submit the value with a native ``. Multiple-mode values submit as repeated entries with the same `name`. -```css -/* ❌ Don't remove focus outlines without replacement */ -.button:focus { - outline: none; -} - -/* ✅ Provide visible focus indicator */ -.button:focus-visible { - outline: 2px solid #3b82f6; - outline-offset: 2px; -} -``` + + diff --git a/packages/@godaddy/antares/components/select/examples/select-static.tsx b/packages/@godaddy/antares/components/select/examples/basic.tsx similarity index 57% rename from packages/@godaddy/antares/components/select/examples/select-static.tsx rename to packages/@godaddy/antares/components/select/examples/basic.tsx index 32f96ba40..a4bf8759b 100644 --- a/packages/@godaddy/antares/components/select/examples/select-static.tsx +++ b/packages/@godaddy/antares/components/select/examples/basic.tsx @@ -1,8 +1,8 @@ -import { Select, SelectItem } from '@godaddy/antares'; +import { Select, SelectItem, type SelectProps } from '@godaddy/antares'; -export function SelectStaticExample() { +export function SelectBasic(props: Omit, 'children'> = {}) { return ( - Espresso Latte Cappuccino diff --git a/packages/@godaddy/antares/components/select/examples/controlled.tsx b/packages/@godaddy/antares/components/select/examples/controlled.tsx new file mode 100644 index 000000000..cabfe33eb --- /dev/null +++ b/packages/@godaddy/antares/components/select/examples/controlled.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react'; +import { Select, SelectItem, Text, type Key } from '@godaddy/antares'; + +export function SelectControlledExample() { + const [value, setValue] = useState('latte'); + + return ( + <> + + + Value: {String(value ?? '(none)')} + + + ); +} diff --git a/packages/@godaddy/antares/components/select/examples/select-multiple.tsx b/packages/@godaddy/antares/components/select/examples/disabled.tsx similarity index 54% rename from packages/@godaddy/antares/components/select/examples/select-multiple.tsx rename to packages/@godaddy/antares/components/select/examples/disabled.tsx index 69162bcc3..446d704c5 100644 --- a/packages/@godaddy/antares/components/select/examples/select-multiple.tsx +++ b/packages/@godaddy/antares/components/select/examples/disabled.tsx @@ -1,13 +1,11 @@ import { Select, SelectItem } from '@godaddy/antares'; -export function SelectMultipleExample() { +export function SelectDisabledExample() { return ( - Espresso Latte Cappuccino - Americano - Mocha ); } diff --git a/packages/@godaddy/antares/components/select/examples/form.tsx b/packages/@godaddy/antares/components/select/examples/form.tsx new file mode 100644 index 000000000..b54567af7 --- /dev/null +++ b/packages/@godaddy/antares/components/select/examples/form.tsx @@ -0,0 +1,52 @@ +import { useState, type FormEvent } from 'react'; +import { Box, Button, Flex, Select, SelectItem, Text } from '@godaddy/antares'; + +export function SelectFormExample() { + const [submitted, setSubmitted] = useState | null>(null); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const entries: Record = {}; + data.forEach((value, key) => { + const stringValue = String(value); + const existing = entries[key]; + if (existing === undefined) { + entries[key] = stringValue; + } else if (Array.isArray(existing)) { + entries[key] = [...existing, stringValue]; + } else { + entries[key] = [existing, stringValue]; + } + }); + setSubmitted(entries); + } + + return ( + + + + + + + + {submitted && ( + + + Submitted: {JSON.stringify(submitted)} + + + )} + + ); +} diff --git a/packages/@godaddy/antares/components/select/examples/invalid.tsx b/packages/@godaddy/antares/components/select/examples/invalid.tsx new file mode 100644 index 000000000..49b8d96c4 --- /dev/null +++ b/packages/@godaddy/antares/components/select/examples/invalid.tsx @@ -0,0 +1,11 @@ +import { Select, SelectItem } from '@godaddy/antares'; + +export function SelectInvalidExample() { + return ( + + ); +} diff --git a/packages/@godaddy/antares/components/select/examples/multiple.tsx b/packages/@godaddy/antares/components/select/examples/multiple.tsx new file mode 100644 index 000000000..c2214e983 --- /dev/null +++ b/packages/@godaddy/antares/components/select/examples/multiple.tsx @@ -0,0 +1,27 @@ +import { useState } from 'react'; +import { Select, SelectItem, Text, type Key } from '@godaddy/antares'; + +export function SelectMultipleExample() { + const [value, setValue] = useState(['latte', 'mocha']); + + return ( + <> + + + Selected: {value.length === 0 ? '(none)' : value.join(', ')} + + + ); +} diff --git a/packages/@godaddy/antares/components/select/examples/select-playground.tsx b/packages/@godaddy/antares/components/select/examples/playground.tsx similarity index 82% rename from packages/@godaddy/antares/components/select/examples/select-playground.tsx rename to packages/@godaddy/antares/components/select/examples/playground.tsx index 7b42fa70b..dce6b9a7b 100644 --- a/packages/@godaddy/antares/components/select/examples/select-playground.tsx +++ b/packages/@godaddy/antares/components/select/examples/playground.tsx @@ -1,6 +1,6 @@ import { Select, SelectItem, type SelectProps } from '@godaddy/antares'; -/** Props for the select playground example. */ +/** Props for the Select playground example. */ export interface PlaygroundExampleProps extends Pick< SelectProps, @@ -8,8 +8,6 @@ export interface PlaygroundExampleProps | 'placeholder' | 'description' | 'errorMessage' - | 'size' - | 'labelStyle' | 'selectionMode' | 'isDisabled' | 'isRequired' @@ -17,12 +15,10 @@ export interface PlaygroundExampleProps > {} export function PlaygroundExample({ - label = 'Pick a drink', - placeholder = 'Select an option', + label = 'Coffee', + placeholder = 'Pick a drink', description, errorMessage, - size = 'md', - labelStyle = 'default', selectionMode = 'single', isDisabled = false, isRequired = false, @@ -34,8 +30,6 @@ export function PlaygroundExample({ placeholder={placeholder} description={description} errorMessage={errorMessage} - size={size} - labelStyle={labelStyle} selectionMode={selectionMode} isDisabled={isDisabled} isRequired={isRequired} diff --git a/packages/@godaddy/antares/components/select/examples/select-controlled.tsx b/packages/@godaddy/antares/components/select/examples/select-controlled.tsx deleted file mode 100644 index 61a3e164c..000000000 --- a/packages/@godaddy/antares/components/select/examples/select-controlled.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useState } from 'react'; -import { Box, Flex, Select, SelectItem } from '@godaddy/antares'; - -export function SelectControlledExample() { - const [selectedKey, setSelectedKey] = useState('latte'); - - return ( - - - - - Selected: {selectedKey} - - - ); -} diff --git a/packages/@godaddy/antares/components/select/examples/select-dynamic.tsx b/packages/@godaddy/antares/components/select/examples/select-dynamic.tsx deleted file mode 100644 index 16b212dc7..000000000 --- a/packages/@godaddy/antares/components/select/examples/select-dynamic.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Select, SelectItem } from '@godaddy/antares'; - -const items = [ - { id: '1', name: 'Espresso', category: 'Classic' }, - { id: '2', name: 'Latte', category: 'Classic' }, - { id: '3', name: 'Cappuccino', category: 'Classic' }, - { id: '4', name: 'Americano', category: 'Classic' }, - { id: '5', name: 'Mocha', category: 'Specialty' }, - { id: '6', name: 'Macchiato', category: 'Specialty' }, - { id: '7', name: 'Cold Brew', category: 'Cold' } -]; - -export function SelectDynamicExample() { - return ( - - ); -} diff --git a/packages/@godaddy/antares/components/select/examples/select-label-styles.tsx b/packages/@godaddy/antares/components/select/examples/select-label-styles.tsx deleted file mode 100644 index 22367c2b3..000000000 --- a/packages/@godaddy/antares/components/select/examples/select-label-styles.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Flex, Select, SelectItem } from '@godaddy/antares'; - -export function SelectLabelStylesExample() { - return ( - - - - - - ); -} diff --git a/packages/@godaddy/antares/components/select/examples/select-render-props.tsx b/packages/@godaddy/antares/components/select/examples/select-render-props.tsx deleted file mode 100644 index b59da0cb3..000000000 --- a/packages/@godaddy/antares/components/select/examples/select-render-props.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Select, SelectItem } from '@godaddy/antares'; - -export function SelectRenderPropsExample() { - return ( - - ); -} diff --git a/packages/@godaddy/antares/components/select/examples/select-sections.tsx b/packages/@godaddy/antares/components/select/examples/select-sections.tsx deleted file mode 100644 index 5bbd53e72..000000000 --- a/packages/@godaddy/antares/components/select/examples/select-sections.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Select, SelectItem, SelectSection, SelectHeader } from '@godaddy/antares'; - -export function SelectSectionsExample() { - return ( - - ); -} diff --git a/packages/@godaddy/antares/components/select/examples/select-sizes.tsx b/packages/@godaddy/antares/components/select/examples/select-sizes.tsx deleted file mode 100644 index 196a59430..000000000 --- a/packages/@godaddy/antares/components/select/examples/select-sizes.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Flex, Select, SelectItem } from '@godaddy/antares'; - -export function SelectSizesExample() { - return ( - - - - - - ); -} diff --git a/packages/@godaddy/antares/components/select/examples/select-validation.tsx b/packages/@godaddy/antares/components/select/examples/select-validation.tsx deleted file mode 100644 index d9b634e3d..000000000 --- a/packages/@godaddy/antares/components/select/examples/select-validation.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Flex, Select, SelectItem } from '@godaddy/antares'; - -export function SelectValidationExample() { - return ( - - - - - - ); -} diff --git a/packages/@godaddy/antares/components/select/select.stories.tsx b/packages/@godaddy/antares/components/select/select.stories.tsx index daf0df664..3b851e3a3 100644 --- a/packages/@godaddy/antares/components/select/select.stories.tsx +++ b/packages/@godaddy/antares/components/select/select.stories.tsx @@ -1,10 +1,12 @@ 'use client'; -import { PlaygroundExample, type PlaygroundExampleProps } from './examples/select-playground.tsx'; +import { PlaygroundExample, type PlaygroundExampleProps } from './examples/playground.tsx'; import { getComponentDocs, getMeta, getStory } from '@bento/storybook-addon-helpers'; -import { SelectControlledExample } from './examples/select-controlled.tsx'; -import { SelectMultipleExample } from './examples/select-multiple.tsx'; -import { SelectDynamicExample } from './examples/select-dynamic.tsx'; -import { SelectStaticExample } from './examples/select-static.tsx'; +import { SelectBasic } from './examples/basic'; +import { SelectControlledExample } from './examples/controlled'; +import { SelectDisabledExample } from './examples/disabled'; +import { SelectFormExample } from './examples/form'; +import { SelectInvalidExample } from './examples/invalid'; +import { SelectMultipleExample } from './examples/multiple'; import { Select } from './src/index.tsx'; export default getMeta({ @@ -16,33 +18,33 @@ export const Props = getComponentDocs(Select); export const Playground = { render: (args: PlaygroundExampleProps) => , args: { - label: 'Pick a drink', - placeholder: 'Select an option', - size: 'md', - labelStyle: 'default', + label: 'Coffee', + placeholder: 'Pick a drink', selectionMode: 'single', isDisabled: false, isRequired: false, isInvalid: false }, argTypes: { - size: { control: 'select', options: ['sm', 'md'] }, - labelStyle: { control: 'select', options: ['default', 'float'] }, - selectionMode: { control: 'select', options: ['single', 'multiple'] }, - isDisabled: { control: 'boolean' }, - isRequired: { control: 'boolean' }, - isInvalid: { control: 'boolean' }, label: { control: 'text' }, placeholder: { control: 'text' }, description: { control: 'text' }, - errorMessage: { control: 'text' } + errorMessage: { control: 'text' }, + selectionMode: { control: 'select', options: ['single', 'multiple'] }, + isDisabled: { control: 'boolean' }, + isRequired: { control: 'boolean' }, + isInvalid: { control: 'boolean' } } }; -export const Static = getStory(SelectStaticExample); +export const Basic = getStory(SelectBasic); export const Controlled = getStory(SelectControlledExample); -export const DynamicItems = getStory(SelectDynamicExample); +export const Invalid = getStory(SelectInvalidExample); + +export const Disabled = getStory(SelectDisabledExample); export const Multiple = getStory(SelectMultipleExample); + +export const Form = getStory(SelectFormExample); diff --git a/packages/@godaddy/antares/components/select/src/index.module.css b/packages/@godaddy/antares/components/select/src/index.module.css index 05f3ab223..d2c9081a3 100644 --- a/packages/@godaddy/antares/components/select/src/index.module.css +++ b/packages/@godaddy/antares/components/select/src/index.module.css @@ -1,83 +1,40 @@ -.select { - display: flex; - flex-direction: column; - gap: var(--sp-sm); - width: 100%; -} - -.label { - font-size: var(--fs-sm); - font-weight: bolder; - line-height: var(--lh-heading); - color: var(--fg-base); -} + /* TODO: REMOVE AS MUCH AS YOU CAN */ -.label--required::after { - content: " *"; - color: var(--critical); +/* .select { + width: 100%; } -.button { +button.trigger[data-trigger] { display: flex; align-items: center; justify-content: space-between; + flex: 1; width: 100%; - font-size: var(--fs-md); - font-weight: normal; - line-height: var(--lh-heading); - color: var(--fg-base); - background: var(--bg-base); - border: 1px solid var(--bd-base); - border-radius: var(--br-sm); + background: transparent; + border: 0; + padding-inline: var(--sp-md); + font: inherit; + color: inherit; cursor: pointer; outline: none; - box-sizing: border-box; - transition: - background 0.15s ease-in-out, - border-color 0.15s ease-in-out, - opacity 0.15s ease-in-out; -} - -.button--md { - padding: 0 var(--sp-md); min-height: 40px; } -.button--sm { - padding: 0 var(--sp-sm); - min-height: 32px; - font-size: var(--fs-sm); -} - -.button[data-focus-visible] { - outline: 2px solid Highlight; - outline-offset: -1px; -} - -.select[data-invalid] .button { - border-color: var(--bd-critical); -} - -.select[data-invalid] .button[data-focus-visible] { - outline: 2px solid color-mix(in oklch, var(--critical) 20%, transparent); -} - -.button[data-hovered]:not([data-disabled]) { +button.trigger[data-trigger][data-hovered]:not([data-disabled]) { background: color-mix(in oklch, var(--bg-base), black 4%); } -.button[data-pressed] { +button.trigger[data-trigger][data-pressed] { background: color-mix(in oklch, var(--bg-base), black 8%); } -.button[data-disabled] { - opacity: 0.4; +button.trigger[data-trigger][data-disabled] { cursor: not-allowed; } .value { flex: 1; - text-align: left; + text-align: start; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -95,41 +52,10 @@ flex-shrink: 0; } -.button[aria-expanded="true"] .chevron { +button.trigger[data-trigger][aria-expanded="true"] .chevron { transform: rotate(180deg); } -.checkbox { - margin-right: var(--sp-xs); - font-size: var(--fs-lg); - line-height: var(--lh-heading); - user-select: none; - flex-shrink: 0; -} - -.description { - font-size: var(--fs-sm); - font-weight: normal; - line-height: var(--lh-base); - color: var(--passive); -} - -.error { - display: flex; - align-items: center; - gap: var(--sp-xs); - font-size: var(--fs-sm); - font-weight: normal; - line-height: var(--lh-base); - color: var(--critical); -} - -.errorIcon { - flex-shrink: 0; - width: 1em; - height: 1em; -} - .popover { background-color: var(--bg-overlay); border: 1px solid var(--bd-base); @@ -187,17 +113,14 @@ .item[data-hovered] { background-color: color-mix(in oklch, var(--bg-base), black 8%); - color: var(--fg-base); } .item[data-focused] { background-color: color-mix(in oklch, var(--fg-base), transparent 90%); - color: var(--fg-base); } .item[data-pressed] { background-color: color-mix(in oklch, var(--bg-base), black 6%); - color: var(--fg-base); } .item[data-selected] { @@ -208,24 +131,9 @@ .item[data-selected][data-hovered] { background-color: color-mix(in oklch, var(--bg-selected), black 8%); - color: var(--fg-selected); } .item[data-disabled] { opacity: 0.4; cursor: not-allowed; -} - -.section { - outline: none; -} - -.header { - padding: var(--sp-xs) var(--sp-md); - font-size: var(--fs-sm); - font-weight: bolder; - line-height: var(--lh-heading); - color: var(--passive); - text-transform: uppercase; - letter-spacing: 0.025em; -} +} */ diff --git a/packages/@godaddy/antares/components/select/src/index.tsx b/packages/@godaddy/antares/components/select/src/index.tsx index dc97c1b8a..5965ecb15 100644 --- a/packages/@godaddy/antares/components/select/src/index.tsx +++ b/packages/@godaddy/antares/components/select/src/index.tsx @@ -1,241 +1,105 @@ +import { cx } from 'cva'; +import type { ReactNode } from 'react'; import { Button as RACButton, - FieldError as RACFieldError, - Header as RACHeader, - Label as RACLabel, ListBox as RACListBox, ListBoxItem as RACListBoxItem, - ListBoxSection as RACListBoxSection, - Popover as RACPopover, + // Popover as RACPopover, Select as RACSelect, SelectValue as RACSelectValue, - type Key as RACKey, - type ListBoxItemRenderProps as RACListBoxItemRenderProps, + type Key, type ListBoxItemProps as RACListBoxItemProps, - type ListBoxSectionProps as RACListBoxSectionProps + type SelectProps as RACSelectProps } from 'react-aria-components'; -import type { ComponentPropsWithoutRef, ReactNode } from 'react'; -import { Flex } from '#components/layout/flex'; -import { Text } from '#components/text'; +import { FieldFrame, type FieldFrameProps } from '#components/_internal/field-frame'; import { Icon } from '#components/icon'; +import { Popover } from '#components/popover'; import styles from './index.module.css'; -import { cx } from 'cva'; + +type SelectionMode = 'single' | 'multiple'; /** - * Common props shared between label variants, used internally for the discriminated union + * Props for the Select component. Wraps React Aria's Select with antares' FieldFrame + * for label, description, and error message rendering. * - * @typeParam T - Item type in the select list + * Either `label` or `aria-label` must be provided for accessibility. + * + * @typeParam T - Item type rendered inside the listbox. + * @typeParam M - Selection mode. Drives the type of `value`, `defaultValue`, and `onChange`. */ -interface SelectBaseProps { - /** Focus on mount */ - autoFocus?: boolean; - - /** SelectItem elements or render function */ - children: ReactNode | ((item: T) => ReactNode); +export interface SelectProps + extends Omit, 'children' | 'items'>, + Pick { + /** SelectItem children. */ + children: ReactNode; - /** Component CSS class */ - className?: string; - - /** Default open state (uncontrolled) */ - defaultOpen?: boolean; - - /** Initial selected key (uncontrolled) */ - defaultSelectedKey?: RACKey; - - /** Help text below the trigger */ + /** Helper text shown below the frame. */ description?: string; - /** Error text shown when invalid */ + /** Error message shown when invalid. Use with `isInvalid`. */ errorMessage?: string; - /** Disables interaction */ - isDisabled?: boolean; - - /** Shows error styling and errorMessage */ - isInvalid?: boolean; - - /** Open state (controlled) */ - isOpen?: boolean; - - /** Shows red asterisk, requires value for submission */ - isRequired?: boolean; - - /** Items for dynamic rendering (children must be render function) */ - items?: Iterable; - - /** - * Label positioning style - * - * @default 'default' - */ - labelStyle?: 'default' | 'float'; - - /** Form submission name */ - name?: string; - - /** Called when open state changes */ - onOpenChange?: (isOpen: boolean) => void; - - /** Called when selection changes */ - onSelectionChange?: (key: RACKey | null) => void; - - /** Placeholder text when empty */ - placeholder?: string; - - /** Selected key (controlled) */ - selectedKey?: RACKey | null; - - /** - * Whether single or multiple selection is enabled - * - * @default 'single' - */ - selectionMode?: 'single' | 'multiple'; - - /** - * Visual size variant - * - * @default 'md' - */ - size?: 'sm' | 'md'; -} - -/** - * Props for the Select component - * - * @typeParam T - Item type in the select list - * - * @remarks - * Either `label` or `aria-label` must be provided for accessibility. - * Props from RACSelectProps are re-declared for documentation due to react-docgen-typescript limitations with Omit<>. - */ -export interface SelectProps extends SelectBaseProps { - /** - * Visible label above the trigger - * Required if aria-label is not provided - */ + /** Visible label above the frame. Required if `aria-label` is not provided. */ label?: string; - - /** - * Accessible label for screen readers - * Required if label is not provided - */ - 'aria-label'?: string; } /** - * Antares Select component + * Antares Select. Single or multiple selection dropdown built on React Aria's Select, + * laid out with FieldFrame so it matches TextField and NumberField. * - * @param props - {@link SelectProps} + * @example + * ```tsx + * + * ``` */ -export function Select({ - label, - description, - errorMessage, - children, - items, - className, - size = 'md', - labelStyle = 'default', - ...props -}: SelectProps) { +export function Select(props: SelectProps) { + const { label, description, errorMessage, children, className, ...racProps } = props; + const { isDisabled, isRequired } = racProps; + return ( - - {label && ( - {label} - )} - - - - {description && ( - - {description} - - )} - {errorMessage && ( - - - - - )} - - - {children} - - + + + + + + + + {children} + ); } /** - * SelectItem for individual options, supports render props for conditional checkbox display - * - * @param props - {@link RACListBoxItemProps} + * Props for SelectItem. Forwards every React Aria ListBoxItem prop. */ -export function SelectItem(props: RACListBoxItemProps) { - if (typeof props.children === 'function') { - return ( - - {props.children} - - ); - } +export interface SelectItemProps extends RACListBoxItemProps {} +/** + * One option inside a Select. Auto-derives `textValue` from string children when not + * provided, so typeahead and the trigger's selected-text display work without extra props. + */ +export function SelectItem(props: SelectItemProps) { + const { textValue, children, className, ...rest } = props; return ( - {function renderItem(renderProps: RACListBoxItemRenderProps) { - return ( - <> - {renderProps.selectionMode === 'multiple' && ( - - )} - {props.children} - - ); - }} + {children} ); } -/** - * SelectSection for grouping options - * - * @param props - {@link RACListBoxSectionProps} - */ -export function SelectSection(props: RACListBoxSectionProps) { - return ; -} - -/** - * Props for SelectHeader component - */ -export interface SelectHeaderProps extends ComponentPropsWithoutRef {} - -/** - * SelectHeader for section titles - * - * @param props - {@link SelectHeaderProps} - */ -export function SelectHeader(props: SelectHeaderProps) { - return ; -} - -export type { RACListBoxItemProps as SelectItemProps, RACListBoxSectionProps as SelectSectionProps }; +export type { Key }; diff --git a/packages/@godaddy/antares/components/select/test/__snapshots__/select.node.test.tsx.snap b/packages/@godaddy/antares/components/select/test/__snapshots__/select.node.test.tsx.snap index 63fcee998..8c6c98886 100644 --- a/packages/@godaddy/antares/components/select/test/__snapshots__/select.node.test.tsx.snap +++ b/packages/@godaddy/antares/components/select/test/__snapshots__/select.node.test.tsx.snap @@ -1,19 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`@godaddy/antares > #examples > renders SelectControlledExample 1`] = `"
Selected: latte
"`; +exports[`@godaddy/antares > #Select > #examples > renders basic example 1`] = `"
Coffee
"`; -exports[`@godaddy/antares > #examples > renders SelectDynamicExample with items prop 1`] = `"
"`; +exports[`@godaddy/antares > #Select > #examples > renders controlled example 1`] = `"
Coffee
Value: latte"`; -exports[`@godaddy/antares > #examples > renders SelectLabelStylesExample 1`] = `"
Default label
Floating label
"`; +exports[`@godaddy/antares > #Select > #examples > renders disabled example 1`] = `"
Coffee
"`; -exports[`@godaddy/antares > #examples > renders SelectMultipleExample 1`] = `"
"`; +exports[`@godaddy/antares > #Select > #examples > renders form example 1`] = `"
Drink
Extras
"`; -exports[`@godaddy/antares > #examples > renders SelectRenderPropsExample 1`] = `"
"`; +exports[`@godaddy/antares > #Select > #examples > renders invalid example 1`] = `"
Coffee
Please choose a drink
"`; -exports[`@godaddy/antares > #examples > renders SelectSectionsExample 1`] = `"
"`; - -exports[`@godaddy/antares > #examples > renders SelectSizesExample 1`] = `"
"`; - -exports[`@godaddy/antares > #examples > renders SelectStaticExample 1`] = `"
"`; - -exports[`@godaddy/antares > #examples > renders SelectValidationExample 1`] = `"
Required fieldThis field is required
Invalid field
Please select a valid option
"`; +exports[`@godaddy/antares > #Select > #examples > renders multiple example 1`] = `"
Coffees you like
Selected: latte, mocha"`; diff --git a/packages/@godaddy/antares/components/select/test/select.browser.test.tsx b/packages/@godaddy/antares/components/select/test/select.browser.test.tsx index 11dac1b28..a2199b24f 100644 --- a/packages/@godaddy/antares/components/select/test/select.browser.test.tsx +++ b/packages/@godaddy/antares/components/select/test/select.browser.test.tsx @@ -1,68 +1,44 @@ -import { SelectStaticExample } from '../examples/select-static.tsx'; -import { SelectControlledExample } from '../examples/select-controlled.tsx'; -import { SelectDynamicExample } from '../examples/select-dynamic.tsx'; -import { SelectMultipleExample } from '../examples/select-multiple.tsx'; -import { SelectRenderPropsExample } from '../examples/select-render-props.tsx'; -import { page, userEvent } from 'vitest/browser'; -import { render } from 'vitest-browser-react'; import { describe, it } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { page, userEvent } from 'vitest/browser'; import assume from 'assume'; +import { SelectBasic } from '../examples/basic'; +import { SelectControlledExample } from '../examples/controlled'; +import { SelectMultipleExample } from '../examples/multiple'; describe('@godaddy/antares', function antares() { - describe('Examples', function examples() { - it('renders SelectStaticExample', async function staticRender() { - await render(); + describe('#Select', function selectSuite() { + it('renders the basic example', async function basicRender() { + await render(); - const button = page.getByRole('button'); - assume(button).is.not.equal(null); + const trigger = page.getByRole('button'); + assume(trigger).is.not.equal(null); }); - it('renders SelectControlledExample with interaction', async function controlledRender() { + it('updates the controlled selection on click', async function controlledInteraction() { await render(); - const button = page.getByRole('button', { name: 'Latte' }); - await userEvent.setup().click(button); + const trigger = page.getByRole('button', { name: /Latte/ }); + await userEvent.setup().click(trigger); - const espressoOption = page.getByRole('option', { name: 'Espresso' }); - await userEvent.setup().click(espressoOption); + const espresso = page.getByRole('option', { name: 'Espresso' }); + await userEvent.setup().click(espresso); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const updatedButton = page.getByRole('button', { name: 'Espresso' }); - assume(updatedButton).is.not.equal(null); + const updated = page.getByRole('button', { name: /Espresso/ }); + assume(updated).is.not.equal(null); }); - it('renders SelectDynamicExample with items prop', async function dynamicRender() { - await render(); - - const button = page.getByRole('button'); - assume(button).is.not.equal(null); - }); - - it('renders SelectMultipleExample with multiple selection', async function multipleRender() { + it('toggles items in multiple selection', async function multipleInteraction() { await render(); - const button = page.getByRole('button', { name: 'Coffee drinks' }); - await userEvent.setup().click(button); - - const espressoOption = page.getByRole('option', { name: 'Espresso' }); - await userEvent.setup().click(espressoOption); - - const latteOption = page.getByRole('option', { name: 'Latte' }); - await userEvent.setup().click(latteOption); - - const checkboxes = document.querySelectorAll('[aria-hidden="true"]'); - assume(checkboxes.length).greaterThan(0); - }); - - it('renders SelectRenderPropsExample with render function children', async function renderPropsRender() { - await render(); + const trigger = page.getByRole('button'); + await userEvent.setup().click(trigger); - const button = page.getByRole('button', { name: 'Render props select' }); - await userEvent.setup().click(button); + const espresso = page.getByRole('option', { name: 'Espresso' }); + await userEvent.setup().click(espresso); - const option = page.getByRole('option').first(); - assume(option).is.not.equal(null); + const selected = document.querySelectorAll('[role="option"][data-selected="true"]'); + assume(selected.length).greaterThan(0); }); }); }); diff --git a/packages/@godaddy/antares/components/select/test/select.node.test.tsx b/packages/@godaddy/antares/components/select/test/select.node.test.tsx index 5f993e424..7de5e26b0 100644 --- a/packages/@godaddy/antares/components/select/test/select.node.test.tsx +++ b/packages/@godaddy/antares/components/select/test/select.node.test.tsx @@ -1,60 +1,46 @@ -import { SelectStaticExample } from '../examples/select-static.tsx'; -import { SelectDynamicExample } from '../examples/select-dynamic.tsx'; -import { SelectControlledExample } from '../examples/select-controlled.tsx'; -import { SelectMultipleExample } from '../examples/select-multiple.tsx'; -import { SelectSizesExample } from '../examples/select-sizes.tsx'; -import { SelectLabelStylesExample } from '../examples/select-label-styles.tsx'; -import { SelectValidationExample } from '../examples/select-validation.tsx'; -import { SelectSectionsExample } from '../examples/select-sections.tsx'; -import { SelectRenderPropsExample } from '../examples/select-render-props.tsx'; +import { describe, expect, it } from 'vitest'; import { renderToString } from 'react-dom/server'; -import { describe, it, expect } from 'vitest'; +import { SelectBasic } from '../examples/basic'; +import { SelectControlledExample } from '../examples/controlled'; +import { SelectDisabledExample } from '../examples/disabled'; +import { SelectFormExample } from '../examples/form'; +import { SelectInvalidExample } from '../examples/invalid'; +import { SelectMultipleExample } from '../examples/multiple'; describe('@godaddy/antares', function antares() { - describe('#examples', function examplesTests() { - it('renders SelectStaticExample', function staticExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); - }); - - it('renders SelectDynamicExample with items prop', function dynamicExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); - }); - - it('renders SelectControlledExample', function controlledExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); - }); - - it('renders SelectMultipleExample', function multipleExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); - }); - - it('renders SelectSizesExample', function sizesExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); - }); - - it('renders SelectLabelStylesExample', function labelStylesExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); - }); - - it('renders SelectValidationExample', function validationExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); - }); - - it('renders SelectSectionsExample', function sectionsExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); - }); - - it('renders SelectRenderPropsExample', function renderPropsExample() { - const result = renderToString(); - expect(result).toMatchSnapshot(); + describe('#Select', function select() { + describe('#examples', function examples() { + it('renders basic example', function basic() { + const result = renderToString(); + expect(result).toMatchSnapshot(); + }); + + it('renders controlled example', function controlled() { + const result = renderToString(); + expect(result).toMatchSnapshot(); + }); + + it('renders disabled example', function disabled() { + const result = renderToString(); + expect(result).toContain('data-disabled'); + expect(result).toMatchSnapshot(); + }); + + it('renders invalid example', function invalid() { + const result = renderToString(); + expect(result).toContain('data-invalid'); + expect(result).toMatchSnapshot(); + }); + + it('renders multiple example', function multiple() { + const result = renderToString(); + expect(result).toMatchSnapshot(); + }); + + it('renders form example', function form() { + const result = renderToString(); + expect(result).toMatchSnapshot(); + }); }); }); }); diff --git a/packages/@godaddy/antares/exports/Select.ts b/packages/@godaddy/antares/exports/Select.ts index b452d441e..1d8da0cbd 100644 --- a/packages/@godaddy/antares/exports/Select.ts +++ b/packages/@godaddy/antares/exports/Select.ts @@ -1,10 +1 @@ -export { - Select, - type SelectProps, - SelectItem, - type SelectItemProps, - SelectSection, - type SelectSectionProps, - SelectHeader, - type SelectHeaderProps -} from '#components/select'; +export { Select, type SelectProps, SelectItem, type SelectItemProps, type Key } from '#components/select'; diff --git a/packages/@godaddy/antares/index.ts b/packages/@godaddy/antares/index.ts index 536253168..89e7df580 100644 --- a/packages/@godaddy/antares/index.ts +++ b/packages/@godaddy/antares/index.ts @@ -15,16 +15,7 @@ export { Box, type BoxProps, type BoxOwnProps } from '#components/layout/box'; export { Flex, type FlexProps, type FlexOwnProps } from '#components/layout/flex'; export { Grid, type GridProps, type GridOwnProps } from '#components/layout/grid'; -export { - Select, - SelectItem, - SelectSection, - SelectHeader, - type SelectProps, - type SelectItemProps, - type SelectSectionProps, - type SelectHeaderProps -} from '#components/select'; +export { Select, SelectItem, type SelectProps, type SelectItemProps, type Key } from '#components/select'; export { Menu, From f57ca4be2e0853ee74fc874ed5a80ac3bbe75256 Mon Sep 17 00:00:00 2001 From: Erwin Gaitan Date: Wed, 17 Jun 2026 21:17:58 -0500 Subject: [PATCH 13/14] refactor(calendar): consolidate Calendar and RangeCalendar components into index.tsx, enhancing structure and documentation --- .../field-frame/src/index.module.css | 8 +- .../components/calendar/calendar.stories.tsx | 3 +- .../components/calendar/src/calendar.tsx | 40 ------ .../antares/components/calendar/src/index.tsx | 92 +++++++++++- .../calendar/src/range-calendar.tsx | 58 -------- .../__snapshots__/calendar.node.test.tsx.snap | 2 +- .../antares/components/listbox/README.mdx | 58 ++++++++ .../components/listbox/examples/basic.tsx | 13 ++ .../listbox/examples/controlled.tsx | 26 ++++ .../components/listbox/examples/multiple.tsx | 27 ++++ .../listbox/examples/playground.tsx | 19 +++ .../components/listbox/listbox.stories.tsx | 31 ++++ .../components/listbox/src/index.module.css | 26 ++++ .../antares/components/listbox/src/index.tsx | 59 ++++++++ .../__snapshots__/listbox.node.test.tsx.snap | 7 + .../listbox/test/listbox.browser.test.tsx | 42 ++++++ .../listbox/test/listbox.node.test.tsx | 26 ++++ .../components/popover/src/index.module.css | 5 +- .../antares/components/popover/src/index.tsx | 28 ++-- .../components/select/src/index.module.css | 133 +----------------- .../antares/components/select/src/index.tsx | 59 ++------ .../__snapshots__/select.node.test.tsx.snap | 12 +- packages/@godaddy/antares/exports/ListBox.ts | 1 + packages/@godaddy/antares/exports/Select.ts | 2 +- packages/@godaddy/antares/index.ts | 4 +- 25 files changed, 477 insertions(+), 304 deletions(-) delete mode 100644 packages/@godaddy/antares/components/calendar/src/calendar.tsx delete mode 100644 packages/@godaddy/antares/components/calendar/src/range-calendar.tsx create mode 100644 packages/@godaddy/antares/components/listbox/README.mdx create mode 100644 packages/@godaddy/antares/components/listbox/examples/basic.tsx create mode 100644 packages/@godaddy/antares/components/listbox/examples/controlled.tsx create mode 100644 packages/@godaddy/antares/components/listbox/examples/multiple.tsx create mode 100644 packages/@godaddy/antares/components/listbox/examples/playground.tsx create mode 100644 packages/@godaddy/antares/components/listbox/listbox.stories.tsx create mode 100644 packages/@godaddy/antares/components/listbox/src/index.module.css create mode 100644 packages/@godaddy/antares/components/listbox/src/index.tsx create mode 100644 packages/@godaddy/antares/components/listbox/test/__snapshots__/listbox.node.test.tsx.snap create mode 100644 packages/@godaddy/antares/components/listbox/test/listbox.browser.test.tsx create mode 100644 packages/@godaddy/antares/components/listbox/test/listbox.node.test.tsx create mode 100644 packages/@godaddy/antares/exports/ListBox.ts diff --git a/packages/@godaddy/antares/components/_internal/field-frame/src/index.module.css b/packages/@godaddy/antares/components/_internal/field-frame/src/index.module.css index 0a7a8d415..7bb14fda6 100644 --- a/packages/@godaddy/antares/components/_internal/field-frame/src/index.module.css +++ b/packages/@godaddy/antares/components/_internal/field-frame/src/index.module.css @@ -56,20 +56,18 @@ } &[data-invalid]:has(input[data-focused="true"]), - &[data-invalid]:has(textarea[data-focused="true"]), - &[data-invalid]:has(button[data-trigger][data-focused="true"]) { + &[data-invalid]:has(textarea[data-focused="true"]) { outline: 2px solid var(--field-frame-critical-color); } &:has(input[data-focused="true"]), - &:has(textarea[data-focused="true"]), - &:has(button[data-trigger][data-focused="true"]) { + &:has(textarea[data-focused="true"]) { outline: 2px solid Highlight; outline: 2px auto -webkit-focus-ring-color; outline-offset: -1px; } - & .button:not([data-trigger])[data-focused="true"] { + & .button[data-focused="true"] { outline: 2px solid Highlight; outline: 2px auto -webkit-focus-ring-color; } diff --git a/packages/@godaddy/antares/components/calendar/calendar.stories.tsx b/packages/@godaddy/antares/components/calendar/calendar.stories.tsx index aa054106d..32da5e53c 100644 --- a/packages/@godaddy/antares/components/calendar/calendar.stories.tsx +++ b/packages/@godaddy/antares/components/calendar/calendar.stories.tsx @@ -5,8 +5,7 @@ import { CalendarDefaultRangeExample } from './examples/default-range.tsx'; import { CalendarTodayDistinctExample } from './examples/today-distinct.tsx'; import { CalendarWithMinMaxExample } from './examples/with-min-max.tsx'; import { CalendarWithUnavailableDatesExample } from './examples/with-unavailable-dates.tsx'; -import { Calendar } from './src/calendar.tsx'; -import { RangeCalendar } from './src/range-calendar.tsx'; +import { Calendar, RangeCalendar } from './src/index.tsx'; export default getMeta({ title: 'components/Calendar' diff --git a/packages/@godaddy/antares/components/calendar/src/calendar.tsx b/packages/@godaddy/antares/components/calendar/src/calendar.tsx deleted file mode 100644 index 4fb08461f..000000000 --- a/packages/@godaddy/antares/components/calendar/src/calendar.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { CalendarDate } from '@internationalized/date'; -import { - Calendar as RACCalendar, - CalendarCell as RACCalendarCell, - CalendarGrid as RACCalendarGrid, - type CalendarProps as RACCalendarProps -} from 'react-aria-components'; -import { Flex } from '#components/layout/flex'; -import { CalendarHeader } from './calendar-header'; -import styles from './index.module.css'; - -export interface CalendarProps extends Omit, 'children' | 'visibleDuration'> {} - -/** - * Single-month date grid typed for `CalendarDate` (date-only, no time, no timezone). - * Replaces RAC's default heading with prev/next arrows + Month + Year `Select` dropdowns. - * - * @param props - {@link CalendarProps} - * - * @example - * ```tsx - * import { parseDate } from '@internationalized/date'; - * - * - * ``` - */ -export function Calendar(props: CalendarProps) { - return ( - {...props} visibleDuration={{ months: 1 }} className={styles.calendar}> - - - - {function renderCell(date) { - return ; - }} - - - - ); -} diff --git a/packages/@godaddy/antares/components/calendar/src/index.tsx b/packages/@godaddy/antares/components/calendar/src/index.tsx index efe2cb750..d9f8c4fd7 100644 --- a/packages/@godaddy/antares/components/calendar/src/index.tsx +++ b/packages/@godaddy/antares/components/calendar/src/index.tsx @@ -1,2 +1,90 @@ -export { Calendar, type CalendarProps } from './calendar'; -export { RangeCalendar, type RangeCalendarProps } from './range-calendar'; +import type { CalendarDate } from '@internationalized/date'; +import { + Calendar as RACCalendar, + CalendarCell as RACCalendarCell, + CalendarGrid as RACCalendarGrid, + RangeCalendar as RACRangeCalendar, + type CalendarProps as RACCalendarProps, + type RangeCalendarProps as RACRangeCalendarProps +} from 'react-aria-components'; +import { Flex } from '#components/layout/flex'; +import { CalendarHeader } from './calendar-header'; +import styles from './index.module.css'; + +export interface CalendarProps extends Omit, 'children' | 'visibleDuration'> {} + +/** + * Single-month date grid typed for `CalendarDate` (date-only, no time, no timezone). + * Replaces RAC's default heading with prev/next arrows + Month + Year `Select` dropdowns. + * + * @param props - {@link CalendarProps} + * + * @example + * ```tsx + * import { parseDate } from '@internationalized/date'; + * + * + * ``` + */ +export function Calendar(props: CalendarProps) { + return ( + {...props} visibleDuration={{ months: 1 }} className={styles.calendar}> + + + + {function renderCell(date) { + return ; + }} + + + + ); +} + +export interface RangeCalendarProps extends Omit, 'children' | 'visibleDuration'> {} + +/** + * Two-month side-by-side date grid for picking a contiguous range, typed for `CalendarDate`. + * Each grid has its own header; selecting a month/year on either side scrolls both together, + * and `pageBehavior="single"` makes the arrows page one month at a time. + * + * @param props - {@link RangeCalendarProps} + * + * @example + * ```tsx + * import { parseDate } from '@internationalized/date'; + * + * + * ``` + */ +export function RangeCalendar(props: RangeCalendarProps) { + return ( + + {...props} + visibleDuration={{ months: 2 }} + pageBehavior="single" + className={styles.calendar} + > + + + + + {function renderStartCell(date) { + return ; + }} + + + + + + {function renderEndCell(date) { + return ; + }} + + + + + ); +} diff --git a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx b/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx deleted file mode 100644 index 0225f9d5d..000000000 --- a/packages/@godaddy/antares/components/calendar/src/range-calendar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { CalendarDate } from '@internationalized/date'; -import { - CalendarCell as RACCalendarCell, - CalendarGrid as RACCalendarGrid, - RangeCalendar as RACRangeCalendar, - type RangeCalendarProps as RACRangeCalendarProps -} from 'react-aria-components'; -import { Flex } from '#components/layout/flex'; -import { CalendarHeader } from './calendar-header'; -import styles from './index.module.css'; - -export interface RangeCalendarProps extends Omit, 'children' | 'visibleDuration'> {} - -/** - * Two-month side-by-side date grid for picking a contiguous range, typed for `CalendarDate`. - * Each grid has its own header; selecting a month/year on either side scrolls both together, - * and `pageBehavior="single"` makes the arrows page one month at a time. - * - * @param props - {@link RangeCalendarProps} - * - * @example - * ```tsx - * import { parseDate } from '@internationalized/date'; - * - * - * ``` - */ -export function RangeCalendar(props: RangeCalendarProps) { - return ( - - {...props} - visibleDuration={{ months: 2 }} - pageBehavior="single" - className={styles.calendar} - > - - - - - {function renderStartCell(date) { - return ; - }} - - - - - - {function renderEndCell(date) { - return ; - }} - - - - - ); -} diff --git a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap index 5e8da1a11..2825ffc98 100644 --- a/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap +++ b/packages/@godaddy/antares/components/calendar/test/__snapshots__/calendar.node.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; +exports[`@godaddy/antares > #Calendar > #examples > renders min/max example 1`] = `"

Q1 2024, February 2024

28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1
2
"`; diff --git a/packages/@godaddy/antares/components/listbox/README.mdx b/packages/@godaddy/antares/components/listbox/README.mdx new file mode 100644 index 000000000..14db03b73 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/README.mdx @@ -0,0 +1,58 @@ +--- +title: ListBox +description: ListBox is a selectable collection of items rendered inline. Use it for non-popover pickers, inline lists, or as a building block for higher-level components like Select. +--- + +import { ArgTypes, Meta, Source, Story } from '@storybook/addon-docs/blocks'; +import * as Stories from './listbox.stories.tsx'; + +import SourceBasic from './examples/basic.tsx?raw'; +import SourceControlled from './examples/controlled.tsx?raw'; +import SourceMultiple from './examples/multiple.tsx?raw'; + + + +## Features + +- **Single or multiple selection**: Set `selectionMode="single"` or `"multiple"`. +- **Controlled or uncontrolled**: Use `selectedKeys` and `onSelectionChange` for controlled state, or `defaultSelectedKeys` for uncontrolled. +- **Layout primitive**: `ListBox` and `ListBoxItem` accept layout props (`gap`, `padding`, etc.) — they extend `FlexOwnProps`. +- **Composes into other components**: `Select` uses `ListBox` internally; reach for `ListBox` directly when you need an inline list rather than a popover dropdown. +- **React Aria integration**: Built on React Aria ListBox for accessibility, keyboard navigation, and typeahead. + +`ListBox` requires either `aria-label` or `aria-labelledby`. For a labeled, popover-style picker, prefer [Select](/?path=/docs/components-select--overview). + +## Installation + +```bash +npm install --save @godaddy/antares +``` + +## Props + +The ListBox component accepts the following props: + + + +## Examples + +### Basic + +Single selection with static children. Use `aria-label` to give the listbox an accessible name. + + + + +### Controlled + +Use `selectedKeys` and `onSelectionChange` for controlled state. + + + + +### Multiple + +Set `selectionMode="multiple"` to allow multiple values. `selectedKeys` is a `Set` (or the literal `'all'`). + + + diff --git a/packages/@godaddy/antares/components/listbox/examples/basic.tsx b/packages/@godaddy/antares/components/listbox/examples/basic.tsx new file mode 100644 index 000000000..5ce5dbdcf --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/examples/basic.tsx @@ -0,0 +1,13 @@ +import { ListBox, ListBoxItem, type ListBoxProps } from '@godaddy/antares'; + +export function ListBoxBasic(props: Omit, 'children' | 'aria-label'> = {}) { + return ( + + Espresso + Latte + Cappuccino + Americano + Mocha + + ); +} diff --git a/packages/@godaddy/antares/components/listbox/examples/controlled.tsx b/packages/@godaddy/antares/components/listbox/examples/controlled.tsx new file mode 100644 index 000000000..12809828e --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/examples/controlled.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; +import { ListBox, ListBoxItem, Text, type Key } from '@godaddy/antares'; + +export function ListBoxControlledExample() { + const [selectedKeys, setSelectedKeys] = useState>(new Set(['latte'])); + + return ( + <> + setSelectedKeys(keys === 'all' ? new Set() : new Set(keys))} + > + Espresso + Latte + Cappuccino + Americano + Mocha + + + Value: {selectedKeys.size === 0 ? '(none)' : Array.from(selectedKeys).join(', ')} + + + ); +} diff --git a/packages/@godaddy/antares/components/listbox/examples/multiple.tsx b/packages/@godaddy/antares/components/listbox/examples/multiple.tsx new file mode 100644 index 000000000..f21995333 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/examples/multiple.tsx @@ -0,0 +1,27 @@ +import { useState } from 'react'; +import { ListBox, ListBoxItem, Text, type Key } from '@godaddy/antares'; + +export function ListBoxMultipleExample() { + const [selectedKeys, setSelectedKeys] = useState<'all' | Set>(new Set(['latte', 'mocha'])); + + return ( + <> + setSelectedKeys(keys === 'all' ? 'all' : new Set(keys))} + > + Espresso + Latte + Cappuccino + Americano + Mocha + + + Selected:{' '} + {selectedKeys === 'all' ? '(all)' : selectedKeys.size === 0 ? '(none)' : Array.from(selectedKeys).join(', ')} + + + ); +} diff --git a/packages/@godaddy/antares/components/listbox/examples/playground.tsx b/packages/@godaddy/antares/components/listbox/examples/playground.tsx new file mode 100644 index 000000000..180d82371 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/examples/playground.tsx @@ -0,0 +1,19 @@ +import { ListBox, ListBoxItem, type ListBoxProps } from '@godaddy/antares'; + +/** Props for the ListBox playground example. */ +export interface PlaygroundExampleProps extends Pick, 'aria-label' | 'selectionMode'> {} + +export function PlaygroundExample({ + 'aria-label': ariaLabel = 'Coffee', + selectionMode = 'single' +}: PlaygroundExampleProps) { + return ( + + Espresso + Latte + Cappuccino + Americano + Mocha + + ); +} diff --git a/packages/@godaddy/antares/components/listbox/listbox.stories.tsx b/packages/@godaddy/antares/components/listbox/listbox.stories.tsx new file mode 100644 index 000000000..831942f40 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/listbox.stories.tsx @@ -0,0 +1,31 @@ +'use client'; +import { PlaygroundExample, type PlaygroundExampleProps } from './examples/playground.tsx'; +import { getComponentDocs, getMeta, getStory } from '@bento/storybook-addon-helpers'; +import { ListBoxBasic } from './examples/basic'; +import { ListBoxControlledExample } from './examples/controlled'; +import { ListBoxMultipleExample } from './examples/multiple'; +import { ListBox } from './src/index.tsx'; + +export default getMeta({ + title: 'components/ListBox' +}); + +export const Props = getComponentDocs(ListBox); + +export const Playground = { + render: (args: PlaygroundExampleProps) => , + args: { + 'aria-label': 'Coffee', + selectionMode: 'single' + }, + argTypes: { + 'aria-label': { control: 'text' }, + selectionMode: { control: 'select', options: ['none', 'single', 'multiple'] } + } +}; + +export const Basic = getStory(ListBoxBasic); + +export const Controlled = getStory(ListBoxControlledExample); + +export const Multiple = getStory(ListBoxMultipleExample); diff --git a/packages/@godaddy/antares/components/listbox/src/index.module.css b/packages/@godaddy/antares/components/listbox/src/index.module.css new file mode 100644 index 000000000..ac8511aa7 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/src/index.module.css @@ -0,0 +1,26 @@ +.item { + background: var(--color-action-background-tertiary-default, var(--ux-1owc8nc, white)); + color: var(--color-action-text-tertiary-default, var(--ux-ut3xrx, royalblue)); + cursor: pointer; +} + +.item[data-selected] { + background: var(--color-action-background-tertiary-pressed, var(--ux-9qpf6c, gainsboro)); + color: var(--color-action-text-tertiary-pressed, var(--ux-h6e7c1, midnightblue)); +} + +.item[data-hovered] { + background: var(--color-action-background-tertiary-hovered, var(--ux-1m7qrkf, whitesmoke)); + color: var(--color-action-text-tertiary-hovered, var(--ux-unx9i2, teal)); +} + +.item[data-focused] { + outline: 2px solid Highlight; + outline-offset: -2px; +} + +.item[data-disabled], +.item:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/packages/@godaddy/antares/components/listbox/src/index.tsx b/packages/@godaddy/antares/components/listbox/src/index.tsx new file mode 100644 index 000000000..3d57f4a83 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/src/index.tsx @@ -0,0 +1,59 @@ +import { cx } from 'cva'; +import { + ListBox as RACListBox, + type ListBoxProps as RACListBoxProps, + ListBoxItem as RACListBoxItem, + type ListBoxItemProps as RACListBoxItemProps, + type Key +} from 'react-aria-components'; +import { Flex, type FlexOwnProps } from '#components/layout/flex'; +import styles from './index.module.css'; + +export type { Key }; + +export interface ListBoxProps extends RACListBoxProps, FlexOwnProps {} + +/** + * Antares ListBox. A selectable collection of items built on React Aria's ListBox, + * laid out as a vertical Flex column. Use directly for inline pickers, palettes, or + * non-popover lists; Select composes this internally. + * + * @typeParam T - Item type rendered inside the listbox. + * + * @example + * ```tsx + * + * Espresso + * Latte + * + * ``` + */ +export function ListBox(props: ListBoxProps) { + const { children, className, ...rest } = props; + return ( + } className={cx(styles.listbox, className)}> + {children} + + ); +} + +export interface ListBoxItemProps extends RACListBoxItemProps, FlexOwnProps {} + +/** + * One option inside a ListBox. Auto-derives `textValue` from string children when not + * provided so typeahead works without extra props. + */ +export function ListBoxItem(props: ListBoxItemProps) { + const { textValue, children, className, ...rest } = props; + return ( + + {children} + + ); +} diff --git a/packages/@godaddy/antares/components/listbox/test/__snapshots__/listbox.node.test.tsx.snap b/packages/@godaddy/antares/components/listbox/test/__snapshots__/listbox.node.test.tsx.snap new file mode 100644 index 000000000..d348ce030 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/test/__snapshots__/listbox.node.test.tsx.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`@godaddy/antares > #ListBox > #examples > renders basic example 1`] = `"
Espresso
Latte
Cappuccino
Americano
Mocha
"`; + +exports[`@godaddy/antares > #ListBox > #examples > renders controlled example 1`] = `"
Espresso
Latte
Cappuccino
Americano
Mocha
Value: latte"`; + +exports[`@godaddy/antares > #ListBox > #examples > renders multiple example 1`] = `"
Espresso
Latte
Cappuccino
Americano
Mocha
Selected: latte, mocha"`; diff --git a/packages/@godaddy/antares/components/listbox/test/listbox.browser.test.tsx b/packages/@godaddy/antares/components/listbox/test/listbox.browser.test.tsx new file mode 100644 index 000000000..ad485a7a3 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/test/listbox.browser.test.tsx @@ -0,0 +1,42 @@ +import { describe, it } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { page, userEvent } from 'vitest/browser'; +import assume from 'assume'; +import { ListBoxBasic } from '../examples/basic'; +import { ListBoxControlledExample } from '../examples/controlled'; +import { ListBoxMultipleExample } from '../examples/multiple'; + +describe('@godaddy/antares', function antares() { + describe('#ListBox', function listBoxSuite() { + it('renders the basic example with options', async function basicRender() { + await render(); + + const listbox = page.getByRole('listbox'); + assume(listbox).is.not.equal(null); + + const espresso = page.getByRole('option', { name: 'Espresso' }); + assume(espresso).is.not.equal(null); + }); + + it('updates the controlled selection on click', async function controlledInteraction() { + await render(); + + const espresso = page.getByRole('option', { name: 'Espresso' }); + await userEvent.setup().click(espresso); + + const selected = document.querySelectorAll('[role="option"][data-selected="true"]'); + assume(selected.length).equals(1); + assume(selected[0]?.textContent).contains('Espresso'); + }); + + it('toggles items in multiple selection', async function multipleInteraction() { + await render(); + + const espresso = page.getByRole('option', { name: 'Espresso' }); + await userEvent.setup().click(espresso); + + const selected = document.querySelectorAll('[role="option"][data-selected="true"]'); + assume(selected.length).greaterThan(0); + }); + }); +}); diff --git a/packages/@godaddy/antares/components/listbox/test/listbox.node.test.tsx b/packages/@godaddy/antares/components/listbox/test/listbox.node.test.tsx new file mode 100644 index 000000000..a4ee984b1 --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/test/listbox.node.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { ListBoxBasic } from '../examples/basic'; +import { ListBoxControlledExample } from '../examples/controlled'; +import { ListBoxMultipleExample } from '../examples/multiple'; + +describe('@godaddy/antares', function antares() { + describe('#ListBox', function listBox() { + describe('#examples', function examples() { + it('renders basic example', function basic() { + const result = renderToString(); + expect(result).toMatchSnapshot(); + }); + + it('renders controlled example', function controlled() { + const result = renderToString(); + expect(result).toMatchSnapshot(); + }); + + it('renders multiple example', function multiple() { + const result = renderToString(); + expect(result).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/packages/@godaddy/antares/components/popover/src/index.module.css b/packages/@godaddy/antares/components/popover/src/index.module.css index cbbbeb7aa..1227d0f5b 100644 --- a/packages/@godaddy/antares/components/popover/src/index.module.css +++ b/packages/@godaddy/antares/components/popover/src/index.module.css @@ -6,7 +6,6 @@ --_content-offset: calc(var(--_arrow-height) + var(--_arrow-rounded-tip)); --_arrow-offset: calc(var(--_arrow-height) * -1); - overflow: auto; transition: transform 0.2s, opacity 0.2s; @@ -79,3 +78,7 @@ rotate: -45deg; margin-right: var(--_arrow-offset); } + +.content { + overflow: auto; +} diff --git a/packages/@godaddy/antares/components/popover/src/index.tsx b/packages/@godaddy/antares/components/popover/src/index.tsx index 9246f3854..a2da1c2f5 100644 --- a/packages/@godaddy/antares/components/popover/src/index.tsx +++ b/packages/@godaddy/antares/components/popover/src/index.tsx @@ -2,20 +2,23 @@ import { forwardRef, type RefObject, type ReactNode } from 'react'; import { cx } from 'cva'; import { Dialog as RACDialog, + type DialogProps as RACDialogProps, Popover as RACPopover, type PopoverProps as RACPopoverProps, DialogTrigger as RACDialogTrigger, type DialogTriggerProps as RACDialogTriggerProps, OverlayArrow as RACOverlayArrow } from 'react-aria-components'; -import { Flex, type FlexProps } from '#components/layout/flex'; +import { Flex, type FlexOwnProps } from '#components/layout/flex'; import { Button } from '#components/button'; import { Icon } from '#components/icon'; import styles from './index.module.css'; +interface ContentProps extends RACDialogProps, FlexOwnProps {} + export interface PopoverTriggerProps extends RACDialogTriggerProps {} -export interface PopoverProps extends RACPopoverProps { +export interface PopoverProps extends RACPopoverProps, FlexOwnProps { /** The content to display inside the popover. */ children?: ReactNode; @@ -23,7 +26,7 @@ export interface PopoverProps extends RACPopoverProps { showCloseButton?: boolean; /** Props to pass to the content container. */ - contentProps?: Omit; + contentProps?: ContentProps; /** The placement of the popover relative to the trigger. */ placement?: RACPopoverProps['placement']; @@ -50,18 +53,23 @@ export const Popover = forwardRef(function Popover(pr const { className, children, header, showCloseButton, hideArrow, contentProps, ...rest } = props; return ( - + + {hideArrow ? null :