From 0152f17a776893842596986f84eac2162f47a4d2 Mon Sep 17 00:00:00 2001 From: Chris Rolfe Date: Wed, 26 Aug 2026 15:59:25 +0200 Subject: [PATCH] feat(time-field): add emptyStateLabel and getValueOnBlur props Lets TimeField show sighted-only placeholder text (e.g. "now") while empty and unfocused, seed the field with the current time on focus, and let consumers decide via getValueOnBlur whether an edited value should revert back to empty on blur. Value state now goes through useControlledState so this works for both controlled and uncontrolled usage (e.g. React Hook Form). Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 2 + package.json | 2 + .../experimental/TimeField/TimeField.tsx | 115 ++++++++++++++---- .../TimeField/docs/TimeField.stories.tsx | 56 ++++++++- 4 files changed, 147 insertions(+), 28 deletions(-) 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 +};