diff --git a/docs/manifest.json b/docs/manifest.json index 8d8c3deaae00f5..b76537553fbe61 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1517,18 +1517,6 @@ "markdown_source": "../packages/components/src/button/README.md", "parent": "components" }, - { - "title": "DateCalendar", - "slug": "date-calendar", - "markdown_source": "../packages/components/src/calendar/date-calendar/README.md", - "parent": "components" - }, - { - "title": "DateRangeCalendar", - "slug": "date-range-calendar", - "markdown_source": "../packages/components/src/calendar/date-range-calendar/README.md", - "parent": "components" - }, { "title": "CardBody", "slug": "card-body", diff --git a/package-lock.json b/package-lock.json index 0d0c4dcf5048af..f014432e75f326 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50947,7 +50947,6 @@ "path-to-regexp": "^6.2.1", "re-resizable": "^6.4.0", "react-colorful": "^5.6.1", - "react-day-picker": "^9.14.0", "remove-accents": "^0.5.0", "uuid": "^14.0.0" }, @@ -54278,6 +54277,8 @@ "@wordpress/style-runtime": "file:../style-runtime", "@wordpress/theme": "file:../theme", "clsx": "^2.1.1", + "date-fns": "^4.4.0", + "react-day-picker": "^9.14.0", "tabbable": "^6.4.0" }, "devDependencies": { diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index bdd73f22921ae0..b9858404d0fa0f 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -7,6 +7,7 @@ - Components that compose Emotion style fragments with `cx()` should pass source-order-dependent fragments in a single `css()` call. Passing separate fragments can change override order after the following components stopped rendering styles through Emotion: - `ItemGroup` ([#80797](https://github.com/WordPress/gutenberg/pull/80797)) - `Spinner` ([#80511](https://github.com/WordPress/gutenberg/pull/80511)) +- Remove the private `DateCalendar`, `DateRangeCalendar` and `TZDate` exports. The calendars now ship as the public `Calendar` and `RangeCalendar` components in `@wordpress/ui`; `TZDate` comes from [`@date-fns/tz`](https://www.npmjs.com/package/@date-fns/tz) ([#81337](https://github.com/WordPress/gutenberg/pull/81337)). ### Deprecations diff --git a/packages/components/package.json b/packages/components/package.json index 3f11e59e10687c..839e7af54a10d5 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -98,7 +98,6 @@ "path-to-regexp": "^6.2.1", "re-resizable": "^6.4.0", "react-colorful": "^5.6.1", - "react-day-picker": "^9.14.0", "remove-accents": "^0.5.0", "uuid": "^14.0.0" }, diff --git a/packages/components/src/calendar/date-calendar/README.md b/packages/components/src/calendar/date-calendar/README.md deleted file mode 100644 index a62ea3e49accff..00000000000000 --- a/packages/components/src/calendar/date-calendar/README.md +++ /dev/null @@ -1,261 +0,0 @@ -# `DateCalendar` - -🔒 This component is locked as a [private API](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-private-apis/). We do not yet recommend using this outside of the Gutenberg project. - -`DateCalendar` is a React component that provides a customizable calendar interface for **single date** selection. - -The component is built with accessibility in mind and follows ARIA best practices for calendar widgets. It provides keyboard navigation, screen reader support, and customizable labels for internationalization. - -## Usage example - -```tsx -import { DateCalendar } from '@wordpress/components'; - -function MyComponent() { - const [ selected, setSelected ] = useState< Date >( new Date() ); - - return ; -} -``` - -## Props - -These props are shared between both single date and date range calendar modes. - -### `required` - -- Type: `boolean` -- Required: No -- Default: `false` - -Whether the selection is required. When `true`, there always needs to be a date selected. - -### `selected` - -- Type: `Date | undefined | null` -- Required: No - -The selected date. - -### `onSelect` - -- Type: `(selected: Date | undefined, triggerDate: Date, modifiers: Modifiers, e: React.MouseEvent | React.KeyboardEvent) => void` -- Required: No - -Event handler when a day is selected. - -### `defaultSelected` - -- Type: `Date` -- Required: No - -The default selected date (for uncontrolled usage). - -### `defaultMonth` - -- Type: `Date` -- Required: No -- Default: Current month - -The initial month to show in the calendar view (uncontrolled). - -### `month` - -- Type: `Date` -- Required: No - -The month displayed in the calendar view (controlled). Use together with `onMonthChange` to change the month programmatically. - -### `numberOfMonths` - -- Type: `number` -- Required: No -- Default: `1` - -The number of months displayed at once. - -### `startMonth` - -- Type: `Date` -- Required: No - -The earliest month to start the month navigation. - -### `endMonth` - -- Type: `Date` -- Required: No - -The latest month to end the month navigation. - -### `autoFocus` - -- Type: `boolean` -- Required: No - -Focus the first selected day (if set) or today's date (if not disabled). Use this prop when you need to focus the calendar after a user action (e.g. opening the dialog with the calendar). - -### `disabled` - -- Type: `Matcher | Matcher[] | undefined` -- Required: No - -Specify which days are disabled. Using `true` will disable all dates. See the [Matcher Types](#matcher-types) section for more details. - -### `disableNavigation` - -- Type: `boolean` -- Required: No - -Disable the navigation buttons. - -### `labels` - -- Type: `object` -- Required: No - -Use custom labels for internationalization. All labels are optional and have sensible defaults: - -```typescript -{ - labelNav?: () => string; // Navigation toolbar label - labelGrid?: (date: Date) => string; // Month grid label (default: "LLLL y") - labelGridcell?: (date: Date, modifiers?: Modifiers) => string; // Grid cell label - labelNext?: (month: Date | undefined) => string; // Next month button label - labelPrevious?: (month: Date | undefined) => string; // Previous month button label - labelDayButton?: (date: Date, modifiers?: Modifiers) => string; // Day button label - labelWeekday?: (date: Date) => string; // Weekday label -} -``` - -**Important: For a correct localized experience, consumers should make sure the locale used for the translated labels and `locale` prop are consistent.** - -### `locale` - -- Type: `Locale` -- Required: No -- Default: `enUS` from `@date-fns/locale` - -The locale object used to localize dates. Pass a locale from `@date-fns/locale` to localize the calendar. - -**Important: For a correct localized experience, consumers should make sure the locale used for the translated labels and `locale` prop are consistent.** - -### `weekStartsOn` - -- Type: `0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined` -- Required: No -- Default: Based on the `locale` prop - -The index of the first day of the week (0 - Sunday). Overrides the locale's setting. - -### `onMonthChange` - -- Type: `(month: Date) => void` -- Required: No - -Event fired when the user navigates between months. - -### `timeZone` - -- Type: `string` -- Required: No - -The time zone (IANA or UTC offset) to use in the calendar. See [Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for possible values. - -When working with time zones, use the `TZDate` object exported by this package instead of the native `Date` object. - -```tsx -import { DateCalendar, TZDate } from '@wordpress/components'; - -export function WithTimeZone() { - const timeZone = 'America/New_York'; - const [ selected, setSelected ] = useState< Date | undefined >( - new TZDate( 2024, 12, 10, timeZone ) // Use `TZDate` instead of `Date` - ); - return ( - - ); -} -``` - -### `role` - -- Type: `'application' | 'dialog' | undefined` -- Required: No -- Default: `'application'` - -The role attribute to add to the container element. - -## Matcher Types - -The calendar component uses a flexible matching system to determine which days should be disabled or have specific modifiers. Here are the available matcher types: - -### Boolean Matcher - -```typescript -const booleanMatcher: Matcher = true; // Will always match the day -``` - -### Date Matcher - -```typescript -const dateMatcher: Matcher = new Date(); // Will match today's date -``` - -### Array Matcher - -```typescript -const arrayMatcher: Matcher = [ - new Date( 2019, 1, 2 ), - new Date( 2019, 1, 4 ), -]; // Will match the days in the array -``` - -### Date After Matcher - -```typescript -const afterMatcher: DateAfter = { after: new Date( 2019, 1, 2 ) }; // Will match days after the 2nd of February 2019 -``` - -### Date Before Matcher - -```typescript -const beforeMatcher: DateBefore = { before: new Date( 2019, 1, 2 ) }; // Will match days before the 2nd of February 2019 -``` - -### Date Interval Matcher - -```typescript -const intervalMatcher: DateInterval = { - after: new Date( 2019, 1, 2 ), - before: new Date( 2019, 1, 5 ), -}; // Will match the days between the 2nd and the 5th of February 2019 (exclusive) -``` - -### Date Range Matcher - -```typescript -const rangeMatcher: DateRange = { - from: new Date( 2019, 1, 2 ), - to: new Date( 2019, 1, 5 ), -}; // Will match the days between the 2nd and the 5th of February 2019 (inclusive) -``` - -### Day of Week Matcher - -```typescript -const dayOfWeekMatcher: DayOfWeek = { dayOfWeek: 0 }; // Will match Sundays -const weekendMatcher: DayOfWeek = { dayOfWeek: [ 0, 6 ] }; // Will match weekends -``` - -### Function Matcher - -```typescript -const functionMatcher: Matcher = ( day: Date ) => { - return day.getMonth() === 2; // Will match when month is March -}; -``` diff --git a/packages/components/src/calendar/date-calendar/index.tsx b/packages/components/src/calendar/date-calendar/index.tsx deleted file mode 100644 index 49372de8de50b6..00000000000000 --- a/packages/components/src/calendar/date-calendar/index.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { DayPicker } from 'react-day-picker'; -import { enUS } from 'react-day-picker/locale'; -import { useCallback } from '@wordpress/element'; -import { COMMON_PROPS } from '../utils/constants'; -import { clampNumberOfMonths } from '../utils/misc'; -import { useControlledValue } from '../../utils/hooks'; -import { useLocalizationProps } from '../utils/use-localization-props'; -import type { DateCalendarProps, OnSelectHandler } from '../types'; - -/** - * `DateCalendar` is a React component that provides a customizable calendar - * interface for **single date** selection. - * - * The component is built with accessibility in mind and follows ARIA best - * practices for calendar widgets. It provides keyboard navigation, screen reader - * support, and customizable labels for internationalization. - */ -export const DateCalendar = ( { - defaultSelected, - selected: selectedProp, - onSelect, - numberOfMonths = 1, - locale = enUS, - timeZone, - ...props -}: DateCalendarProps ) => { - const localizationProps = useLocalizationProps( { - locale, - timeZone, - mode: 'single', - } ); - - const onChange: OnSelectHandler< typeof selectedProp > = useCallback( - ( selected, triggerDate, modifiers, e ) => { - // Convert internal `null` to `undefined` for the public event handler. - onSelect?.( selected ?? undefined, triggerDate, modifiers, e ); - }, - [ onSelect ] - ); - - const [ selected, setSelected ] = useControlledValue< typeof selectedProp >( - { - defaultValue: defaultSelected, - value: selectedProp, - onChange, - } - ); - - return ( - - ); -}; diff --git a/packages/components/src/calendar/date-range-calendar/README.md b/packages/components/src/calendar/date-range-calendar/README.md deleted file mode 100644 index 3bfc9560a1c870..00000000000000 --- a/packages/components/src/calendar/date-range-calendar/README.md +++ /dev/null @@ -1,298 +0,0 @@ -# `DateRangeCalendar` - -🔒 This component is locked as a [private API](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-private-apis/). We do not yet recommend using this outside of the Gutenberg project. - -`DateRangeCalendar` is a React component that provides a customizable calendar interface for **date range** selection. - -The component is built with accessibility in mind and follows ARIA best practices for calendar widgets. It provides keyboard navigation, screen reader support, and customizable labels for internationalization. - -## Usage example - -```tsx -import { DateRangeCalendar } from '@wordpress/components'; - -type DateRange = { - from: Date | undefined; - to?: Date | undefined; -}; - -function MyComponent() { - const [ selected, setSelected ] = useState< DateRange >( { - from: new Date( date.getFullYear(), date.getMonth(), 1 ), - to: new Date(), - } ); - - return ; -} -``` - -## Props - -These props are shared between both single date and date range calendar modes. - -### `required` - -- Type: `boolean` -- Required: No -- Default: `false` - -Whether the selection is required. When `true`, there always needs to be a date selected. - -### `selected` - -- Type: `DateRange | undefined | null` -- Required: No - -The selected date range. A `DateRange` object has the following shape: - -```typescript -{ - from: Date | undefined; - to?: Date | undefined; -} -``` - -### `onSelect` - -- Type: `(selected: DateRange | undefined, triggerDate: Date, modifiers: Modifiers, e: React.MouseEvent | React.KeyboardEvent) => void` -- Required: No - -Event handler when the selection changes. The `selected` parameter will contain the new date range. - -### `defaultSelected` - -- Type: `DateRange` -- Required: No - -The default selected range (for uncontrolled usage). - -### `excludeDisabled` - -- Type: `boolean` -- Required: No - -When `true`, the range will reset when including a disabled day. This is useful to prevent users from selecting ranges that include unavailable dates. - -### `min` - -- Type: `number` -- Required: No - -The minimum number of days to include in the range. If a user tries to select a range shorter than this, the selection will be adjusted to meet the minimum requirement. - -### `max` - -- Type: `number` -- Required: No - -The maximum number of days to include in the range. If a user tries to select a range longer than this, the selection will be adjusted to meet the maximum requirement. - -### `defaultMonth` - -- Type: `Date` -- Required: No -- Default: Current month - -The initial month to show in the calendar view (uncontrolled). - -### `month` - -- Type: `Date` -- Required: No - -The month displayed in the calendar view (controlled). Use together with `onMonthChange` to change the month programmatically. - -### `numberOfMonths` - -- Type: `number` -- Required: No -- Default: `1` - -The number of months displayed at once. - -### `startMonth` - -- Type: `Date` -- Required: No - -The earliest month to start the month navigation. - -### `endMonth` - -- Type: `Date` -- Required: No - -The latest month to end the month navigation. - -### `autoFocus` - -- Type: `boolean` -- Required: No - -Focus the first selected day (if set) or today's date (if not disabled). Use this prop when you need to focus the calendar after a user action (e.g. opening the dialog with the calendar). - -### `disabled` - -- Type: `Matcher | Matcher[] | undefined` -- Required: No - -Specify which days are disabled. Using `true` will disable all dates. See the [Matcher Types](#matcher-types) section for more details. - -### `disableNavigation` - -- Type: `boolean` -- Required: No - -Disable the navigation buttons. - -### `labels` - -- Type: `object` -- Required: No - -Use custom labels for internationalization. All labels are optional and have sensible defaults: - -```typescript -{ - labelNav?: () => string; // Navigation toolbar label - labelGrid?: (date: Date) => string; // Month grid label (default: "LLLL y") - labelGridcell?: (date: Date, modifiers?: Modifiers) => string; // Grid cell label - labelNext?: (month: Date | undefined) => string; // Next month button label - labelPrevious?: (month: Date | undefined) => string; // Previous month button label - labelDayButton?: (date: Date, modifiers?: Modifiers) => string; // Day button label - labelWeekday?: (date: Date) => string; // Weekday label -} -``` - -**Important: For a correct localized experience, consumers should make sure the locale used for the translated labels and `locale` prop are consistent.** - -### `locale` - -- Type: `Locale` -- Required: No -- Default: `enUS` from `@date-fns/locale` - -The locale object used to localize dates. Pass a locale from `@date-fns/locale` to localize the calendar. - -**Important: For a correct localized experience, consumers should make sure the locale used for the translated labels and `locale` prop are consistent.** - -### `weekStartsOn` - -- Type: `0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined` -- Required: No -- Default: Based on the `locale` prop - -The index of the first day of the week (0 - Sunday). Overrides the locale's setting. - -### `onMonthChange` - -- Type: `(month: Date) => void` -- Required: No - -Event fired when the user navigates between months. - -### `timeZone` - -- Type: `string` -- Required: No - -The time zone (IANA or UTC offset) to use in the calendar. See [Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for possible values. - -When working with time zones, use the `TZDate` object exported by this package instead of the native `Date` object. - -```tsx -import { DateRangeCalendar, TZDate } from '@wordpress/components'; - -export function WithTimeZone() { - const timeZone = 'America/New_York'; - const [ selected, setSelected ] = useState< Date | undefined >( { - from: new TZDate( 2024, 12, 10, timeZone ), // Use `TZDate` instead of `Date` - to: new TZDate( 2024, 12, 8, timeZone ), // Use `TZDate` instead of `Date` - } ); - return ( - - ); -} -``` - -### `role` - -- Type: `'application' | 'dialog' | undefined` -- Required: No -- Default: `'application'` - -The role attribute to add to the container element. - -## Matcher Types - -The calendar component uses a flexible matching system to determine which days should be disabled or have specific modifiers. Here are the available matcher types: - -### Boolean Matcher - -```typescript -const booleanMatcher: Matcher = true; // Will always match the day -``` - -### Date Matcher - -```typescript -const dateMatcher: Matcher = new Date(); // Will match today's date -``` - -### Array Matcher - -```typescript -const arrayMatcher: Matcher = [ - new Date( 2019, 1, 2 ), - new Date( 2019, 1, 4 ), -]; // Will match the days in the array -``` - -### Date After Matcher - -```typescript -const afterMatcher: DateAfter = { after: new Date( 2019, 1, 2 ) }; // Will match days after the 2nd of February 2019 -``` - -### Date Before Matcher - -```typescript -const beforeMatcher: DateBefore = { before: new Date( 2019, 1, 2 ) }; // Will match days before the 2nd of February 2019 -``` - -### Date Interval Matcher - -```typescript -const intervalMatcher: DateInterval = { - after: new Date( 2019, 1, 2 ), - before: new Date( 2019, 1, 5 ), -}; // Will match the days between the 2nd and the 5th of February 2019 (exclusive) -``` - -### Date Range Matcher - -```typescript -const rangeMatcher: DateRange = { - from: new Date( 2019, 1, 2 ), - to: new Date( 2019, 1, 5 ), -}; // Will match the days between the 2nd and the 5th of February 2019 (inclusive) -``` - -### Day of Week Matcher - -```typescript -const dayOfWeekMatcher: DayOfWeek = { dayOfWeek: 0 }; // Will match Sundays -const weekendMatcher: DayOfWeek = { dayOfWeek: [ 0, 6 ] }; // Will match weekends -``` - -### Function Matcher - -```typescript -const functionMatcher: Matcher = ( day: Date ) => { - return day.getMonth() === 2; // Will match when month is March -}; -``` diff --git a/packages/components/src/calendar/date-range-calendar/index.tsx b/packages/components/src/calendar/date-range-calendar/index.tsx deleted file mode 100644 index ec96a095c838d2..00000000000000 --- a/packages/components/src/calendar/date-range-calendar/index.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { differenceInCalendarDays } from 'date-fns'; -import { DayPicker, rangeContainsModifiers } from 'react-day-picker'; -import { enUS } from 'react-day-picker/locale'; -import { useMemo, useState, useCallback } from '@wordpress/element'; -import { COMMON_PROPS, MODIFIER_CLASSNAMES } from '../utils/constants'; -import { clampNumberOfMonths } from '../utils/misc'; -import { useControlledValue } from '../../utils/hooks'; -import { useLocalizationProps } from '../utils/use-localization-props'; -import type { - DateRangeCalendarProps, - DateRange, - OnSelectHandler, -} from '../types'; - -export function usePreviewRange( { - selected, - hoveredDate, - excludeDisabled, - min, - max, - disabled, -}: Pick< - DateRangeCalendarProps, - 'selected' | 'excludeDisabled' | 'min' | 'max' | 'disabled' -> & { - hoveredDate: Date | undefined; -} ) { - return useMemo( () => { - if ( ! hoveredDate || ! selected?.from ) { - return; - } - - let previewHighlight: DateRange | undefined; - let potentialNewRange: { from: Date; to: Date } | undefined; - - // Hovering on a date before the start of the selected range - if ( hoveredDate < selected.from ) { - previewHighlight = { - from: hoveredDate, - to: selected.from, - }; - - potentialNewRange = { - from: hoveredDate, - to: selected.to ?? selected.from, - }; - } else if ( - selected.to && - hoveredDate > selected.from && - hoveredDate < selected.to - ) { - // Hovering on a date between the start and end of the selected range - previewHighlight = { - from: selected.from, - to: hoveredDate, - }; - - potentialNewRange = { - from: selected.from, - to: hoveredDate, - }; - } else if ( hoveredDate > selected.from ) { - // Hovering on a date after the end of the selected range (either - // because it's greater than selected.to, or because it's not defined) - previewHighlight = { - from: selected.to ?? selected.from, - to: hoveredDate, - }; - - potentialNewRange = { - from: selected.from, - to: hoveredDate, - }; - } - - if ( - min !== undefined && - min > 0 && - potentialNewRange && - differenceInCalendarDays( - potentialNewRange.to, - potentialNewRange.from - ) < min - ) { - previewHighlight = { - from: hoveredDate, - to: hoveredDate, - }; - } - - if ( - max !== undefined && - max > 0 && - potentialNewRange && - differenceInCalendarDays( - potentialNewRange.to, - potentialNewRange.from - ) > max - ) { - previewHighlight = { - from: hoveredDate, - to: hoveredDate, - }; - } - - if ( - excludeDisabled && - disabled && - potentialNewRange && - rangeContainsModifiers( potentialNewRange, disabled ) - ) { - previewHighlight = { - from: hoveredDate, - to: hoveredDate, - }; - } - - return previewHighlight; - }, [ selected, hoveredDate, excludeDisabled, min, max, disabled ] ); -} - -/** - * `DateRangeCalendar` is a React component that provides a customizable calendar - * interface for **date range** selection. - * - * The component is built with accessibility in mind and follows ARIA best - * practices for calendar widgets. It provides keyboard navigation, screen reader - * support, and customizable labels for internationalization. - */ -export const DateRangeCalendar = ( { - defaultSelected, - selected: selectedProp, - onSelect, - numberOfMonths = 1, - excludeDisabled, - min, - max, - disabled, - locale = enUS, - timeZone, - ...props -}: DateRangeCalendarProps ) => { - const localizationProps = useLocalizationProps( { - locale, - timeZone, - mode: 'range', - } ); - - const onChange: OnSelectHandler< typeof selectedProp > = useCallback( - ( selected, triggerDate, modifiers, e ) => { - // Convert internal `null` to `undefined` for the public event handler. - onSelect?.( selected ?? undefined, triggerDate, modifiers, e ); - }, - [ onSelect ] - ); - - const [ selected, setSelected ] = useControlledValue< typeof selectedProp >( - { - defaultValue: defaultSelected, - value: selectedProp, - onChange, - } - ); - - const [ hoveredDate, setHoveredDate ] = useState< Date | undefined >( - undefined - ); - - // Compute the preview range for hover effect - const previewRange = usePreviewRange( { - selected, - hoveredDate, - excludeDisabled, - min, - max, - disabled, - } ); - - const modifiers = useMemo( () => { - return { - preview: previewRange, - preview_start: previewRange?.from, - preview_end: previewRange?.to, - }; - }, [ previewRange ] ); - - return ( - setHoveredDate( date ) } - onDayMouseLeave={ () => setHoveredDate( undefined ) } - modifiers={ modifiers } - modifiersClassNames={ MODIFIER_CLASSNAMES } - /> - ); -}; diff --git a/packages/components/src/calendar/index.tsx b/packages/components/src/calendar/index.tsx deleted file mode 100644 index dca0d755ec9ce8..00000000000000 --- a/packages/components/src/calendar/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export { TZDate } from 'react-day-picker'; -export { DateCalendar } from './date-calendar'; -export { DateRangeCalendar } from './date-range-calendar'; diff --git a/packages/components/src/calendar/stories/date-calendar.story.tsx b/packages/components/src/calendar/stories/date-calendar.story.tsx deleted file mode 100644 index 917f2b7122b6b6..00000000000000 --- a/packages/components/src/calendar/stories/date-calendar.story.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { fn } from 'storybook/test'; -import { - enUS, - fr, - es, - de, - it, - he, - ru, - ja, - ptBR, - nl, - ko, - tr, - id, - zhCN, - zhTW, - ar, - sv, -} from 'date-fns/locale'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useState, useEffect } from '@wordpress/element'; -import { DateCalendar, TZDate } from '..'; - -// Storybook date controls pass a number, but react-day-picker expects a Date for `endMonth`. -function toDate( value: Date | number | undefined ): Date | undefined { - return value === undefined ? undefined : new Date( value ); -} - -const meta: Meta< typeof DateCalendar > = { - title: 'Components/Selection & Input/Time & Date/DateCalendar', - component: DateCalendar, - tags: [ 'status-private' ], - render: ( { endMonth, ...args } ) => ( - - ), - argTypes: { - locale: { - options: [ - 'English (US)', - 'French', - 'Spanish', - 'German', - 'Italian', - 'Hebrew', - 'Russian', - 'Japanese', - 'Portuguese (Brazil)', - 'Dutch', - 'Korean', - 'Turkish', - 'Indonesian', - 'Chinese (Simplified)', - 'Chinese (Traditional)', - 'Arabic', - 'Swedish', - ], - mapping: { - 'English (US)': enUS, - French: fr, - Spanish: es, - German: de, - Italian: it, - Hebrew: he, - Russian: ru, - Japanese: ja, - 'Portuguese (Brazil)': ptBR, - Dutch: nl, - Korean: ko, - Turkish: tr, - Indonesian: id, - 'Chinese (Simplified)': zhCN, - 'Chinese (Traditional)': zhTW, - Arabic: ar, - Swedish: sv, - }, - control: 'select', - }, - timeZone: { - options: [ - 'Pacific/Honolulu', - 'America/New_York', - 'Europe/London', - 'Asia/Tokyo', - 'Pacific/Auckland', - ], - control: 'select', - }, - labels: { - control: false, - }, - defaultSelected: { control: 'date' }, - selected: { control: 'date' }, - onSelect: { - control: false, - }, - defaultMonth: { control: 'date' }, - month: { control: 'date' }, - onMonthChange: { - control: false, - }, - endMonth: { control: 'date' }, - startMonth: { control: 'date' }, - }, - args: { - onMonthChange: fn(), - onSelect: fn(), - }, -}; -export default meta; - -type Story = StoryObj< typeof DateCalendar >; - -export const Default: Story = {}; - -export const DisabledDates: Story = { - args: { - disabled: [ - // Disable tomorrow (single date) - new Date( new Date().setDate( new Date().getDate() + 1 ) ), - // Disable all dates after Feb 1st of next year - { after: new Date( new Date().getFullYear() + 1, 1, 1 ) }, - // Disable all dates before Dec 1st of last year - { before: new Date( new Date().getFullYear() - 1, 11, 1 ) }, - // Disable all dates between 12th and 14th of August of this year - { - after: new Date( new Date().getFullYear(), 7, 11 ), - before: new Date( new Date().getFullYear(), 7, 15 ), - }, - // Disable all dates between 21st and 26th of October of this year - { - from: new Date( new Date().getFullYear(), 9, 21 ), - to: new Date( new Date().getFullYear(), 9, 26 ), - }, - // Disable all Wednesdays - { dayOfWeek: 3 }, - // Disable all prime day numbers - function isPrimeDate( date: Date ) { - return [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31 ].includes( - date.getDate() - ); - }, - ], - }, -}; - -const nextMonth = new Date().getMonth() === 11 ? 0 : new Date().getMonth() + 1; -const nextMonthYear = - new Date().getMonth() === 11 - ? new Date().getFullYear() + 1 - : new Date().getFullYear(); -const firstDayOfNextMonth = new Date( nextMonthYear, nextMonth, 1 ); -export const WithSelectedDateAndMonth: Story = { - args: { - defaultSelected: firstDayOfNextMonth, - defaultMonth: firstDayOfNextMonth, - }, -}; - -/** - * Shows days from adjacent months in the grid. Outside days use a lighter style - * and are still interactive. Use `fixedWeeks` to keep the grid height constant. - */ -export const WithOutsideDays: Story = { - args: { - showOutsideDays: true, - fixedWeeks: true, - }, -}; - -/** - * When working with time zones, use the `TZDate` object exported by this package instead of the native `Date` object. - */ -export const WithTimeZone: Story = { - render: function DateCalendarWithTimeZone( { endMonth, ...args } ) { - const [ selected, setSelected ] = useState< TZDate | null >( null ); - - useEffect( () => { - setSelected( - // Select one week from today every time the time zone changes. - new TZDate( - new Date().setDate( new Date().getDate() + 7 ), - args.timeZone - ) - ); - }, [ args.timeZone ] ); - - return ( - <> - { - setSelected( - selectedDate - ? new TZDate( selectedDate, args.timeZone ) - : null - ); - args.onSelect?.( selectedDate, ...rest ); - } } - disabled={ [ - { - // Disable any date before today - before: new TZDate( new Date(), args.timeZone ), - }, - ] } - /> - -

- Calendar set to { args.timeZone ?? 'current' } timezone, - disabling selection for all dates before today, and starting - with a default date of 1 week from today. -

- - ); - }, - args: { - timeZone: 'Pacific/Auckland', - }, - argTypes: { - selected: { - control: false, - }, - defaultSelected: { - control: false, - }, - disabled: { - control: false, - }, - }, -}; diff --git a/packages/components/src/calendar/stories/date-range-calendar.story.tsx b/packages/components/src/calendar/stories/date-range-calendar.story.tsx deleted file mode 100644 index b51fcb131400c2..00000000000000 --- a/packages/components/src/calendar/stories/date-range-calendar.story.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { fn } from 'storybook/test'; -import { - enUS, - fr, - es, - de, - it, - he, - ru, - ja, - ptBR, - nl, - ko, - tr, - id, - zhCN, - zhTW, - ar, - sv, -} from 'date-fns/locale'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useState, useEffect } from '@wordpress/element'; -import { DateRangeCalendar, TZDate } from '..'; - -// Storybook date controls pass a number, but react-day-picker expects a Date for `endMonth`. -function toDate( value: Date | number | undefined ): Date | undefined { - return value === undefined ? undefined : new Date( value ); -} - -const meta: Meta< typeof DateRangeCalendar > = { - title: 'Components/Selection & Input/Time & Date/DateRangeCalendar', - component: DateRangeCalendar, - tags: [ 'status-private' ], - render: ( { endMonth, ...args } ) => ( - - ), - argTypes: { - locale: { - options: [ - 'English (US)', - 'French', - 'Spanish', - 'German', - 'Italian', - 'Hebrew', - 'Russian', - 'Japanese', - 'Portuguese (Brazil)', - 'Dutch', - 'Korean', - 'Turkish', - 'Indonesian', - 'Chinese (Simplified)', - 'Chinese (Traditional)', - 'Arabic', - 'Swedish', - ], - mapping: { - 'English (US)': enUS, - French: fr, - Spanish: es, - German: de, - Italian: it, - Hebrew: he, - Russian: ru, - Japanese: ja, - 'Portuguese (Brazil)': ptBR, - Dutch: nl, - Korean: ko, - Turkish: tr, - Indonesian: id, - 'Chinese (Simplified)': zhCN, - 'Chinese (Traditional)': zhTW, - Arabic: ar, - Swedish: sv, - }, - control: 'select', - }, - timeZone: { - options: [ - 'Pacific/Honolulu', - 'America/New_York', - 'Europe/London', - 'Asia/Tokyo', - 'Pacific/Auckland', - ], - control: 'select', - }, - labels: { - control: false, - }, - defaultSelected: { control: false }, - selected: { control: false }, - onSelect: { - control: false, - }, - defaultMonth: { control: 'date' }, - month: { control: 'date' }, - onMonthChange: { - control: false, - }, - endMonth: { control: 'date' }, - startMonth: { control: 'date' }, - }, - args: { - onMonthChange: fn(), - onSelect: fn(), - }, -}; -export default meta; - -type Story = StoryObj< typeof DateRangeCalendar >; - -export const Default: Story = {}; - -export const DisabledDates: Story = { - args: { - disabled: [ - // Disable tomorrow (single date) - new Date( new Date().setDate( new Date().getDate() + 1 ) ), - // Disable all dates after Feb 1st of next year - { after: new Date( new Date().getFullYear() + 1, 1, 1 ) }, - // Disable all dates before Dec 1st of last year - { before: new Date( new Date().getFullYear() - 1, 11, 1 ) }, - // Disable all dates between 12th and 14th of August of this year - { - after: new Date( new Date().getFullYear(), 7, 11 ), - before: new Date( new Date().getFullYear(), 7, 15 ), - }, - // Disable all dates between 21st and 26th of October of this year - { - from: new Date( new Date().getFullYear(), 9, 21 ), - to: new Date( new Date().getFullYear(), 9, 26 ), - }, - // Disable all Wednesdays - { dayOfWeek: 3 }, - // Disable all prime day numbers - function isPrimeDate( date: Date ) { - return [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31 ].includes( - date.getDate() - ); - }, - ], - }, -}; - -const nextMonth = new Date().getMonth() === 11 ? 0 : new Date().getMonth() + 1; -const nextMonthYear = - new Date().getMonth() === 11 - ? new Date().getFullYear() + 1 - : new Date().getFullYear(); -const firstDayOfNextMonth = new Date( nextMonthYear, nextMonth, 1 ); -const fourthDayOfNextMonth = new Date( nextMonthYear, nextMonth, 4 ); -export const WithSelectedRangeAndMonth: Story = { - args: { - defaultSelected: { - from: firstDayOfNextMonth, - to: fourthDayOfNextMonth, - }, - defaultMonth: firstDayOfNextMonth, - }, -}; - -/** - * Shows days from adjacent months in the grid. Outside days use a lighter style - * and are still interactive. Use `fixedWeeks` to keep the grid height constant. - */ -export const WithOutsideDays: Story = { - args: { - showOutsideDays: true, - fixedWeeks: true, - }, -}; - -/** - * When working with time zones, use the `TZDate` object exported by this package instead of the native `Date` object. - */ -export const WithTimeZone: Story = { - render: function DateCalendarWithTimeZone( { endMonth, ...args } ) { - const [ range, setRange ] = useState< typeof args.selected | null >( - null - ); - - useEffect( () => { - setRange( - // Select from one week from today to two weeks from today - // every time the timezone changes. - { - from: new TZDate( - new Date().setDate( new Date().getDate() + 7 ), - args.timeZone - ), - to: new TZDate( - new Date().setDate( new Date().getDate() + 14 ), - args.timeZone - ), - } - ); - }, [ args.timeZone ] ); - - return ( - <> - { - setRange( - // Set controlled state to null if there's no selection - ! selectedDate || - ( selectedDate.from === undefined && - selectedDate.to === undefined ) - ? null - : selectedDate - ); - args.onSelect?.( selectedDate, ...rest ); - } } - disabled={ [ - { - // Disable any date before today - before: new TZDate( new Date(), args.timeZone ), - }, - ] } - /> -

