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 ( + + {picker.items.map(function renderMonthItem(item) { + return ( + + {item.formatted} + + ); + })} + + ); + }} + + + {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 2024February JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecemberSMTWTFS28293031123456789101112131415161718192021222324252627282912"`; 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`] = `"EspressoLatteCappuccinoAmericanoMocha"`; + +exports[`@godaddy/antares > #ListBox > #examples > renders controlled example 1`] = `"EspressoLatteCappuccinoAmericanoMochaValue: latte"`; + +exports[`@godaddy/antares > #ListBox > #examples > renders multiple example 1`] = `"EspressoLatteCappuccinoAmericanoMochaSelected: 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 : } - {hideArrow ? null : } - {showCloseButton ? ( {header} 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 - - - Espresso - - - Featured - Special Blend - - -``` - -## 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 - - Espresso - + + -// ✅ Controlled mode - - Espresso - +### Form -// ✅ Uncontrolled mode - - Espresso - -``` - -### 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 ( + <> + + Espresso + Latte + Cappuccino + Americano + Mocha + + + 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 ( + + + Espresso + Latte + Cappuccino + + + Oat milk + Extra shot + Vanilla syrup + + + Submit + setSubmitted(null)}> + Reset + + + {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 ( + + Espresso + Latte + Cappuccino + + ); +} 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 ( + <> + + Espresso + Latte + Cappuccino + Americano + Mocha + + + 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 ( - - setSelectedKey(key as string)} - > - Espresso - Latte - Cappuccino - Americano - Mocha - - - - 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 ( - - {function renderItem(item) { - return ( - - {item.name} ({item.category}) - - ); - }} - - ); -} 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 ( - - - Item 1 - Item 2 - - - - Item 1 - Item 2 - - - ); -} 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 ( - - - {function renderChildren(renderProps) { - return {renderProps.isSelected ? 'Selected' : 'Not selected'}; - }} - - Regular item - - JSX Element Child - - - ); -} 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 ( - - - Fruits - Apple - Banana - - - Vegetables - Carrot - Lettuce - - - ); -} 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 ( - - - Item 1 - Item 2 - - - - Item 1 - Item 2 - - - ); -} 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 ( - - - Item 1 - Item 2 - - - - Item 1 - Item 2 - - - ); -} 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..619524112 100644 --- a/packages/@godaddy/antares/components/select/src/index.module.css +++ b/packages/@godaddy/antares/components/select/src/index.module.css @@ -1,231 +1,8 @@ -.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); -} - -.label--required::after { - content: " *"; - color: var(--critical); -} - .button { - display: flex; - align-items: center; - justify-content: space-between; - 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); - 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]) { - background: color-mix(in oklch, var(--bg-base), black 4%); -} - -.button[data-pressed] { - background: color-mix(in oklch, var(--bg-base), black 8%); -} - -.button[data-disabled] { - opacity: 0.4; - cursor: not-allowed; + flex: 1; } .value { flex: 1; - text-align: left; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.value[data-placeholder] { - color: var(--passive); -} - -.chevron { - width: 1em; - height: 1em; - color: var(--fg-base); - transition: transform 0.2s ease-in-out; - flex-shrink: 0; -} - -.button[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); - border-radius: var(--br-sm); - min-width: var(--trigger-width); - filter: var(--sh-overlay); - padding: var(--sp-xs); -} - -.popover[data-entering] { - animation: slideIn 0.2s ease-out; -} - -.popover[data-exiting] { - animation: slideOut 0.15s ease-in; -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes slideOut { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(-8px); - } -} - -.listbox { - outline: none; -} - -.item { - display: flex; - align-items: center; - border-radius: var(--br-sm); - padding: var(--sp-xs) var(--sp-sm); - font-size: var(--fs-sm); - color: var(--fg-base); - background-color: var(--bg-base); - cursor: pointer; - outline: none; -} - -.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] { - background-color: var(--bg-selected); - color: var(--fg-selected); - font-weight: bolder; -} - -.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; + text-align: start; } diff --git a/packages/@godaddy/antares/components/select/src/index.tsx b/packages/@godaddy/antares/components/select/src/index.tsx index dc97c1b8a..f1b15762f 100644 --- a/packages/@godaddy/antares/components/select/src/index.tsx +++ b/packages/@godaddy/antares/components/select/src/index.tsx @@ -1,241 +1,73 @@ 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, Select as RACSelect, SelectValue as RACSelectValue, - type Key as RACKey, - type ListBoxItemRenderProps as RACListBoxItemRenderProps, - 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 { Button } from '#components/button'; +import { FieldFrame, type FieldFrameProps } from '#components/_internal/field-frame'; import { Icon } from '#components/icon'; +import { Popover } from '#components/popover'; +import { ListBox, ListBoxItem, type ListBoxItemProps } from '#components/listbox'; import styles from './index.module.css'; -import { cx } from 'cva'; -/** - * Common props shared between label variants, used internally for the discriminated union - * - * @typeParam T - Item type in the select list - */ -interface SelectBaseProps { - /** Focus on mount */ - autoFocus?: boolean; - - /** SelectItem elements or render function */ - children: ReactNode | ((item: T) => ReactNode); - - /** Component CSS class */ - className?: string; - - /** Default open state (uncontrolled) */ - defaultOpen?: boolean; - - /** Initial selected key (uncontrolled) */ - defaultSelectedKey?: RACKey; - - /** Help text below the trigger */ - description?: string; - - /** Error text shown when invalid */ - 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'; -} +type SelectionMode = 'single' | 'multiple'; /** - * Props for the Select component - * - * @typeParam T - Item type in the select list + * Props for the Select component. Wraps React Aria's Select with antares' FieldFrame + * for label, description, and error message rendering. * - * @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 - */ - label?: string; - - /** - * Accessible label for screen readers - * Required if label is not provided - */ - 'aria-label'?: string; -} - -/** - * Antares Select component * - * @param props - {@link SelectProps} + * @typeParam T - Item type rendered inside the listbox. + * @typeParam M - Selection mode. Drives the type of `value`, `defaultValue`, and `onChange`. */ -export function Select({ - label, - description, - errorMessage, - children, - items, - className, - size = 'md', - labelStyle = 'default', - ...props -}: SelectProps) { - return ( - - {label && ( - {label} - )} - - - - - {description && ( - - {description} - - )} - {errorMessage && ( - - - - {errorMessage} - - - )} - - - {children} - - - - ); -} +export interface SelectProps + extends RACSelectProps, + Pick {} /** - * SelectItem for individual options, supports render props for conditional checkbox display + * 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 RACListBoxItemProps} + * @example + * ```tsx + * + * Espresso + * Latte + * + * ``` */ -export function SelectItem(props: RACListBoxItemProps) { - if (typeof props.children === 'function') { - return ( - - {props.children} - - ); - } +export function Select(props: SelectProps) { + const { label, description, errorMessage, children, className, ...racProps } = props; + const { isDisabled, isRequired } = racProps; return ( - - {function renderItem(renderProps: RACListBoxItemRenderProps) { - return ( - <> - {renderProps.selectionMode === 'multiple' && ( - - {renderProps.isSelected ? '☑' : '☐'} - - )} - {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 {} +export type SelectItemProps = ListBoxItemProps; /** - * SelectHeader for section titles - * - * @param props - {@link SelectHeaderProps} + * One option inside a Select. Thin wrapper over `ListBoxItem` so consumers can + * read `` cohesively. */ -export function SelectHeader(props: SelectHeaderProps) { - return ; +export function SelectItem(props: SelectItemProps) { + return ; } - -export type { RACListBoxItemProps as SelectItemProps, RACListBoxSectionProps as SelectSectionProps }; 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..7aaced4f1 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`] = `"Latte EspressoLatteCappuccinoAmericanoMochaSelected: latte"`; +exports[`@godaddy/antares > #Select > #examples > renders basic example 1`] = `"CoffeePick a drink EspressoLatteCappuccinoAmericanoMocha"`; -exports[`@godaddy/antares > #examples > renders SelectDynamicExample with items prop 1`] = `"Select an item EspressoLatteCappuccinoAmericanoMochaMacchiatoCold Brew"`; +exports[`@godaddy/antares > #Select > #examples > renders controlled example 1`] = `"CoffeeLatte EspressoLatteCappuccinoAmericanoMochaValue: latte"`; -exports[`@godaddy/antares > #examples > renders SelectLabelStylesExample 1`] = `"Default labelSelect an item Item 1Item 2Floating labelSelect an item Item 1Item 2"`; +exports[`@godaddy/antares > #Select > #examples > renders disabled example 1`] = `"CoffeeLatte EspressoLatteCappuccino"`; -exports[`@godaddy/antares > #examples > renders SelectMultipleExample 1`] = `"Select an item EspressoLatteCappuccinoAmericanoMocha"`; +exports[`@godaddy/antares > #Select > #examples > renders form example 1`] = `"Drink *Pick a drink EspressoLatteCappuccinoExtrasPick any extras Oat milkExtra shotVanilla syrupSubmitReset"`; -exports[`@godaddy/antares > #examples > renders SelectRenderPropsExample 1`] = `"Select an item Item with render functionRegular item"`; +exports[`@godaddy/antares > #Select > #examples > renders invalid example 1`] = `"Coffee *Pick a drinkPlease choose a drink EspressoLatteCappuccino"`; -exports[`@godaddy/antares > #examples > renders SelectSectionsExample 1`] = `"Select an item AppleBananaCarrotLettuce"`; - -exports[`@godaddy/antares > #examples > renders SelectSizesExample 1`] = `"Select an item Item 1Item 2Select an item Item 1Item 2"`; - -exports[`@godaddy/antares > #examples > renders SelectStaticExample 1`] = `"Select an item EspressoLatteCappuccinoAmericanoMocha"`; - -exports[`@godaddy/antares > #examples > renders SelectValidationExample 1`] = `"This field is requiredRequired fieldSelect an itemThis field is required Item 1Item 2Invalid fieldSelect an itemPlease select a valid option Item 1Item 2"`; +exports[`@godaddy/antares > #Select > #examples > renders multiple example 1`] = `"Coffees you likeLatte and Mocha EspressoLatteCappuccinoAmericanoMochaSelected: 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/Calendar.ts b/packages/@godaddy/antares/exports/Calendar.ts new file mode 100644 index 000000000..988870fb5 --- /dev/null +++ b/packages/@godaddy/antares/exports/Calendar.ts @@ -0,0 +1 @@ +export { Calendar, RangeCalendar, type CalendarProps, type RangeCalendarProps } from '#components/calendar'; diff --git a/packages/@godaddy/antares/exports/ListBox.ts b/packages/@godaddy/antares/exports/ListBox.ts new file mode 100644 index 000000000..01db87444 --- /dev/null +++ b/packages/@godaddy/antares/exports/ListBox.ts @@ -0,0 +1 @@ +export { ListBox, ListBoxItem, type ListBoxProps, type ListBoxItemProps, type Key } from '#components/listbox'; diff --git a/packages/@godaddy/antares/exports/Select.ts b/packages/@godaddy/antares/exports/Select.ts index b452d441e..2cdb76872 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 } from '#components/select'; diff --git a/packages/@godaddy/antares/index.ts b/packages/@godaddy/antares/index.ts index e5f084c7d..a5a60a1a1 100644 --- a/packages/@godaddy/antares/index.ts +++ b/packages/@godaddy/antares/index.ts @@ -15,16 +15,9 @@ 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 { ListBox, ListBoxItem, type ListBoxProps, type ListBoxItemProps, type Key } from '#components/listbox'; + +export { Select, SelectItem, type SelectProps, type SelectItemProps } from '#components/select'; export { Menu, @@ -61,6 +54,8 @@ export { TextField, type TextFieldProps } from '#components/text-field'; export { NumberField, type NumberFieldProps } from '#components/number-field'; +export { Calendar, RangeCalendar, type CalendarProps, type RangeCalendarProps } from '#components/calendar'; + export { Carousel, type CarouselProps, type CarouselRef } from '#components/carousel'; export { Pagination, type PaginationProps } from '#components/pagination'; diff --git a/packages/@godaddy/antares/package.json b/packages/@godaddy/antares/package.json index 37e05e1da..23a240e4a 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",