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 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/package-lock.json b/package-lock.json index 91b3e5b9e..2d5b583cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24974,6 +24974,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/components/calendar/README.mdx b/packages/@godaddy/antares/components/calendar/README.mdx new file mode 100644 index 000000000..9edfd2799 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/README.mdx @@ -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'; + + + +## 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 `` / `` 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 + + + +### RangeCalendar + + + +## Examples + +### Default + +A single calendar with one month visible. + + + + +### 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. + + + + +### Min and max + +`minValue` and `maxValue` disable out-of-range days and clamp the year `Select`. + + + + +### Unavailable dates + +Use `isDateUnavailable` to disable specific dates such as weekends or holidays. + + + + +### Today inside a range + +The "today" visual state coexists with `selected` and `in-range`. + + + 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..32da5e53c --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/calendar.stories.tsx @@ -0,0 +1,25 @@ +'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, RangeCalendar } from './src/index.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); 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/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-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..318d30707 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/calendar-header.tsx @@ -0,0 +1,117 @@ +import type { CalendarDate } from '@internationalized/date'; +import { useContext } from 'react'; +import { + CalendarMonthPicker as RACCalendarMonthPicker, + CalendarStateContext, + RangeCalendarStateContext, + useLocale +} from 'react-aria-components'; +import { Button } from '#components/button'; +import { Flex } from '#components/layout/flex'; +import { Icon } from '#components/icon'; +import { NumberField } from '#components/number-field'; +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 `` / + * `` 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 = ( + + {showPrev && ( + + )} + + {function renderMonthPicker(picker) { + return ( + + ); + }} + + + {showNext && ( + + )} + + ); + + if (calendarState) { + return ( + + {headerContent} + + ); + } + return ( + + {headerContent} + + ); +} 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..7cdb62824 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/index.module.css @@ -0,0 +1,85 @@ +.calendar { + --calendar-cell-size: 2.25rem; + --calendar-cell-radius: 0.25rem; + display: inline-block; +} + +.headerSelect { + flex: 1; +} + +.headerYear { + flex: 0 0 6ch; +} + +.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; + font-variant-numeric: tabular-nums; + + &[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); + } +} 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..d9f8c4fd7 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/src/index.tsx @@ -0,0 +1,90 @@ +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/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..a688c2260 --- /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..f3335e03c --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/test/calendar.browser.test.tsx @@ -0,0 +1,182 @@ +import assume from 'assume'; +import { beforeAll, describe, it } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { page, userEvent } from 'vitest/browser'; +import { set } from '#components/icon'; +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'; + +describe('@godaddy/antares', function antares() { + beforeAll(function setupIcons() { + set({ + 'chevron-down': ( + + + + ), + 'chevron-left': ( + + + + ), + 'chevron-right': ( + + + + ), + 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 input changes', async function changesYear() { + const { container } = await render(); + const yearInput = page.getByRole('textbox', { name: /year/i }); + + await userEvent.tripleClick(yearInput); + await userEvent.keyboard('2025'); + await userEvent.keyboard('{Enter}'); + + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const yearEl = container.querySelector('input[aria-label="Year"]') as HTMLInputElement | null; + assume(yearEl?.value ?? '').contains('2025'); + }); + + it('clamps the Year input to the configured min/max range', async function limitedYears() { + const { container } = await render(); + const yearInput = page.getByRole('textbox', { name: /year/i }); + + await userEvent.tripleClick(yearInput); + await userEvent.keyboard('2099'); + await userEvent.keyboard('{Enter}'); + + await new Promise(function wait(resolve) { + setTimeout(resolve, 100); + }); + + const yearEl = container.querySelector('input[aria-label="Year"]') as HTMLInputElement | null; + assume(yearEl?.value ?? '').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('#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('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); + const someHaveContent = Array.from(outsideMonth).some(function hasContent(cell) { + return (cell.textContent ?? '').trim().length > 0; + }); + 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[1]?.textContent ?? '').contains('April'); + }); + }); + + 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); + }); + }); + }); +}); 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..d3b8b2f72 --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/test/calendar.node.test.tsx @@ -0,0 +1,38 @@ +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 { CalendarTodayDistinctExample } from '../examples/today-distinct.tsx'; +import { CalendarWithMinMaxExample } from '../examples/with-min-max.tsx'; +import { CalendarWithUnavailableDatesExample } from '../examples/with-unavailable-dates.tsx'; + +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 today-distinct example', function todayDistinct() { + const result = renderToString(); + expect(result).toContain('data-today'); + }); + }); + }); +}); 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..2a88242fd --- /dev/null +++ b/packages/@godaddy/antares/components/calendar/test/calendar.visual.test.tsx @@ -0,0 +1,44 @@ +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 { CalendarTodayDistinctExample } from '../examples/today-distinct.tsx'; + +describe('@godaddy/antares', function antares() { + beforeAll(function setupIcons() { + set({ + 'chevron-down': ( + + + + ), + 'chevron-left': ( + + + + ), + 'chevron-right': ( + + + + ), + alert: ( + + + + ) + }); + }); + + describe('#Calendar', function calendarTests() { + 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'); + }); + }); +}); 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..eb2a2fe1f --- /dev/null +++ b/packages/@godaddy/antares/components/listbox/examples/basic.tsx @@ -0,0 +1,13 @@ +import { ListBox, ListBoxItem } from '@godaddy/antares'; + +export function ListBoxBasic() { + 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 55b69a45f..1227d0f5b 100644 --- a/packages/@godaddy/antares/components/popover/src/index.module.css +++ b/packages/@godaddy/antares/components/popover/src/index.module.css @@ -78,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 :