diff --git a/packages/@react-aria/calendar/src/index.ts b/packages/@react-aria/calendar/src/index.ts index 1676e603a42..f9da6b5f40c 100644 --- a/packages/@react-aria/calendar/src/index.ts +++ b/packages/@react-aria/calendar/src/index.ts @@ -14,6 +14,7 @@ export {useCalendar} from './useCalendar'; export {useRangeCalendar} from './useRangeCalendar'; export {useCalendarGrid} from './useCalendarGrid'; export {useCalendarCell} from './useCalendarCell'; +export {getEraFormat} from './utils'; export type {AriaCalendarProps, AriaRangeCalendarProps, CalendarProps, DateValue, RangeCalendarProps} from '@react-types/calendar'; export type {CalendarAria} from './useCalendarBase'; diff --git a/packages/@react-spectrum/s2/chromatic/Calendar.stories.tsx b/packages/@react-spectrum/s2/chromatic/Calendar.stories.tsx new file mode 100644 index 00000000000..c082bef108f --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/Calendar.stories.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Calendar} from '../src'; +import {CalendarDate} from '@internationalized/date'; +import {Custom454Calendar} from '../../../@internationalized/date/tests/customCalendarImpl'; +import {DateValue} from 'react-aria'; +import type {Meta, StoryObj} from '@storybook/react'; +import {screen, userEvent, within} from '@storybook/test'; + +const meta: Meta = { + component: Calendar, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/Calendar' +}; + +export default meta; + +type Story = StoryObj; + +const date = new CalendarDate(2022, 2, 3); + +export const Default: Story = { + args: { + defaultFocusedValue: date + } +}; + +export const MultiMonth: Story = { + args: { + defaultFocusedValue: date, + visibleMonths: 3 + } +}; + +export const UnavailableDays: Story = { + args: { + defaultFocusedValue: date, + minValue: new CalendarDate(2022, 2, 2), + maxValue: new CalendarDate(2022, 2, 20), + isDateUnavailable: (date: DateValue) => { + return date.day >= 15 && date.day <= 18; + }, + isInvalid: true, + errorMessage: 'Invalid date' + } +}; + +export const CustomCalendar: Story = { + args: { + defaultFocusedValue: date, + createCalendar: () => new Custom454Calendar() + }, + parameters: { + chromaticProvider: { + // only works for en-US? + locales: ['en-US'] + } + } +}; + +export const DefaultHover: Story = { + args: { + defaultFocusedValue: date + }, + play: async () => { + let grid = screen.getByRole('grid'); + let cell = within(grid).getAllByRole('button')[7]; + await userEvent.hover(cell); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +export const DefaultKeyboardFocus: Story = { + args: { + defaultFocusedValue: date + }, + play: async () => { + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +export const DefaultKeyboardSelected: Story = { + args: { + defaultFocusedValue: date + }, + play: async () => { + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/chromatic/DateField.stories.tsx b/packages/@react-spectrum/s2/chromatic/DateField.stories.tsx new file mode 100644 index 00000000000..bdae39164f9 --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/DateField.stories.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarDate} from '@internationalized/date'; +import {DateField} from '../'; +import type {Meta, StoryObj} from '@storybook/react'; +import {userEvent} from '@storybook/test'; + +const meta: Meta = { + component: DateField, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/DateField' +}; + +export default meta; + +type Story = StoryObj; + +const date = new CalendarDate(2022, 2, 3); + +export const Default: Story = { + args: { + label: 'Date of birth' + } +}; + +export const WithValue: Story = { + args: { + label: 'Date of birth', + value: date + } +}; + +export const Focused: Story = { + args: { + label: 'Date of birth', + value: date + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/chromatic/DatePicker.stories.tsx b/packages/@react-spectrum/s2/chromatic/DatePicker.stories.tsx new file mode 100644 index 00000000000..3569333ccee --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/DatePicker.stories.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarDate, CalendarDateTime} from '@internationalized/date'; +import {DatePicker} from '../'; +import type {Meta, StoryObj} from '@storybook/react'; +import {userEvent} from '@storybook/test'; + +const meta: Meta = { + component: DatePicker, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/DatePicker' +}; + +export default meta; + +type Story = StoryObj; + +const date = new CalendarDate(2022, 2, 3); + +export const Default: Story = { + args: { + label: 'Date of birth' + } +}; + +export const WithValue: Story = { + args: { + label: 'Date of birth', + value: date + } +}; + +export const Focused: Story = { + args: { + label: 'Date of birth', + value: date + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +export const OpenPicker: Story = { + args: { + label: 'Date of birth', + value: date + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{Enter}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +export const OpenPickerWithTime: Story = { + args: { + label: 'Date of birth', + value: new CalendarDateTime(2022, 2, 3, 12, 0, 0), + granularity: 'second' + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{Enter}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/chromatic/DateRangePicker.stories.tsx b/packages/@react-spectrum/s2/chromatic/DateRangePicker.stories.tsx new file mode 100644 index 00000000000..db68daecacc --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/DateRangePicker.stories.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarDate, CalendarDateTime} from '@internationalized/date'; +import {DateRangePicker} from '../'; +import type {Meta, StoryObj} from '@storybook/react'; +import {userEvent} from '@storybook/test'; + +const meta: Meta = { + component: DateRangePicker, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/DateRangePicker' +}; + +export default meta; + +type Story = StoryObj; + +const startDate = new CalendarDate(2022, 2, 3); +const endDate = new CalendarDate(2022, 2, 10); + +export const Default: Story = { + args: { + label: 'Date of birth' + } +}; + +export const WithValue: Story = { + args: { + label: 'Date of birth', + value: {start: startDate, end: endDate} + } +}; + +export const Focused: Story = { + args: { + label: 'Date of birth', + value: {start: startDate, end: endDate} + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +export const OpenPicker: Story = { + args: { + label: 'Date of birth', + value: {start: startDate, end: endDate} + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{Enter}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +export const OpenPickerWithTime: Story = { + args: { + label: 'Date of birth', + value: {start: new CalendarDateTime(2022, 2, 3, 12, 0, 0), end: new CalendarDateTime(2022, 2, 10, 18, 40, 35)}, + granularity: 'second' + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{Enter}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/chromatic/RangeCalendar.stories.tsx b/packages/@react-spectrum/s2/chromatic/RangeCalendar.stories.tsx new file mode 100644 index 00000000000..d99beb64599 --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/RangeCalendar.stories.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarDate} from '@internationalized/date'; +import {Custom454Calendar} from '../../../@internationalized/date/tests/customCalendarImpl'; +import {DateValue} from 'react-aria'; +import type {Meta, StoryObj} from '@storybook/react'; +import {RangeCalendar} from '../src'; +import {userEvent} from '@storybook/test'; + +const meta: Meta = { + component: RangeCalendar, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/RangeCalendar' +}; + +export default meta; + +type Story = StoryObj; + +const date = new CalendarDate(2022, 2, 3); + +export const Default: Story = { + args: { + defaultFocusedValue: date + } +}; + +export const MultiMonth: Story = { + args: { + defaultFocusedValue: date, + visibleMonths: 3 + } +}; + +export const CustomCalendar: Story = { + args: { + defaultFocusedValue: date, + createCalendar: () => new Custom454Calendar() + }, + parameters: { + chromaticProvider: { + // only works for en-US? + locales: ['en-US'] + } + } +}; + +export const DefaultSelectRange: Story = { + args: { + defaultFocusedValue: date, + minValue: new CalendarDate(2022, 2, 2), + maxValue: new CalendarDate(2022, 2, 26), + isDateUnavailable: (date: DateValue) => { + return date.day >= 15 && date.day <= 18; + }, + allowsNonContiguousRanges: true + }, + play: async () => { + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{Enter}'); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/chromatic/TimeField.stories.tsx b/packages/@react-spectrum/s2/chromatic/TimeField.stories.tsx new file mode 100644 index 00000000000..668d897006a --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/TimeField.stories.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type {Meta, StoryObj} from '@storybook/react'; +import {Time} from '@internationalized/date'; +import {TimeField} from '../'; +import {userEvent} from '@storybook/test'; + +const meta: Meta = { + component: TimeField, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/TimeField' +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Water plants reminder' + } +}; + +export const WithValue: Story = { + args: { + label: 'Water plants reminder', + value: new Time(2, 15, 56), + granularity: 'second' + } +}; + +export const Focused: Story = { + args: { + label: 'Water plants reminder', + value: new Time(18, 13) + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowRight}'); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index 6f12fab4668..20d6593b516 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -35,5 +35,8 @@ "breadcrumbs.more": "More items", "toast.clearAll": "Clear all", "toast.collapse": "Collapse", - "toast.showAll": "Show all" + "toast.showAll": "Show all", + "rangeCalendar.startTime": "Start time", + "rangeCalendar.endTime": "End time", + "calendar.time": "Time" } diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index b50a1d30d4a..70d4f993606 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -150,7 +150,9 @@ "jest": "^29.5.0" }, "dependencies": { + "@internationalized/date": "^3.8.2", "@internationalized/number": "^3.6.3", + "@react-aria/calendar": "^3.8.3", "@react-aria/collections": "3.0.0-rc.3", "@react-aria/focus": "^3.20.5", "@react-aria/i18n": "^3.12.10", diff --git a/packages/@react-spectrum/s2/src/Calendar.tsx b/packages/@react-spectrum/s2/src/Calendar.tsx index 7d6f5e07794..c869e47882a 100644 --- a/packages/@react-spectrum/s2/src/Calendar.tsx +++ b/packages/@react-spectrum/s2/src/Calendar.tsx @@ -10,38 +10,581 @@ * governing permissions and limitations under the License. */ +import {ActionButton, Header, Heading, pressScale} from './'; import { Calendar as AriaCalendar, + CalendarCell as AriaCalendarCell, + CalendarGrid as AriaCalendarGrid, + CalendarHeaderCell as AriaCalendarHeaderCell, CalendarProps as AriaCalendarProps, - Button, - CalendarCell, - CalendarGrid, + ButtonProps, + CalendarCellProps, + CalendarCellRenderProps, + CalendarGridBody, + CalendarGridHeader, + CalendarHeaderCellProps, + CalendarState, + CalendarStateContext, + ContextValue, DateValue, - Heading, + RangeCalendarState, + RangeCalendarStateContext, Text } from 'react-aria-components'; -import {ReactNode} from 'react'; +import {AriaCalendarGridProps, getEraFormat} from '@react-aria/calendar'; +import {baseColor, focusRing, lightDark, style} from '../style' with {type: 'macro'}; +import { + CalendarDate, + getDayOfWeek, + startOfMonth +} from '@internationalized/date'; +import ChevronLeftIcon from '../s2wf-icons/S2_Icon_ChevronLeft_20_N.svg'; +import ChevronRightIcon from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg'; +import {controlFont, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {forwardRefType, ValidationResult} from '@react-types/shared'; +import React, {createContext, CSSProperties, ForwardedRef, forwardRef, Fragment, PropsWithChildren, ReactElement, ReactNode, useContext, useMemo, useRef} from 'react'; +import {useDateFormatter, useLocale} from '@react-aria/i18n'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface CalendarProps - extends AriaCalendarProps { - errorMessage?: string + extends Omit, 'visibleDuration' | 'style' | 'className' | 'styles'>, + StyleProps { + errorMessage?: ReactNode | ((v: ValidationResult) => ReactNode), + /** + * The number of months to display at once. + * @default 1 + */ + visibleMonths?: number } -export function Calendar( - {errorMessage, ...props}: CalendarProps -): ReactNode { +export const CalendarContext = createContext>, HTMLDivElement>>(null); + +const calendarStyles = style({ + display: 'flex', + flexDirection: 'column', + gap: 24, + width: 'fit' +}, getAllowedOverrides()); + +const headerStyles = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: 'full' +}); + +const headingStyles = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + margin: 0, + width: 'full' +}); + +const titleStyles = style({ + font: 'title-lg', + textAlign: 'center', + flexGrow: 1, + flexShrink: 0, + flexBasis: '0%', + minWidth: 0 +}); + +const headerCellStyles = style({ + font: 'title-sm', + cursor: 'default', + textAlign: 'center', + paddingStart: { + default: 4, + ':first-child': 0 + }, + paddingEnd: { + default: 4, + ':last-child': 0 + }, + paddingBottom: 12 +}); + +const cellStyles = style({ + outlineStyle: 'none', + '--cell-gap': { + type: 'paddingStart', + value: 4 + }, + paddingStart: { + default: 4, + isFirstChild: 0 + }, + paddingEnd: { + default: 4, + isLastChild: 0 + }, + paddingTop: { + default: 2, + isFirstWeek: 0 + }, + paddingBottom: 2, + position: 'relative', + width: 32, + height: 32, + display: { + default: 'flex', + isOutsideMonth: 'none' + }, + alignItems: 'center', + justifyContent: 'center' +}); + +const cellInnerStyles = style({ + ...focusRing(), + transition: { + default: 'default', + forcedColors: 'none' + }, + outlineOffset: { + default: -2, + isToday: 2, + isSelected: { + selectionMode: { + single: 2, + range: -2 + } + }, + isSelectionStart: 2, + isSelectionEnd: 2 + }, + position: 'relative', + font: 'body-sm', + cursor: 'default', + width: 'full', + height: 32, + borderRadius: 'full', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + forcedColorAdjust: 'none', + backgroundColor: { + default: 'transparent', + isHovered: 'gray-100', + isDisabled: 'transparent', + isToday: { + default: baseColor('gray-300'), + isDisabled: 'disabled' + }, + isSelected: { + selectionMode: { + single: { + default: lightDark('accent-900', 'accent-700'), + isHovered: lightDark('accent-1000', 'accent-600'), + isPressed: lightDark('accent-1000', 'accent-600'), + isFocusVisible: lightDark('accent-1000', 'accent-600'), + isDisabled: 'transparent' + }, + range: { + isHovered: 'blue-500' + } + } + }, + isSelectionStart: { + default: lightDark('accent-900', 'accent-700'), + isHovered: lightDark('accent-1000', 'accent-600'), + isPressed: lightDark('accent-1000', 'accent-600'), + isFocusVisible: lightDark('accent-1000', 'accent-600') + }, + isSelectionEnd: { + default: lightDark('accent-900', 'accent-700'), + isHovered: lightDark('accent-1000', 'accent-600'), + isPressed: lightDark('accent-1000', 'accent-600'), + isFocusVisible: lightDark('accent-1000', 'accent-600') + }, + isUnavailable: 'transparent', + forcedColors: { + default: 'transparent', + isToday: 'ButtonFace', + isHovered: 'Highlight', + isSelected: { + selectionMode: { + single: 'Highlight', + range: { + isHovered: 'Highlight' + } + } + }, + isSelectionStart: 'Highlight', + isSelectionEnd: 'Highlight', + isUnavailable: 'transparent' + } + }, + color: { + default: 'neutral', + isSelected: { + default: 'white', + selectionMode: { + range: 'neutral' + } + }, + isSelectionStart: 'white', + isSelectionEnd: 'white', + isDisabled: 'disabled', + forcedColors: { + default: 'ButtonText', + isToday: 'ButtonFace', + isSelected: 'HighlightText', + isSelectionStart: 'HighlightText', + isSelectionEnd: 'HighlightText', + isDisabled: 'GrayText' + } + } +}); + +const unavailableStyles = style({ + position: 'absolute', + top: 'calc(50% - 1px)', + left: 'calc(25% - 1px)', + right: 'calc(25% - 1px)', + height: 2, + transform: 'rotate(-16deg)', + borderRadius: 'full', + backgroundColor: '[currentColor]' +}); + +const selectionSpanStyles = style({ + position: 'absolute', + zIndex: -1, + top: 0, + insetStart: 'calc(-1 * var(--selection-span) * (var(--cell-width) + var(--cell-gap) + var(--cell-gap)))', + insetEnd: 0, + bottom: 0, + borderWidth: 2, + borderStyle: 'dashed', + borderColor: { + default: 'blue-800', // focus-indicator-color + forcedColors: { + default: 'ButtonText' + } + }, + borderStartRadius: 'full', + borderEndRadius: 'full', + backgroundColor: { + default: 'blue-subtle', + forcedColors: { + default: 'Highlight' + } + }, + forcedColorAdjust: 'none' +}); + +export const helpTextStyles = style({ + gridArea: 'helptext', + display: 'flex', + alignItems: 'baseline', + gap: 'text-to-visual', + font: controlFont(), + color: { + default: 'neutral-subdued', + isInvalid: 'negative', + isDisabled: 'disabled' + }, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + }, + contain: 'inline-size', + paddingTop: '--field-gap', + cursor: { + default: 'text', + isDisabled: 'default' + } +}); + + +export const Calendar = /*#__PURE__*/ (forwardRef as forwardRefType)(function Calendar(props: CalendarProps, ref: ForwardedRef) { + [props, ref] = useSpectrumContextProps(props, ref, CalendarContext); + let { + visibleMonths = 1, + errorMessage, + UNSAFE_style, + UNSAFE_className, + styles, + ...otherProps + } = props; return ( - -
- - - -
- - {(date) => } - - {errorMessage && {errorMessage}} + + {({isInvalid, isDisabled}) => { + return ( + <> +
+ + + +
+
+ {Array.from({length: visibleMonths}).map((_, i) => ( + + ))} +
+ {errorMessage && ( + + {/* @ts-ignore */} + {errorMessage} + + )} + + ); + }}
); +}); + +export const CalendarGrid = (props: Omit & PropsWithChildren & {months: number}): ReactElement => { + // use isolation to start a new stacking context so that we can use zIndex -1 for the selection span. + return ( + + + {(day) => ( + + {day} + + )} + + + {(date) => ( + + )} + + + ); +}; + +// Ordinarily the heading is a formatted date range, ie January 2025 - February 2025. +// However, we want to show each month individually. +export const CalendarHeading = (): ReactElement => { + let calendarStateContext = useContext(CalendarStateContext); + let rangeCalendarStateContext = useContext(RangeCalendarStateContext); + let {visibleRange, timeZone} = calendarStateContext ?? rangeCalendarStateContext ?? {}; + let era: any = getEraFormat(visibleRange?.start) || getEraFormat(visibleRange?.end); + let monthFormatter = useDateFormatter({ + month: 'long', + year: 'numeric', + era, + calendar: visibleRange?.start.calendar.identifier, + timeZone + }); + let months = useMemo(() => { + if (!visibleRange) { + return []; + } + let months: string[] = []; + for (let i = visibleRange.start; i.compare(visibleRange.end) <= 0; i = i.add({months: 1})) { + // TODO: account for the first week possibly overlapping, like with a custom 454 calendar. + // there has to be a better way to do this... + if (i.month === visibleRange.start.month) { + i = i.add({weeks: 1}); + } + months.push(monthFormatter.format(i.toDate(timeZone!))); + } + return months; + }, [visibleRange, monthFormatter, timeZone]); + + return ( + + {months.map((month, i) => { + if (i === 0) { + return ( + +
{month}
+
+ ); + } else { + return ( + + {/* Spacers to account for Next/Previous buttons and gap, spelled out to show the math */} +
+
+
+
{month}
+ + ); + } + })} + + ); +}; + +export const CalendarButton = (props: Omit & {children: ReactNode}): ReactElement => { + let {direction} = useLocale(); + return ( +
+ + {props.children} + +
+ ); +}; + +const CalendarHeaderCell = (props: Omit & PropsWithChildren): ReactElement => { + return ( + + {props.children} + + ); +}; + +const CalendarCell = (props: Omit & {firstDayOfWeek: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | undefined}): ReactElement => { + let {locale} = useLocale(); + let defaultFirstDayOfWeek = useDefaultFirstDayOfWeek(locale); + let firstDayOfWeek = props.firstDayOfWeek ?? defaultFirstDayOfWeek; + // Calculate the day and week index based on the date. + let {dayIndex, weekIndex} = useWeekAndDayIndices(props.date, locale, firstDayOfWeek); + + let calendarStateContext = useContext(CalendarStateContext); + let rangeCalendarStateContext = useContext(RangeCalendarStateContext); + let state = (calendarStateContext ?? rangeCalendarStateContext)!; + + let isFirstWeek = weekIndex === 0; + let isFirstChild = dayIndex === 0; + let isLastChild = dayIndex === 6; + let isNextDaySelected = state.isSelected(props.date.add({days: 1})); + return ( + cellStyles({...renderProps, isFirstChild, isLastChild, isFirstWeek})}> + {(renderProps) => } + + ); +}; + +const CalendarCellInner = (props: Omit & {isNextDaySelected: boolean, isLastChild: boolean, isRangeSelection: boolean, state: CalendarState | RangeCalendarState, weekIndex: number, dayIndex: number, renderProps?: CalendarCellRenderProps, date: DateValue}): ReactElement => { + let {weekIndex, dayIndex, date, renderProps, state, isRangeSelection, isNextDaySelected, isLastChild} = props; + let {getDatesInWeek} = state; + let ref = useRef(null); + let {isUnavailable, formattedDate, isSelected} = renderProps!; + let datesInWeek = getDatesInWeek(weekIndex); + + // Starting from the current day, find the first day before it in the current week that is not selected. + // Then, the span of selected days is the current day minus the first unselected day. + let firstUnselectedInRangeInWeek = datesInWeek.slice(0, dayIndex + 1).reverse().findIndex((date, i) => date && i > 0 && !state.isSelected(date)); + let selectionSpan = -1; + if (firstUnselectedInRangeInWeek > -1 && isSelected) { + selectionSpan = firstUnselectedInRangeInWeek - 1; + } else if (isSelected) { + selectionSpan = dayIndex; + } + + let isBackgroundStyleApplied = ( + isSelected + && (isLastChild || !isNextDaySelected) + && isRangeSelection + && (state.isSelected(date.subtract({days: 1})) + || state.isSelected(date.add({days: 1})) + )); + + return ( +
+
+
+ {formattedDate} +
+ {isUnavailable &&
} +
+ {isBackgroundStyleApplied &&
} +
+ ); +}; + +type DayOfWeek = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'; + +/** + * Calculate the week index (0-based) and day index (0-based) for a given date within a month in a calendar. + * @param date - The date to calculate indices for. + * @param locale - The locale string (e.g., 'en-US', 'fr-FR', 'hi-IN-u-ca-indian'). + * @param firstDayOfWeek - Optional override for the first day of the week ('sun', 'mon', 'tue', etc.). + * @returns Object with weekIndex and dayIndex. + */ +function useWeekAndDayIndices( + date: CalendarDate, + locale: string, + firstDayOfWeek?: DayOfWeek +) { + let {dayIndex, weekIndex} = useMemo(() => { + // Get the day index within the week (0-6) + const dayIndex = getDayOfWeek(date, locale, firstDayOfWeek); + + const monthStart = startOfMonth(date); + + // Calculate the week index by finding which week this date falls into + // within the month's calendar grid + const monthStartDayOfWeek = getDayOfWeek(monthStart, locale, firstDayOfWeek); + const dayOfMonth = date.day; + + const weekIndex = Math.floor((dayOfMonth + monthStartDayOfWeek - 1) / 7); + + return { + weekIndex, + dayIndex + }; + }, [date, locale, firstDayOfWeek]); + + return {dayIndex, weekIndex}; +} + +function useDefaultFirstDayOfWeek(locale: string): DayOfWeek { + let day = useMemo(() => { + // Create a known date (e.g., January 1, 2024, which was a Monday) + const knownDate = new CalendarDate(2024, 1, 1); + + // Get the day of week for this date without specifying firstDayOfWeek + // This will use the locale's default + const dayOfWeek = getDayOfWeek(knownDate, locale); + + // Since we know this date was a Monday (day 1 in our system), + // we can calculate the first day of the week + const firstDayNumber = (1 - dayOfWeek + 7) % 7; + + const dayMap = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const; + return dayMap[firstDayNumber]; + }, [locale]); + + return day; } diff --git a/packages/@react-spectrum/s2/src/Content.tsx b/packages/@react-spectrum/s2/src/Content.tsx index 7c64e6fe7d1..bc7395724de 100644 --- a/packages/@react-spectrum/s2/src/Content.tsx +++ b/packages/@react-spectrum/s2/src/Content.tsx @@ -27,7 +27,8 @@ interface ContentProps extends UnsafeStyles, SlotProps { id?: string } -interface HeadingProps extends ContentProps { +interface HeadingProps extends Omit { + children?: ReactNode, level?: number } diff --git a/packages/@react-spectrum/s2/src/DateField.tsx b/packages/@react-spectrum/s2/src/DateField.tsx index 48609aca2fa..7db48ebf41e 100644 --- a/packages/@react-spectrum/s2/src/DateField.tsx +++ b/packages/@react-spectrum/s2/src/DateField.tsx @@ -13,33 +13,169 @@ import { DateField as AriaDateField, DateFieldProps as AriaDateFieldProps, - DateInput, - DateSegment, + DateInput as AriaDateInput, + DateSegment as AriaDateSegment, + ContextValue, + DateInputProps, + DateSegmentRenderProps, DateValue, - FieldError, - Label, - Text + FormContext } from 'react-aria-components'; -import {HelpTextProps} from '@react-types/shared'; -import {ReactNode} from 'react'; +import {createContext, forwardRef, PropsWithChildren, ReactElement, Ref, useContext} from 'react'; +import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText} from './Field'; +import {forwardRefType, HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; +import {style} from '../style' with {type: 'macro'}; +import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface DateFieldProps - extends AriaDateFieldProps, HelpTextProps { - label?: ReactNode +export interface DateFieldProps extends + Omit, 'children' | 'className' | 'style'>, + StyleProps, + SpectrumLabelableProps, + HelpTextProps { + /** + * The size of the DateField. + * + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL' } -export function DateField( - {label, description, errorMessage, ...props}: DateFieldProps -): ReactNode { +export const DateFieldContext = createContext>, HTMLDivElement>>(null); + +const segmentContainer = style({ + flexGrow: 1, + flexShrink: 1, + minWidth: 0, + height: 'full', + overflow: 'hidden', + display: 'flex', + alignItems: 'center' +}); + +const dateSegment = style({ + outlineStyle: 'none', + caretColor: 'transparent', + backgroundColor: { + default: 'transparent', + isFocused: 'blue-800', + forcedColors: { + default: 'transparent', + isFocused: 'Highlight' + } + }, + color: { + isFocused: 'white', + forcedColors: { + isFocused: 'HighlightText' + } + }, + borderRadius: '[2px]', + paddingX: { + default: 2, + isPunctuation: 0 + }, + paddingY: 2, + forcedColorAdjust: 'none' +}); + +const iconStyles = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'end' +}); + +export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function DateField( + props: DateFieldProps, ref: Ref +): ReactElement { + [props, ref] = useSpectrumContextProps(props, ref, DateFieldContext); + let { + label, + contextualHelp, + description: descriptionMessage, + errorMessage, + isRequired, + size = 'M', + labelPosition = 'top', + necessityIndicator, + labelAlign = 'start', + UNSAFE_style, + UNSAFE_className, + styles, + ...dateFieldProps + } = props; + let formContext = useContext(FormContext); + return ( - - - - {(segment) => } - - {description && {description}} - {errorMessage} + + {({isDisabled, isInvalid}) => { + return ( + <> + + {label} + + + + + + + + + + {errorMessage} + + + ); + }} ); +}); + +export function DateInputContainer(props: PropsWithChildren): ReactElement { + return
{props.children}
; +} + +export function DateInput(props: Omit): ReactElement { + return ( + + {(segment) => ( + dateSegment({...renderProps, isPunctuation: segment.type === 'literal'})} /> + )} + + ); +} + +export function InvalidIndicator(props: {isInvalid: boolean, isDisabled: boolean}): ReactElement | null { + return props.isInvalid ?
: null; } diff --git a/packages/@react-spectrum/s2/src/DatePicker.tsx b/packages/@react-spectrum/s2/src/DatePicker.tsx index 9332aecb4d7..29bd236a3ff 100644 --- a/packages/@react-spectrum/s2/src/DatePicker.tsx +++ b/packages/@react-spectrum/s2/src/DatePicker.tsx @@ -14,56 +14,270 @@ import { DatePicker as AriaDatePicker, DatePickerProps as AriaDatePickerProps, Button, - Calendar, - CalendarCell, - CalendarGrid, - DateInput, - DateSegment, + ButtonRenderProps, + ContextValue, DateValue, - Dialog, - FieldError, - Group, - Heading, - Label, - Popover, - Text + DialogContext, + FormContext, + Provider, + TimeValue } from 'react-aria-components'; -import {HelpTextProps} from '@react-types/shared'; -import {ReactNode} from 'react'; +import {baseColor, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; +import {Calendar, CalendarProps, IconContext, TimeField} from '../'; +import CalendarIcon from '../s2wf-icons/S2_Icon_Calendar_20_N.svg'; +import {controlBorderRadius, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {createContext, forwardRef, PropsWithChildren, ReactElement, Ref, useContext, useRef, useState} from 'react'; +import {DateInput, DateInputContainer, InvalidIndicator} from './DateField'; +import {FieldGroup, FieldLabel, HelpText} from './Field'; +import {forwardRefType, HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {PopoverBase} from './Popover'; +import {pressScale} from './pressScale'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface DatePickerProps - extends AriaDatePickerProps, HelpTextProps { - label?: ReactNode + +export interface DatePickerProps extends + Omit, 'children' | 'className' | 'style'>, + Pick, 'visibleMonths' | 'createCalendar'>, + StyleProps, + SpectrumLabelableProps, + HelpTextProps { + /** + * The size of the DateField. + * + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL' } -export function DatePicker( - {label, description, errorMessage, ...props}: DatePickerProps -): ReactNode { +export const DatePickerContext = createContext>, HTMLDivElement>>(null); + +const inputButton = style({ + ...focusRing(), + ...controlBorderRadius('sm'), + font: { + size: { + S: 'ui-sm', + M: 'ui', + L: 'ui-lg', + XL: 'ui-xl' + } + }, + cursor: 'default', + display: 'flex', + textAlign: 'center', + borderStyle: 'none', + alignItems: 'center', + justifyContent: 'center', + width: { + size: { + S: 16, + M: 20, + L: 24, + XL: 32 + } + }, + height: 'auto', + marginStart: 'text-to-control', + aspectRatio: 'square', + flexShrink: 0, + transition: { + default: 'default', + forcedColors: 'none' + }, + backgroundColor: { + default: baseColor('gray-100'), + isOpen: 'gray-200', + isDisabled: 'disabled', + forcedColors: { + default: 'ButtonText', + isHovered: 'Highlight', + isOpen: 'Highlight', + isDisabled: 'GrayText' + } + }, + color: { + default: baseColor('neutral'), + isDisabled: 'disabled', + forcedColors: 'ButtonFace' + } +}); + +export const timeField = style({ + flexShrink: 1, + flexGrow: 0, + minWidth: 0, + width: 'unset' +}); + +export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function DatePicker( + props: DatePickerProps, ref: Ref +): ReactElement { + [props, ref] = useSpectrumContextProps(props, ref, DatePickerContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + let { + label, + contextualHelp, + description: descriptionMessage, + errorMessage, + isRequired, + size = 'M', + labelPosition = 'top', + necessityIndicator, + labelAlign = 'start', + UNSAFE_style, + UNSAFE_className, + styles, + placeholderValue, + visibleMonths, + createCalendar, + ...dateFieldProps + } = props; + let formContext = useContext(FormContext); + let [buttonHasFocus, setButtonHasFocus] = useState(false); + return ( - - - - - {(segment) => } - - - - {description && {description}} - {errorMessage} - - - -
- - - -
- - {(date) => } - -
-
-
+ + {({isDisabled, isInvalid, isOpen, state}) => { + let placeholder: DateValue | undefined = placeholderValue ?? undefined; + let timePlaceholder = placeholder && 'hour' in placeholder ? placeholder : undefined; + let timeMinValue = props.minValue && 'hour' in props.minValue ? props.minValue : undefined; + let timeMaxValue = props.maxValue && 'hour' in props.maxValue ? props.maxValue : undefined; + let timeGranularity = state.granularity === 'hour' || state.granularity === 'minute' || state.granularity === 'second' ? state.granularity : undefined; + let showTimeField = !!timeGranularity; + return ( + <> + + {label} + + + + + + + + + + + {showTimeField && ( +
+ state.setTimeValue(v as TimeValue)} + placeholderValue={timePlaceholder} + granularity={timeGranularity} + minValue={timeMinValue} + maxValue={timeMaxValue} + hourCycle={props.hourCycle} + hideTimeZone={props.hideTimeZone} /> +
+ )} +
+ + {errorMessage} + + + ); + }}
); +}); + +export function CalendarPopover(props: PropsWithChildren): ReactElement { + // We don't have a dialog anymore, so we don't consume DialogContext. Have to place the + // id and aria label on something otherwise we get a violation. + let dialogProps = useContext(DialogContext) as any; + return ( + + {props.children} + + ); +} + + +export function CalendarButton(props: {isOpen: boolean, size: 'S' | 'M' | 'L' | 'XL', setButtonHasFocus: (hasFocus: boolean) => void}): ReactElement { + let buttonRef = useRef(null); + let {isOpen, size, setButtonHasFocus} = props; + return ( + + ); } diff --git a/packages/@react-spectrum/s2/src/DateRangePicker.tsx b/packages/@react-spectrum/s2/src/DateRangePicker.tsx index b1fffd39cd2..25fb2e1ad49 100644 --- a/packages/@react-spectrum/s2/src/DateRangePicker.tsx +++ b/packages/@react-spectrum/s2/src/DateRangePicker.tsx @@ -13,61 +13,165 @@ import { DateRangePicker as AriaDateRangePicker, DateRangePickerProps as AriaDateRangePickerProps, - Button, - CalendarCell, - CalendarGrid, - DateInput, - DateSegment, + ContextValue, DateValue, - Dialog, - FieldError, - Group, - Heading, - Label, - Popover, - RangeCalendar, - Text + FormContext } from 'react-aria-components'; -import {HelpTextProps} from '@react-types/shared'; -import {ReactNode} from 'react'; +import {CalendarButton, CalendarPopover, timeField} from './DatePicker'; +import {createContext, forwardRef, ReactElement, Ref, useContext, useState} from 'react'; +import {DateInput, DateInputContainer, InvalidIndicator} from './DateField'; +import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {FieldGroup, FieldLabel, HelpText} from './Field'; +import {forwardRefType, HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {RangeCalendar, TimeField} from '../'; +import {style} from '../style' with {type: 'macro'}; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface DateRangePickerProps - extends AriaDateRangePickerProps, HelpTextProps { - label?: ReactNode + +export interface DateRangePickerProps extends + Omit, 'children' | 'className' | 'style'>, + StyleProps, + SpectrumLabelableProps, + HelpTextProps { + /** + * The size of the DateField. + * + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL', + visibleMonths?: number } -export function DateRangePicker( - {label, description, errorMessage, ...props}: DateRangePickerProps -): ReactNode { +export const DateRangePickerContext = createContext>, HTMLDivElement>>(null); + +export const DateRangePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function DateRangePicker( + props: DateRangePickerProps, ref: Ref +): ReactElement { + [props, ref] = useSpectrumContextProps(props, ref, DateRangePickerContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + let { + label, + contextualHelp, + description: descriptionMessage, + errorMessage, + isRequired, + size = 'M', + labelPosition = 'top', + necessityIndicator, + labelAlign = 'start', + UNSAFE_style, + UNSAFE_className, + styles, + placeholderValue, + visibleMonths = 1, + ...dateFieldProps + } = props; + let formContext = useContext(FormContext); + let [buttonHasFocus, setButtonHasFocus] = useState(false); + return ( - - - - - {(segment) => } - - - - {(segment) => } - - - - {description && {description}} - {errorMessage} - - - -
- - - -
- - {(date) => } - -
-
-
+ + {({isDisabled, isInvalid, isOpen, state}) => { + let placeholder: DateValue | undefined = placeholderValue || undefined; + let timePlaceholder = placeholder && 'hour' in placeholder ? placeholder : undefined; + let timeMinValue = props.minValue && 'hour' in props.minValue ? props.minValue : undefined; + let timeMaxValue = props.maxValue && 'hour' in props.maxValue ? props.maxValue : undefined; + let timeGranularity = state.granularity === 'hour' + || state.granularity === 'minute' + || state.granularity === 'second' + ? state.granularity : undefined; + let showTimeField = !!timeGranularity; + return ( + <> + + {label} + + + + + + + + +
+ +
+
+ + + {showTimeField && ( +
+ state.setTime('start', v)} + placeholderValue={timePlaceholder} + granularity={timeGranularity} + minValue={timeMinValue} + maxValue={timeMaxValue} + hourCycle={props.hourCycle} + hideTimeZone={props.hideTimeZone} /> + state.setTime('end', v)} + placeholderValue={timePlaceholder} + granularity={timeGranularity} + minValue={timeMinValue} + maxValue={timeMaxValue} + hourCycle={props.hourCycle} + hideTimeZone={props.hideTimeZone} /> +
+ )} +
+ + {errorMessage} + + + ); + }}
); -} +}); diff --git a/packages/@react-spectrum/s2/src/Field.tsx b/packages/@react-spectrum/s2/src/Field.tsx index d4bbe520d38..3c915ad0b3f 100644 --- a/packages/@react-spectrum/s2/src/Field.tsx +++ b/packages/@react-spectrum/s2/src/Field.tsx @@ -152,7 +152,8 @@ export const FieldLabel = forwardRef(function FieldLabel(props: FieldLabelProps, interface FieldGroupProps extends Omit, UnsafeStyles { size?: 'S' | 'M' | 'L' | 'XL', children: ReactNode, - styles?: StyleString + styles?: StyleString, + shouldTurnOffFocusRing?: boolean } const fieldGroupStyles = style({ @@ -187,10 +188,11 @@ const fieldGroupStyles = style({ }); export const FieldGroup = forwardRef(function FieldGroup(props: FieldGroupProps, ref: ForwardedRef) { + let {shouldTurnOffFocusRing, ...otherProps} = props; return ( { // Forward focus to input element when clicking on a non-interactive child (e.g. icon or padding) if (e.pointerType === 'mouse' && !(e.target as Element).closest('button,input,textarea')) { @@ -206,7 +208,12 @@ export const FieldGroup = forwardRef(function FieldGroup(props: FieldGroupProps, }} style={props.UNSAFE_style} className={renderProps => (props.UNSAFE_className || '') + ' ' + centerBaselineBefore + mergeStyles( - fieldGroupStyles({...renderProps, size: props.size || 'M'}), + fieldGroupStyles({ + ...renderProps, + isFocusWithin: shouldTurnOffFocusRing ? false : renderProps.isFocusWithin, + isFocusVisible: shouldTurnOffFocusRing ? false : renderProps.isFocusVisible, + size: props.size || 'M' + }), props.styles )} /> ); diff --git a/packages/@react-spectrum/s2/src/NumberField.tsx b/packages/@react-spectrum/s2/src/NumberField.tsx index 132e03728a9..e3fbf79c83a 100644 --- a/packages/@react-spectrum/s2/src/NumberField.tsx +++ b/packages/@react-spectrum/s2/src/NumberField.tsx @@ -176,6 +176,7 @@ export const NumberField = forwardRef(function NumberField(props: NumberFieldPro return ( - extends AriaRangeCalendarProps { - errorMessage?: string + extends Omit, 'visibleDuration' | 'style' | 'className' | 'styles'>, + StyleProps { + errorMessage?: ReactNode | ((v: ValidationResult) => ReactNode), + visibleMonths?: number } -export function RangeCalendar( - {errorMessage, ...props}: RangeCalendarProps -): ReactNode { +export const RangeCalendarContext = createContext>, HTMLDivElement>>(null); + + +const calendarStyles = style({ + display: 'flex', + flexDirection: 'column', + gap: 24, + width: 'fit' +}, getAllowedOverrides()); + +const headerStyles = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: 'full' +}); + +export const helpTextStyles = style({ + gridArea: 'helptext', + display: 'flex', + alignItems: 'baseline', + gap: 'text-to-visual', + font: controlFont(), + color: { + default: 'neutral-subdued', + isInvalid: 'negative', + isDisabled: 'disabled' + }, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + }, + contain: 'inline-size', + paddingTop: '--field-gap', + cursor: { + default: 'text', + isDisabled: 'default' + } +}); + +export const RangeCalendar = /*#__PURE__*/ (forwardRef as forwardRefType)(function RangeCalendar(props: RangeCalendarProps, ref: ForwardedRef) { + [props, ref] = useSpectrumContextProps(props, ref, RangeCalendarContext); + let { + visibleMonths = 1, + errorMessage, + UNSAFE_style, + UNSAFE_className, + styles, + ...otherProps + } = props; + return ( - -
- - - -
- - {(date) => } - - {errorMessage && {errorMessage}} + + {({isInvalid, isDisabled}) => { + return ( + <> +
+ + + +
+
+ {Array.from({length: visibleMonths}).map((_, i) => ( + + ))} +
+ {errorMessage && ( + + {/* @ts-ignore */} + {errorMessage} + + )} + + ); + }}
); -} +}); diff --git a/packages/@react-spectrum/s2/src/TimeField.tsx b/packages/@react-spectrum/s2/src/TimeField.tsx index fc058f7e65c..31b6e7797a3 100644 --- a/packages/@react-spectrum/s2/src/TimeField.tsx +++ b/packages/@react-spectrum/s2/src/TimeField.tsx @@ -13,32 +13,104 @@ import { TimeField as AriaTimeField, TimeFieldProps as AriaTimeFieldProps, - DateInput, - DateSegment, - FieldError, - Label, - Text, + ContextValue, + FormContext, TimeValue } from 'react-aria-components'; -import {HelpTextProps} from '@react-types/shared'; -import {ReactNode} from 'react'; +import {createContext, forwardRef, ReactElement, Ref, useContext} from 'react'; +import {DateInput, DateInputContainer, InvalidIndicator} from './DateField'; +import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {FieldGroup, FieldLabel, HelpText} from './Field'; +import {forwardRefType, HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; +import {style} from '../style' with {type: 'macro'}; +import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface TimeFieldProps - extends AriaTimeFieldProps, HelpTextProps { - label?: ReactNode + +export interface TimeFieldProps extends + Omit, 'children' | 'className' | 'style'>, + StyleProps, + SpectrumLabelableProps, + HelpTextProps { + /** + * The size of the TimeField. + * + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL' } -export function TimeField( - {label, description, errorMessage, ...props}: TimeFieldProps -): ReactNode { +export const TimeFieldContext = createContext>, HTMLDivElement>>(null); + +export const TimeField = /*#__PURE__*/ (forwardRef as forwardRefType)(function TimeField( + props: TimeFieldProps, ref: Ref +): ReactElement { + [props, ref] = useSpectrumContextProps(props, ref, TimeFieldContext); + let { + label, + contextualHelp, + description: descriptionMessage, + errorMessage, + isRequired, + size = 'M', + labelPosition = 'top', + necessityIndicator, + labelAlign = 'start', + UNSAFE_style, + UNSAFE_className, + styles, + ...timeFieldProps + } = props; + let formContext = useContext(FormContext); + return ( - - - - {(segment) => } - - {description && {description}} - {errorMessage} + + {({isDisabled, isInvalid}) => { + return ( + <> + + {label} + + + + + + + + + + {errorMessage} + + + ); + }} ); -} +}); diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index bd022c26ddc..e58969bb7a3 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -24,6 +24,7 @@ export {Badge, BadgeContext} from './Badge'; export {Breadcrumbs, Breadcrumb, BreadcrumbsContext} from './Breadcrumbs'; export {Button, LinkButton, ButtonContext, LinkButtonContext} from './Button'; export {ButtonGroup, ButtonGroupContext} from './ButtonGroup'; +export {Calendar, CalendarContext} from './Calendar'; export {Card, CardPreview, CollectionCardPreview, AssetCard, UserCard, ProductCard, CardContext} from './Card'; export {CardView, CardViewContext} from './CardView'; export {Checkbox, CheckboxContext} from './Checkbox'; @@ -37,6 +38,9 @@ export {ColorSwatchPicker, ColorSwatchPickerContext} from './ColorSwatchPicker'; export {ColorWheel, ColorWheelContext} from './ColorWheel'; export {ComboBox, ComboBoxItem, ComboBoxSection, ComboBoxContext} from './ComboBox'; export {ContextualHelp, ContextualHelpContext} from './ContextualHelp'; +export {DateField, DateFieldContext} from './DateField'; +export {DatePicker, DatePickerContext} from './DatePicker'; +export {DateRangePicker, DateRangePickerContext} from './DateRangePicker'; export {DisclosureHeader, Disclosure, DisclosurePanel, DisclosureContext, DisclosureTitle} from './Disclosure'; export {Heading, HeadingContext, Header, HeaderContext, Content, ContentContext, Footer, FooterContext, Text, TextContext, Keyboard, KeyboardContext} from './Content'; export {Dialog} from './Dialog'; @@ -64,6 +68,7 @@ export {ProgressCircle, ProgressCircleContext} from './ProgressCircle'; export {Provider} from './Provider'; export {Radio} from './Radio'; export {RadioGroup, RadioGroupContext} from './RadioGroup'; +export {RangeCalendar, RangeCalendarContext} from './RangeCalendar'; export {RangeSlider, RangeSliderContext} from './RangeSlider'; export {SearchField, SearchFieldContext} from './SearchField'; export {SegmentedControl, SegmentedControlItem, SegmentedControlContext} from './SegmentedControl'; @@ -76,6 +81,7 @@ export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext} from export {Tabs, TabList, Tab, TabPanel, TabsContext} from './Tabs'; export {TagGroup, Tag, TagGroupContext} from './TagGroup'; export {TextArea, TextField, TextAreaContext, TextFieldContext} from './TextField'; +export {TimeField, TimeFieldContext} from './TimeField'; export {ToastContainer as UNSTABLE_ToastContainer, ToastQueue as UNSTABLE_ToastQueue} from './Toast'; export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {ToggleButtonGroup, ToggleButtonGroupContext} from './ToggleButtonGroup'; @@ -100,6 +106,7 @@ export type {BreadcrumbsProps, BreadcrumbProps} from './Breadcrumbs'; export type {BadgeProps} from './Badge'; export type {ButtonProps, LinkButtonProps} from './Button'; export type {ButtonGroupProps} from './ButtonGroup'; +export type {CalendarProps} from './Calendar'; export type {CardProps, CardPreviewProps, AssetCardProps, ProductCardProps, UserCardProps} from './Card'; export type {CardViewProps} from './CardView'; export type {CheckboxProps} from './Checkbox'; @@ -112,6 +119,9 @@ export type {ColorSwatchProps} from './ColorSwatch'; export type {ColorSwatchPickerProps} from './ColorSwatchPicker'; export type {ColorWheelProps} from './ColorWheel'; export type {ComboBoxProps, ComboBoxItemProps, ComboBoxSectionProps} from './ComboBox'; +export type {DateFieldProps} from './DateField'; +export type {DatePickerProps} from './DatePicker'; +export type {DateRangePickerProps} from './DateRangePicker'; export type {DialogProps} from './Dialog'; export type {CustomDialogProps} from './CustomDialog'; export type {FullscreenDialogProps} from './FullscreenDialog'; @@ -139,6 +149,7 @@ export type {RadioGroupProps} from './RadioGroup'; export type {SearchFieldProps} from './SearchField'; export type {SegmentedControlProps, SegmentedControlItemProps} from './SegmentedControl'; export type {SliderProps} from './Slider'; +export type {RangeCalendarProps} from './RangeCalendar'; export type {RangeSliderProps} from './RangeSlider'; export type {SkeletonProps} from './Skeleton'; export type {SkeletonCollectionProps} from './SkeletonCollection'; @@ -148,6 +159,7 @@ export type {TableViewProps, TableHeaderProps, TableBodyProps, RowProps, CellPro export type {TabsProps, TabProps, TabListProps, TabPanelProps} from './Tabs'; export type {TagGroupProps, TagProps} from './TagGroup'; export type {TextFieldProps, TextAreaProps} from './TextField'; +export type {TimeFieldProps} from './TimeField'; export type {ToastOptions, ToastContainerProps} from './Toast'; export type {ToggleButtonProps} from './ToggleButton'; export type {ToggleButtonGroupProps} from './ToggleButtonGroup'; diff --git a/packages/@react-spectrum/s2/stories/Calendar.stories.tsx b/packages/@react-spectrum/s2/stories/Calendar.stories.tsx new file mode 100644 index 00000000000..00643dbc7cf --- /dev/null +++ b/packages/@react-spectrum/s2/stories/Calendar.stories.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ActionButton, Calendar, CalendarProps} from '../src'; +import {CalendarDate, getLocalTimeZone, today} from '@internationalized/date'; +import {CalendarSwitcher, categorizeArgTypes} from './utils'; +import {Custom454Calendar} from '../../../@internationalized/date/tests/customCalendarImpl'; +import {DateValue} from 'react-aria'; +import type {Meta, StoryObj} from '@storybook/react'; +import {ReactElement, useState} from 'react'; +import {style} from '../style' with {type: 'macro'}; + +const meta: Meta = { + component: Calendar, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + ...categorizeArgTypes('Events', ['onChange']), + errorMessage: {control: {type: 'text'}}, + visibleMonths: { + control: { + type: 'select' + }, + options: [1, 2, 3] + } + }, + title: 'Calendar', + decorators: [ + (Story) => ( + + + + ) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + 'aria-label': 'Birthday' + } +}; + +export const DateUnavailable: Story = { + args: { + isDateUnavailable: (date: DateValue) => { + const disabledIntervals = [[today(getLocalTimeZone()).subtract({days: 13}), today(getLocalTimeZone()), today(getLocalTimeZone()).add({weeks: 1})], [today(getLocalTimeZone()).add({weeks: 2}), today(getLocalTimeZone()).add({weeks: 3})]]; + return disabledIntervals.some((interval) => date.compare(interval[0]) > 0 && date.compare(interval[1]) < 0); + }, + 'aria-label': 'Birthday' + } +}; + +export const MinValue: Story = { + args: { + minValue: today(getLocalTimeZone()).add({days: 1}), + 'aria-label': 'Birthday' + } +}; + +function ControlledFocus(props: CalendarProps): ReactElement { + const defaultFocusedDate = props.focusedValue ?? new CalendarDate(2019, 6, 5); + let [focusedDate, setFocusedDate] = useState(defaultFocusedDate); + return ( +
+ setFocusedDate(defaultFocusedDate)}>Reset focused date + +
+ ); +} + +function CustomCalendar(props: CalendarProps): ReactElement { + return ( + new Custom454Calendar()} focusedValue={new CalendarDate(2023, 2, 5)} /> + ); +} + +export const Custom454Example: Story = { + render: (args) => , + args: { + 'aria-label': 'Birthday' + } +}; diff --git a/packages/@react-spectrum/s2/stories/DateField.stories.tsx b/packages/@react-spectrum/s2/stories/DateField.stories.tsx new file mode 100644 index 00000000000..da912c31554 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/DateField.stories.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Button, Content, ContextualHelp, DateField, Footer, Form, Heading, Link, Text} from '../src'; +import {CalendarSwitcher, categorizeArgTypes} from './utils'; +import type {Meta, StoryObj} from '@storybook/react'; +import {style} from '../style' with {type: 'macro'}; + +const meta: Meta = { + component: DateField, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + ...categorizeArgTypes('Events', ['onChange']), + label: {control: {type: 'text'}}, + description: {control: {type: 'text'}}, + errorMessage: {control: {type: 'text'}}, + contextualHelp: {table: {disable: true}} + }, + title: 'DateField', + decorators: [ + (Story) => ( + + + + ) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + label: 'Birthday' + } +}; + +export const AriaLabel: Story = { + args: { + 'aria-label': 'Birthday' + } +}; + +export const Validation: Story = { + render: (args) => ( +
+ + + + ), + args: { + label: 'Birthday', + isRequired: true + } +}; + +export const CustomWidth: Story = { + render: (args) => ( + + ), + args: { + label: 'Birthday' + } +}; + +export const ContextualHelpExample: Story = { + render: (args) => ( + + Quantity + + + Enter a date, any date. May I recommend today? + + +
+ Learn more about what happened on this date. +
+ + } /> + ), + args: { + label: 'On this day' + } +}; diff --git a/packages/@react-spectrum/s2/stories/DatePicker.stories.tsx b/packages/@react-spectrum/s2/stories/DatePicker.stories.tsx new file mode 100644 index 00000000000..6d9faab4b4d --- /dev/null +++ b/packages/@react-spectrum/s2/stories/DatePicker.stories.tsx @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Button, Content, ContextualHelp, DatePicker, Footer, Form, Heading, Link, Text} from '../src'; +import {CalendarSwitcher, categorizeArgTypes} from './utils'; +import {fn} from '@storybook/test'; +import type {Meta, StoryObj} from '@storybook/react'; +import {style} from '../style' with {type: 'macro'}; + +const meta: Meta = { + component: DatePicker, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + ...categorizeArgTypes('Events', ['onChange']), + label: {control: {type: 'text'}}, + description: {control: {type: 'text'}}, + errorMessage: {control: {type: 'text'}}, + contextualHelp: {table: {disable: true}}, + visibleMonths: { + control: { + type: 'select' + }, + options: [1, 2, 3] + } + }, + args: { + onOpenChange: fn(), + onChange: fn() + }, + title: 'DatePicker', + decorators: [ + (Story) => ( + + + + ) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + label: 'Birthday' + } +}; + +export const AriaLabel: Story = { + args: { + 'aria-label': 'Birthday' + } +}; +export const Validation: Story = { + render: (args) => ( +
+ + + + ), + args: { + label: 'Birthday', + isRequired: true + } +}; + +export const CustomWidth: Story = { + render: (args) => ( + + ), + args: { + label: 'Birthday' + } +}; + +export const ContextualHelpExample: Story = { + render: (args) => ( + + Quantity + + + Enter a date, any date. May I recommend today? + + +
+ Learn more about what happened on this date. +
+ + } /> + ), + args: { + label: 'On this day' + } +}; diff --git a/packages/@react-spectrum/s2/stories/DateRangePicker.stories.tsx b/packages/@react-spectrum/s2/stories/DateRangePicker.stories.tsx new file mode 100644 index 00000000000..4e025518f41 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/DateRangePicker.stories.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Button, Content, ContextualHelp, DateRangePicker, Footer, Form, Heading, Link, Text} from '../src'; +import {CalendarSwitcher, categorizeArgTypes} from './utils'; +import {fn} from '@storybook/test'; +import type {Meta, StoryObj} from '@storybook/react'; +import {style} from '../style' with {type: 'macro'}; + +const meta: Meta = { + component: DateRangePicker, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + ...categorizeArgTypes('Events', ['onChange', 'onOpenChange']), + label: {control: {type: 'text'}}, + description: {control: {type: 'text'}}, + errorMessage: {control: {type: 'text'}}, + contextualHelp: {table: {disable: true}}, + visibleMonths: { + control: { + type: 'select' + }, + options: [1, 2, 3] + } + }, + args: { + onOpenChange: fn(), + onChange: fn() + }, + title: 'DateRangePicker', + decorators: [ + (Story) => ( + + + + ) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + label: 'Reservation dates' + } +}; + +export const AriaLabel: Story = { + args: { + 'aria-label': 'Reservation dates' + } +}; + +export const Validation: Story = { + render: (args) => ( +
+ + + + ), + args: { + label: 'Reservation dates', + isRequired: true + } +}; + +export const CustomWidth: Story = { + render: (args) => ( + + ), + args: { + label: 'Reservation dates' + } +}; + +export const ContextualHelpExample: Story = { + render: (args) => ( + + Quantity + + + Enter a date, any date. May I recommend today? + + +
+ Learn more about what happened on this date. +
+ + } /> + ), + args: { + label: 'On this day' + } +}; diff --git a/packages/@react-spectrum/s2/stories/RangeCalendar.stories.tsx b/packages/@react-spectrum/s2/stories/RangeCalendar.stories.tsx new file mode 100644 index 00000000000..cd6aab12004 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/RangeCalendar.stories.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ActionButton, RangeCalendar, RangeCalendarProps} from '../src'; +import {CalendarDate, getLocalTimeZone, isWeekend, today} from '@internationalized/date'; +import {CalendarSwitcher, categorizeArgTypes} from './utils'; +import {Custom454Calendar} from '../../../@internationalized/date/tests/customCalendarImpl'; +import {DateValue} from 'react-aria'; +import type {Meta, StoryObj} from '@storybook/react'; +import {ReactElement, useState} from 'react'; +import {style} from '../style' with {type: 'macro'}; +import {useLocale} from '@react-aria/i18n'; + +const meta: Meta> = { + component: RangeCalendar, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + ...categorizeArgTypes('Events', ['onChange']), + visibleMonths: { + control: { + type: 'select' + }, + options: [1, 2, 3] + } + }, + title: 'RangeCalendar', + decorators: [ + (Story) => ( + + + + ) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + 'aria-label': 'Reservation' + } +}; + +export const DateUnavailable: Story = { + args: { + isDateUnavailable: (date: DateValue) => { + const disabledIntervals = [[today(getLocalTimeZone()).subtract({days: 13}), today(getLocalTimeZone()), today(getLocalTimeZone()).add({weeks: 1})], [today(getLocalTimeZone()).add({weeks: 2}), today(getLocalTimeZone()).add({weeks: 3})]]; + return disabledIntervals.some((interval) => date.compare(interval[0]) > 0 && date.compare(interval[1]) < 0); + }, + 'aria-label': 'Reservation', + allowsNonContiguousRanges: true + } +}; + +export const WeekendsUnavailable: Story = { + render: function UnavailableWeekendsRender(args) { + let {locale} = useLocale(); + + return ( + isWeekend(date, locale)} /> + ); + }, + args: { + 'aria-label': 'Reservation', + allowsNonContiguousRanges: true + } +}; + +export const MinValue: Story = { + args: { + minValue: today(getLocalTimeZone()).add({days: 1}), + 'aria-label': 'Reservation' + } +}; + +function ControlledFocus(props: RangeCalendarProps): ReactElement { + const defaultFocusedDate = props.focusedValue ?? new CalendarDate(2019, 6, 5); + let [focusedDate, setFocusedDate] = useState(defaultFocusedDate); + return ( +
+ setFocusedDate(defaultFocusedDate)}>Reset focused date + +
+ ); +} + +function CustomCalendar(props: RangeCalendarProps): ReactElement { + return ( + new Custom454Calendar()} focusedValue={new CalendarDate(2023, 2, 5)} /> + ); +} + +export const Custom454Example: Story = { + render: (args) => , + args: { + 'aria-label': 'Reservation' + } +}; diff --git a/packages/@react-spectrum/s2/stories/TimeField.stories.tsx b/packages/@react-spectrum/s2/stories/TimeField.stories.tsx new file mode 100644 index 00000000000..5aa597409bb --- /dev/null +++ b/packages/@react-spectrum/s2/stories/TimeField.stories.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Button, Content, ContextualHelp, Footer, Form, Heading, Link, Text, TimeField} from '../src'; +import {CalendarSwitcher, categorizeArgTypes} from './utils'; +import type {Meta, StoryObj} from '@storybook/react'; +import {style} from '../style' with {type: 'macro'}; + +const meta: Meta = { + component: TimeField, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + ...categorizeArgTypes('Events', ['onChange']), + label: {control: {type: 'text'}}, + description: {control: {type: 'text'}}, + errorMessage: {control: {type: 'text'}}, + contextualHelp: {table: {disable: true}} + }, + title: 'TimeField', + decorators: [ + (Story) => ( + + + + ) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + label: 'Launch time' + } +}; + +export const AriaLabel: Story = { + args: { + 'aria-label': 'Launch time' + } +}; + +export const Validation: Story = { + render: (args) => ( +
+ + + + ), + args: { + label: 'Launch time', + isRequired: true + } +}; + +export const CustomWidth: Story = { + render: (args) => ( + + ), + args: { + label: 'Launch time' + } +}; + +export const ContextualHelpExample: Story = { + render: (args) => ( + + Quantity + + + Enter a date, any date. May I recommend today? + + +
+ Learn more about what happened on this date. +
+ + } /> + ), + args: { + label: 'On this day' + } +}; diff --git a/packages/@react-spectrum/s2/stories/utils.tsx b/packages/@react-spectrum/s2/stories/utils.tsx index 9a166a56d8e..706d8fe7e69 100644 --- a/packages/@react-spectrum/s2/stories/utils.tsx +++ b/packages/@react-spectrum/s2/stories/utils.tsx @@ -11,7 +11,9 @@ */ -import {ReactNode, useState} from 'react'; +import {Collection, Header, Heading, Picker, PickerItem, PickerSection, Provider} from '../src'; +import {Key, useLocale} from 'react-aria'; +import {PropsWithChildren, ReactElement, ReactNode, useMemo, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; type StaticColor = 'black' | 'white' | 'auto' | undefined; @@ -39,7 +41,7 @@ export function StaticColorProvider(props: {children: ReactNode, staticColor?: S {props.children}
{props.staticColor === 'auto' && ( -