Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calendar-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@godaddy/antares': minor
---

feat(antares): add Calendar and RangeCalendar components
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 83 additions & 0 deletions packages/@godaddy/antares/components/calendar/README.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
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 SourceTodayDistinct from './examples/today-distinct.tsx?raw';

<Meta of={Stories} name="Overview" />

## Features

- **Single and range**: `Calendar` for a single date, `RangeCalendar` for a contiguous range
- **Built-in Month + Year pickers**: prev/next arrows alongside React Aria's `<CalendarMonthPicker>` / `<CalendarYearPicker>` 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

```bash
npm install --save @godaddy/antares
```

## Working with dates

Both components are typed for `CalendarDate` from `@internationalized/date`. See the
`DateField` component docs for installation, locale handling, and common patterns.

## Props

### Calendar

<ArgTypes of={Stories.Props} />

### RangeCalendar

<ArgTypes of={Stories.RangeProps} />

## Examples

### Default

A single calendar with one month visible.

<Source language="tsx" code={SourceDefault} />
<Story of={Stories.Default} inline />

### Default range

`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.

<Source language="tsx" code={SourceDefaultRange} />
<Story of={Stories.DefaultRange} inline />

### Min and max

`minValue` and `maxValue` disable out-of-range days and clamp the year `Select`.

<Source language="tsx" code={SourceWithMinMax} />
<Story of={Stories.WithMinMax} inline />

### Unavailable dates

Use `isDateUnavailable` to disable specific dates such as weekends or holidays.

<Source language="tsx" code={SourceWithUnavailableDates} />
<Story of={Stories.WithUnavailableDates} inline />

### Today inside a range

The "today" visual state coexists with `selected` and `in-range`.

<Source language="tsx" code={SourceTodayDistinct} />
<Story of={Stories.TodayDistinct} inline />
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'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 { 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';

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 TodayDistinct = getStory(CalendarTodayDistinctExample);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { parseDate } from '@internationalized/date';
import { RangeCalendar } from '@godaddy/antares';

export function CalendarDefaultRangeExample() {
return (
<RangeCalendar
aria-label="Booking range"
defaultValue={{
start: parseDate('2024-03-05'),
end: parseDate('2024-03-12')
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { parseDate } from '@internationalized/date';
import { Calendar } from '@godaddy/antares';

export function CalendarDefaultExample() {
return <Calendar aria-label="Date" defaultValue={parseDate('2024-03-15')} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getLocalTimeZone, today } from '@internationalized/date';
import { RangeCalendar } from '@godaddy/antares';

export function CalendarTodayDistinctExample() {
const now = today(getLocalTimeZone());
return (
<RangeCalendar
aria-label="Today inside selected range"
defaultValue={{ start: now.subtract({ days: 3 }), end: now.add({ days: 3 }) }}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { parseDate } from '@internationalized/date';
import { Calendar } from '@godaddy/antares';

export function CalendarWithMinMaxExample() {
return (
<Calendar
aria-label="Q1 2024"
defaultValue={parseDate('2024-02-15')}
minValue={parseDate('2024-01-01')}
maxValue={parseDate('2024-03-31')}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Calendar
aria-label="Weekday only"
defaultValue={parseDate('2024-03-13')}
isDateUnavailable={isWeekend}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { CalendarDate } from '@internationalized/date';
import { useContext } from 'react';
import {
CalendarMonthPicker as RACCalendarMonthPicker,
CalendarStateContext,
CalendarYearPicker as RACCalendarYearPicker,
RangeCalendarStateContext,
useLocale
} 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';
import styles from './index.module.css';

interface CalendarHeaderProps {
/**
* `'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';
}

/**
* Header with prev/next + Month/Year selects, powered by RAC's `<CalendarMonthPicker>` /
* `<CalendarYearPicker>` 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 `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({ range }: CalendarHeaderProps) {
const calendarState = useContext(CalendarStateContext);
const rangeState = useContext(RangeCalendarStateContext);
const baseState = calendarState ?? rangeState;
const { direction } = useLocale();
const monthOffset = range === 'end' ? 1 : 0;

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 = range !== 'end';
const showNext = range !== 'start';
const isRtl = direction === 'rtl';

const headerContent = (
<Flex gap="sm" alignItems="center" className={styles.header}>
{showPrev && (
<Button slot="previous" variant="minimal" size="sm" aria-label="Previous">
<Icon icon={isRtl ? 'chevron-right' : 'chevron-left'} />
</Button>
)}
<RACCalendarMonthPicker format="long">
{function renderMonthPicker(picker) {
return (
<Select
aria-label={picker['aria-label']}
selectedKey={picker.value}
onSelectionChange={picker.onChange}
className={styles.headerSelect}
>
{picker.items.map(function renderMonthItem(item) {
return (
<SelectItem key={item.id} id={item.id} textValue={item.formatted}>
{item.formatted}
</SelectItem>
);
})}
</Select>
);
}}
</RACCalendarMonthPicker>
<RACCalendarYearPicker>
{function renderYearPicker(picker) {
return (
<Select
aria-label={picker['aria-label']}
selectedKey={picker.value}
onSelectionChange={picker.onChange}
className={styles.headerSelect}
>
{picker.items.map(function renderYearItem(item) {
return (
<SelectItem key={item.id} id={item.id} textValue={item.formatted}>
{item.formatted}
</SelectItem>
);
})}
</Select>
);
}}
</RACCalendarYearPicker>
{showNext && (
<Button slot="next" variant="minimal" size="sm" aria-label="Next">
<Icon icon={isRtl ? 'chevron-left' : 'chevron-right'} />
</Button>
)}
</Flex>
);

if (calendarState) {
return (
<CalendarStateContext.Provider value={pickerState as typeof calendarState}>
{headerContent}
</CalendarStateContext.Provider>
);
}
return (
<RangeCalendarStateContext.Provider value={pickerState as typeof rangeState}>
{headerContent}
</RangeCalendarStateContext.Provider>
);
}
40 changes: 40 additions & 0 deletions packages/@godaddy/antares/components/calendar/src/calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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<RACCalendarProps<CalendarDate>, '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';
*
* <Calendar defaultValue={parseDate('2024-03-15')} />
* ```
*/
export function Calendar(props: CalendarProps) {
return (
<RACCalendar<CalendarDate> {...props} visibleDuration={{ months: 1 }} className={styles.calendar}>
<Flex direction="column" gap="md" padding="md">
<CalendarHeader />
<RACCalendarGrid className={styles.grid}>
{function renderCell(date) {
return <RACCalendarCell date={date} className={styles.cell} />;
}}
</RACCalendarGrid>
</Flex>
</RACCalendar>
);
}
Loading
Loading