- Calendar set to { args.timeZone ?? 'current' } timezone, - disabling selection for all dates before today, and starting - with a default date range of 1 week from today to 2 weeks - from today. -

- - ); - }, - args: { - timeZone: 'Pacific/Auckland', - }, - argTypes: { - disabled: { - control: false, - }, - }, -}; diff --git a/packages/components/src/calendar/style.scss b/packages/components/src/calendar/style.scss deleted file mode 100644 index 3d38c8eb3c8763..00000000000000 --- a/packages/components/src/calendar/style.scss +++ /dev/null @@ -1,440 +0,0 @@ -@use "@wordpress/base-styles/colors" as *; -@use "@wordpress/base-styles/variables" as *; -@use "../utils/theme-variables" as *; - -/* Root of the component. */ -// Internal variables -$wp-components-calendar-outline-focus: var(--wp-admin-border-width-focus) - solid $components-color-accent; -$wp-components-calendar-button-height: $grid-unit-40; -$wp-components-calendar-button-width: $grid-unit-40; -$wp-components-calendar-nav-height: $grid-unit-40; -$wp-components-calendar-range-middle-background-color: color-mix(in srgb, $components-color-accent 4%, transparent); -$wp-components-calendar-preview-border-color: color-mix(in srgb, $components-color-accent 16%, transparent); - -.components-calendar { - // TODO: add font family rule when Theme is ready - - position: relative; /* Required to position the navigation toolbar. */ - box-sizing: border-box; - display: inline flow-root; - color: $components-color-foreground; - background-color: $components-color-background; - font-size: $font-size-medium; - font-weight: var(--wpds-typography-font-weight-default); - z-index: 0; // Create a stacking context and render on top of the background. - - *, - *::before, - *::after { - box-sizing: border-box; - } -} - -.components-calendar__day { - padding: 0; - position: relative; - - // Setting text color on the day container instead of directly on the - // day button to ensure that the color of the dot used to indicate today's - // date follows the same color as the button's text, since the button - // inherits its text color. - &:has(.components-calendar__day-button:disabled) { - // Use a lighter shade of gray for less visual prominence. - color: $components-color-gray-400; - } - &:has(.components-calendar__day-button:hover:not(:disabled)), - &:has(.components-calendar__day-button:focus-visible) { - color: $components-color-accent; - } -} - -.components-calendar__day-button { - background: none; - padding: 0; - margin: 0; - cursor: var(--wpds-cursor-control); - justify-content: center; - align-items: center; - display: flex; - position: relative; - - width: $wp-components-calendar-button-width; - height: $wp-components-calendar-button-height; - - border: none; - border-radius: $radius-small; - - font: inherit; - font-variant-numeric: tabular-nums; - color: inherit; - - // Use the button's ::before to render date's background, which keeps the - // border-radius of the button intact. This technique allows the focus ring - // to have rounded corners even when the background needs square corners - // (eg. in the middle of a date range). - &::before { - content: ""; - position: absolute; - z-index: -1; - inset: 0; - border: none; // No default border to avoid polluting high-contrast mode. - border-radius: $radius-small; - } - - // Use the button's ::after to show the selection preview. - &::after { - content: ""; - position: absolute; - z-index: 1; - inset: 0; - pointer-events: none; - } - - &:disabled { - cursor: revert; - - @media ( forced-colors: active ) { - // As an extra visual cue, show a line-through on disabled days - // in forced-colors (high-contrast) mode. - text-decoration: line-through; - } - } - - &:focus-visible { - outline: $wp-components-calendar-outline-focus; - outline-offset: 1px; - } -} - -.components-calendar__caption-label { - z-index: 1; - - position: relative; - display: inline-flex; - align-items: center; - - white-space: nowrap; - border: 0; - - text-transform: capitalize; -} - -.components-calendar__button-next, -.components-calendar__button-previous { - border: none; - border-radius: $radius-small; - background: none; - padding: 0; - margin: 0; - cursor: var(--wpds-cursor-control); - appearance: none; - display: inline-flex; - align-items: center; - justify-content: center; - position: relative; - - width: $wp-components-calendar-button-width; - height: $wp-components-calendar-button-height; - - color: inherit; - - &:disabled, - &[aria-disabled="true"] { - cursor: revert; - - color: $components-color-disabled; - } - - &:focus-visible { - outline: $wp-components-calendar-outline-focus; - } -} - -.components-calendar__chevron { - display: inline-block; - fill: currentColor; - width: $grid-unit-20; - height: $grid-unit-20; -} - -.components-calendar[dir="rtl"] -.components-calendar__nav -.components-calendar__chevron { - transform: rotate(180deg); - transform-origin: 50%; -} - -.components-calendar__month-caption { - display: flex; - justify-content: center; - align-content: center; - - height: $wp-components-calendar-nav-height; - margin-bottom: $grid-unit-15; -} - -.components-calendar__months { - position: relative; - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: $grid-unit-20; - max-width: fit-content; -} - -.components-calendar__month-grid { - border-collapse: separate; - border-spacing: 0 $grid-unit-05; -} - -.components-calendar__nav { - position: absolute; - inset-block-start: 0; - inset-inline-start: 0; - inset-inline-end: 0; - - display: flex; - align-items: center; - justify-content: space-between; - - height: $wp-components-calendar-nav-height; -} - -.components-calendar__weekday { - width: $wp-components-calendar-button-width; - height: $wp-components-calendar-button-height; - padding: 0; - - color: $components-color-gray-700; - text-align: center; - text-transform: uppercase; -} - -/* DAY MODIFIERS */ -// Today's date: show a dot in the top-right corner of the button -.components-calendar__day--today::after { - content: ""; - position: absolute; - z-index: 1; - inset-block-start: 2px; - inset-inline-end: 2px; - width: 0; - height: 0; - border-radius: 50%; - border: 2px solid currentColor; -} - -// Selected date button (individual date, range start, and range end) -.components-calendar__day--selected:not(.components-calendar__range-middle) { - &:has( - .components-calendar__day-button, - .components-calendar__day-button:hover:not(:disabled) -) { - color: $components-color-foreground-inverted; - } - - .components-calendar__day-button { - &::before { - background-color: $components-color-foreground; - // Render a transparent border to highlight the selected day in - // forced-colors (high-contrast) mode, since the background is not - // visible. - border: 1px solid transparent; - } - - &:disabled::before { - background-color: $components-color-gray-400; - } - - &:hover:not(:disabled)::before { - background-color: $components-color-gray-800; - } - } -} - -// Outside month days (visible when showOutsideDays is true): use a lighter text color. -.components-calendar__day--outside { - color: $components-color-gray-600; -} - -// Hidden button (ie. outside current month but still rendered) -.components-calendar__day--hidden { - visibility: hidden; -} - -// Range start button, but not when start and end are the same day. -.components-calendar__range-start:not(.components-calendar__range-end) -.components-calendar__day-button { - // Apply border-radius changes to the button itself too, so that the focus - // ring follows the same shape as the button's background. - &, - &::before { - border-start-end-radius: 0; - border-end-end-radius: 0; - } -} - -// Middle of date range -.components-calendar__range-middle .components-calendar__day-button { - &::before { - background-color: $wp-components-calendar-range-middle-background-color; - border-radius: 0; - - // Render a top and bottom transparent border to highlight the selected - // day in forced-colors (high-contrast) mode, since the background is not - // visible. - border-width: 1px 0; - border-color: transparent; - border-style: solid; - } -} - -// Range end button, but not when start and end are the same day. -.components-calendar__range-end:not(.components-calendar__range-start) -.components-calendar__day-button { - // Apply border-radius changes to the button itself too, so that the focus - // ring follows the same shape as the button's background. - &, - &::before { - border-start-start-radius: 0; - border-end-start-radius: 0; - } -} - -/* - * RANGE PREVIEW (range calendar only) - * - * The preview is rendered in the button's ::after pseudo-element, so that it - * can be rendered over the button's contents. - * The selection preview is shown with a dashed border. In order to have - * control over the dash pattern (especially the seams between days), the - * dashed borders are rendered as SVGs via the url() CSS function. - * Since SVGs rendered in the url() function don't seem to be able to access - * CSS variables, we're using the SVGs as masks, and using `background-color` - * to consume the accent color variable. - */ -.components-calendar__day--preview svg { - position: absolute; - inset: 0; - pointer-events: none; - color: $wp-components-calendar-preview-border-color; - - @media ( forced-colors: active ) { - color: inherit; - } - - .components-calendar[dir="rtl"] & { - transform: scaleX(-1); - } -} - -.components-calendar__day--preview.components-calendar__range-middle -.components-calendar__day-button::before { - // Remove the transparent border shown on the middle of the range - // in forced-colors (high-contrast) mode, to allow for the dashed border - // to be visible. - border: none; -} - -/* ANIMATIONS */ -@keyframes slide-in-left { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(0); - } -} - -@keyframes slide-in-right { - 0% { - transform: translateX(100%); - } - 100% { - transform: translateX(0); - } -} - -@keyframes slide-out-left { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(-100%); - } -} - -@keyframes slide-out-right { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(100%); - } -} - -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes fade-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -.components-calendar__weeks-before-enter, -.components-calendar__weeks-before-exit, -.components-calendar__weeks-after-enter, -.components-calendar__weeks-after-exit, -.components-calendar__caption-after-enter, -.components-calendar__caption-after-exit, -.components-calendar__caption-before-enter, -.components-calendar__caption-before-exit { - animation-duration: 0s; - animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - animation-fill-mode: forwards; - - @media not ( prefers-reduced-motion ) { - animation-duration: 0.3s; - } -} - -.components-calendar__weeks-before-enter, -.components-calendar[dir="rtl"] .components-calendar__weeks-after-enter { - animation-name: slide-in-left; -} -.components-calendar__weeks-before-exit, -.components-calendar[dir="rtl"] .components-calendar__weeks-after-exit { - animation-name: slide-out-left; -} -.components-calendar__weeks-after-enter, -.components-calendar[dir="rtl"] .components-calendar__weeks-before-enter { - animation-name: slide-in-right; -} -.components-calendar__weeks-after-exit, -.components-calendar[dir="rtl"] .components-calendar__weeks-before-exit { - animation-name: slide-out-right; -} - -.components-calendar__caption-after-enter { - animation-name: fade-in; -} -.components-calendar__caption-after-exit { - animation-name: fade-out; -} -.components-calendar__caption-before-enter { - animation-name: fade-in; -} -.components-calendar__caption-before-exit { - animation-name: fade-out; -} diff --git a/packages/components/src/calendar/utils/constants.ts b/packages/components/src/calendar/utils/constants.ts deleted file mode 100644 index 60d391c13afda4..00000000000000 --- a/packages/components/src/calendar/utils/constants.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Day } from './day-cell'; - -const CLASSNAMES = { - root: 'components-calendar', - day: 'components-calendar__day', - day_button: 'components-calendar__day-button', - outside: 'components-calendar__day--outside', - caption_label: 'components-calendar__caption-label', - button_next: 'components-calendar__button-next', - button_previous: 'components-calendar__button-previous', - chevron: 'components-calendar__chevron', - nav: 'components-calendar__nav', - month_caption: 'components-calendar__month-caption', - months: 'components-calendar__months', - month_grid: 'components-calendar__month-grid', - weekday: 'components-calendar__weekday', - today: 'components-calendar__day--today', - selected: 'components-calendar__day--selected', - disabled: 'components-calendar__day--disabled', - hidden: 'components-calendar__day--hidden', - range_start: 'components-calendar__range-start', - range_end: 'components-calendar__range-end', - range_middle: 'components-calendar__range-middle', - weeks_before_enter: 'components-calendar__weeks-before-enter', - weeks_before_exit: 'components-calendar__weeks-before-exit', - weeks_after_enter: 'components-calendar__weeks-after-enter', - weeks_after_exit: 'components-calendar__weeks-after-exit', - caption_after_enter: 'components-calendar__caption-after-enter', - caption_after_exit: 'components-calendar__caption-after-exit', - caption_before_enter: 'components-calendar__caption-before-enter', - caption_before_exit: 'components-calendar__caption-before-exit', -}; -export const MODIFIER_CLASSNAMES = { - preview: 'components-calendar__day--preview', - preview_start: 'components-calendar__day--preview-start', - preview_end: 'components-calendar__day--preview-end', -}; - -export const COMMON_PROPS = { - animate: true, - // Only show days in the current month - showOutsideDays: false, - // Hide week number column - showWeekNumber: false, - // Show weekdays row - hideWeekdays: false, - // Month and year caption are not interactive - captionLayout: 'label', - // Show a variable number of weeks depending on the month - fixedWeeks: false, - // Show navigation buttons - hideNavigation: false, - // Class names - classNames: CLASSNAMES, - // Default role - role: 'application', - components: { - Day, - }, -} as const; diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts index a0777cfeb5335c..bd791f7f088791 100644 --- a/packages/components/src/private-apis.ts +++ b/packages/components/src/private-apis.ts @@ -5,7 +5,6 @@ import { ComponentsContext } from './context/context-system-provider'; import { Tabs } from './tabs'; import { lock } from './lock-unlock'; import Badge from './badge'; -import { DateCalendar, DateRangeCalendar, TZDate } from './calendar'; import { ValidatedCheckboxControl, ValidatedComboboxControl, @@ -30,9 +29,6 @@ lock( privateApis, { Tabs, Menu, Badge, - DateCalendar, - DateRangeCalendar, - TZDate, useDrag, ValidatedInputControl, ValidatedCheckboxControl, diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss index 6d85fd28f7ac60..936d6f05a1946f 100644 --- a/packages/components/src/style.scss +++ b/packages/components/src/style.scss @@ -4,7 +4,6 @@ @use "./badge/styles.scss" as *; @use "./button-group/style.scss" as *; @use "./button/style.scss" as *; -@use "./calendar/style.scss" as *; @use "./checkbox-control/style.scss" as *; @use "./circular-option-picker/style.scss" as *; @use "./palette-edit/style.scss" as *; diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 61cd2670576f5c..299d6939ac526d 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -27,6 +27,7 @@ - Remove obsolete dependency grouping comments as part of the repository-wide separator-free import migration. ([#81248](https://github.com/WordPress/gutenberg/pull/81248)) - DataViews: Inline a verbatim copy of the `kebabCase` utility instead of unlocking the private one from `@wordpress/components`, as part of removing the package's reliance on private cross-package APIs ([#81230](https://github.com/WordPress/gutenberg/issues/81230)). Adds a direct `change-case` dependency; no behavior change. ([#81284](https://github.com/WordPress/gutenberg/pull/81284)) - DataViews: Use the public `Badge` from `@wordpress/ui` in the `grid` and `picker-grid` layouts instead of the private one from `@wordpress/components`, removing the last `unlock()` call from those files. Badges pick up the `@wordpress/ui` neutral tokens, so they gain a border and a slightly different background ([#81236](https://github.com/WordPress/gutenberg/pull/81236)). +- DataForms: The `date` and `datetime` controls now import the public `Calendar` and `RangeCalendar` from `@wordpress/ui` instead of unlocking `DateCalendar` / `DateRangeCalendar` from `@wordpress/components`, as part of removing the package's reliance on private cross-package APIs ([#81230](https://github.com/WordPress/gutenberg/issues/81230)). The `date` control no longer unlocks anything; `datetime` keeps a single unlock for `ValidatedInputControl`. ([#81337](https://github.com/WordPress/gutenberg/pull/81337)) - Update `@ariakit/react` to 0.4.35 ([#80765](https://github.com/WordPress/gutenberg/pull/80765)). ## 17.3.0 (2026-07-29) diff --git a/packages/dataviews/src/components/dataform-controls/date.tsx b/packages/dataviews/src/components/dataform-controls/date.tsx index 02cbb7cd8110ba..e90d334d174fc8 100644 --- a/packages/dataviews/src/components/dataform-controls/date.tsx +++ b/packages/dataviews/src/components/dataform-controls/date.tsx @@ -12,7 +12,6 @@ import { BaseControl, Button, Icon as WCIcon, - privateApis as componentsPrivateApis, __experimentalInputControl as InputControl, } from '@wordpress/components'; import { speak } from '@wordpress/a11y'; @@ -26,7 +25,7 @@ import { import { __ } from '@wordpress/i18n'; import { getDate, getSettings } from '@wordpress/date'; import { error as errorIcon } from '@wordpress/icons'; -import { Stack } from '@wordpress/ui'; +import { Calendar, RangeCalendar, Stack } from '@wordpress/ui'; import RelativeDateControl from './utils/relative-date-control'; import useDisabledDateMatchers from './utils/use-disabled-date-matchers'; import { @@ -34,7 +33,6 @@ import { OPERATOR_OVER, OPERATOR_BETWEEN, } from '../../constants'; -import { unlock } from '../../lock-unlock'; import type { DataFormControlProps, FieldValidity, @@ -43,8 +41,6 @@ import type { } from '../../types'; import getCustomValidity from './utils/get-custom-validity'; -const { DateCalendar, DateRangeCalendar } = unlock( componentsPrivateApis ); - type DateRange = [ string, string ] | undefined; const DATE_PRESETS: { @@ -324,7 +320,7 @@ function CalendarDateControl< Item >( { ); const onSelectDate = useCallback( - ( newDate: Date | undefined | null ) => { + ( newDate: Date | null ) => { const dateValue = newDate ? format( newDate, 'yyyy-MM-dd' ) : undefined; @@ -443,12 +439,10 @@ function CalendarDateControl< Item >( { /> { /* Calendar widget */ } - ( { const selectedRange = useMemo( () => { if ( ! value ) { - return { from: undefined, to: undefined }; + return null; } const [ from, to ] = value; @@ -526,7 +520,7 @@ function CalendarDateRangeControl< Item >( { }, [ value ] ); const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { - return selectedRange.from || new Date(); + return selectedRange?.from || new Date(); } ); const [ isTouched, setIsTouched ] = useState( false ); @@ -553,9 +547,7 @@ function CalendarDateRangeControl< Item >( { const onSelectCalendarRange = useCallback( ( - newRange: - | { from: Date | undefined; to?: Date | undefined } - | undefined + newRange: { from: Date | undefined; to?: Date | undefined } | null ) => { updateDateRange( newRange?.from, newRange?.to ); setSelectedPresetId( null ); @@ -699,10 +691,10 @@ function CalendarDateRangeControl< Item >( { /> - { if ( ! value ) { @@ -68,7 +68,7 @@ function CalendarDateTimeControl< Item >( { }, [] ); const onSelectDate = useCallback( - ( newDate: Date | undefined | null ) => { + ( newDate: Date | null ) => { let dateTimeValue: string | undefined; if ( newDate ) { // Extract the date part in WP timezone from the calendar selection @@ -190,14 +190,10 @@ function CalendarDateTimeControl< Item >( { /> { /* Calendar widget */ } { ! compact && ( - ( + function Calendar( + { + defaultValue, + value: valueProp, + onValueChange, + numberOfMonths = 1, + locale = enUS, + timeZone, + render, + labels: customLabels, + ...props + }, + ref + ) { + const localizationProps = useLocalizationProps( { + locale, + timeZone, + mode: 'single', + } ); + + const labels = useMemo( + () => + customLabels + ? { ...localizationProps.labels, ...customLabels } + : localizationProps.labels, + [ localizationProps.labels, customLabels ] + ); + + const onChange: OnValueChangeHandler< Date | null | undefined > = + useCallback( + ( selected, triggerDate, modifiers, e ) => { + onValueChange?.( + selected ?? null, + triggerDate, + modifiers, + e + ); + }, + [ onValueChange ] + ); + + const [ selected, setSelected ] = useControlledValue< + Date | null | undefined + >( { + defaultValue, + value: valueProp, + onChange, + } ); + + const rootContextValue = useMemo( + () => ( { render, ref } ), + [ render, ref ] + ); + + return ( + + + + ); + } +); diff --git a/packages/ui/src/calendar/index.ts b/packages/ui/src/calendar/index.ts new file mode 100644 index 00000000000000..facc40dfb98967 --- /dev/null +++ b/packages/ui/src/calendar/index.ts @@ -0,0 +1,2 @@ +export { Calendar } from './calendar'; +export { RangeCalendar } from './range-calendar'; diff --git a/packages/ui/src/calendar/range-calendar.tsx b/packages/ui/src/calendar/range-calendar.tsx new file mode 100644 index 00000000000000..e3277037f21662 --- /dev/null +++ b/packages/ui/src/calendar/range-calendar.tsx @@ -0,0 +1,236 @@ +import { differenceInCalendarDays } from 'date-fns'; +import { DayPicker, rangeContainsModifiers } from 'react-day-picker'; +import { enUS } from 'react-day-picker/locale'; +import { forwardRef, useMemo, useState, useCallback } from '@wordpress/element'; +import { COMMON_PROPS, MODIFIER_CLASSNAMES } from './utils/constants'; +import { clampNumberOfMonths } from './utils/misc'; +import { useControlledValue } from './utils/use-controlled-value'; +import { useLocalizationProps } from './utils/use-localization-props'; +import { RootContext } from './utils/root-context'; +import type { + RangeCalendarProps, + DateRange, + OnValueChangeHandler, +} from './types'; + +export function usePreviewRange( { + value, + hoveredDate, + excludeDisabled, + min, + max, + disabled, +}: Pick< + RangeCalendarProps, + 'value' | 'excludeDisabled' | 'min' | 'max' | 'disabled' +> & { + hoveredDate: Date | undefined; +} ) { + return useMemo( () => { + if ( ! hoveredDate || ! value?.from ) { + return; + } + + let previewHighlight: DateRange | undefined; + let potentialNewRange: { from: Date; to: Date } | undefined; + + // Hovering on a date before the start of the selected range + if ( hoveredDate < value.from ) { + previewHighlight = { + from: hoveredDate, + to: value.from, + }; + + potentialNewRange = { + from: hoveredDate, + to: value.to ?? value.from, + }; + } else if ( + value.to && + hoveredDate > value.from && + hoveredDate < value.to + ) { + // Hovering on a date between the start and end of the selected range + previewHighlight = { + from: value.from, + to: hoveredDate, + }; + + potentialNewRange = { + from: value.from, + to: hoveredDate, + }; + } else if ( hoveredDate > value.from ) { + // Hovering on a date after the end of the selected range (either + // because it's greater than selected.to, or because it's not defined) + previewHighlight = { + from: value.to ?? value.from, + to: hoveredDate, + }; + + potentialNewRange = { + from: value.from, + to: hoveredDate, + }; + } + + if ( + min !== undefined && + min > 0 && + potentialNewRange && + differenceInCalendarDays( + potentialNewRange.to, + potentialNewRange.from + ) < min + ) { + previewHighlight = { + from: hoveredDate, + to: hoveredDate, + }; + } + + if ( + max !== undefined && + max > 0 && + potentialNewRange && + differenceInCalendarDays( + potentialNewRange.to, + potentialNewRange.from + ) > max + ) { + previewHighlight = { + from: hoveredDate, + to: hoveredDate, + }; + } + + if ( + excludeDisabled && + disabled && + potentialNewRange && + rangeContainsModifiers( potentialNewRange, disabled ) + ) { + previewHighlight = { + from: hoveredDate, + to: hoveredDate, + }; + } + + return previewHighlight; + }, [ value, hoveredDate, excludeDisabled, min, max, disabled ] ); +} + +/** + * `RangeCalendar` provides a customizable calendar interface for **date range** + * selection. + * + * The component is built with accessibility in mind and follows ARIA best + * practices for calendar widgets. It provides keyboard navigation, screen reader + * support, and customizable labels for internationalization. + */ +export const RangeCalendar = forwardRef< HTMLDivElement, RangeCalendarProps >( + function RangeCalendar( + { + defaultValue, + value: valueProp, + onValueChange, + numberOfMonths = 1, + excludeDisabled, + min, + max, + disabled, + locale = enUS, + timeZone, + render, + labels: customLabels, + ...props + }, + ref + ) { + const localizationProps = useLocalizationProps( { + locale, + timeZone, + mode: 'range', + } ); + + const labels = useMemo( + () => + customLabels + ? { ...localizationProps.labels, ...customLabels } + : localizationProps.labels, + [ localizationProps.labels, customLabels ] + ); + + const onChange: OnValueChangeHandler< DateRange | null | undefined > = + useCallback( + ( selected, triggerDate, modifiers, e ) => { + onValueChange?.( + selected ?? null, + triggerDate, + modifiers, + e + ); + }, + [ onValueChange ] + ); + + const [ selected, setSelected ] = useControlledValue< + DateRange | null | undefined + >( { + defaultValue, + value: valueProp, + onChange, + } ); + + const [ hoveredDate, setHoveredDate ] = useState< Date | undefined >( + undefined + ); + + // Compute the preview range for hover effect + const previewRange = usePreviewRange( { + value: selected, + hoveredDate, + excludeDisabled, + min, + max, + disabled, + } ); + + const modifiers = useMemo( () => { + return { + preview: previewRange, + preview_start: previewRange?.from, + preview_end: previewRange?.to, + }; + }, [ previewRange ] ); + + const rootContextValue = useMemo( + () => ( { render, ref } ), + [ render, ref ] + ); + + return ( + + setHoveredDate( date ) } + onDayMouseLeave={ () => setHoveredDate( undefined ) } + modifiers={ modifiers } + modifiersClassNames={ MODIFIER_CLASSNAMES } + /> + + ); + } +); diff --git a/packages/ui/src/calendar/stories/index.story.tsx b/packages/ui/src/calendar/stories/index.story.tsx new file mode 100644 index 00000000000000..81a893fbd99e58 --- /dev/null +++ b/packages/ui/src/calendar/stories/index.story.tsx @@ -0,0 +1,125 @@ +import { fn } from 'storybook/test'; +import { TZDate } from 'react-day-picker'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState, useEffect } from '@wordpress/element'; +import { Calendar } from '../index'; +import { + toDate, + SHARED_ARG_TYPES, + DISABLED_DATES_SAMPLE, + firstDayOfNextMonth, +} from './shared'; + +const meta: Meta< typeof Calendar > = { + tags: [ 'manifest' ], + title: 'Design System/Components/Calendar/Calendar', + component: Calendar, + parameters: { + componentStatus: { + status: 'recommended', + whereUsed: 'global', + }, + }, + render: ( { endMonth, ...args } ) => ( + + ), + argTypes: SHARED_ARG_TYPES, + args: { + onMonthChange: fn(), + onValueChange: fn(), + }, +}; +export default meta; + +type Story = StoryObj< typeof Calendar >; + +export const Default: Story = {}; + +export const DisabledDates: Story = { + args: { + disabled: DISABLED_DATES_SAMPLE, + }, +}; + +export const WithSelectedDateAndMonth: Story = { + args: { + defaultValue: firstDayOfNextMonth, + defaultMonth: firstDayOfNextMonth, + }, +}; + +/** + * Shows days from adjacent months in the grid. Outside days use a lighter style + * and are still interactive. Use `fixedWeeks` to keep the grid height constant. + */ +export const WithOutsideDays: Story = { + args: { + showOutsideDays: true, + fixedWeeks: true, + }, +}; + +/** + * When working with time zones, use the `TZDate` object from the + * [`@date-fns/tz`](https://www.npmjs.com/package/@date-fns/tz) package instead + * of the native `Date` object. + */ +export const WithTimeZone: Story = { + render: function CalendarWithTimeZone( { endMonth, ...args } ) { + const [ selected, setSelected ] = useState< TZDate | null >( null ); + + useEffect( () => { + setSelected( + // Select one week from today every time the time zone changes. + new TZDate( + new Date().setDate( new Date().getDate() + 7 ), + args.timeZone + ) + ); + }, [ args.timeZone ] ); + + return ( + <> + { + setSelected( + selectedDate + ? new TZDate( selectedDate, args.timeZone ) + : null + ); + args.onValueChange?.( selectedDate, ...rest ); + } } + disabled={ [ + { + // Disable any date before today + before: new TZDate( new Date(), args.timeZone ), + }, + ] } + /> + +

