diff --git a/package-lock.json b/package-lock.json index f896d1d58..1cdabb0e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@datepicker-react/hooks": "^2.3.1", "@fontsource-variable/roboto-flex": "^5.2.6", + "@internationalized/date": "3.12.0", "@popperjs/core": "^2.11.5", + "@react-stately/utils": "3.11.0", "@styled-system/theme-get": "^5.1.2", "@types/react-select": "^4.0.18", "@types/styled-system": "^5.1.9", diff --git a/package.json b/package.json index 49e61abcc..2ced0cb6c 100644 --- a/package.json +++ b/package.json @@ -163,7 +163,9 @@ "dependencies": { "@datepicker-react/hooks": "^2.3.1", "@fontsource-variable/roboto-flex": "^5.2.6", + "@internationalized/date": "3.12.0", "@popperjs/core": "^2.11.5", + "@react-stately/utils": "3.11.0", "@styled-system/theme-get": "^5.1.2", "@types/react-select": "^4.0.18", "@types/styled-system": "^5.1.9", diff --git a/src/components/experimental/TimeField/TimeField.tsx b/src/components/experimental/TimeField/TimeField.tsx index d453e1808..21fee865d 100644 --- a/src/components/experimental/TimeField/TimeField.tsx +++ b/src/components/experimental/TimeField/TimeField.tsx @@ -1,20 +1,45 @@ import React from 'react'; +import styled from 'styled-components'; import { TimeValue } from 'react-aria'; import { FieldError, TimeField as BaseTimeField, TimeFieldProps as BaseTimeFieldProps } from 'react-aria-components'; +import { useControlledState } from '@react-stately/utils'; +import { now, getLocalTimeZone } from '@internationalized/date'; +import { getSemanticValue } from '../../../essentials/experimental'; import { Label } from '../Field/Label'; import { Footer } from '../Field/Footer'; import { FakeInput } from '../Field/FakeInput'; import { InnerWrapper } from '../Field/InnerWrapper'; -import { DateInput } from '../Field/Field'; +import { DateInput, fieldTextStyles } from '../Field/Field'; import { DateSegment } from '../Field/DateSegment'; import { Wrapper } from '../Field/Wrapper'; import { FieldProps } from '../Field/Props'; import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; +const TimeInputWrapper = styled.div` + position: relative; +`; + +const StyledDateInput = styled(DateInput)<{ $isHidden?: boolean }>` + ${props => props.$isHidden && 'opacity: 0;'} +`; + +const EmptyStateLabel = styled.span` + ${fieldTextStyles} + position: absolute; + inset: 0; + display: flex; + align-items: center; + pointer-events: none; + color: ${getSemanticValue('on-surface-variant')}; +`; + type TimeFieldProps = Omit & BaseTimeFieldProps & { label: string; hideLabel?: boolean; + emptyStateLabel?: string; + getValueOnBlur?: (value: TimeValue) => TimeValue | null; + getNow?: () => TimeValue; }; const TimeField = React.forwardRef( @@ -28,34 +53,72 @@ const TimeField = React.forwardRef( actionIcon, isVisuallyFocused = false, hideTimeZone = true, + emptyStateLabel, + getValueOnBlur, + getNow = () => now(getLocalTimeZone()), + value: valueProp, + defaultValue, + onChange, + onFocusChange, ...props }, forwardedRef - ) => ( - - - {({ isInvalid }) => ( - <> - - {leadingIcon} - - {hideLabel ? ( - - - - ) : ( - - )} - {segment => } - - {actionIcon} - -
{isInvalid ? {errorMessage} : description}
- - )} -
-
- ) + ) => { + const [value, setValue] = useControlledState(valueProp, defaultValue ?? null, onChange); + const [isFocused, setIsFocused] = React.useState(false); + + const handleFocusChange = (focused: boolean) => { + if (focused && value == null && getValueOnBlur) { + setValue(getNow()); + } else if (!focused && value != null && getValueOnBlur) { + setValue(getValueOnBlur(value)); + } + setIsFocused(focused); + onFocusChange?.(focused); + }; + + const showEmptyStateLabel = !isFocused && value == null && !!emptyStateLabel; + + return ( + + + {({ isInvalid }) => ( + <> + + {leadingIcon} + + {hideLabel ? ( + + + + ) : ( + + )} + + + {segment => } + + {showEmptyStateLabel && ( + + )} + + + {actionIcon} + +
{isInvalid ? {errorMessage} : description}
+ + )} +
+
+ ); + } ); export { TimeField, TimeFieldProps }; diff --git a/src/components/experimental/TimeField/docs/TimeField.stories.tsx b/src/components/experimental/TimeField/docs/TimeField.stories.tsx index 5b6b85680..83f606578 100644 --- a/src/components/experimental/TimeField/docs/TimeField.stories.tsx +++ b/src/components/experimental/TimeField/docs/TimeField.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { StoryObj, Meta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { getLocalTimeZone, now, parseTime } from '@internationalized/date'; -import { TimeField } from '../TimeField'; +import { TimeField, TimeFieldProps } from '../TimeField'; import ClockIcon from '../../../../icons/basic/ClockIcon'; import DropdownSelectIcon from '../../../../icons/arrows/DropdownSelectIcon'; @@ -18,7 +18,7 @@ const meta: Meta = { ) - ], + ] }; export default meta; @@ -88,3 +88,55 @@ export const WithActionIcon: Story = { actionIcon: } }; + +const resetThresholdArgType = { + name: 'resetThresholdMinutes', + description: 'On blur, if the edited time is within this many minutes of "now" the field resets to its empty state', + control: { type: 'number', min: 0, max: 60 } +} as const; + +const getValueOnBlurArgType = { + table: { disable: true } +} as const; + +const renderWithResetThreshold = (args: TimeFieldProps & { resetThresholdMinutes?: number }): JSX.Element => { + const { resetThresholdMinutes = 1, ...rest } = args; + return ( + { + action('getValueOnBlur')(value.toString()); + const current = now(getLocalTimeZone()); + const minutesApart = Math.abs(value.hour * 60 + value.minute - (current.hour * 60 + current.minute)); + return minutesApart <= resetThresholdMinutes ? null : value; + }} + /> + ); +}; + +export const WithEmptyStateDefaultingToNow = { + args: { + label: 'Appointment time', + emptyStateLabel: 'Now', + resetThresholdMinutes: 1 + }, + argTypes: { + resetThresholdMinutes: resetThresholdArgType, + getValueOnBlur: getValueOnBlurArgType + }, + render: renderWithResetThreshold +}; + +export const WithEmptyStateAndLeadingIcon = { + args: { + label: 'Appointment time', + emptyStateLabel: 'Now', + leadingIcon: , + resetThresholdMinutes: 1 + }, + argTypes: { + resetThresholdMinutes: resetThresholdArgType, + getValueOnBlur: getValueOnBlurArgType + }, + render: renderWithResetThreshold +};