diff --git a/apps/docs/docs/components/other/Calendar/_mobileExamples.mdx b/apps/docs/docs/components/other/Calendar/_mobileExamples.mdx new file mode 100644 index 000000000..b36dd16cd --- /dev/null +++ b/apps/docs/docs/components/other/Calendar/_mobileExamples.mdx @@ -0,0 +1,232 @@ +### Basic usage + +A basic Calendar with date selection functionality. The Calendar component is used within the DatePicker and can also be used independently. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + return ; +} +``` + +### No selection + +A Calendar without an initially selected date. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(null); + + return ; +} +``` + +### Seeding the calendar + +The `seedDate` prop controls which month the Calendar opens to when there is no selected date value. Defaults to today when undefined. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(null); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const seedDate = new Date(today.getFullYear(), today.getMonth() + 1, 15); + + return ; +} +``` + +### Minimum and maximum dates + +Use `minDate` and `maxDate` to restrict the selectable date range. Navigation to dates before the `minDate` and after the `maxDate` is disabled. Make sure to provide the `disabledDateError` prop. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const lastMonth15th = new Date(today.getFullYear(), today.getMonth() - 1, 15); + const nextMonth15th = new Date(today.getFullYear(), today.getMonth() + 1, 15); + + return ( + + ); +} +``` + +### Future dates only + +Restrict selection to future dates by setting `minDate` to today. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(null); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + + return ( + + ); +} +``` + +### Highlighted dates + +Use `highlightedDates` to visually emphasize specific dates or date ranges. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1); + const nextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); + + return ( + + ); +} +``` + +### Disabled dates + +Use `disabledDates` to prevent selection of specific dates or date ranges. Make sure to provide the `disabledDateError` prop. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(null); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + + // Disable weekends for demonstration + const getNextWeekendDates = (centerDate) => { + const weekends = []; + const currentDate = new Date(centerDate); + + // Find next 4 weekends + for (let i = 0; i < 4; i++) { + // Find next Saturday + const daysUntilSaturday = (6 - currentDate.getDay() + 7) % 7 || 7; + currentDate.setDate(currentDate.getDate() + daysUntilSaturday); + + const saturday = new Date(currentDate); + const sunday = new Date(currentDate); + sunday.setDate(sunday.getDate() + 1); + + weekends.push([saturday, sunday]); + + // Move to next week + currentDate.setDate(currentDate.getDate() + 7); + } + + return weekends; + }; + + return ( + + ); +} +``` + +### Date ranges + +Highlight a date range using a tuple `[startDate, endDate]`. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1); + const nextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); + + return ( + + ); +} +``` + +### Hidden controls + +Hide the navigation arrows with `hideControls`. This is typically used when `minDate` and `maxDate` are set to the first and last days of the same month. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + return ( + + ); +} +``` + +### Disabled + +Disable the entire Calendar with the `disabled` prop. + +```jsx +function Example() { + const selectedDate = new Date(); + + return ; +} +``` + +### Accessibility + +Always provide accessibility labels for the navigation controls and error messages for disabled dates. + +```jsx +function Example() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, today.getDate()); + + return ( + + ); +} +``` diff --git a/apps/docs/docs/components/other/Calendar/_mobilePropsTable.mdx b/apps/docs/docs/components/other/Calendar/_mobilePropsTable.mdx new file mode 100644 index 000000000..5d811d0d8 --- /dev/null +++ b/apps/docs/docs/components/other/Calendar/_mobilePropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import mobilePropsData from ':docgen/mobile/dates/Calendar/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/other/Calendar/index.mdx b/apps/docs/docs/components/other/Calendar/index.mdx index 5d8c7137e..1aed2a0d4 100644 --- a/apps/docs/docs/components/other/Calendar/index.mdx +++ b/apps/docs/docs/components/other/Calendar/index.mdx @@ -1,7 +1,7 @@ --- id: calendar title: Calendar -platform_switcher_options: { web: true, mobile: false } +platform_switcher_options: { web: true, mobile: true } hide_title: true --- @@ -10,16 +10,26 @@ import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; import webPropsToc from ':docgen/web/dates/Calendar/toc-props'; +import mobilePropsToc from ':docgen/mobile/dates/Calendar/toc-props'; + import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; + import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; - + } webExamples={} + mobilePropsTable={} + mobileExamples={} webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} webPropsToc={webPropsToc} + mobilePropsToc={mobilePropsToc} /> diff --git a/apps/docs/docs/components/other/Calendar/mobileMetadata.json b/apps/docs/docs/components/other/Calendar/mobileMetadata.json new file mode 100644 index 000000000..297b563f5 --- /dev/null +++ b/apps/docs/docs/components/other/Calendar/mobileMetadata.json @@ -0,0 +1,11 @@ +{ + "import": "import { Calendar } from '@coinbase/cds-mobile/dates/Calendar'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/dates/Calendar.tsx", + "description": "Calendar is a flexible, accessible date grid component for selecting dates, supporting keyboard navigation, disabled/highlighted dates, and custom rendering.", + "relatedComponents": [ + { + "label": "DatePicker", + "url": "/components/other/DatePicker/" + } + ] +} diff --git a/apps/docs/docs/components/other/DatePicker/_mobileExamples.mdx b/apps/docs/docs/components/other/DatePicker/_mobileExamples.mdx index 7f5abec8e..644e36db9 100644 --- a/apps/docs/docs/components/other/DatePicker/_mobileExamples.mdx +++ b/apps/docs/docs/components/other/DatePicker/_mobileExamples.mdx @@ -90,12 +90,9 @@ function Example() { error={error} onChangeDate={setDate} onErrorDate={setError} - disabledDates={[new Date()]} label="Birthdate" - calendarIconButtonAccessibilityLabel="Birthdate calendar" - nextArrowAccessibilityLabel="Next month" - previousArrowAccessibilityLabel="Previous month" - helperTextErrorIconAccessibilityLabel="Error" + calendarIconButtonAccessibilityLabel="Open calendar to select birthdate" + confirmButtonAccessibilityLabel="Confirm birthdate selection" invalidDateError="Please enter a valid date" disabledDateError="Date unavailable" requiredError="This field is required" @@ -132,7 +129,7 @@ function Example() { Defaults to today when undefined. -On mobile the `seedDate` prop is the default date that the react-native-date-picker keyboard control will open to when there is no selected date value. +The `seedDate` prop is used to generate the Calendar month when there is no selected date value. ```jsx function Example() { @@ -179,6 +176,36 @@ function Example() { } ``` +### Highlighted dates + +The `highlightedDates` prop is an array of Dates and Date tuples for date ranges. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. + +```jsx +function Example() { + const [date, setDate] = useState(null); + const [error, setError] = useState(null); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const oneWeekAgo = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7); + const twoDaysAgo = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 2); + const oneWeekLater = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); + + const highlightedDates = [[oneWeekAgo, twoDaysAgo], oneWeekLater]; + + return ( + + ); +} +``` + ### Minimum and maximum dates Make sure to provide the `disabledDateError` prop when providing `minDate`, `maxDate`, or `disabledDates` props. Navigation to dates before the `minDate` and after the `maxDate` is disabled. @@ -208,6 +235,41 @@ function Example() { } ``` +### Disabled dates + +The `disabledDates` prop is an array of Dates and Date tuples for date ranges. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. + +Make sure to provide the `disabledDateError` prop when providing `minDate`, `maxDate`, or `disabledDates` props. + +```jsx +function Example() { + const [date, setDate] = useState(null); + const [error, setError] = useState(null); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); + const startOfNextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); + const endOfNextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 13); + + return ( + + ); +} +``` + ### Multiple pickers This is a complex example using many different props. We use multiple DatePickers together to allow a user to select a date range. @@ -223,11 +285,8 @@ function Example() { const today = new Date(new Date().setHours(0, 0, 0, 0)); const firstDayThisMonth = new Date(today.getFullYear(), today.getMonth(), 1); - const seventhDayThisMonth = new Date(today.getFullYear(), today.getMonth(), 7); const lastDayThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); - const disabledDates = [[firstDayThisMonth, seventhDayThisMonth]]; - const updateEndDate = (endDate, startDate) => { setEndDate(endDate); setEndError(null); @@ -266,12 +325,10 @@ function Example() { }; return ( - + - + ); } ``` ### Event lifecycle -- Selecting a date with the native picker (mobile) or Calendar (web): +- Selecting a date with the Calendar: `onOpen -> onConfirm -> onChangeDate -> onErrorDate -> onClose` -- Closing the native picker (mobile) or Calendar (web) without selecting a date: +- Closing the Calendar without selecting a date: `onOpen -> onCancel -> onClose` @@ -328,6 +384,10 @@ function Example() { `onChange -> onChangeDate -> onChange -> onChange -> ... -> onChangeDate -> onErrorDate` +:::note +The Calendar picker requires pressing the confirm button to select a date. +::: + ```jsx function Example() { const [date, setDate] = useState(null); diff --git a/apps/docs/docs/components/other/DatePicker/_webExamples.mdx b/apps/docs/docs/components/other/DatePicker/_webExamples.mdx index b88c1e199..641fdd0dd 100644 --- a/apps/docs/docs/components/other/DatePicker/_webExamples.mdx +++ b/apps/docs/docs/components/other/DatePicker/_webExamples.mdx @@ -132,9 +132,7 @@ function Example() { Defaults to today when undefined. -On web the `seedDate` prop is used to generate the Calendar month when there is no selected date value. - -On mobile the `seedDate` prop is the default date that the react-native-date-picker keyboard control will open to when there is no selected date value. +The `seedDate` prop is used to generate the Calendar month when there is no selected date value. ```jsx live function Example() { @@ -185,8 +183,6 @@ function Example() { The `highlightedDates` prop is an array of Dates and Date tuples for date ranges. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. -The `highlightedDates` prop is only available on web because the mobile DatePicker uses react-native-date-picker instead of a Calendar component. - ```jsx live function Example() { const [date, setDate] = useState(null); @@ -246,8 +242,6 @@ function Example() { The `disabledDates` prop is an array of Dates and Date tuples for date ranges. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. -The `disabledDates` prop is only available on web because the mobile DatePicker uses react-native-date-picker instead of a Calendar component. - Make sure to provide the `disabledDateError` prop when providing `minDate`, `maxDate`, or `disabledDates` props. ```jsx live @@ -383,11 +377,11 @@ function Example() { ### Event lifecycle -- Selecting a date with the native picker (mobile) or Calendar (web): +- Selecting a date with the Calendar: `onOpen -> onConfirm -> onChangeDate -> onErrorDate -> onClose` -- Closing the native picker (mobile) or Calendar (web) without selecting a date: +- Closing the Calendar without selecting a date: `onOpen -> onCancel -> onClose` diff --git a/apps/docs/docs/components/other/DatePicker/mobileMetadata.json b/apps/docs/docs/components/other/DatePicker/mobileMetadata.json index 7fda90133..f6dc4495f 100644 --- a/apps/docs/docs/components/other/DatePicker/mobileMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/mobileMetadata.json @@ -2,21 +2,19 @@ "import": "import { DatePicker } from '@coinbase/cds-mobile/dates/DatePicker'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/dates/DatePicker.tsx", "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=14743-52589", - "description": "Date Picker allows our global users to input past, present, future and important dates into our interface in a simple and intuitive manner. Date Picker offers both manual and calendar entry options - accommodating both internationalization and accessibility needs while being adaptable across screen platforms.", + "description": "Date Picker allows our global users to input past, present, future and important dates into our interface in a simple and intuitive manner. Date Picker offers both manual and calendar entry options - accommodating both internationalization and accessibility needs.", "relatedComponents": [ + { + "label": "Calendar", + "url": "/components/other/Calendar/" + }, { "label": "TextInput", "url": "/components/inputs/TextInput/" }, { - "label": "Modal", - "url": "/components/overlay/Modal/" - } - ], - "dependencies": [ - { - "name": "react-native-date-picker", - "version": "^4.4.2" + "label": "Tray", + "url": "/components/overlay/Tray/" } ] } diff --git a/apps/docs/docs/components/other/DatePicker/webMetadata.json b/apps/docs/docs/components/other/DatePicker/webMetadata.json index 2ef73aa40..d8d791525 100644 --- a/apps/docs/docs/components/other/DatePicker/webMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/webMetadata.json @@ -5,6 +5,10 @@ "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=14743-52589", "description": "Date Picker allows our global users to input past, present, future and important dates into our interface in a simple and intuitive manner. Date Picker offers both manual and calendar entry options - accommodating both internationalization and accessibility needs while being adaptable across screen platforms.", "relatedComponents": [ + { + "label": "Calendar", + "url": "/components/other/Calendar/" + }, { "label": "TextInput", "url": "/components/inputs/TextInput/" diff --git a/apps/docs/docs/components/overlay/Tooltip/_mobileExamples.mdx b/apps/docs/docs/components/overlay/Tooltip/_mobileExamples.mdx index 57d836899..7c7a18b8a 100644 --- a/apps/docs/docs/components/overlay/Tooltip/_mobileExamples.mdx +++ b/apps/docs/docs/components/overlay/Tooltip/_mobileExamples.mdx @@ -1,22 +1,29 @@ +### Basic usage + +A basic Tooltip that displays additional information when the trigger element is pressed. + +```jsx +function Example() { + return ( + + + + ); +} +``` + ### Placement +Control the tooltip position using the `placement` prop. Available options are `top` and `bottom`. + ```jsx -function DefaultSelect() { - const content = 'This is the tooltip Content'; +function Example() { + const content = 'This is the tooltip content'; return ( - - - - + - - - - - - @@ -24,3 +31,56 @@ function DefaultSelect() { ); } ``` + +### Disabled trigger + +Use `triggerDisabled` to indicate that the trigger element represents disabled content. When true, screen readers will perceive the element as disabled, but the tooltip remains tappable for sighted users. This is useful for displaying tooltips on disabled interactive elements. + +```jsx +function Example() { + return ( + + + Disabled feature + + + ); +} +``` + +### Accessibility + +Always provide appropriate accessibility labels when the tooltip trigger is not a simple text string. + +```jsx +function Example() { + return ( + + + + ); +} +``` + +### Color scheme + +By default, tooltips use an inverted color scheme. You can disable this with `invertColorScheme={false}`. + +```jsx +function Example() { + return ( + + + + ); +} +``` diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index 12a21f64b..146419a70 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -48,7 +48,6 @@ "lottie-react-native": "6.7.0", "react": "^18.3.1", "react-native": "0.74.5", - "react-native-date-picker": "4.4.2", "react-native-gesture-handler": "2.16.2", "react-native-inappbrowser-reborn": "3.7.0", "react-native-navigation-bar-color": "2.0.2", diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 6b48a77ff..21d57d5f6 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -86,6 +86,10 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/buttons/__stories__/ButtonGroup.stories').default, }, + { + key: 'Calendar', + getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/Calendar.stories').default, + }, { key: 'Card', getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/Card.stories').default, diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 4cd337441..e7d07bc88 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.20.0 ((10/30/2025, 08:33 PM PST)) + +This is an artificial version bump with no new change. + ## 8.19.0 (10/29/2025 PST) #### 🚀 Updates diff --git a/packages/common/package.json b/packages/common/package.json index c47282673..0ef3b71bd 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.19.0", + "version": "8.20.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 6d9db3dba..2db0b0fa8 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.20.0 ((10/30/2025, 08:33 PM PST)) + +This is an artificial version bump with no new change. + ## 8.19.0 ((10/29/2025, 02:11 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 42c15d705..4f41aaf68 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.19.0", + "version": "8.20.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 6e687591c..8ad277cd7 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,23 @@ All notable changes to this project will be documented in this file. +## 8.20.0 (10/30/2025 PST) + +#### 🚀 Updates + +- Added Calendar component to Mobile. [[#139](https://github.com/coinbase/cds/pull/139)] +- Integrated Calendar into DatePicker. [[#139](https://github.com/coinbase/cds/pull/139)] + +#### 🐞 Fixes + +- Added "triggerDisabled" prop for Tooltip for accessibility. [[#139](https://github.com/coinbase/cds/pull/139)] +- Removed react-native-date-picker dependencies. [[#139](https://github.com/coinbase/cds/pull/139)] + +#### 📘 Misc + +- Added unit and a11y tests for Calendar and DatePicker. [[#139](https://github.com/coinbase/cds/pull/139)] +- Added Mobile docs for Calendar, updated mobile docs for DatePicker and Tooltip. [[#139](https://github.com/coinbase/cds/pull/139)] + ## 8.19.0 (10/29/2025 PST) #### 🚀 Updates diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 8082dd234..aef3d7f00 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.19.0", + "version": "8.20.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", @@ -139,7 +139,6 @@ "lottie-react-native": "^6.7.0", "react": "^18.3.1", "react-native": "^0.74.5", - "react-native-date-picker": "^4.4.2", "react-native-gesture-handler": "^2.16.2", "react-native-inappbrowser-reborn": "^3.7.0", "react-native-linear-gradient": "^2.8.3", @@ -175,7 +174,6 @@ "eslint-plugin-reanimated": "^2.0.1", "lottie-react-native": "6.7.0", "react-native-accessibility-engine": "^3.2.0", - "react-native-date-picker": "4.4.2", "react-native-gesture-handler": "2.16.2", "react-native-inappbrowser-reborn": "3.7.0", "react-native-linear-gradient": "2.8.3", diff --git a/packages/mobile/src/dates/Calendar.tsx b/packages/mobile/src/dates/Calendar.tsx new file mode 100644 index 000000000..ca03a7a2e --- /dev/null +++ b/packages/mobile/src/dates/Calendar.tsx @@ -0,0 +1,488 @@ +import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type StyleProp, StyleSheet, type View, type ViewStyle } from 'react-native'; +import { generateCalendarMonth } from '@coinbase/cds-common/dates/generateCalendarMonth'; +import { getMidnightDate } from '@coinbase/cds-common/dates/getMidnightDate'; +import { getTimesFromDatesAndRanges } from '@coinbase/cds-common/dates/getTimesFromDatesAndRanges'; +import { useLocale } from '@coinbase/cds-common/system/LocaleProvider'; +import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable'; + +import { useA11y } from '../hooks/useA11y'; +import { Icon } from '../icons/Icon'; +import { Box } from '../layout/Box'; +import { HStack } from '../layout/HStack'; +import { VStack, type VStackProps } from '../layout/VStack'; +import { Tooltip } from '../overlays/tooltip/Tooltip'; +import { Pressable, type PressableBaseProps } from '../system/Pressable'; +import { Text } from '../typography/Text'; + +const CALENDAR_DAY_SIZE = 40; +// Delay for initial focus - waiting for Calendar date refs to populate after mount +const A11Y_INITIAL_FOCUS_DELAY_MS = 300; + +const styles = StyleSheet.create({ + pressable: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + }, +}); + +export type CalendarPressableBaseProps = PressableBaseProps & { + borderRadius?: number; + width?: number; + height?: number; + background?: 'transparent' | 'bg' | 'bgPrimary'; +}; + +const CalendarPressable = memo( + forwardRef( + ( + { + background = 'transparent', + borderRadius = 1000, + width = CALENDAR_DAY_SIZE, + height = CALENDAR_DAY_SIZE, + children, + ...props + }, + ref, + ) => { + return ( + + {children} + + ); + }, + ), +); + +CalendarPressable.displayName = 'CalendarPressable'; + +export type CalendarDayProps = { + /** Date of this CalendarDay. */ + date: Date; + /** Callback function fired when pressing this CalendarDay. */ + onPress?: (date: Date) => void; + /** Toggle active styles. */ + active?: boolean; + /** Disables user interaction. */ + disabled?: boolean; + /** Toggle highlighted styles. */ + highlighted?: boolean; + /** Toggle today's date styles. */ + isToday?: boolean; + /** Toggle current month styles. */ + isCurrentMonth?: boolean; + /** Tooltip content shown when hovering or focusing a disabled Calendar Day. */ + disabledError?: string; +}; + +const getDayAccessibilityLabel = (date: Date, locale = 'en-US') => + `${date.toLocaleDateString(locale, { + weekday: 'long', + day: 'numeric', + })} ${date.toLocaleDateString(locale, { + month: 'long', + year: 'numeric', + })}`; + +const CalendarDay = memo( + forwardRef( + ( + { + date, + active, + disabled, + highlighted, + isToday, + isCurrentMonth, + onPress, + disabledError = 'Date unavailable', + }, + ref, + ) => { + const { locale } = useLocale(); + const handlePress = useCallback(() => onPress?.(date), [date, onPress]); + const accessibilityLabel = getDayAccessibilityLabel(date, locale); + + if (!isCurrentMonth) { + return ; + } + + // Render disabled dates as non-interactive elements + if (disabled) { + const disabledDayView = ( + + + {date.getDate()} + + + ); + + return ( + + {disabledDayView} + + ); + } + + // Render interactive dates as Pressable buttons + return ( + + + {date.getDate()} + + + ); + }, + ), +); + +CalendarDay.displayName = 'CalendarDay'; + +export type CalendarBaseProps = { + /** Currently selected Calendar date. Date used to generate the Calendar month. Will be rendered with active styles. */ + selectedDate?: Date | null; + /** Date used to generate the Calendar month when there is no value for the `selectedDate` prop, defaults to today. */ + seedDate?: Date; + /** Callback function fired when pressing a Calendar date. */ + onPressDate?: (date: Date) => void; + /** Disables user interaction. */ + disabled?: boolean; + /** Hides the Calendar next and previous month arrows, but does not prevent navigating to the next or previous months via keyboard. This probably only makes sense to be used when `minDate` and `maxDate` are set to the first and last days of the same month. */ + hideControls?: boolean; + /** Array of disabled dates, and date tuples for date ranges. Make sure to set `disabledDateError` as well. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. */ + disabledDates?: (Date | [Date, Date])[]; + /** Array of highlighted dates, and date tuples for date ranges. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. */ + highlightedDates?: (Date | [Date, Date])[]; + /** Minimum date allowed to be selected, inclusive. Dates before the `minDate` are disabled. All navigation to months before the `minDate` is disabled. */ + minDate?: Date; + /** Maximum date allowed to be selected, inclusive. Dates after the `maxDate` are disabled. All navigation to months after the `maxDate` is disabled. */ + maxDate?: Date; + /** + * Tooltip content shown when hovering or focusing a disabled date, including dates before the `minDate` or after the `maxDate`. + * @default 'Date unavailable' + */ + disabledDateError?: string; + /** + * Accessibility label describing the Calendar next month arrow. + * @default 'Go to next month' + */ + nextArrowAccessibilityLabel?: string; + /** + * Accessibility label describing the Calendar previous month arrow. + * @default 'Go to previous month' + */ + previousArrowAccessibilityLabel?: string; + /** Used to locate this element in unit and end-to-end tests. */ + testID?: string; + /** Custom style to apply to the Calendar container. */ + style?: StyleProp; +}; + +export type CalendarProps = CalendarBaseProps & Omit; + +// These could be dynamically generated, but our Calendar and DatePicker aren't localized so there's no point +const daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +export const Calendar = memo( + forwardRef( + ( + { + selectedDate, + seedDate, + onPressDate, + disabled, + hideControls, + disabledDates, + highlightedDates, + minDate, + maxDate, + disabledDateError = 'Date unavailable', + nextArrowAccessibilityLabel = 'Go to next month', + previousArrowAccessibilityLabel = 'Go to previous month', + testID, + style, + ...props + }, + ref, + ) => { + const { setA11yFocus } = useA11y(); + const today = useMemo(() => getMidnightDate(new Date()), []); + + // Determine default calendar seed date: use whichever comes first between maxDate and today + const defaultSeedDate = useMemo(() => { + if (selectedDate) { + return selectedDate; + } + if (seedDate) { + return seedDate; + } + if (maxDate) { + const maxDateTime = getMidnightDate(maxDate).getTime(); + const todayTime = today.getTime(); + return maxDateTime < todayTime ? maxDate : today; + } + return today; + }, [selectedDate, seedDate, maxDate, today]); + + const [calendarSeedDate, setCalendarSeedDate] = useState(defaultSeedDate); + + // Refs to track date buttons for focus management + const dateRefs = useRef>(new Map()); + const hasInitializedFocusRef = useRef(false); + const calendarMonth = useMemo( + () => generateCalendarMonth(calendarSeedDate), + [calendarSeedDate], + ); + + const selectedTime = useMemo( + () => (selectedDate ? getMidnightDate(selectedDate).getTime() : null), + [selectedDate], + ); + + const disabledTimes = useMemo( + () => new Set(getTimesFromDatesAndRanges(disabledDates || [])), + [disabledDates], + ); + + // Callback for setting date refs + const setDateRef = useCallback((time: number, node: View | null) => { + if (node) { + dateRefs.current.set(time, node); + } else { + dateRefs.current.delete(time); + } + }, []); + + // Handle date selection with focus management + const handleDatePress = useCallback( + (date: Date) => { + onPressDate?.(date); + + // Note: We don't need to re-focus the button after selection. + // The button remains focused after activation, and both TalkBack and VoiceOver + // automatically announce the updated accessibilityState={{ selected: true }}. + }, + [onPressDate], + ); + + // Set initial focus on mount + useEffect(() => { + if (disabled || hasInitializedFocusRef.current) { + return; + } + + hasInitializedFocusRef.current = true; + + // Focus on selected date or today + const focusTime = selectedTime || today.getTime(); + + const timeoutId = setTimeout(() => { + const dateButton = dateRefs.current.get(focusTime); + if (dateButton) { + setA11yFocus({ current: dateButton }); + } + }, A11Y_INITIAL_FOCUS_DELAY_MS); + + return () => { + clearTimeout(timeoutId); + }; + }, [disabled, selectedTime, today, setA11yFocus]); + + const minTime = useMemo(() => minDate && getMidnightDate(minDate).getTime(), [minDate]); + + const maxTime = useMemo(() => maxDate && getMidnightDate(maxDate).getTime(), [maxDate]); + + const highlightedTimes = useMemo( + () => new Set(getTimesFromDatesAndRanges(highlightedDates || [])), + [highlightedDates], + ); + + const handleGoNextMonth = useCallback( + () => setCalendarSeedDate((s) => new Date(s.getFullYear(), s.getMonth() + 1, 1)), + [setCalendarSeedDate], + ); + + const handleGoPreviousMonth = useCallback( + () => setCalendarSeedDate((s) => new Date(s.getFullYear(), s.getMonth() - 1, 1)), + [setCalendarSeedDate], + ); + + const disableGoNextMonth = useMemo(() => { + if (disabled) { + return true; + } + const firstDateOfNextMonth = new Date( + calendarSeedDate.getFullYear(), + calendarSeedDate.getMonth() + 1, + 1, + ); + return maxTime ? maxTime < firstDateOfNextMonth.getTime() : false; + }, [maxTime, calendarSeedDate, disabled]); + + const disableGoPreviousMonth = useMemo(() => { + if (disabled) { + return true; + } + const lastDateOfPreviousMonth = new Date( + calendarSeedDate.getFullYear(), + calendarSeedDate.getMonth(), + 0, + ); + return minTime ? minTime > lastDateOfPreviousMonth.getTime() : false; + }, [minTime, calendarSeedDate, disabled]); + + // Split calendar month into weeks (rows of 7 days) + const calendarWeeks = useMemo(() => { + const weeks = []; + for (let i = 0; i < calendarMonth.length; i += 7) { + weeks.push(calendarMonth.slice(i, i + 7)); + } + return weeks; + }, [calendarMonth]); + + return ( + + + + {calendarSeedDate.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + })} + + {!hideControls && ( + + + + + + + + + )} + + + {/* Days of week header */} + + {daysOfWeek.map((day) => ( + + + {day.charAt(0)} + + + ))} + + + {/* Calendar grid - weeks */} + + {calendarWeeks.map((week, weekIndex) => ( + + {week.map((date) => { + const time = date.getTime(); + return ( + setDateRef(time, node)} + active={time === selectedTime} + date={date} + disabled={ + disabled || + (minTime !== undefined && minTime !== null && time < minTime) || + (maxTime !== undefined && maxTime !== null && time > maxTime) || + disabledTimes.has(time) + } + disabledError={disabledDateError} + highlighted={highlightedTimes.has(time)} + isCurrentMonth={date.getMonth() === calendarSeedDate.getMonth()} + isToday={time === today.getTime()} + onPress={handleDatePress} + /> + ); + })} + + ))} + + + ); + }, + ), +); + +Calendar.displayName = 'Calendar'; diff --git a/packages/mobile/src/dates/DateInput.tsx b/packages/mobile/src/dates/DateInput.tsx index dedd0aaf6..83b1f9ea2 100644 --- a/packages/mobile/src/dates/DateInput.tsx +++ b/packages/mobile/src/dates/DateInput.tsx @@ -19,7 +19,7 @@ export type DateInputProps = { /** Date format separator character, e.g. the / in "MM/DD/YYYY". Defaults to forward slash (/). */ separator?: string; style?: StyleProp; -} & Omit & +} & Omit & Omit; export const DateInput = memo( @@ -32,6 +32,7 @@ export const DateInput = memo( onErrorDate, required, separator = '/', + disabledDates, minDate, maxDate, requiredError, @@ -70,6 +71,7 @@ export const DateInput = memo( onErrorDate, intlDateFormat, required, + disabledDates, minDate, maxDate, requiredError, diff --git a/packages/mobile/src/dates/DatePicker.tsx b/packages/mobile/src/dates/DatePicker.tsx index 4abf8049e..343b8b01b 100644 --- a/packages/mobile/src/dates/DatePicker.tsx +++ b/packages/mobile/src/dates/DatePicker.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'; +import { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'; import { type NativeSyntheticEvent, type StyleProp, @@ -7,12 +7,15 @@ import { View, type ViewStyle, } from 'react-native'; -import NativeDatePicker from 'react-native-date-picker'; import { type DateInputValidationError } from '@coinbase/cds-common/dates/DateInputValidationError'; +import { Button } from '../buttons/Button'; import { InputIconButton } from '../controls/InputIconButton'; +import { useA11y } from '../hooks/useA11y'; import { VStack } from '../layout/VStack'; +import { Tray } from '../overlays/tray/Tray'; +import { Calendar, type CalendarProps } from './Calendar'; import { DateInput, type DateInputProps } from './DateInput'; export type DatePickerProps = { @@ -24,10 +27,10 @@ export type DatePickerProps = { error: DateInputValidationError | null; /** Callback function fired when validation finds an error, e.g. required input fields and impossible or disabled dates. Will always be called after `onChangeDate`. */ onErrorDate: (error: DateInputValidationError | null) => void; - /** Date that the react-native-date-picker keyboard control will open to when there is no value for the `date` prop, defaults to today. */ - seedDate?: Date; /** Disables user interaction. */ disabled?: boolean; + /** Array of disabled dates, and date tuples for date ranges. Make sure to set `disabledDateError` as well. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. */ + disabledDates?: (Date | [Date, Date])[]; /** Minimum date allowed to be selected, inclusive. Dates before the `minDate` are disabled. All navigation to months before the `minDate` is disabled. */ minDate?: Date; /** Maximum date allowed to be selected, inclusive. Dates after the `maxDate` are disabled. All navigation to months after the `maxDate` is disabled. */ @@ -39,20 +42,50 @@ export type DatePickerProps = { disabledDateError?: string; /** Callback function fired when the DateInput text value changes. Prefer to use `onChangeDate` instead. Will always be called before `onChangeDate`. This prop should only be used for edge cases, such as custom error handling. */ onChange?: (event: NativeSyntheticEvent) => void; - /** Callback function fired when the react-native-date-picker keyboard control is opened. */ + /** Callback function fired when the picker is opened. */ onOpen?: () => void; - /** Callback function fired when the react-native-date-picker keyboard control is closed. Will always be called after `onCancel`, `onConfirm`, and `onChangeDate`. */ + /** Callback function fired when the picker is closed. Will always be called after `onCancel`, `onConfirm`, and `onChangeDate`. */ onClose?: () => void; - /** Callback function fired when the user selects a date using the react-native-date-picker keyboard control. Interacting with the DateInput does not fire this callback. Will always be called before `onClose`. */ + /** Callback function fired when the user selects a date using the picker. Interacting with the DateInput does not fire this callback. Will always be called before `onClose`. */ onConfirm?: () => void; - /** Callback function fired when the user closes the react-native-date-picker keyboard control without selecting a date. Interacting with the DateInput does not fire this callback. Will always be called before `onClose`. */ + /** Callback function fired when the user closes the picker without selecting a date. Interacting with the DateInput does not fire this callback. Will always be called before `onClose`. */ onCancel?: () => void; + /** + * If `true`, the focus trap will restore focus to the previously focused element when it unmounts. + * + * WARNING: If you disable this, you need to ensure that focus is restored properly so it doesn't end up on the body + * @default true + */ + restoreFocusOnUnmount?: boolean; /** * Accessibility label describing the calendar IconButton, which opens the calendar when pressed. - * @default 'Open calendar' / 'Close calendar' + * @default 'Open calendar' */ calendarIconButtonAccessibilityLabel?: string; + /** + * Accessibility label for the handle bar that closes the picker. + * @default 'Close calendar' + */ + handleBarAccessibilityLabel?: string; dateInputStyle?: StyleProp; + /** + * Text to display on the confirm button. + * @default 'Confirm' + */ + confirmText?: string; + /** + * Accessibility label for the confirm button. + * @default 'Confirm date selection' + */ + confirmButtonAccessibilityLabel?: string; + /** + * Accessibility hint for the confirm button in its disabled state. + * Only applies when no date is selected. When a date is selected, no hint is shown. + * @default 'Select a date first' + */ + confirmButtonDisabledAccessibilityHint?: string; + /** Custom style to apply to the Calendar container. */ + calendarStyle?: StyleProp; } & Omit< DateInputProps, | 'date' @@ -63,13 +96,25 @@ export type DatePickerProps = { | 'maxDate' | 'disabledDateError' | 'style' ->; +> & + Pick< + CalendarProps, + | 'seedDate' + | 'highlightedDates' + | 'nextArrowAccessibilityLabel' + | 'previousArrowAccessibilityLabel' + >; export const DatePicker = memo( forwardRef( ( { date, + calendarStyle, + highlightedDates, + nextArrowAccessibilityLabel, + previousArrowAccessibilityLabel, + disabledDates, onChangeDate, error, onErrorDate, @@ -82,12 +127,18 @@ export const DatePicker = memo( invalidDateError = 'Please enter a valid date', disabledDateError = 'Date unavailable', label, + accessibilityHint = 'Enter date or select from calendar using the calendar button.', accessibilityLabel, accessibilityLabelledBy, - calendarIconButtonAccessibilityLabel, + calendarIconButtonAccessibilityLabel = 'Open calendar', + handleBarAccessibilityLabel = 'Close calendar', + restoreFocusOnUnmount = true, dateInputStyle, compact, variant, + confirmText = 'Confirm', + confirmButtonDisabledAccessibilityHint = 'Select a date first', + confirmButtonAccessibilityLabel = 'Confirm date selection', helperText, onOpen, onClose, @@ -98,62 +149,79 @@ export const DatePicker = memo( }, ref, ) => { - const [showNativePicker, setShowNativePicker] = useState(false); + const { setA11yFocus } = useA11y(); + const [showPicker, setShowPicker] = useState(false); + const [calendarSelectedDate, setCalendarSelectedDate] = useState(null); const dateInputRef = useRef(null); - - const today = useMemo(() => new Date(), []); + const calendarButtonRef = useRef(null); /** * Be careful to preserve the correct event orders - * 1. Selecting a date with the native picker: onOpen -> onConfirm -> onChangeDate -> onErrorDate -> onClose - * 2. Closing the native picker without selecting a date: onOpen -> onCancel -> onClose + * 1. Selecting a date with the picker: onOpen -> onConfirm -> onChangeDate -> onErrorDate -> onClose + * 2. Closing the picker without selecting a date: onOpen -> onCancel -> onClose * 3. Typing a date in a blank DateInput: onChange -> onChange -> ... -> onChangeDate -> onErrorDate * 4. Typing a date in a DateInput that already had a date: onChange -> onChangeDate -> onChange -> onChange -> ... -> onChangeDate -> onErrorDate */ - const handleOpenNativePicker = useCallback(() => { + const handleOpenPicker = useCallback(() => { onOpen?.(); - setShowNativePicker(true); - }, [onOpen]); + setCalendarSelectedDate(date); // Initialize with current date + setShowPicker(true); + }, [onOpen, date]); - const handleCloseNativePicker = useCallback(() => { + const handleClosePicker = useCallback(() => { onClose?.(); - setShowNativePicker(false); - }, [onClose]); + setShowPicker(false); + + // Restore focus to the calendar button when picker closes + if (restoreFocusOnUnmount && calendarButtonRef.current) { + setA11yFocus(calendarButtonRef); + } + }, [onClose, restoreFocusOnUnmount, setA11yFocus]); - const handleConfirmNativePicker = useCallback( + const handleConfirmPicker = useCallback( (date: Date) => { onConfirm?.(); onChangeDate(date); - if (error && error.type !== 'custom') onErrorDate(null); - handleCloseNativePicker(); - dateInputRef.current?.focus(); + if (error && error.type !== 'custom') { + onErrorDate(null); + } + handleClosePicker(); }, - [onChangeDate, onConfirm, error, onErrorDate, handleCloseNativePicker], + [onChangeDate, onConfirm, error, onErrorDate, handleClosePicker], ); - const handleCancelNativePicker = useCallback(() => { + const handleCancelPicker = useCallback(() => { onCancel?.(); - handleCloseNativePicker(); - }, [onCancel, handleCloseNativePicker]); + setCalendarSelectedDate(null); // Reset calendar selection + handleClosePicker(); + }, [onCancel, handleClosePicker]); + + const handleCalendarDatePress = useCallback((selectedDate: Date) => { + // Update local state, user must press confirm button + setCalendarSelectedDate(selectedDate); + }, []); + + const handleConfirmCalendar = useCallback(() => { + if (calendarSelectedDate) { + handleConfirmPicker(calendarSelectedDate); + } + }, [calendarSelectedDate, handleConfirmPicker]); const dateInputCalendarButton = useMemo( () => ( - + ), - [handleOpenNativePicker, showNativePicker, calendarIconButtonAccessibilityLabel], + [handleOpenPicker, calendarIconButtonAccessibilityLabel], ); return ( @@ -161,12 +229,14 @@ export const DatePicker = memo( - {showNativePicker && ( - + {showPicker && ( + + + + + + )} ); }, ), ); + +DatePicker.displayName = 'DatePicker'; diff --git a/packages/mobile/src/dates/__stories__/Calendar.stories.tsx b/packages/mobile/src/dates/__stories__/Calendar.stories.tsx new file mode 100644 index 000000000..e412cdba8 --- /dev/null +++ b/packages/mobile/src/dates/__stories__/Calendar.stories.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; + +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { Calendar } from '../Calendar'; + +const today = new Date(new Date().setHours(0, 0, 0, 0)); +const nextMonth15th = new Date(today.getFullYear(), today.getMonth() + 1, 15); +const lastMonth15th = new Date(today.getFullYear(), today.getMonth() - 1, 15); +const nextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); +const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1); + +// Generate all weekend date ranges for a wide range (10 years before and after) +const getWeekendDates = (centerDate: Date): [Date, Date][] => { + const weekends: [Date, Date][] = []; + + // Cover 10 years before and after to ensure all weekends are disabled + const startDate = new Date(centerDate.getFullYear() - 10, 0, 1); + const endDate = new Date(centerDate.getFullYear() + 10, 11, 31); + + // Find the first Saturday in the range + const currentDate = new Date(startDate); + const dayOfWeek = currentDate.getDay(); + const daysUntilSaturday = dayOfWeek === 6 ? 0 : (6 - dayOfWeek + 7) % 7; + currentDate.setDate(currentDate.getDate() + daysUntilSaturday); + + // Iterate through weekends, jumping 7 days at a time + while (currentDate <= endDate) { + const saturday = new Date(currentDate); + const sunday = new Date(currentDate); + sunday.setDate(sunday.getDate() + 1); + + // Add the weekend as a date range tuple + weekends.push([saturday, sunday]); + + // Jump to next Saturday (7 days later) + currentDate.setDate(currentDate.getDate() + 7); + } + + return weekends; +}; + +// Compute weekends once at module level +const disabledWeekend = getWeekendDates(today); + +const CalendarScreen = () => { + const [basicDate, setBasicDate] = useState(today); + const [noSelectionDate, setNoSelectionDate] = useState(null); + const [seedDateDate, setSeedDateDate] = useState(null); + const [minMaxDate, setMinMaxDate] = useState(today); + const [futureDatesDate, setFutureDatesDate] = useState(null); + const [highlightedDate, setHighlightedDate] = useState(today); + const [disabledDatesDate, setDisabledDatesDate] = useState(null); + const [rangeDate, setRangeDate] = useState(today); + const [hiddenControlsDate, setHiddenControlsDate] = useState(today); + + const highlightedRange: [Date, Date] = [yesterday, nextWeek]; + const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CalendarScreen; diff --git a/packages/mobile/src/dates/__stories__/DatePicker.stories.tsx b/packages/mobile/src/dates/__stories__/DatePicker.stories.tsx index 1b821879b..4f2800ce5 100644 --- a/packages/mobile/src/dates/__stories__/DatePicker.stories.tsx +++ b/packages/mobile/src/dates/__stories__/DatePicker.stories.tsx @@ -9,8 +9,8 @@ const nextMonth15th = new Date(today.getFullYear(), today.getMonth() + 1, 15); const lastMonth15th = new Date(today.getFullYear(), today.getMonth() - 1, 15); const exampleProps = { - maxDate: nextMonth15th, - minDate: lastMonth15th, + // maxDate: nextMonth15th, + // minDate: lastMonth15th, invalidDateError: 'Please enter a valid date', disabledDateError: 'Date unavailable', requiredError: 'This field is required', diff --git a/packages/mobile/src/dates/__tests__/Calendar.test.tsx b/packages/mobile/src/dates/__tests__/Calendar.test.tsx new file mode 100644 index 000000000..2a41277b9 --- /dev/null +++ b/packages/mobile/src/dates/__tests__/Calendar.test.tsx @@ -0,0 +1,340 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { DefaultThemeProvider } from '../../utils/testHelpers'; +import type { CalendarProps } from '../Calendar'; +import { Calendar } from '../Calendar'; + +const testID = 'test-calendar'; +const CalendarExample = (props: Partial) => ( + + + +); + +describe('Calendar', () => { + it('passes accessibility', async () => { + // Use specific date range to ensure all dates are enabled + const seedDate = new Date(2024, 6, 15); + const minDate = new Date(2024, 6, 1); + const maxDate = new Date(2024, 6, 31); + + render(); + + expect(screen.getByTestId(testID)).toBeAccessible({ + // Disable 'disabled-state-required' since it's flagging passing disabled + // to Interactable and unclear if we're lacking a11y affordances here. + customViolationHandler: (violations) => { + return violations.filter( + (v) => + v.problem !== "This component has a disabled state but it isn't exposed to the user", + ); + }, + }); + }); + + it('renders current month by default', () => { + render(); + + const today = new Date(); + const monthYear = today.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }); + + expect(screen.getByText(monthYear)).toBeTruthy(); + }); + + it('renders with seedDate', () => { + const seedDate = new Date(2024, 0, 15); // January 15, 2024 + render(); + + expect(screen.getByText('January 2024')).toBeTruthy(); + }); + + it('renders with selectedDate', () => { + const selectedDate = new Date(2024, 5, 20); // June 20, 2024 + render(); + + expect(screen.getByText('June 2024')).toBeTruthy(); + }); + + it('hides controls when hideControls is true', () => { + render( + , + ); + + expect(screen.queryByLabelText('Next month')).toBeNull(); + expect(screen.queryByLabelText('Previous month')).toBeNull(); + }); + + it('renders navigation controls with correct accessibility labels', () => { + render( + , + ); + + expect(screen.getByLabelText('Next month')).toBeTruthy(); + expect(screen.getByLabelText('Previous month')).toBeTruthy(); + }); + + it('renders days of the week', () => { + render(); + + // Check for first letter of each day + const sLetters = screen.getAllByText('S'); + expect(sLetters.length).toBeGreaterThanOrEqual(2); // Sunday and Saturday (plus potentially dates) + expect(screen.getByText('M')).toBeTruthy(); + expect(screen.getAllByText('T').length).toBeGreaterThanOrEqual(1); // Tuesday and Thursday + expect(screen.getByText('W')).toBeTruthy(); + expect(screen.getByText('F')).toBeTruthy(); + }); + + it('handles disabled state correctly', () => { + const mockOnPressDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + + render(); + + // Navigation arrows should be disabled + const prevArrow = screen.getByLabelText('Go to previous month'); + const nextArrow = screen.getByLabelText('Go to next month'); + + expect(prevArrow).toHaveProp('accessibilityState', expect.objectContaining({ disabled: true })); + expect(nextArrow).toHaveProp('accessibilityState', expect.objectContaining({ disabled: true })); + + expect(prevArrow).toBeDisabled(); + expect(nextArrow).toBeDisabled(); + + // Calendar container should have reduced opacity + const calendar = screen.getByTestId(testID); + expect(calendar).toHaveStyle({ opacity: 0.5 }); // accessibleOpacityDisabled value + }); + + it('does not call onPressDate when date buttons are disabled', () => { + const mockOnPressDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const minDate = new Date(2024, 6, 1); + const maxDate = new Date(2024, 6, 10); // Only first 10 days are enabled + + render( + , + ); + + // Try to get a disabled date (after maxDate) + // Disabled dates are rendered as Box, not Pressable, so they won't have role="button" + const allButtons = screen.getAllByRole('button'); + const dateButtons = allButtons.filter((button) => { + const label = button.props.accessibilityLabel; + return label && label.includes('July') && label.includes('2024'); + }); + + // Dates after maxDate (July 10) should not be pressable buttons + // They should be rendered as non-interactive Box elements + expect(dateButtons.length).toBeLessThan(31); // Not all 31 days should be buttons + }); + + it('calls onPressDate when a date is pressed', () => { + const mockOnPressDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + + render(); + + // Find and press July 15 - match label with both day and month/year + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + expect(mockOnPressDate).toHaveBeenCalledTimes(1); + expect(mockOnPressDate).toHaveBeenCalledWith(expect.any(Date)); + + const calledDate = mockOnPressDate.mock.calls[0][0]; + expect(calledDate.getDate()).toBe(15); + expect(calledDate.getMonth()).toBe(6); // July (0-indexed) + expect(calledDate.getFullYear()).toBe(2024); + }); + + it('navigates to next month when next arrow is pressed', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + expect(screen.getByText('July 2024')).toBeTruthy(); + + const nextArrow = screen.getByLabelText('Go to next month'); + fireEvent.press(nextArrow); + + expect(screen.getByText('August 2024')).toBeTruthy(); + }); + + it('navigates to previous month when previous arrow is pressed', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + expect(screen.getByText('July 2024')).toBeTruthy(); + + const prevArrow = screen.getByLabelText('Go to previous month'); + fireEvent.press(prevArrow); + + expect(screen.getByText('June 2024')).toBeTruthy(); + }); + + it('disables next arrow when maxDate is in current month', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const maxDate = new Date(2024, 6, 31); // July 31, 2024 + + render(); + + const nextArrow = screen.getByLabelText('Go to next month'); + expect(nextArrow).toBeDisabled(); + expect(nextArrow).toHaveProp('accessibilityState', expect.objectContaining({ disabled: true })); + }); + + it('disables previous arrow when minDate is in current month', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const minDate = new Date(2024, 6, 1); // July 1, 2024 + + render(); + + const prevArrow = screen.getByLabelText('Go to previous month'); + expect(prevArrow).toBeDisabled(); + expect(prevArrow).toHaveProp('accessibilityState', expect.objectContaining({ disabled: true })); + }); + + it('selected date has correct accessibility state', () => { + const selectedDate = new Date(2024, 6, 15); // July 15, 2024 + + render(); + + const selectedButton = screen.getByLabelText(/15.*July.*2024/); + expect(selectedButton).toHaveProp( + 'accessibilityState', + expect.objectContaining({ selected: true }), + ); + }); + + it('date buttons have detailed accessibility labels', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // Check that date labels include weekday, day, month, and year + const july15Button = screen.getByLabelText(/15.*July.*2024/); + expect(july15Button).toBeTruthy(); + + // The label should include day of week, date, month and year + expect(july15Button.props.accessibilityLabel).toMatch(/15/); + expect(july15Button.props.accessibilityLabel).toMatch(/July/); + expect(july15Button.props.accessibilityLabel).toMatch(/2024/); + }); + + it('month and year header has accessibilityRole header', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + const headerText = screen.getByText('July 2024'); + expect(headerText).toHaveProp('accessibilityRole', 'header'); + }); + + it('days of week header is not accessible to screen readers', () => { + render(); + + // The days of week header HStack should have accessible={false} + // This is tested indirectly by checking the structure + const calendar = screen.getByTestId(testID); + expect(calendar).toBeTruthy(); + + // Days of week letters should still be present in the DOM + expect(screen.getAllByText('S').length).toBeGreaterThan(0); + expect(screen.getAllByText('M').length).toBeGreaterThan(0); + }); + + it('respects minDate and disables dates before it', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const minDate = new Date(2024, 6, 10); // July 10, 2024 + + render(); + + const allButtons = screen.getAllByRole('button'); + const dateButtons = allButtons.filter((button) => { + const label = button.props.accessibilityLabel; + return label && label.includes('July') && label.includes('2024'); + }); + + // Dates before July 10 should not be interactive buttons + // Check that we don't have all 31 days as buttons + expect(dateButtons.length).toBeLessThan(31); + }); + + it('respects maxDate and disables dates after it', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const maxDate = new Date(2024, 6, 20); // July 20, 2024 + + render(); + + const allButtons = screen.getAllByRole('button'); + const dateButtons = allButtons.filter((button) => { + const label = button.props.accessibilityLabel; + return label && label.includes('July') && label.includes('2024'); + }); + + // Dates after July 20 should not be interactive buttons + expect(dateButtons.length).toBeLessThan(31); + }); + + it('respects disabledDates prop', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const disabledDates = [new Date(2024, 6, 10), new Date(2024, 6, 20)]; + + render(); + + const allButtons = screen.getAllByRole('button'); + const dateButtons = allButtons.filter((button) => { + const label = button.props.accessibilityLabel; + return label && label.includes('July') && label.includes('2024'); + }); + + // Should have fewer buttons than days in the month (due to disabled dates) + // July has 31 days, but 2 are disabled, plus navigation arrows + expect(dateButtons.length).toBeLessThan(31); + }); + + it('respects disabledDates with date ranges', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const disabledDates: [Date, Date][] = [[new Date(2024, 6, 10), new Date(2024, 6, 20)]]; + + render(); + + const allButtons = screen.getAllByRole('button'); + const dateButtons = allButtons.filter((button) => { + const label = button.props.accessibilityLabel; + return label && label.includes('July') && label.includes('2024'); + }); + + // July 10-20 should be disabled (11 days) + // So we should have 31 - 11 = 20 date buttons + expect(dateButtons.length).toBe(20); + }); + + it('renders today with correct accessibility hint', () => { + const today = new Date(); + const todayDate = today.getDate(); + const todayMonth = today.toLocaleDateString('en-US', { month: 'long' }); + const todayYear = today.getFullYear(); + + render(); + + // Find today's date button using flexible regex + const todayButton = screen.getByLabelText( + new RegExp(`${todayDate}.*${todayMonth}.*${todayYear}`), + ); + expect(todayButton).toHaveProp('accessibilityHint', 'Today'); + }); +}); diff --git a/packages/mobile/src/dates/__tests__/DatePicker.test.tsx b/packages/mobile/src/dates/__tests__/DatePicker.test.tsx new file mode 100644 index 000000000..f95a47465 --- /dev/null +++ b/packages/mobile/src/dates/__tests__/DatePicker.test.tsx @@ -0,0 +1,642 @@ +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { DateInputValidationError } from '@coinbase/cds-common/dates/DateInputValidationError'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { DefaultThemeProvider, SAFE_AREA_METRICS } from '../../utils/testHelpers'; +import type { DatePickerProps } from '../DatePicker'; +import { DatePicker } from '../DatePicker'; + +const testID = 'test-datepicker'; + +const DatePickerExample = (props: Partial) => { + return ( + + + + + + ); +}; + +describe('DatePicker', () => { + it('passes accessibility', () => { + render( + , + ); + + expect(screen.getByTestId(testID)).toBeAccessible(); + }); + + it('renders DateInput with calendar button', () => { + render(); + + // Calendar button should be present + const calendarButton = screen.getByLabelText('Open calendar'); + expect(calendarButton).toBeTruthy(); + }); + + it('renders with custom calendar button accessibility label', () => { + render( + , + ); + + expect(screen.getByLabelText('Custom calendar label')).toBeTruthy(); + }); + + it('displays the selected date in DateInput', () => { + const selectedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // DateInput should show the formatted date + const input = screen.getByTestId(testID); + expect(input).toBeTruthy(); + }); + + it('opens calendar tray when calendar button is pressed', async () => { + const mockOnOpen = jest.fn(); + render(); + + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + expect(mockOnOpen).toHaveBeenCalledTimes(1); + + // Calendar should be visible + await waitFor(() => { + expect(screen.getByText('Confirm')).toBeTruthy(); + }); + }); + + it('closes calendar when handle bar is pressed', async () => { + const mockOnCancel = jest.fn(); + const mockOnClose = jest.fn(); + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('Confirm')).toBeTruthy(); + }); + + // Close calendar via handle bar using testID + const handleBar = screen.getByTestId('handleBar'); + fireEvent(handleBar, 'accessibilityAction', { nativeEvent: { actionName: 'activate' } }); + + // Wait for animations to complete and callbacks to be called + await waitFor(() => { + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + // onCancel should be called before onClose + expect(mockOnCancel.mock.invocationCallOrder[0]).toBeLessThan( + mockOnClose.mock.invocationCallOrder[0], + ); + }); + + it('renders custom handle bar accessibility label', async () => { + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByLabelText('Custom close label')).toBeTruthy(); + }); + }); + + it('displays confirm button with custom text', async () => { + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('Done')).toBeTruthy(); + }); + }); + + it('confirm button is disabled when no date is selected', async () => { + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + const confirmButton = screen.getByLabelText('Confirm date selection'); + expect(confirmButton).toBeDisabled(); + }); + }); + + it('confirm button has custom accessibility hint when disabled', async () => { + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + const confirmButton = screen.getByLabelText('Confirm date selection'); + expect(confirmButton).toHaveProp('accessibilityHint', 'Custom disabled hint'); + }); + }); + + it('confirm button is enabled after selecting a date from calendar', async () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Confirm button should now be enabled + const confirmButton = screen.getByLabelText('Confirm date selection'); + expect(confirmButton).not.toBeDisabled(); + }); + + it('calls correct callbacks in order when confirming date selection', async () => { + const mockOnOpen = jest.fn(); + const mockOnConfirm = jest.fn(); + const mockOnChangeDate = jest.fn(); + const mockOnClose = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + expect(mockOnOpen).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Confirm selection + const confirmButton = screen.getByLabelText('Confirm date selection'); + fireEvent.press(confirmButton); + + // Wait for animations to complete and callbacks to be called + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(mockOnChangeDate).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + // Verify callback order: onOpen -> onConfirm -> onChangeDate -> onClose + expect(mockOnChangeDate).toHaveBeenCalledWith(expect.any(Date)); + expect(mockOnOpen.mock.invocationCallOrder[0]).toBeLessThan( + mockOnConfirm.mock.invocationCallOrder[0], + ); + expect(mockOnConfirm.mock.invocationCallOrder[0]).toBeLessThan( + mockOnChangeDate.mock.invocationCallOrder[0], + ); + expect(mockOnChangeDate.mock.invocationCallOrder[0]).toBeLessThan( + mockOnClose.mock.invocationCallOrder[0], + ); + }); + + it('calls correct callbacks in order when canceling date selection', async () => { + const mockOnOpen = jest.fn(); + const mockOnCancel = jest.fn(); + const mockOnClose = jest.fn(); + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + expect(mockOnOpen).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText('Confirm')).toBeTruthy(); + }); + + // Close calendar using testID + const handleBar = screen.getByTestId('handleBar'); + fireEvent(handleBar, 'accessibilityAction', { nativeEvent: { actionName: 'activate' } }); + + // Wait for animations to complete and callbacks to be called + await waitFor(() => { + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + // Verify callback order: onOpen -> onCancel -> onClose + expect(mockOnOpen.mock.invocationCallOrder[0]).toBeLessThan( + mockOnCancel.mock.invocationCallOrder[0], + ); + expect(mockOnCancel.mock.invocationCallOrder[0]).toBeLessThan( + mockOnClose.mock.invocationCallOrder[0], + ); + }); + + it('initializes calendar with current date when opening', async () => { + const currentDate = new Date(2024, 5, 20); // June 20, 2024 + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + // Should show June 2024 (the month of the current date) + expect(screen.getByText('June 2024')).toBeTruthy(); + }); + }); + + it('passes disabled state to DateInput and Calendar', () => { + render(); + + // Calendar button should be disabled + const calendarButton = screen.getByLabelText('Open calendar'); + expect(calendarButton).toBeDisabled(); + }); + + it('passes minDate to DateInput and Calendar', async () => { + const minDate = new Date(2024, 6, 10); // July 10, 2024 + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Previous month arrow should be disabled since minDate is in current month + const prevArrow = screen.getByLabelText('Go to previous month'); + expect(prevArrow).toBeDisabled(); + }); + + it('passes maxDate to DateInput and Calendar', async () => { + const maxDate = new Date(2024, 6, 20); // July 20, 2024 + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Next month arrow should be disabled since maxDate is in current month + const nextArrow = screen.getByLabelText('Go to next month'); + expect(nextArrow).toBeDisabled(); + }); + + it('passes disabledDates to DateInput and Calendar', async () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const disabledDates = [new Date(2024, 6, 10), new Date(2024, 6, 20)]; + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Check that the calendar is rendered (specific dates being disabled is tested in Calendar.test.tsx) + const allButtons = screen.getAllByRole('button'); + expect(allButtons.length).toBeGreaterThan(0); + }); + + it('passes custom error messages to DateInput', () => { + render( + , + ); + + // DateInput should receive these error messages + // (Detailed error testing is in DateInput tests) + expect(screen.getByTestId(testID)).toBeTruthy(); + }); + + it('renders with custom accessibility properties', () => { + render( + , + ); + + const input = screen.getByTestId(testID); + expect(input).toHaveProp('accessibilityHint', 'Custom hint'); + expect(input).toHaveProp('accessibilityLabel', 'Custom label'); + }); + + it('renders with custom confirm button accessibility label', async () => { + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByLabelText('Custom confirm label')).toBeTruthy(); + }); + }); + + it('renders DateInput with compact variant', () => { + render(); + + const input = screen.getByTestId(testID); + expect(input).toBeTruthy(); + }); + + it('renders DateInput with variant prop', () => { + render(); + + const input = screen.getByTestId(testID); + expect(input).toBeTruthy(); + }); + + it('applies custom dateInputStyle', () => { + const customStyle = { marginBottom: 20 }; + render(); + + const input = screen.getByTestId(testID); + expect(input).toBeTruthy(); + }); + + it('applies custom calendarStyle', async () => { + const customStyle = { backgroundColor: 'red' }; + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('Confirm')).toBeTruthy(); + }); + }); + + it('passes helperText to DateInput', () => { + const helperText = 'Custom helper text'; + render(); + + expect(screen.getByText(helperText)).toBeTruthy(); + }); + + it('passes onChange callback to DateInput', () => { + const mockOnChange = jest.fn(); + render(); + + // onChange is passed through to DateInput + expect(screen.getByTestId(testID)).toBeTruthy(); + }); + + it('clears error when confirming valid date selection', async () => { + const mockOnChangeDate = jest.fn(); + const mockOnErrorDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const error = new DateInputValidationError('required', 'This field is required'); + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Confirm selection + const confirmButton = screen.getByLabelText('Confirm date selection'); + fireEvent.press(confirmButton); + + // Error should be cleared + expect(mockOnErrorDate).toHaveBeenCalledWith(null); + }); + + it('does not clear custom error when confirming date selection', async () => { + const mockOnChangeDate = jest.fn(); + const mockOnErrorDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const error = new DateInputValidationError('custom', 'Custom error message'); + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Confirm selection + const confirmButton = screen.getByLabelText('Confirm date selection'); + fireEvent.press(confirmButton); + + // Custom error should NOT be cleared + expect(mockOnErrorDate).not.toHaveBeenCalledWith(null); + expect(mockOnChangeDate).toHaveBeenCalled(); + }); + + it('passes highlighted dates to Calendar', async () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const highlightedDates = [new Date(2024, 6, 10), new Date(2024, 6, 20)]; + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + }); + + it('passes navigation accessibility labels to Calendar', async () => { + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByLabelText('Next month custom')).toBeTruthy(); + }); + expect(screen.getByLabelText('Previous month custom')).toBeTruthy(); + }); + + it('passes required prop to DateInput', () => { + render(); + + expect(screen.getByTestId(testID)).toBeTruthy(); + }); + + it('resets calendar selection when canceling', async () => { + const mockOnChangeDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Close without confirming + const handleBar = screen.getByLabelText('Close calendar'); + fireEvent.press(handleBar); + + // onChangeDate should not have been called + expect(mockOnChangeDate).not.toHaveBeenCalled(); + + // Open calendar again + fireEvent.press(calendarButton); + + await waitFor(() => { + // Confirm button should be disabled (selection was reset) + const confirmButton = screen.getByLabelText('Confirm date selection'); + expect(confirmButton).toBeDisabled(); + }); + }); + + it('does not confirm when confirm button is disabled', async () => { + const mockOnConfirm = jest.fn(); + const mockOnChangeDate = jest.fn(); + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('Confirm')).toBeTruthy(); + }); + + // Try to press disabled confirm button + const confirmButton = screen.getByLabelText('Confirm date selection'); + expect(confirmButton).toBeDisabled(); + + // Press it anyway (should not trigger callbacks) + fireEvent.press(confirmButton); + + // Callbacks should not be called + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(mockOnChangeDate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mobile/src/overlays/tooltip/Tooltip.tsx b/packages/mobile/src/overlays/tooltip/Tooltip.tsx index 180a64059..e38d00c15 100644 --- a/packages/mobile/src/overlays/tooltip/Tooltip.tsx +++ b/packages/mobile/src/overlays/tooltip/Tooltip.tsx @@ -1,5 +1,6 @@ -import React, { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; +import { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; import { Modal as RNModal, TouchableOpacity, View } from 'react-native'; +import type { AccessibilityRole } from 'react-native'; import { InvertedThemeProvider } from '../../system/ThemeProvider'; @@ -24,6 +25,7 @@ export const Tooltip = memo( visible, invertColorScheme = true, elevation, + triggerDisabled = false, }: TooltipProps) => { const subjectRef = useRef(null); const [isOpen, setIsOpen] = useState(false); @@ -54,21 +56,52 @@ export const Tooltip = memo( onOpenTooltip?.(); }, [onOpenTooltip]); + const computedAccessibilityLabel = useMemo( + () => + typeof children === 'string' && accessibilityLabel === undefined + ? children + : accessibilityLabel, + [children, accessibilityLabel], + ); + + const computedAccessibilityHint = useMemo( + () => + typeof children === 'string' && accessibilityHint === undefined + ? children + : accessibilityHint, + [children, accessibilityHint], + ); + + // When trigger is disabled, make wrapper accessible instead of TouchableOpacity + // This prevents TalkBack from detecting the onPress handler + const accessibilityPropsForWrapper = useMemo(() => { + if (!triggerDisabled) { + return undefined; + } + + return { + accessible: true, + accessibilityRole: 'text' as AccessibilityRole, + accessibilityLabel: computedAccessibilityLabel, + accessibilityHint: computedAccessibilityHint, + }; + }, [triggerDisabled, computedAccessibilityLabel, computedAccessibilityHint]); + // The accessibility props for the trigger component. Trigger component // equals the component where when you click on it, it will show the tooltip - const accessibilityPropsForTrigger = useMemo( - () => ({ - accessibilityLabel: - typeof children === 'string' && accessibilityLabel === undefined - ? children - : accessibilityLabel, - accessibilityHint: - typeof children === 'string' && accessibilityHint === undefined - ? children - : accessibilityHint, - }), - [children, accessibilityLabel, accessibilityHint], - ); + const accessibilityPropsForTrigger = useMemo(() => { + if (triggerDisabled) { + return { + 'aria-hidden': true, + }; + } + + return { + accessibilityLabel: computedAccessibilityLabel, + accessibilityHint: computedAccessibilityHint, + accessibilityRole: 'button' as AccessibilityRole, + }; + }, [triggerDisabled, computedAccessibilityLabel, computedAccessibilityHint]); const accessibilityPropsForContent = useMemo( () => ({ @@ -87,12 +120,8 @@ export const Tooltip = memo( ); return ( - - + + {children} diff --git a/packages/mobile/src/overlays/tooltip/TooltipProps.ts b/packages/mobile/src/overlays/tooltip/TooltipProps.ts index e70ea469d..445b80f6c 100644 --- a/packages/mobile/src/overlays/tooltip/TooltipProps.ts +++ b/packages/mobile/src/overlays/tooltip/TooltipProps.ts @@ -1,4 +1,10 @@ -import type { Animated, LayoutRectangle, ViewProps } from 'react-native'; +import type { + AccessibilityRole, + AccessibilityState, + Animated, + LayoutRectangle, + ViewProps, +} from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { BaseTooltipPlacement, @@ -66,6 +72,13 @@ export type TooltipBaseProps = SharedProps & * is read correctly for voice-overs. */ accessibilityHint?: SharedAccessibilityProps['accessibilityHint']; + /** + * Indicates that the trigger element represents disabled content. + * When true, screen readers will perceive the element as disabled, + * but the tooltip remains tappable for sighted users. + * @default false + */ + triggerDisabled?: boolean; }; export type TooltipProps = TooltipBaseProps; diff --git a/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx b/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx index 00e8492be..7e677c09f 100644 --- a/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx +++ b/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx @@ -1,8 +1,8 @@ import { renderHook } from '@testing-library/react-hooks'; import { fireEvent, render, screen } from '@testing-library/react-native'; -import { Button } from '../../../buttons'; import { useDimensions } from '../../../hooks/useDimensions'; +import { Text } from '../../../typography/Text'; import { DefaultThemeProvider } from '../../../utils/testHelpers'; import { Tooltip } from '../Tooltip'; import type { TooltipPlacement, TooltipProps, UseTooltipPositionParams } from '../TooltipProps'; @@ -88,7 +88,7 @@ describe('Tooltip', () => { accessibilityLabel="test-a11y-label" onOpenTooltip={onOpenTooltip} > - + {subjectText} , ); @@ -100,4 +100,49 @@ describe('Tooltip', () => { expect(await screen.findByText(contentText)).toBeTruthy(); expect(onOpenTooltip).toHaveBeenCalled(); }); + + it('sets accessibilityRole to "text" when triggerDisabled is true', () => { + render( + + {subjectText} + , + ); + + const trigger = screen.getByAccessibilityHint('disabled-date-hint'); + expect(trigger.props.accessibilityRole).toBe('text'); + }); + + it('sets accessibilityRole to "button" when triggerDisabled is false', () => { + render( + + {subjectText} + , + ); + + const trigger = screen.getByAccessibilityHint('enabled-date-hint'); + expect(trigger.props.accessibilityRole).toBe('button'); + }); + + it('keeps TouchableOpacity interactive when triggerDisabled is true', async () => { + const onOpenTooltip = jest.fn(); + render( + + {subjectText} + , + ); + + const wrapper = screen.getByAccessibilityHint('disabled-but-interactive-hint'); + expect(wrapper.props.accessibilityRole).toBe('text'); + + const touchable = screen.getByText(subjectText); + fireEvent.press(touchable); + + // Tooltip should still open for sighted users + expect(await screen.findByText(contentText)).toBeTruthy(); + expect(onOpenTooltip).toHaveBeenCalled(); + }); }); diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index dc7c6bf9c..94716aca1 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.20.0 ((10/30/2025, 08:33 PM PST)) + +This is an artificial version bump with no new change. + ## 8.19.0 (10/29/2025 PST) #### 🚀 Updates diff --git a/packages/web/package.json b/packages/web/package.json index db8e11bb1..3dbbdbd05 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.19.0", + "version": "8.20.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/yarn.lock b/yarn.lock index 5de5a490d..b1f15befd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2462,7 +2462,6 @@ __metadata: lodash: "npm:^4.17.21" lottie-react-native: "npm:6.7.0" react-native-accessibility-engine: "npm:^3.2.0" - react-native-date-picker: "npm:4.4.2" react-native-gesture-handler: "npm:2.16.2" react-native-inappbrowser-reborn: "npm:3.7.0" react-native-linear-gradient: "npm:2.8.3" @@ -2480,7 +2479,6 @@ __metadata: lottie-react-native: ^6.7.0 react: ^18.3.1 react-native: ^0.74.5 - react-native-date-picker: ^4.4.2 react-native-gesture-handler: ^2.16.2 react-native-inappbrowser-reborn: ^3.7.0 react-native-linear-gradient: ^2.8.3 @@ -28528,7 +28526,6 @@ __metadata: react: "npm:^18.3.1" react-native: "npm:0.74.5" react-native-bundle-visualizer: "npm:^3.1.3" - react-native-date-picker: "npm:4.4.2" react-native-gesture-handler: "npm:2.16.2" react-native-inappbrowser-reborn: "npm:3.7.0" react-native-navigation-bar-color: "npm:2.0.2" @@ -32433,16 +32430,6 @@ __metadata: languageName: node linkType: hard -"react-native-date-picker@npm:4.4.2": - version: 4.4.2 - resolution: "react-native-date-picker@npm:4.4.2" - peerDependencies: - react: ">= 17.0.1" - react-native: ">= 0.64.3" - checksum: 10c0/d7cab97a574b2f3c85bef92686cba65a32a75823260cfabca2c19467ee9296bbd55bdcd937e4a90264823c76cb5a2fa3c058639528c308c6e8bb94ffc1a28179 - languageName: node - linkType: hard - "react-native-gesture-handler@npm:2.16.2": version: 2.16.2 resolution: "react-native-gesture-handler@npm:2.16.2"