+ Calendar set to { args.timeZone ?? 'current' } timezone, + disabling selection for all dates before today, and starting + with a default date of 1 week from today. +

+ + ); + }, + args: { + timeZone: 'Pacific/Auckland', + }, + argTypes: { + value: { + control: false, + }, + defaultValue: { + control: false, + }, + disabled: { + control: false, + }, + }, +}; diff --git a/packages/ui/src/calendar/stories/range-calendar.story.tsx b/packages/ui/src/calendar/stories/range-calendar.story.tsx new file mode 100644 index 00000000000000..cf9ef1c638bce3 --- /dev/null +++ b/packages/ui/src/calendar/stories/range-calendar.story.tsx @@ -0,0 +1,150 @@ +import { fn } from 'storybook/test'; +import { TZDate } from 'react-day-picker'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState, useEffect } from '@wordpress/element'; +import { RangeCalendar } from '../index'; +import { + toDate, + SHARED_ARG_TYPES, + DISABLED_DATES_SAMPLE, + firstDayOfNextMonth, + fourthDayOfNextMonth, +} from './shared'; + +const meta: Meta< typeof RangeCalendar > = { + tags: [ 'manifest' ], + title: 'Design System/Components/Calendar/RangeCalendar', + component: RangeCalendar, + parameters: { + componentStatus: { + status: 'recommended', + whereUsed: 'global', + }, + }, + render: ( { endMonth, ...args } ) => ( + + ), + argTypes: { + ...SHARED_ARG_TYPES, + value: { control: false }, + defaultValue: { control: false }, + }, + args: { + onMonthChange: fn(), + onValueChange: fn(), + }, +}; +export default meta; + +type Story = StoryObj< typeof RangeCalendar >; + +export const Default: Story = {}; + +export const DisabledDates: Story = { + args: { + disabled: DISABLED_DATES_SAMPLE, + }, +}; + +export const WithSelectedRangeAndMonth: Story = { + args: { + defaultValue: { + from: firstDayOfNextMonth, + to: fourthDayOfNextMonth, + }, + defaultMonth: firstDayOfNextMonth, + }, +}; + +/** + * Use `min` and `max` to constrain the number of nights the range may span, and + * `excludeDisabled` to reset the range when it would include a disabled day. + */ +export const WithRangeConstraints: Story = { + args: { + min: 2, + max: 7, + excludeDisabled: true, + disabled: { dayOfWeek: [ 0, 6 ] }, + }, +}; + +/** + * Shows days from adjacent months in the grid. Outside days use a lighter style + * and are still interactive. Use `fixedWeeks` to keep the grid height constant. + */ +export const WithOutsideDays: Story = { + args: { + showOutsideDays: true, + fixedWeeks: true, + }, +}; + +/** + * When working with time zones, use the `TZDate` object from the + * [`@date-fns/tz`](https://www.npmjs.com/package/@date-fns/tz) package instead + * of the native `Date` object. + */ +export const WithTimeZone: Story = { + render: function RangeCalendarWithTimeZone( { endMonth, ...args } ) { + const [ range, setRange ] = useState< typeof args.value >( null ); + + useEffect( () => { + setRange( + // Select from one week from today to two weeks from today + // every time the timezone changes. + { + from: new TZDate( + new Date().setDate( new Date().getDate() + 7 ), + args.timeZone + ), + to: new TZDate( + new Date().setDate( new Date().getDate() + 14 ), + args.timeZone + ), + } + ); + }, [ args.timeZone ] ); + + return ( + <> + { + setRange( + // Set controlled state to null if there's no selection + ! selectedDate || + ( selectedDate.from === undefined && + selectedDate.to === undefined ) + ? null + : selectedDate + ); + args.onValueChange?.( selectedDate, ...rest ); + } } + disabled={ [ + { + // Disable any date before today + before: new TZDate( new Date(), args.timeZone ), + }, + ] } + /> +

+ Calendar set to { args.timeZone ?? 'current' } timezone, + disabling selection for all dates before today, and starting + with a default date range of 1 week from today to 2 weeks + from today. +

+ + ); + }, + args: { + timeZone: 'Pacific/Auckland', + }, + argTypes: { + disabled: { + control: false, + }, + }, +}; diff --git a/packages/ui/src/calendar/stories/shared.ts b/packages/ui/src/calendar/stories/shared.ts new file mode 100644 index 00000000000000..297fa0c0b5d3ce --- /dev/null +++ b/packages/ui/src/calendar/stories/shared.ts @@ -0,0 +1,139 @@ +import { + enUS, + fr, + es, + de, + it, + he, + ru, + ja, + ptBR, + nl, + ko, + tr, + id, + zhCN, + zhTW, + ar, + sv, +} from 'date-fns/locale'; + +// Storybook date controls pass a number, but react-day-picker expects a Date +// for props such as `endMonth`. +export function toDate( value: Date | number | undefined ): Date | undefined { + return value === undefined ? undefined : new Date( value ); +} + +/** + * The `argTypes` shared by `Calendar` and `RangeCalendar` — the entire + * `BaseProps` surface is common to both. + */ +export const SHARED_ARG_TYPES = { + locale: { + options: [ + 'English (US)', + 'French', + 'Spanish', + 'German', + 'Italian', + 'Hebrew', + 'Russian', + 'Japanese', + 'Portuguese (Brazil)', + 'Dutch', + 'Korean', + 'Turkish', + 'Indonesian', + 'Chinese (Simplified)', + 'Chinese (Traditional)', + 'Arabic', + 'Swedish', + ], + mapping: { + 'English (US)': enUS, + French: fr, + Spanish: es, + German: de, + Italian: it, + Hebrew: he, + Russian: ru, + Japanese: ja, + 'Portuguese (Brazil)': ptBR, + Dutch: nl, + Korean: ko, + Turkish: tr, + Indonesian: id, + 'Chinese (Simplified)': zhCN, + 'Chinese (Traditional)': zhTW, + Arabic: ar, + Swedish: sv, + }, + control: 'select', + }, + timeZone: { + options: [ + 'Pacific/Honolulu', + 'America/New_York', + 'Europe/London', + 'Asia/Tokyo', + 'Pacific/Auckland', + ], + control: 'select', + }, + labels: { + control: false, + }, + value: { control: 'date' }, + defaultValue: { control: 'date' }, + onValueChange: { + control: false, + }, + defaultMonth: { control: 'date' }, + month: { control: 'date' }, + onMonthChange: { + control: false, + }, + endMonth: { control: 'date' }, + startMonth: { control: 'date' }, + render: { control: false }, +} as const; + +/** + * A representative mix of every `Matcher` shape supported by the `disabled` + * prop. + */ +export const DISABLED_DATES_SAMPLE = [ + // Disable tomorrow (single date) + new Date( new Date().setDate( new Date().getDate() + 1 ) ), + // Disable all dates after Feb 1st of next year + { after: new Date( new Date().getFullYear() + 1, 1, 1 ) }, + // Disable all dates before Dec 1st of last year + { before: new Date( new Date().getFullYear() - 1, 11, 1 ) }, + // Disable all dates between 12th and 14th of August of this year + { + after: new Date( new Date().getFullYear(), 7, 11 ), + before: new Date( new Date().getFullYear(), 7, 15 ), + }, + // Disable all dates between 21st and 26th of October of this year + { + from: new Date( new Date().getFullYear(), 9, 21 ), + to: new Date( new Date().getFullYear(), 9, 26 ), + }, + // Disable all Wednesdays + { dayOfWeek: 3 }, + // Disable all prime day numbers + function isPrimeDate( date: Date ) { + return [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31 ].includes( + date.getDate() + ); + }, +]; + +const nextMonth = new Date().getMonth() === 11 ? 0 : new Date().getMonth() + 1; +const nextMonthYear = + new Date().getMonth() === 11 + ? new Date().getFullYear() + 1 + : new Date().getFullYear(); + +export const firstDayOfNextMonth = new Date( nextMonthYear, nextMonth, 1 ); +export const fourthDayOfNextMonth = new Date( nextMonthYear, nextMonth, 4 ); diff --git a/packages/ui/src/calendar/stories/usage-guidelines.mdx b/packages/ui/src/calendar/stories/usage-guidelines.mdx new file mode 100644 index 00000000000000..3a2fc04a04ac53 --- /dev/null +++ b/packages/ui/src/calendar/stories/usage-guidelines.mdx @@ -0,0 +1,133 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Calendar + +`Calendar` selects a **single date**. `RangeCalendar` selects a **date range**. Both share the same prop surface apart from the shape of `value` / `defaultValue` / `onValueChange`, and the range-only `min`, `max` and `excludeDisabled` props. + +Both are built for accessibility and follow ARIA best practices for calendar widgets: keyboard navigation, screen reader support, and customizable labels for internationalization. + +## Usage + +### Uncontrolled Mode + +The component manages its own selection. Use `defaultValue` to set the initial value, and `onValueChange` to react to changes. + +```jsx +import { Calendar } from '@wordpress/ui'; + +const MyUncontrolledCalendar = () => ( + console.log( 'Selected: ', date ) } + /> +); +``` + +### Controlled Mode + +Pass `value` and update it from `onValueChange`. Pass `null` to represent no selection while staying controlled. + +```jsx +import { useState } from 'react'; +import { Calendar } from '@wordpress/ui'; + +const MyControlledCalendar = () => { + const [ value, setValue ] = useState( new Date() ); + + return ; +}; +``` + +The displayed month is a separate piece of state, controlled the same way through `month` / `defaultMonth` and `onMonthChange`. + +### Range Selection + +```jsx +import { useState } from 'react'; +import { RangeCalendar } from '@wordpress/ui'; + +const MyRangeCalendar = () => { + const [ range, setRange ] = useState( null ); + + return ; +}; +``` + +Use `min` and `max` to constrain how many nights a range may span, and `excludeDisabled` to reset the range when a disabled day falls inside it. + +## Disabling Days + +The `disabled` prop takes a matcher, or an array of matchers. Every shape below is supported: + +```jsx +// Always matches — disables every day. +const booleanMatcher = true; + +// Matches a specific day. +const dateMatcher = new Date(); + +// Matches any of the days in the array. +const arrayMatcher = [ new Date( 2019, 1, 2 ), new Date( 2019, 1, 4 ) ]; + +// Matches days after the 2nd of February 2019 (exclusive). +const afterMatcher = { after: new Date( 2019, 1, 2 ) }; + +// Matches days before the 2nd of February 2019 (exclusive). +const beforeMatcher = { before: new Date( 2019, 1, 2 ) }; + +// Matches the days between the 2nd and the 5th of February 2019 (exclusive). +const intervalMatcher = { + after: new Date( 2019, 1, 2 ), + before: new Date( 2019, 1, 5 ), +}; + +// Matches the days between the 2nd and the 5th of February 2019 (inclusive). +const rangeMatcher = { + from: new Date( 2019, 1, 2 ), + to: new Date( 2019, 1, 5 ), +}; + +// Matches Sundays; an array matches weekends. +const dayOfWeekMatcher = { dayOfWeek: 0 }; +const weekendMatcher = { dayOfWeek: [ 0, 6 ] }; + +// Matches when the function returns true. +const functionMatcher = ( day ) => day.getMonth() === 2; +``` + +## Localization + +Dates are formatted with the `locale` prop, which takes a locale object from [`date-fns`](https://github.com/date-fns/date-fns/tree/main/src/locale). Text direction is derived from that locale, so an RTL locale flips the layout and the navigation chevrons automatically. + +```jsx +import { fr } from 'date-fns/locale'; +import { Calendar } from '@wordpress/ui'; + +const MyFrenchCalendar = () => ; +``` + +The visible strings (button labels, the "today" announcement) come from `@wordpress/i18n`, which uses the site locale rather than the `locale` prop. For a coherent experience, make sure the two agree — or override the strings through the `labels` prop. + +Use `weekStartsOn` to override the first day of the week that the locale would otherwise imply. + +## Time Zones + +Set the `timeZone` prop to an [IANA time zone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) to render the calendar in a zone other than the browser's. + +When you do, pass `TZDate` objects rather than native `Date` objects, otherwise the value you hand in is interpreted in the browser's zone and the selection can land on the wrong day. `TZDate` comes from [`@date-fns/tz`](https://www.npmjs.com/package/@date-fns/tz): + +```jsx +import { TZDate } from '@date-fns/tz'; +import { Calendar } from '@wordpress/ui'; + +const timeZone = 'America/New_York'; + +const MyCalendarWithTimeZone = () => ( + +); +``` diff --git a/packages/ui/src/calendar/style.module.css b/packages/ui/src/calendar/style.module.css new file mode 100644 index 00000000000000..58667dd9d9dcd0 --- /dev/null +++ b/packages/ui/src/calendar/style.module.css @@ -0,0 +1,426 @@ +@layer wp-ui { + @layer utilities, components, compositions, overrides; + + @layer components { + .root { + /* Internal variables */ + --wp-ui-calendar-cell-size: var(--wpds-dimension-size-md); + --wp-ui-calendar-range-middle-background-color: color-mix(in srgb, var(--wpds-color-background-interactive-brand-strong) 4%, transparent); + --wp-ui-calendar-preview-border-color: color-mix(in srgb, var(--wpds-color-background-interactive-brand-strong) 16%, transparent); + + position: relative; /* Required to position the navigation toolbar. */ + display: inline flow-root; + color: var(--wpds-color-foreground-content-neutral); + background-color: var(--wpds-color-background-surface-neutral-strong); + font-family: var(--wpds-typography-font-family-body); + font-size: var(--wpds-typography-font-size-md); + font-weight: var(--wpds-typography-font-weight-default); + z-index: 0; /* Create a stacking context and render on top of the background. */ + } + + .day { + padding: 0; + position: relative; + + /* + * Setting text color on the day container instead of directly on the + * day button to ensure that the color of the dot used to indicate today's + * date follows the same color as the button's text, since the button + * inherits its text color. + */ + &:has(.day-button:disabled) { + color: var(--wpds-color-foreground-interactive-neutral-disabled); + } + + &:has(.day-button:hover:not(:disabled)), + &:has(.day-button:focus-visible) { + color: var(--wpds-color-foreground-interactive-brand); + } + } + + .day-button { + background: none; + padding: 0; + margin: 0; + cursor: var(--wpds-cursor-control); + justify-content: center; + align-items: center; + display: flex; + position: relative; + + width: var(--wp-ui-calendar-cell-size); + height: var(--wp-ui-calendar-cell-size); + + border: none; + border-radius: var(--wpds-border-radius-sm); + + font: inherit; + font-variant-numeric: tabular-nums; + color: inherit; + + /* + * Use the button's ::before to render date's background, which keeps the + * border-radius of the button intact. This technique allows the focus ring + * to have rounded corners even when the background needs square corners + * (eg. in the middle of a date range). + */ + &::before { + content: ""; + position: absolute; + z-index: -1; + inset: 0; + border: none; /* No default border to avoid polluting high-contrast mode. */ + border-radius: var(--wpds-border-radius-sm); + } + + /* Use the button's ::after to show the selection preview. */ + &::after { + content: ""; + position: absolute; + z-index: 1; + inset: 0; + pointer-events: none; + } + + &:disabled { + cursor: revert; + + @media (forced-colors: active) { + /* + * As an extra visual cue, show a line-through on disabled days + * in forced-colors (high-contrast) mode. + */ + text-decoration: line-through; + } + } + } + + .caption-label { + z-index: 1; + + position: relative; + display: inline-flex; + align-items: center; + + white-space: nowrap; + border: 0; + + text-transform: capitalize; + } + + .chevron { + display: inline-block; + fill: currentColor; + width: var(--wpds-dimension-size-2xs); + height: var(--wpds-dimension-size-2xs); + } + + .root[dir="rtl"] .nav .chevron { + transform: rotate(180deg); + transform-origin: 50%; + } + + .month-caption { + display: flex; + justify-content: center; + align-content: center; + + height: var(--wp-ui-calendar-cell-size); + margin-bottom: var(--wpds-dimension-gap-md); + } + + .months { + position: relative; + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: var(--wpds-dimension-gap-lg); + max-width: fit-content; + } + + .month-grid { + border-collapse: separate; + border-spacing: 0 var(--wpds-dimension-gap-xs); + } + + .nav { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + inset-inline-end: 0; + + display: flex; + align-items: center; + justify-content: space-between; + + height: var(--wp-ui-calendar-cell-size); + } + + .weekday { + width: var(--wp-ui-calendar-cell-size); + height: var(--wp-ui-calendar-cell-size); + padding: 0; + + color: var(--wpds-color-foreground-content-neutral-weak); + font-weight: var(--wpds-typography-font-weight-default); + text-align: center; + text-transform: uppercase; + } + + /* DAY MODIFIERS */ + + /* Today's date: show a dot in the top-right corner of the button */ + .today::after { + content: ""; + position: absolute; + z-index: 1; + inset-block-start: 2px; + inset-inline-end: 2px; + width: 0; + height: 0; + border-radius: 50%; + border: 2px solid currentColor; + } + + /* Selected date button (individual date, range start, and range end) */ + .selected:not(.range-middle) { + &:has(.day-button, .day-button:hover:not(:disabled)) { + color: var(--wpds-color-foreground-interactive-neutral-strong); + } + + &:has(.day-button:disabled) { + color: var(--wpds-color-foreground-interactive-neutral-strong-disabled); + } + + .day-button { + &::before { + background-color: var(--wpds-color-background-interactive-neutral-strong); + /* + * Render a transparent border to highlight the selected day in + * forced-colors (high-contrast) mode, since the background is not + * visible. + */ + border: 1px solid transparent; + } + + &:disabled::before { + background-color: var(--wpds-color-background-interactive-neutral-strong-disabled); + } + + &:hover:not(:disabled)::before { + background-color: var(--wpds-color-background-interactive-neutral-strong-active); + } + } + } + + /* Outside month days (visible when showOutsideDays is true): use a lighter text color. */ + .outside { + color: var(--wpds-color-foreground-content-neutral-weak); + } + + /* Hidden button (ie. outside current month but still rendered) */ + .hidden { + visibility: hidden; + } + + /* Range start button, but not when start and end are the same day. */ + .range-start:not(.range-end) .day-button { + /* + * Apply border-radius changes to the button itself too, so that the focus + * ring follows the same shape as the button's background. + */ + &, + &::before { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + } + + /* Middle of date range */ + .range-middle .day-button { + &::before { + background-color: var(--wp-ui-calendar-range-middle-background-color); + border-radius: 0; + + /* + * Render a top and bottom transparent border to highlight the selected + * day in forced-colors (high-contrast) mode, since the background is not + * visible. + */ + border-width: 1px 0; + border-color: transparent; + border-style: solid; + } + } + + /* Range end button, but not when start and end are the same day. */ + .range-end:not(.range-start) .day-button { + /* + * Apply border-radius changes to the button itself too, so that the focus + * ring follows the same shape as the button's background. + */ + &, + &::before { + border-start-start-radius: 0; + border-end-start-radius: 0; + } + } + + /* + * RANGE PREVIEW (range calendar only) + * + * The preview is rendered in the button's ::after pseudo-element, so that it + * can be rendered over the button's contents. + * The selection preview is shown with a dashed border. In order to have + * control over the dash pattern (especially the seams between days), the + * dashed borders are rendered as SVGs via the url() CSS function. + * Since SVGs rendered in the url() function don't seem to be able to access + * CSS variables, we're using the SVGs as masks, and using `background-color` + * to consume the accent color variable. + */ + .preview svg { + position: absolute; + inset: 0; + pointer-events: none; + color: var(--wp-ui-calendar-preview-border-color); + + @media (forced-colors: active) { + color: inherit; + } + } + + .root[dir="rtl"] .preview svg { + transform: scaleX(-1); + } + + .preview.range-middle .day-button::before { + /* + * Remove the transparent border shown on the middle of the range + * in forced-colors (high-contrast) mode, to allow for the dashed border + * to be visible. + */ + border: none; + } + + /* ANIMATIONS */ + @keyframes slide-in-left { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(0); + } + } + + @keyframes slide-in-right { + 0% { + transform: translateX(100%); + } + + 100% { + transform: translateX(0); + } + } + + @keyframes slide-out-left { + 0% { + transform: translateX(0); + } + + 100% { + transform: translateX(-100%); + } + } + + @keyframes slide-out-right { + 0% { + transform: translateX(0); + } + + 100% { + transform: translateX(100%); + } + } + + @keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + + @keyframes fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } + } + + .weeks-before-enter, + .weeks-before-exit, + .weeks-after-enter, + .weeks-after-exit, + .caption-after-enter, + .caption-after-exit, + .caption-before-enter, + .caption-before-exit { + animation-duration: 0s; + animation-timing-function: var(--wpds-motion-easing-balanced); + animation-fill-mode: forwards; + + @media not (prefers-reduced-motion) { + animation-duration: var(--wpds-motion-duration-lg); + } + } + + .weeks-before-enter, + .root[dir="rtl"] .weeks-after-enter { + animation-name: slide-in-left; + } + + .weeks-before-exit, + .root[dir="rtl"] .weeks-after-exit { + animation-name: slide-out-left; + } + + .weeks-after-enter, + .root[dir="rtl"] .weeks-before-enter { + animation-name: slide-in-right; + } + + .weeks-after-exit, + .root[dir="rtl"] .weeks-before-exit { + animation-name: slide-out-right; + } + + .caption-after-enter, + .caption-before-enter { + animation-name: fade-in; + } + + .caption-after-exit, + .caption-before-exit { + animation-name: fade-out; + } + } + + @layer compositions { + /* + * The month navigation buttons render a `Button`. Square them off to the + * calendar's cell size, the same way `IconButton` does. + */ + .nav-button { + --wp-ui-button-aspect-ratio: 1; + --wp-ui-button-padding-inline: 0px; + --wp-ui-button-min-width: unset; + --wp-ui-button-height: var(--wp-ui-calendar-cell-size); + } + } +} diff --git a/packages/components/src/calendar/test/__utils__/index.ts b/packages/ui/src/calendar/test/__utils__/index.ts similarity index 100% rename from packages/components/src/calendar/test/__utils__/index.ts rename to packages/ui/src/calendar/test/__utils__/index.ts diff --git a/packages/components/src/calendar/test/date-calendar.tsx b/packages/ui/src/calendar/test/calendar.test.tsx similarity index 84% rename from packages/components/src/calendar/test/date-calendar.tsx rename to packages/ui/src/calendar/test/calendar.test.tsx index e900ee5dff9a3b..b6de82620caa27 100644 --- a/packages/components/src/calendar/test/date-calendar.tsx +++ b/packages/ui/src/calendar/test/calendar.test.tsx @@ -15,49 +15,50 @@ import { } from 'date-fns'; import { ar } from 'date-fns/locale'; import { useState } from '@wordpress/element'; -import { DateCalendar, TZDate } from '..'; +import { TZDate } from 'react-day-picker'; +import { Calendar } from '..'; import { getDateButton, getDateCell, queryDateCell, monthNameFormatter, } from './__utils__'; -import type { DateCalendarProps } from '../types'; +import type { CalendarProps } from '../types'; -const UncontrolledDateCalendar = ( - props: DateCalendarProps & { +const UncontrolledCalendar = ( + props: CalendarProps & { initialSelected?: Date | undefined | null; initialMonth?: Date | undefined; } ) => { return ( - ); }; -const ControlledDateCalendar = ( - props: DateCalendarProps & { +const ControlledCalendar = ( + props: CalendarProps & { initialSelected?: Date | undefined | null; initialMonth?: Date | undefined; } ) => { - const [ selected, setSelected ] = useState< Date | undefined | null >( - props.initialSelected + const [ selected, setSelected ] = useState< Date | null >( + props.initialSelected ?? null ); const [ month, setMonth ] = useState< Date | undefined >( props.initialMonth ); return ( - { + value={ selected } + onValueChange={ ( ...args ) => { setSelected( args[ 0 ] ); - props.onSelect?.( ...args ); + props.onValueChange?.( ...args ); } } month={ month } onMonthChange={ ( newMonth ) => { @@ -75,7 +76,7 @@ function setupUserEvent() { return user; } -describe( 'DateCalendar', () => { +describe( 'Calendar', () => { let today: Date; let tomorrow: Date; let yesterday: Date; @@ -106,7 +107,7 @@ describe( 'DateCalendar', () => { describe( 'Semantics and basic behavior', () => { it( 'should apply the correct roles, semantics and attributes', async () => { - render( ); + render( ); expect( screen.getByRole( 'application', { name: 'Date calendar' } ) @@ -127,7 +128,7 @@ describe( 'DateCalendar', () => { } ); it( 'should show multiple months at once via the `numberOfMonths` prop', () => { - render( ); + render( ); const grids = screen.getAllByRole( 'grid' ); expect( grids ).toHaveLength( 2 ); @@ -141,8 +142,8 @@ describe( 'DateCalendar', () => { } ); describe( 'Date selection', () => { - it( 'should select an initial date in uncontrolled mode via the `defaultSelected` prop', () => { - render( ); + it( 'should select an initial date in uncontrolled mode via the `defaultValue` prop', () => { + render( ); expect( getDateCell( today, { selected: true } ) ).toBeVisible(); @@ -151,11 +152,9 @@ describe( 'DateCalendar', () => { expect( todayButton ).toHaveAccessibleName( /selected/i ); } ); - it( 'should select an initial date in controlled mode via the `selected` prop', () => { - // Note: the `defaultSelected` prop is ignored when the `selected` prop is set. - render( - - ); + it( 'should select an initial date in controlled mode via the `value` prop', () => { + // Note: the `defaultValue` prop is ignored when the `value` prop is set. + render( ); expect( getDateCell( today, { selected: true } ) ).toBeVisible(); @@ -164,8 +163,8 @@ describe( 'DateCalendar', () => { expect( todayButton ).toHaveAccessibleName( /selected/i ); } ); - it( 'should have no date selected in uncontrolled mode when the `selected` and `defaultSelected` props are set to `undefined`', () => { - render( ); + it( 'should have no date selected in uncontrolled mode when no initial value is provided', () => { + render( ); expect( screen.queryByRole( 'gridcell', { selected: true } ) @@ -175,11 +174,9 @@ describe( 'DateCalendar', () => { ).not.toBeInTheDocument(); } ); - it( 'should have no date selected in controlled mode when the `selected` prop is set to `null`', () => { - // Note: the `defaultSelected` prop is ignored when the `selected` prop is set. - render( - - ); + it( 'should have no date selected in controlled mode when the `value` prop is set to `null`', () => { + // Note: the `defaultValue` prop is ignored when the `value` prop is set. + render( ); expect( screen.queryByRole( 'gridcell', { selected: true } ) @@ -189,12 +186,25 @@ describe( 'DateCalendar', () => { ).not.toBeInTheDocument(); } ); - it( 'should select a date in uncontrolled mode via the `defaultSelected` prop even if the date is disabled`', () => { + it( 'should stay controlled when a direct state setter clears the value', async () => { + const user = setupUserEvent(); + + function CalendarWithDirectStateSetter() { + const [ value, setValue ] = useState< Date | null >( today ); + return ; + } + + render( ); + await user.click( getDateButton( today ) ); + + expect( + screen.queryByRole( 'gridcell', { selected: true } ) + ).not.toBeInTheDocument(); + } ); + + it( 'should select a date in uncontrolled mode via the `defaultValue` prop even if the date is disabled`', () => { render( - + ); expect( getDateCell( tomorrow, { selected: true } ) ).toBeVisible(); @@ -205,10 +215,8 @@ describe( 'DateCalendar', () => { expect( tomorrowButton ).toBeDisabled(); } ); - it( 'should select a date in controlled mode via the `selected` prop even if the date is disabled`', () => { - render( - - ); + it( 'should select a date in controlled mode via the `value` prop even if the date is disabled`', () => { + render( ); expect( getDateCell( tomorrow, { selected: true } ) ).toBeVisible(); @@ -219,20 +227,20 @@ describe( 'DateCalendar', () => { } ); describe.each( [ - [ 'Uncontrolled', UncontrolledDateCalendar ], - [ 'Controlled', ControlledDateCalendar ], + [ 'Uncontrolled', UncontrolledCalendar ], + [ 'Controlled', ControlledCalendar ], ] )( '[`%s`]', ( _mode, Component ) => { it( 'should select a date when a date button is clicked', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( ); const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( today, today, expect.objectContaining( { today: true } ), @@ -249,15 +257,18 @@ describe( 'DateCalendar', () => { it( 'should not select a disabled date when a date button is clicked', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); render( - + ); await user.click( getDateButton( tomorrow ) ); - expect( onSelect ).not.toHaveBeenCalled(); + expect( onValueChange ).not.toHaveBeenCalled(); expect( screen.queryByRole( 'button', { name: /selected/i } ) ).not.toBeInTheDocument(); @@ -265,20 +276,20 @@ describe( 'DateCalendar', () => { it( 'should select a new date when a different date button is clicked', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); render( ); const tomorrowButton = getDateButton( tomorrow ); await user.click( tomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( tomorrow, tomorrow, expect.objectContaining( { today: false } ), @@ -295,21 +306,21 @@ describe( 'DateCalendar', () => { it( 'should de-select the selected date when the selected date button is clicked', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( + const { rerender } = render( ); const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( - undefined, + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( + null, today, expect.objectContaining( { today: true, selected: true } ), expect.objectContaining( { @@ -321,16 +332,26 @@ describe( 'DateCalendar', () => { expect( queryDateCell( today, { selected: true } ) ).not.toBeInTheDocument(); + + rerender( + + ); + expect( + queryDateCell( today, { selected: true } ) + ).not.toBeInTheDocument(); } ); it( 'should not de-select the selected date when the selected date button is clicked if the `required` prop is set to `true`', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); render( ); @@ -338,8 +359,8 @@ describe( 'DateCalendar', () => { const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( today, today, expect.objectContaining( { today: true, selected: true } ), @@ -357,7 +378,7 @@ describe( 'DateCalendar', () => { describe( 'Month navigation', () => { it( 'should select an initial month in uncontrolled mode via the `defaultMonth` prop', () => { - render( ); + render( ); expect( screen.getByRole( 'grid', { @@ -369,7 +390,7 @@ describe( 'DateCalendar', () => { } ); it( 'should select an initial month in controlled mode via the `month` prop', () => { - render( ); + render( ); expect( screen.getByRole( 'grid', { @@ -381,8 +402,8 @@ describe( 'DateCalendar', () => { } ); describe.each( [ - [ 'Uncontrolled', UncontrolledDateCalendar ], - [ 'Controlled', ControlledDateCalendar ], + [ 'Uncontrolled', UncontrolledCalendar ], + [ 'Controlled', ControlledCalendar ], ] )( '[`%s`]', ( _mode, Component ) => { it( 'should navigate to the previous and next months when the previous and next month buttons are clicked', async () => { const user = setupUserEvent(); @@ -485,7 +506,10 @@ describe( 'DateCalendar', () => { expect( getDateCell( nextNextMonth ) ).toBeVisible(); expect( getDateButton( nextNextMonth ) ).toBeVisible(); - expect( prevButton ).not.toHaveAttribute( 'aria-disabled' ); + expect( prevButton ).toHaveAttribute( + 'aria-disabled', + 'false' + ); } ); it( 'should not navigate to a month that is after the `endMonth` prop', async () => { @@ -535,7 +559,10 @@ describe( 'DateCalendar', () => { expect( getDateCell( prevPrevMonth ) ).toBeVisible(); expect( getDateButton( prevPrevMonth ) ).toBeVisible(); - expect( nextButton ).not.toHaveAttribute( 'aria-disabled' ); + expect( nextButton ).toHaveAttribute( + 'aria-disabled', + 'false' + ); } ); } ); } ); @@ -543,19 +570,19 @@ describe( 'DateCalendar', () => { describe( 'Keyboard focus and navigation', () => { it( 'should auto-focus the selected day when the `autoFocus` prop is set to `true`', async () => { // eslint-disable-next-line jsx-a11y/no-autofocus - render( ); + render( ); expect( getDateButton( tomorrow ) ).toHaveFocus(); } ); it( "should auto-focus today's date if there is not selected date when the `autoFocus` prop is set to `true`", async () => { // eslint-disable-next-line jsx-a11y/no-autofocus - render( ); + render( ); expect( getDateButton( today ) ).toHaveFocus(); } ); it( 'should focus each arrow as a tab stop, but treat the grid as a 2d composite widget', async () => { const user = setupUserEvent(); - render( ); + render( ); // Focus previous month button await user.tab(); @@ -655,7 +682,7 @@ describe( 'DateCalendar', () => { const user = setupUserEvent(); render( - { it( 'should focus the selected date when tabbing into the calendar', async () => { const user = setupUserEvent(); - render( ); + render( ); // Tab to the calendar grid await user.tab(); @@ -702,7 +729,7 @@ describe( 'DateCalendar', () => { it( 'should support disabling all dates via the `disabled` prop', async () => { const user = setupUserEvent(); - render( ); + render( ); within( screen.getByRole( 'grid' ) ) .getAllByRole( 'button' ) @@ -731,7 +758,7 @@ describe( 'DateCalendar', () => { } ); it( 'should support disabling single dates via the `disabled` prop', async () => { - render( ); + render( ); expect( getDateButton( tomorrow ) ).toBeDisabled(); } ); @@ -739,7 +766,7 @@ describe( 'DateCalendar', () => { it( 'should support passing a custom function via the `disabled` prop', async () => { const primeNumbers = [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31 ]; render( - primeNumbers.includes( date.getDate() ) } @@ -756,7 +783,7 @@ describe( 'DateCalendar', () => { } ); it( 'should support disabling all dates before a certain date via the `disabled` prop', async () => { - render( ); + render( ); for ( let date = 1; date < today.getDate(); date++ ) { expect( @@ -769,7 +796,7 @@ describe( 'DateCalendar', () => { } ); it( 'should support disabling all dates after a certain date via the `disabled` prop', async () => { - render( ); + render( ); for ( let date = today.getDate() + 1; date < 32; date++ ) { expect( @@ -783,7 +810,7 @@ describe( 'DateCalendar', () => { it( 'should support disabling all dates before a certain date and after a certain date via the `disabled` prop', async () => { render( - { it( 'should support disabling all dates within a certain date range via the `disabled` prop', async () => { render( - ); @@ -844,7 +871,7 @@ describe( 'DateCalendar', () => { it( 'should support disabling specific days of the week via the `disabled` prop', async () => { const weekendsInMay = [ 3, 4, 10, 11, 17, 18, 24, 25, 31 ]; - render( ); + render( ); for ( const date of weekendsInMay ) { expect( @@ -858,7 +885,7 @@ describe( 'DateCalendar', () => { it( 'should disable the previous and next months buttons if the `disableNavigation` is set to `true`', async () => { const user = setupUserEvent(); - render( ); + render( ); expect( screen.getByRole( 'button', { name: /previous month/i } ) @@ -880,7 +907,7 @@ describe( 'DateCalendar', () => { it( 'should localize the calendar based on the `locale` prop', async () => { const user = setupUserEvent(); - render( ); + render( ); // Check computed writing direction expect( @@ -911,10 +938,13 @@ describe( 'DateCalendar', () => { it( 'should support timezones according to the `timeZone` prop', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); render( - + ); // For someone in Tokyo, the current time simulated in the test @@ -932,7 +962,7 @@ describe( 'DateCalendar', () => { new TZDate( tomorrow, 'Asia/Tokyo' ).getTimezoneOffset() / 60 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledWith( tomorrowFromTokyoTimezone, tomorrowFromTokyoTimezone, expect.objectContaining( { today: true } ), @@ -951,8 +981,8 @@ describe( 'DateCalendar', () => { ); render( - diff --git a/packages/ui/src/calendar/test/localization.test.tsx b/packages/ui/src/calendar/test/localization.test.tsx new file mode 100644 index 00000000000000..3899c12f38b892 --- /dev/null +++ b/packages/ui/src/calendar/test/localization.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import { startOfDay } from 'date-fns'; +import { ckb, ug } from 'date-fns/locale'; +import { Calendar, RangeCalendar } from '..'; + +jest.mock( '@wordpress/i18n', () => { + const actual = jest.requireActual( '@wordpress/i18n' ); + const translations: Record< string, string > = { + 'Go to the Previous Month': 'Translated previous month', + 'Today, %s, selected': 'Today and selected: %s', + }; + + return { + ...actual, + __: ( text: string ) => translations[ text ] ?? text, + }; +} ); + +describe.each( [ + [ 'Calendar', Calendar ], + [ 'RangeCalendar', RangeCalendar ], +] as const )( '%s localization', ( _name, Component ) => { + it( 'should preserve localized defaults when overriding one label', () => { + render( + 'Custom next month' } } /> + ); + + expect( + screen.getByRole( 'button', { name: 'Custom next month' } ) + ).toBeVisible(); + expect( + screen.getByRole( 'button', { + name: 'Translated previous month', + } ) + ).toBeVisible(); + } ); +} ); + +describe( 'Calendar day labels', () => { + it( 'should announce when today is selected in a single calendar', () => { + const today = startOfDay( new Date() ); + render( ); + + expect( + screen.getByRole( 'button', { name: /^Today and selected:/ } ) + ).toBeVisible(); + } ); + + it( 'should announce when today is selected in a range calendar', () => { + const today = startOfDay( new Date() ); + render( ); + + expect( + screen.getByRole( 'button', { name: /^Today and selected:/ } ) + ).toBeVisible(); + } ); +} ); + +describe( 'Calendar text direction fallback', () => { + const getTextInfoDescriptor = Object.getOwnPropertyDescriptor( + Intl.Locale.prototype, + 'getTextInfo' + ); + + beforeAll( () => { + Object.defineProperty( Intl.Locale.prototype, 'getTextInfo', { + configurable: true, + value: undefined, + } ); + } ); + + afterAll( () => { + if ( getTextInfoDescriptor ) { + Object.defineProperty( + Intl.Locale.prototype, + 'getTextInfo', + getTextInfoDescriptor + ); + } else { + delete ( Intl.Locale.prototype as { getTextInfo?: unknown } ) + .getTextInfo; + } + } ); + + it.each( [ + [ 'Central Kurdish', ckb ], + [ 'Uyghur', ug ], + ] )( + 'should render %s right-to-left without Intl.Locale.getTextInfo', + ( _, locale ) => { + render( ); + + expect( + screen.getByRole( 'application', { name: 'Date calendar' } ) + ).toHaveAttribute( 'dir', 'rtl' ); + } + ); +} ); diff --git a/packages/components/src/calendar/test/date-range-calendar.tsx b/packages/ui/src/calendar/test/range-calendar.test.tsx similarity index 82% rename from packages/components/src/calendar/test/date-range-calendar.tsx rename to packages/ui/src/calendar/test/range-calendar.test.tsx index d8df678569e0cb..c92191e8534b77 100644 --- a/packages/components/src/calendar/test/date-range-calendar.tsx +++ b/packages/ui/src/calendar/test/range-calendar.test.tsx @@ -15,45 +15,45 @@ import { } from 'date-fns'; import { ar } from 'date-fns/locale'; import { useState } from '@wordpress/element'; -import { usePreviewRange, DateRangeCalendar } from '../date-range-calendar'; -import { TZDate } from '../'; +import { TZDate } from 'react-day-picker'; +import { usePreviewRange, RangeCalendar } from '../range-calendar'; import { getDateButton, getDateCell, monthNameFormatter } from './__utils__'; -import type { DateRange, DateRangeCalendarProps } from '../types'; +import type { DateRange, RangeCalendarProps } from '../types'; -const UncontrolledDateRangeCalendar = ( - props: DateRangeCalendarProps & { +const UncontrolledRangeCalendar = ( + props: RangeCalendarProps & { initialSelected?: DateRange | undefined | null; initialMonth?: Date | undefined; } ) => { return ( - ); }; -const ControlledDateRangeCalendar = ( - props: DateRangeCalendarProps & { +const ControlledRangeCalendar = ( + props: RangeCalendarProps & { initialSelected?: DateRange | undefined | null; initialMonth?: Date | undefined; } ) => { - const [ selected, setSelected ] = useState< DateRange | undefined | null >( - props.initialSelected + const [ selected, setSelected ] = useState< DateRange | null >( + props.initialSelected ?? null ); const [ month, setMonth ] = useState< Date | undefined >( props.initialMonth ); return ( - { + value={ selected } + onValueChange={ ( ...args ) => { setSelected( args[ 0 ] ); - props.onSelect?.( ...args ); + props.onValueChange?.( ...args ); } } month={ month } onMonthChange={ ( newMonth ) => { @@ -71,7 +71,7 @@ function setupUserEvent() { return user; } -describe( 'DateRangeCalendar', () => { +describe( 'RangeCalendar', () => { let today: Date; let tomorrow: Date; let yesterday: Date; @@ -103,7 +103,7 @@ describe( 'DateRangeCalendar', () => { describe( 'Semantics and basic behavior', () => { it( 'should apply the correct roles, semantics and attributes', async () => { - render( ); + render( ); expect( screen.getByRole( 'application', { @@ -126,7 +126,7 @@ describe( 'DateRangeCalendar', () => { } ); it( 'should show multiple months at once via the `numberOfMonths` prop', () => { - render( ); + render( ); const grids = screen.getAllByRole( 'grid' ); expect( grids ).toHaveLength( 2 ); @@ -140,9 +140,9 @@ describe( 'DateRangeCalendar', () => { } ); describe( 'Date selection', () => { - it( 'should select an initial date range in uncontrolled mode via the `defaultSelected` prop', () => { + it( 'should select an initial date range in uncontrolled mode via the `defaultValue` prop', () => { const dateRange = { from: today, to: tomorrow }; - render( ); + render( ); expect( getDateCell( today, { selected: true } ) ).toBeVisible(); expect( getDateCell( tomorrow, { selected: true } ) ).toBeVisible(); @@ -155,15 +155,15 @@ describe( 'DateRangeCalendar', () => { expect( tomorrowButton ).toHaveAccessibleName( /selected/i ); } ); - it( 'should select an initial date range in controlled mode via the `selected` prop', () => { + it( 'should select an initial date range in controlled mode via the `value` prop', () => { const defaultRange = { from: yesterday, to: today }; const controlledRange = { from: today, to: tomorrow }; - // Note: the `defaultSelected` prop is ignored when the `selected` prop is set. + // Note: the `defaultValue` prop is ignored when the `value` prop is set. render( - ); @@ -178,8 +178,8 @@ describe( 'DateRangeCalendar', () => { expect( tomorrowButton ).toHaveAccessibleName( /selected/i ); } ); - it( 'should have no date selected in uncontrolled mode when the `selected` prop is set to `undefined`', () => { - render( ); + it( 'should have no date selected in uncontrolled mode when no initial value is provided', () => { + render( ); expect( screen.queryByRole( 'gridcell', { selected: true } ) @@ -189,15 +189,12 @@ describe( 'DateRangeCalendar', () => { ).not.toBeInTheDocument(); } ); - it( 'should have no date selected in controlled mode when the `selected` prop is set to `null`', () => { + it( 'should have no date selected in controlled mode when the `value` prop is set to `null`', () => { const defaultRange = { from: today, to: tomorrow }; - // Note: the `defaultSelected` prop is ignored when the `selected` prop is set. + // Note: the `defaultValue` prop is ignored when the `value` prop is set. render( - + ); expect( @@ -208,12 +205,33 @@ describe( 'DateRangeCalendar', () => { ).not.toBeInTheDocument(); } ); - it( 'should select a date in uncontrolled mode via the `defaultSelected` prop even if the date is disabled`', () => { + it( 'should stay controlled when a direct state setter clears the value', async () => { + const user = setupUserEvent(); + const initialRange = { from: today, to: today }; + + function RangeCalendarWithDirectStateSetter() { + const [ value, setValue ] = useState< DateRange | null >( + initialRange + ); + return ( + + ); + } + + render( ); + await user.click( getDateButton( today ) ); + + expect( + screen.queryByRole( 'gridcell', { selected: true } ) + ).not.toBeInTheDocument(); + } ); + + it( 'should select a date in uncontrolled mode via the `defaultValue` prop even if the date is disabled`', () => { const defaultRange = { from: today, to: tomorrow }; render( - ); @@ -231,12 +249,12 @@ describe( 'DateRangeCalendar', () => { expect( tomorrowButton ).toBeDisabled(); } ); - it( 'should select a date in controlled mode via the `selected` prop even if the date is disabled`', () => { + it( 'should select a date in controlled mode via the `value` prop even if the date is disabled`', () => { const defaultRange = { from: today, to: tomorrow }; render( - ); @@ -255,20 +273,20 @@ describe( 'DateRangeCalendar', () => { } ); describe.each( [ - [ 'Uncontrolled', UncontrolledDateRangeCalendar ], - [ 'Controlled', ControlledDateRangeCalendar ], + [ 'Uncontrolled', UncontrolledRangeCalendar ], + [ 'Controlled', ControlledRangeCalendar ], ] )( '[`%s`]', ( _mode, Component ) => { it( 'should start selecting a range when a date button is clicked', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( ); const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( { from: today, to: today }, today, expect.objectContaining( { today: true } ), @@ -285,9 +303,9 @@ describe( 'DateRangeCalendar', () => { it( 'should complete a range selection when a second date button is clicked', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( ); const todayButton = getDateButton( today ); const tomorrowButton = getDateButton( tomorrow ); @@ -295,8 +313,8 @@ describe( 'DateRangeCalendar', () => { // First click - start range await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: today, to: today }, today, expect.objectContaining( { today: true } ), @@ -309,8 +327,8 @@ describe( 'DateRangeCalendar', () => { // Second click - complete range await user.click( tomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: today, to: tomorrow }, tomorrow, expect.objectContaining( { today: false } ), @@ -330,16 +348,16 @@ describe( 'DateRangeCalendar', () => { it( 'should handle selecting dates in reverse order (end date first)', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( ); // First click on tomorrow const tomorrowButton = getDateButton( tomorrow ); await user.click( tomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( { from: tomorrow, to: tomorrow }, tomorrow, expect.objectContaining( { today: false } ), @@ -353,8 +371,8 @@ describe( 'DateRangeCalendar', () => { const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 2, { from: today, to: tomorrow }, today, @@ -375,16 +393,16 @@ describe( 'DateRangeCalendar', () => { it( 'should expand the current range when clicking a third date after the existing range end', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( ); // First click - start range const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( { from: today, to: today }, today, expect.objectContaining( { today: true } ), @@ -398,8 +416,8 @@ describe( 'DateRangeCalendar', () => { const tomorrowButton = getDateButton( tomorrow ); await user.click( tomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 2, { from: today, to: tomorrow }, tomorrow, @@ -416,8 +434,8 @@ describe( 'DateRangeCalendar', () => { getDateButton( dayAfterTomorrow ); await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 3 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 3 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 3, { from: today, to: dayAfterTomorrow }, dayAfterTomorrow, @@ -431,16 +449,16 @@ describe( 'DateRangeCalendar', () => { it( 'should update the current range when clicking a third date in between the existing range start and end', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( ); // First click - start range const yesterdayButton = getDateButton( yesterday ); await user.click( yesterdayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( { from: yesterday, to: yesterday }, yesterday, expect.objectContaining( { today: false } ), @@ -456,8 +474,8 @@ describe( 'DateRangeCalendar', () => { getDateButton( dayAfterTomorrow ); await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 2, { from: yesterday, to: dayAfterTomorrow }, dayAfterTomorrow, @@ -472,8 +490,8 @@ describe( 'DateRangeCalendar', () => { const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 3 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 3 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 3, { from: yesterday, to: today }, today, @@ -487,16 +505,16 @@ describe( 'DateRangeCalendar', () => { it( 'should expand the current range when clicking a third date before the existing range start', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( ); // First click - start range const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( { from: today, to: today }, today, expect.objectContaining( { today: true } ), @@ -510,8 +528,8 @@ describe( 'DateRangeCalendar', () => { const tomorrowButton = getDateButton( tomorrow ); await user.click( tomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 2, { from: today, to: tomorrow }, tomorrow, @@ -526,8 +544,8 @@ describe( 'DateRangeCalendar', () => { const yesterdayButton = getDateButton( yesterday ); await user.click( yesterdayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 3 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 3 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 3, { from: yesterday, to: tomorrow }, yesterday, @@ -541,16 +559,19 @@ describe( 'DateRangeCalendar', () => { it( 'should not select a disabled date when a date button is clicked', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); render( - + ); const tomorrowButton = getDateButton( tomorrow ); await user.click( tomorrowButton ); - expect( onSelect ).not.toHaveBeenCalled(); + expect( onValueChange ).not.toHaveBeenCalled(); expect( screen.queryByRole( 'button', { name: /selected/i } ) ).not.toBeInTheDocument(); @@ -558,13 +579,13 @@ describe( 'DateRangeCalendar', () => { it( 'should clear the range when defining a one-day range and clicking on the same date again', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); const dayAfterTomorrow = addDays( today, 2 ); - render( + const { rerender } = render( { getDateButton( dayAfterTomorrow ); await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 1, { from: dayAfterTomorrow, to: dayAfterTomorrow }, dayAfterTomorrow, @@ -592,10 +613,10 @@ describe( 'DateRangeCalendar', () => { // Fourth click - remove date range await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 2, - undefined, + null, dayAfterTomorrow, expect.objectContaining( { today: false } ), expect.objectContaining( { @@ -603,17 +624,30 @@ describe( 'DateRangeCalendar', () => { target: dayAfterTomorrowButton, } ) ); + + rerender( + + ); + expect( + screen.queryByRole( 'gridcell', { selected: true } ) + ).not.toBeInTheDocument(); } ); it( 'should not clear the range when clicking a selected date if the `required` prop is set to `true`', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); const dayAfterTomorrow = addDays( today, 2 ); render( { getDateButton( dayAfterTomorrow ); await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 1, { from: dayAfterTomorrow, to: dayAfterTomorrow }, dayAfterTomorrow, @@ -642,8 +676,8 @@ describe( 'DateRangeCalendar', () => { // Fourth click - doesn't remove date range await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenNthCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenNthCalledWith( 2, { from: dayAfterTomorrow, to: dayAfterTomorrow }, dayAfterTomorrow, @@ -657,10 +691,13 @@ describe( 'DateRangeCalendar', () => { it( 'should complete a range selection even if there are disabled dates in the range', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); render( - + ); const todayButton = getDateButton( today ); @@ -668,8 +705,8 @@ describe( 'DateRangeCalendar', () => { // First click - start range await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: today, to: today }, today, expect.objectContaining( { today: true } ), @@ -686,8 +723,8 @@ describe( 'DateRangeCalendar', () => { getDateButton( dayAfterTomorrow ); await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: today, to: dayAfterTomorrow }, dayAfterTomorrow, expect.objectContaining( { today: false } ), @@ -700,11 +737,11 @@ describe( 'DateRangeCalendar', () => { it( 'should not complete a range selection if the `excludeDisabled` prop is set to `true` and there is at least one disabled date in the range', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); render( @@ -715,8 +752,8 @@ describe( 'DateRangeCalendar', () => { // First click - start range await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: today, to: today }, today, expect.objectContaining( { today: true } ), @@ -733,8 +770,8 @@ describe( 'DateRangeCalendar', () => { getDateButton( dayAfterTomorrow ); await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: dayAfterTomorrow, to: undefined }, dayAfterTomorrow, expect.objectContaining( { today: false } ), @@ -747,17 +784,19 @@ describe( 'DateRangeCalendar', () => { it( 'should not complete a range selection if the range has a duration of less than the value of the `min` prop', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( + + ); const todayButton = getDateButton( today ); // First click - start range await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: today, to: undefined }, today, expect.objectContaining( { today: true } ), @@ -774,8 +813,8 @@ describe( 'DateRangeCalendar', () => { getDateButton( dayAfterTomorrow ); await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: dayAfterTomorrow, to: undefined }, dayAfterTomorrow, expect.objectContaining( { today: false } ), @@ -790,8 +829,8 @@ describe( 'DateRangeCalendar', () => { const yesterdayButton = getDateButton( yesterday ); await user.click( yesterdayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 3 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 3 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: yesterday, to: dayAfterTomorrow }, yesterday, expect.objectContaining( { today: false } ), @@ -804,16 +843,18 @@ describe( 'DateRangeCalendar', () => { it( 'should not complete a range selection if the range has a duration of more than the value of the `max` prop', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); - render( ); + render( + + ); // First click - start range const yesterdayButton = getDateButton( yesterday ); await user.click( yesterdayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: yesterday, to: yesterday }, yesterday, expect.objectContaining( { today: false } ), @@ -830,8 +871,8 @@ describe( 'DateRangeCalendar', () => { getDateButton( dayAfterTomorrow ); await user.click( dayAfterTomorrowButton ); - expect( onSelect ).toHaveBeenCalledTimes( 2 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 2 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: dayAfterTomorrow, to: undefined }, dayAfterTomorrow, expect.objectContaining( { today: false } ), @@ -846,8 +887,8 @@ describe( 'DateRangeCalendar', () => { const todayButton = getDateButton( today ); await user.click( todayButton ); - expect( onSelect ).toHaveBeenCalledTimes( 3 ); - expect( onSelect ).toHaveBeenLastCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 3 ); + expect( onValueChange ).toHaveBeenLastCalledWith( { from: today, to: dayAfterTomorrow }, today, expect.objectContaining( { today: true } ), @@ -862,7 +903,7 @@ describe( 'DateRangeCalendar', () => { describe( 'Month navigation', () => { it( 'should select an initial month in uncontrolled mode via the `defaultMonth` prop', () => { - render( ); + render( ); expect( screen.getByRole( 'grid', { @@ -874,7 +915,7 @@ describe( 'DateRangeCalendar', () => { } ); it( 'should select an initial month in controlled mode via the `month` prop', () => { - render( ); + render( ); expect( screen.getByRole( 'grid', { @@ -886,8 +927,8 @@ describe( 'DateRangeCalendar', () => { } ); describe.each( [ - [ 'Uncontrolled', UncontrolledDateRangeCalendar ], - [ 'Controlled', ControlledDateRangeCalendar ], + [ 'Uncontrolled', UncontrolledRangeCalendar ], + [ 'Controlled', ControlledRangeCalendar ], ] )( '[`%s`]', ( _mode, Component ) => { it( 'should navigate to the previous and next months when the previous and next month buttons are clicked', async () => { const user = setupUserEvent(); @@ -990,7 +1031,10 @@ describe( 'DateRangeCalendar', () => { expect( getDateCell( nextNextMonth ) ).toBeVisible(); expect( getDateButton( nextNextMonth ) ).toBeVisible(); - expect( prevButton ).not.toHaveAttribute( 'aria-disabled' ); + expect( prevButton ).toHaveAttribute( + 'aria-disabled', + 'false' + ); } ); it( 'should not navigate to a month that is after the `endMonth` prop', async () => { @@ -1040,7 +1084,10 @@ describe( 'DateRangeCalendar', () => { expect( getDateCell( prevPrevMonth ) ).toBeVisible(); expect( getDateButton( prevPrevMonth ) ).toBeVisible(); - expect( nextButton ).not.toHaveAttribute( 'aria-disabled' ); + expect( nextButton ).toHaveAttribute( + 'aria-disabled', + 'false' + ); } ); } ); } ); @@ -1048,10 +1095,10 @@ describe( 'DateRangeCalendar', () => { describe( 'Keyboard focus and navigation', () => { it( 'should auto-focus the selected day when the `autoFocus` prop is set to `true`', async () => { render( - ); expect( getDateButton( today ) ).toHaveFocus(); @@ -1059,13 +1106,13 @@ describe( 'DateRangeCalendar', () => { it( "should auto-focus today's date if there is not selected date when the `autoFocus` prop is set to `true`", async () => { // eslint-disable-next-line jsx-a11y/no-autofocus - render( ); + render( ); expect( getDateButton( today ) ).toHaveFocus(); } ); it( 'should focus each arrow as a tab stop, but treat the grid as a 2d composite widget', async () => { const user = setupUserEvent(); - render( ); + render( ); // Focus previous month button await user.tab(); @@ -1165,7 +1212,7 @@ describe( 'DateRangeCalendar', () => { const user = setupUserEvent(); render( - { it( 'should focus the selected date when tabbing into the calendar', async () => { const user = setupUserEvent(); - render( - - ); + render( ); // Tab to the calendar grid await user.tab(); @@ -1214,7 +1259,7 @@ describe( 'DateRangeCalendar', () => { it( 'should support disabling all dates via the `disabled` prop', async () => { const user = setupUserEvent(); - render( ); + render( ); within( screen.getByRole( 'grid' ) ) .getAllByRole( 'button' ) @@ -1243,7 +1288,7 @@ describe( 'DateRangeCalendar', () => { } ); it( 'should support disabling single dates via the `disabled` prop', async () => { - render( ); + render( ); expect( getDateButton( tomorrow ) ).toBeDisabled(); } ); @@ -1251,7 +1296,7 @@ describe( 'DateRangeCalendar', () => { it( 'should support passing a custom function via the `disabled` prop', async () => { const primeNumbers = [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31 ]; render( - primeNumbers.includes( date.getDate() ) } @@ -1268,7 +1313,7 @@ describe( 'DateRangeCalendar', () => { } ); it( 'should support disabling all dates before a certain date via the `disabled` prop', async () => { - render( ); + render( ); for ( let date = 1; date < today.getDate(); date++ ) { expect( @@ -1281,7 +1326,7 @@ describe( 'DateRangeCalendar', () => { } ); it( 'should support disabling all dates after a certain date via the `disabled` prop', async () => { - render( ); + render( ); for ( let date = today.getDate() + 1; date < 32; date++ ) { expect( @@ -1295,7 +1340,7 @@ describe( 'DateRangeCalendar', () => { it( 'should support disabling all dates before a certain date and after a certain date via the `disabled` prop', async () => { render( - { it( 'should support disabling all dates within a certain date range via the `disabled` prop', async () => { render( - ); @@ -1356,9 +1401,7 @@ describe( 'DateRangeCalendar', () => { it( 'should support disabling specific days of the week via the `disabled` prop', async () => { const weekendsInMay = [ 3, 4, 10, 11, 17, 18, 24, 25, 31 ]; - render( - - ); + render( ); for ( const date of weekendsInMay ) { expect( @@ -1372,7 +1415,7 @@ describe( 'DateRangeCalendar', () => { it( 'should disable the previous and next months buttons if the `disableNavigation` is set to `true`', async () => { const user = setupUserEvent(); - render( ); + render( ); expect( screen.getByRole( 'button', { name: /previous month/i } ) @@ -1394,7 +1437,7 @@ describe( 'DateRangeCalendar', () => { it( 'should localize the calendar based on the `locale` prop', async () => { const user = setupUserEvent(); - render( ); + render( ); // Check computed writing direction expect( @@ -1427,12 +1470,12 @@ describe( 'DateRangeCalendar', () => { it( 'should support timezones according to the `timeZone` prop', async () => { const user = setupUserEvent(); - const onSelect = jest.fn(); + const onValueChange = jest.fn(); render( - ); @@ -1451,8 +1494,8 @@ describe( 'DateRangeCalendar', () => { new TZDate( tomorrow, 'Asia/Tokyo' ).getTimezoneOffset() / 60 ); - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( + expect( onValueChange ).toHaveBeenCalledTimes( 1 ); + expect( onValueChange ).toHaveBeenCalledWith( { from: tomorrowFromTokyoTimezone, to: tomorrowFromTokyoTimezone, @@ -1482,8 +1525,8 @@ describe( 'DateRangeCalendar', () => { }; render( - @@ -1505,7 +1548,7 @@ describe( 'DateRangeCalendar', () => { it( 'should return undefined when there is no hovered date', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday, to: previewTomorrow }, + value: { from: previewToday, to: previewTomorrow }, hoveredDate: undefined, } ) ); @@ -1516,7 +1559,7 @@ describe( 'DateRangeCalendar', () => { it( 'should return undefined when there is no selected date', () => { const { result } = renderHook( () => usePreviewRange( { - selected: undefined, + value: undefined, hoveredDate: previewToday, } ) ); @@ -1527,7 +1570,7 @@ describe( 'DateRangeCalendar', () => { it( 'should return undefined when there is no selected start date', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: undefined, to: previewTomorrow }, + value: { from: undefined, to: previewTomorrow }, hoveredDate: previewToday, } ) ); @@ -1538,7 +1581,7 @@ describe( 'DateRangeCalendar', () => { it( 'should show preview when hovering before selected range', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday, to: previewTomorrow }, + value: { from: previewToday, to: previewTomorrow }, hoveredDate: previewYesterday, } ) ); @@ -1552,7 +1595,7 @@ describe( 'DateRangeCalendar', () => { it( 'should show preview when hovering between selected range dates', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewYesterday, to: previewTomorrow }, + value: { from: previewYesterday, to: previewTomorrow }, hoveredDate: previewToday, } ) ); @@ -1566,7 +1609,7 @@ describe( 'DateRangeCalendar', () => { it( 'should show preview when hovering after selected range', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewYesterday, to: previewToday }, + value: { from: previewYesterday, to: previewToday }, hoveredDate: previewTomorrow, } ) ); @@ -1580,7 +1623,7 @@ describe( 'DateRangeCalendar', () => { it( 'should show preview when hovering after selected range with no end date', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday }, + value: { from: previewToday }, hoveredDate: previewTomorrow, } ) ); @@ -1595,7 +1638,7 @@ describe( 'DateRangeCalendar', () => { it( 'should collapse preview to single date when range is less than min', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday }, + value: { from: previewToday }, hoveredDate: previewTomorrow, min: 3, } ) @@ -1610,7 +1653,7 @@ describe( 'DateRangeCalendar', () => { it( 'should allow preview when range meets min requirement', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday }, + value: { from: previewToday }, hoveredDate: previewNextWeek, min: 3, } ) @@ -1627,7 +1670,7 @@ describe( 'DateRangeCalendar', () => { it( 'should collapse preview to single date when range exceeds max', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday }, + value: { from: previewToday }, hoveredDate: previewNextWeek, max: 3, } ) @@ -1642,7 +1685,7 @@ describe( 'DateRangeCalendar', () => { it( 'should allow preview when range meets max requirement', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday }, + value: { from: previewToday }, hoveredDate: previewTomorrow, max: 3, } ) @@ -1659,7 +1702,7 @@ describe( 'DateRangeCalendar', () => { it( 'should collapse preview to single date when range contains disabled dates and excludeDisabled is true', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday }, + value: { from: previewToday }, hoveredDate: previewNextWeek, disabled: [ previewTomorrow ], excludeDisabled: true, @@ -1675,7 +1718,7 @@ describe( 'DateRangeCalendar', () => { it( 'should allow preview when range contains disabled dates but excludeDisabled is false', () => { const { result } = renderHook( () => usePreviewRange( { - selected: { from: previewToday }, + value: { from: previewToday }, hoveredDate: previewNextWeek, disabled: [ previewTomorrow ], excludeDisabled: false, diff --git a/packages/ui/src/calendar/test/render-prop.test.tsx b/packages/ui/src/calendar/test/render-prop.test.tsx new file mode 100644 index 00000000000000..0b52e4e0cc8f7b --- /dev/null +++ b/packages/ui/src/calendar/test/render-prop.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, within } from '@testing-library/react'; +import { createRef } from '@wordpress/element'; +import { Calendar, RangeCalendar } from '..'; + +describe.each( [ + [ 'Calendar', Calendar ], + [ 'RangeCalendar', RangeCalendar ], +] )( '%s', ( _name, Component ) => { + describe( 'render prop', () => { + it( 'should render a `div` by default', () => { + render( ); + + expect( screen.getByLabelText( 'Test calendar' ).tagName ).toBe( + 'DIV' + ); + } ); + + it( 'should replace the root element when `render` is provided', () => { + render( + } /> + ); + + const root = screen.getByLabelText( 'Test calendar' ); + expect( root.tagName ).toBe( 'SECTION' ); + // The calendar still renders its contents inside the custom element. + expect( within( root ).getByRole( 'grid' ) ).toBeVisible(); + } ); + + it( 'should merge the class names of the custom element with the calendar ones', () => { + render( + } + /> + ); + + const root = screen.getByLabelText( 'Test calendar' ); + expect( root ).toHaveClass( 'from-prop' ); + expect( root ).toHaveClass( 'from-render' ); + } ); + + it( 'should not remount the calendar when an inline `render` element is re-created', () => { + const { rerender } = render( + } /> + ); + + const rootBefore = screen.getByLabelText( 'Test calendar' ); + + // Re-render with a brand new `render` element object. + rerender( + } /> + ); + + expect( screen.getByLabelText( 'Test calendar' ) ).toBe( + rootBefore + ); + } ); + } ); + + describe( 'ref forwarding', () => { + it( 'should forward the ref to the root element', () => { + const ref = createRef< HTMLDivElement >(); + render( ); + + expect( ref.current ).toBe( + screen.getByLabelText( 'Test calendar' ) + ); + } ); + + it( 'should forward the ref to the element provided through `render`', () => { + const ref = createRef< HTMLDivElement >(); + render( + } + /> + ); + + expect( ref.current ).toBe( + screen.getByLabelText( 'Test calendar' ) + ); + expect( ref.current?.tagName ).toBe( 'SECTION' ); + } ); + } ); +} ); diff --git a/packages/components/src/calendar/types.ts b/packages/ui/src/calendar/types.ts similarity index 81% rename from packages/components/src/calendar/types.ts rename to packages/ui/src/calendar/types.ts index c0032edf605348..fb730cbe826b08 100644 --- a/packages/components/src/calendar/types.ts +++ b/packages/ui/src/calendar/types.ts @@ -1,5 +1,6 @@ import type { Locale } from 'date-fns'; import type * as React from 'react'; +import type { ComponentProps } from '../utils/types'; /** * Represents the modifiers that match a specific day in the calendar. @@ -131,27 +132,27 @@ type DayOfWeek = { }; /** - * Shared handler type for `onSelect` callback when a selection mode is set. + * Shared handler type for `onValueChange` callbacks. * @example - * const handleSelect: OnSelectHandler = ( - * selected, + * const handleValueChange: OnValueChangeHandler = ( + * value, * triggerDate, * modifiers, * e * ) => { - * console.log( "Selected:", selected ); + * console.log( "Value:", value ); * console.log( "Triggered by:", triggerDate ); * }; - * @template T - The type of the selected item. - * @callback OnSelectHandler - * @param {T} selected - The selected item after the event. + * @template T - The value type. + * @callback OnValueChangeHandler + * @param {T} value - The new value after the event. * @param {Date} triggerDate - The date when the event was triggered. This is * typically the day clicked or interacted with. * @param {Modifiers} modifiers - The modifiers associated with the event. * @param {React.MouseEvent | React.KeyboardEvent} e - The event object. */ -export type OnSelectHandler< T > = ( - selected: T, +export type OnValueChangeHandler< T > = ( + value: T, triggerDate: Date, modifiers: Modifiers, e: React.MouseEvent | React.KeyboardEvent @@ -159,8 +160,8 @@ export type OnSelectHandler< T > = ( export interface BaseProps extends Omit< - React.HTMLAttributes< HTMLDivElement >, - 'onSelect' | 'defaultValue' + ComponentProps< 'div' >, + 'onSelect' | 'defaultValue' | 'role' > { /** * Whether the selection is required. @@ -265,12 +266,12 @@ export interface BaseProps /** * The locale object used to localize dates. Pass a locale from - * `@date-fns/locale` to localize the calendar. + * `date-fns/locale` to localize the calendar. * * For a correct localized experience, consumers should make sure the locale * used for the translated labels and `locale` prop are consistent. * @see https://github.com/date-fns/date-fns/tree/main/src/locale for a list of the supported locales - * @default The `enUS` locale from `@date-fns/locale` + * @default The `enUS` locale from `date-fns/locale` */ locale?: Locale; /** @@ -290,43 +291,43 @@ export interface BaseProps * [Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) * for the possible values. * - * When working with time zones, use the `TZDate` object exported by this - * package instead of the native `Date` object. + * When working with time zones, use the `TZDate` object from the + * [`@date-fns/tz`](https://www.npmjs.com/package/@date-fns/tz) package + * instead of the native `Date` object. * @example - * import { DateCalendar, TZDate } from "@wordpress/components"; + * import { TZDate } from "@date-fns/tz"; + * import { Calendar } from "@wordpress/ui"; * * export function WithTimeZone() { * const timeZone = "America/New_York"; - * const [ selected, setSelected ] = useState< Date | undefined >( + * const [ value, setValue ] = useState< Date | null >( * new TZDate( 2024, 12, 10, timeZone ) // Use `TZDate` instead of `Date` * ); * return ( - * * ); * } */ timeZone?: string; - /** - * The role attribute to add to the container element. - * @default 'application' - */ - role?: 'application' | 'dialog' | undefined; } -interface SingleProps { - /** The selected date. */ - selected?: Date | undefined | null; - /** Event handler when a day is selected. */ - onSelect?: OnSelectHandler< Date | undefined >; - /** The default selected date (for uncontrolled usage). */ - defaultSelected?: Date; +export interface SingleProps { + /** + * The selected date (controlled). Use `null` when there is no selection. + * To render an uncontrolled calendar, use `defaultValue` instead. + */ + value?: Date | null; + /** Event handler called when the selected date changes. */ + onValueChange?: OnValueChangeHandler< Date | null >; + /** The initially selected date (uncontrolled). */ + defaultValue?: Date; } -interface RangeProps { +export interface RangeProps { /** * When `true`, the range will reset when including a disabled day. */ @@ -339,13 +340,16 @@ interface RangeProps { * The maximum number of nights to include in the range. */ max?: number; - /** The selected range. */ - selected?: DateRange | undefined | null; - /** Event handler when the selection changes. */ - onSelect?: OnSelectHandler< DateRange | undefined >; - /** The default selected range (for uncontrolled usage). */ - defaultSelected?: DateRange; + /** + * The selected range (controlled). Use `null` when there is no selection. + * To render an uncontrolled calendar, use `defaultValue` instead. + */ + value?: DateRange | null; + /** Event handler called when the selected range changes. */ + onValueChange?: OnValueChangeHandler< DateRange | null >; + /** The initially selected range (uncontrolled). */ + defaultValue?: DateRange; } -export type DateCalendarProps = BaseProps & SingleProps; -export type DateRangeCalendarProps = BaseProps & RangeProps; +export type CalendarProps = BaseProps & SingleProps; +export type RangeCalendarProps = BaseProps & RangeProps; diff --git a/packages/components/src/calendar/utils/day-cell.tsx b/packages/ui/src/calendar/utils/components.tsx similarity index 62% rename from packages/components/src/calendar/utils/day-cell.tsx rename to packages/ui/src/calendar/utils/components.tsx index 72c1390c860337..2be6d9d02f30de 100644 --- a/packages/components/src/calendar/utils/day-cell.tsx +++ b/packages/ui/src/calendar/utils/components.tsx @@ -1,4 +1,11 @@ -import type { CalendarDay } from 'react-day-picker'; +import { useRender } from '@base-ui/react'; +import type { CalendarDay, RootProps, ChevronProps } from 'react-day-picker'; +import { useContext } from '@wordpress/element'; +import { useMergeRefs } from '@wordpress/compose'; +import { chevronLeft, chevronRight } from '@wordpress/icons'; +import { Button } from '../../button'; +import { Icon } from '../../icon'; +import { RootContext } from './root-context'; import type { Modifiers } from '../types'; /** @@ -125,3 +132,63 @@ export function Day( ); } + +/** + * Render the root element of the calendar. + * + * Wired to `useRender` so that consumers can swap the underlying element + * through the `render` prop, like every other `@wordpress/ui` component. + * @see https://daypicker.dev/guides/custom-components + */ +export function Root( { rootRef, ...props }: RootProps ) { + const { render, ref } = useContext( RootContext ); + + // `rootRef` is only set by `react-day-picker` when `animate` is enabled. + const mergedRef = useMergeRefs( [ rootRef ?? null, ref ?? null ] ); + + return useRender( { + render, + defaultTagName: 'div', + ref: mergedRef, + props, + } ); +} + +/** + * Render the chevron icon used in the navigation buttons. + * @see https://daypicker.dev/guides/custom-components + */ +export function Chevron( { orientation, className }: ChevronProps ) { + return ( + + ); +} + +/** + * Render a month navigation button. + * + * `react-day-picker` marks the button as `aria-disabled` (rather than + * `disabled`) when there is no month to navigate to, so that it stays + * discoverable. `Button`'s `focusableWhenDisabled` produces the same DOM. + * @see https://daypicker.dev/guides/custom-components + */ +export function NavButton( { + className, + 'aria-disabled': ariaDisabled, + ...props +}: React.ButtonHTMLAttributes< HTMLButtonElement > ) { + return ( +