Add placeholder, empty-value, and onConfirm support to DateField

Extracts the DateField changes from #10992: a placeholder shown when the
value is empty, support for an empty initial value (the picker opens at
maximumDate or today), and an onConfirm callback that fires only when the
user commits a date, distinct from onChangeDate which on iOS fires on every
scroll tick.

Adds Storybook examples demonstrating each behavior, and documents comment
style conventions (docblocks for declarations, block syntax for multiline
comments) in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DS Boyce
2026-06-25 17:37:33 -05:00
committed by Eric Bailey
parent 6b342f61b4
commit 1f2cf3ba02
7 changed files with 154 additions and 18 deletions
@@ -16,6 +16,8 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
placeholder,
label,
isInvalid,
testID,
@@ -26,14 +28,28 @@ export function DateField({
const t = useTheme()
const [open, setOpen] = useState(false)
/*
* The picker requires a valid date, so when value is empty we open at
* maximumDate (if set) or today. Normalize through toSimpleDateString so a
* date-only value is parsed as UTC midnight, consistent with the picker's
* timeZoneOffsetInMinutes={0} and the maximumDate below.
*/
const initialDate =
value === ''
? maximumDate
? new Date(toSimpleDateString(maximumDate))
: new Date()
: new Date(toSimpleDateString(value))
const onChangeInternal = useCallback(
(date: Date) => {
setOpen(false)
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
onConfirm?.(formatted)
},
[onChangeDate, setOpen],
[onChangeDate, onConfirm, setOpen],
)
useImperativeHandle(
@@ -63,6 +79,7 @@ export function DateField({
<DateFieldButton
label={label}
value={value}
placeholder={placeholder}
onPress={onPress}
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
@@ -77,7 +94,7 @@ export function DateField({
theme={t.scheme}
// @ts-ignore TODO
buttonColor={t.name === 'light' ? '#000000' : '#ffffff'}
date={new Date(value)}
date={initialDate}
onConfirm={onChangeInternal}
onCancel={onCancel}
mode="date"
@@ -14,12 +14,14 @@ import {Text} from '#/components/Typography'
export function DateFieldButton({
label,
value,
placeholder,
onPress,
isInvalid,
accessibilityHint,
}: {
label: string
value: string | Date
placeholder?: string
onPress: () => void
isInvalid?: boolean
accessibilityHint?: string
@@ -78,20 +80,18 @@ export function DateFieldButton({
a.align_center,
hovered ? chromeHover : {},
focused || pressed ? chromeFocus : {},
isInvalid || isInvalid ? chromeError : {},
(isInvalid || isInvalid) && (hovered || focused)
? chromeErrorHover
: {},
isInvalid ? chromeError : {},
isInvalid && (hovered || focused) ? chromeErrorHover : {},
]}>
<TextField.Icon icon={CalendarDays} />
<Text
style={[
a.text_md,
a.pl_xs,
t.atoms.text,
value === '' ? t.atoms.text_contrast_low : t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
{i18n.date(value, {timeZone: 'UTC'})}
{value === '' ? placeholder : i18n.date(value, {timeZone: 'UTC'})}
</Text>
</Pressable>
</View>
+25 -4
View File
@@ -1,4 +1,4 @@
import {useCallback, useImperativeHandle} from 'react'
import {useCallback, useImperativeHandle, useState} from 'react'
import {Keyboard, View} from 'react-native'
import DatePicker from 'react-native-date-picker'
import {msg} from '@lingui/core/macro'
@@ -28,6 +28,8 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
placeholder,
testID,
label,
isInvalid,
@@ -38,10 +40,23 @@ export function DateField({
const t = useTheme()
const control = Dialog.useDialogControl()
/*
* The picker requires a valid date, so when value is empty we fall back to
* maximumDate (if set) or today. Draft state lets the picker scroll even when
* the parent does not echo value back (e.g. a clearable field).
*/
const fallbackDate = maximumDate
? toSimpleDateString(maximumDate)
: toSimpleDateString(new Date())
const [draft, setDraft] = useState(() =>
value === '' ? fallbackDate : toSimpleDateString(value),
)
const onChangeInternal = useCallback(
(date: Date | undefined) => {
if (date) {
const formatted = toSimpleDateString(date)
setDraft(formatted)
onChangeDate(formatted)
}
},
@@ -53,13 +68,14 @@ export function DateField({
() => ({
focus: () => {
Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open()
},
blur: () => {
control.close()
},
}),
[control],
[control, value, fallbackDate],
)
return (
@@ -67,8 +83,10 @@ export function DateField({
<DateFieldButton
label={label}
value={value}
placeholder={placeholder}
onPress={() => {
Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open()
}}
isInvalid={isInvalid}
@@ -85,7 +103,7 @@ export function DateField({
<DatePicker
timeZoneOffsetInMinutes={0}
theme={t.scheme}
date={new Date(toSimpleDateString(value))}
date={new Date(draft)}
onDateChange={onChangeInternal}
mode="date"
locale={i18n.locale}
@@ -102,7 +120,10 @@ export function DateField({
</View>
<Button
label={_(msg`Done`)}
onPress={() => control.close()}
onPress={() => {
onConfirm?.(draft)
control.close()
}}
size="large"
color="primary"
variant="solid">
+4 -2
View File
@@ -36,6 +36,7 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
label,
isInvalid,
testID,
@@ -49,16 +50,17 @@ export function DateField({
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
onConfirm?.(formatted)
}
},
[onChangeDate],
[onChangeDate, onConfirm],
)
return (
<TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={CalendarDays} />
<Input
value={toSimpleDateString(value)}
value={value === '' ? '' : toSimpleDateString(value)}
inputRef={inputRef as React.Ref<TextInput>}
label={label}
onChange={handleOnChange}
+15
View File
@@ -3,8 +3,23 @@ export type DateFieldRef = {
blur: () => void
}
export type DateFieldProps = {
/**
* An empty string renders the placeholder and opens the picker at today (or
* maximumDate, if earlier).
*/
value: string | Date
onChangeDate: (date: string) => void
/**
* Fired when the user commits a date: iOS "Done", Android confirm, or web
* input change. Distinct from onChangeDate, which on iOS fires on every
* scroll tick.
*/
onConfirm?: (date: string) => void
/**
* Shown on native when value is empty. Web uses the browser's native date
* placeholder.
*/
placeholder?: string
label: string
inputRef?: React.Ref<DateFieldRef>
isInvalid?: boolean
+51 -3
View File
@@ -6,7 +6,7 @@ import {type CountryCode} from '#/lib/international-telephone-codes'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
import {DateField, LabelText} from '#/components/forms/DateField'
import {DateField, LabelText, utils} from '#/components/forms/DateField'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
@@ -28,6 +28,8 @@ export function Forms() {
const [value, setValue] = useState('')
const [date, setDate] = useState('2001-01-01')
const [emptyDate, setEmptyDate] = useState('')
const [confirmDate, setConfirmDate] = useState('2001-01-01')
const [countryCode, setCountryCode] = useState<CountryCode>('US')
const [phoneNumber, setPhoneNumber] = useState('')
const [lang, setLang] = useState('en')
@@ -175,17 +177,63 @@ export function Forms() {
<H3>DateField</H3>
<View style={[a.w_full]}>
<LabelText>Date</LabelText>
<LabelText>1. Date</LabelText>
<DateField
testID="date"
value={date}
onChangeDate={date => {
console.log(date)
console.log('[1] changed', date)
setDate(date)
}}
label="Input"
/>
</View>
<View style={[a.w_full]}>
<LabelText>2. Empty value with placeholder</LabelText>
<DateField
testID="dateEmpty"
value={emptyDate}
onChangeDate={date => {
console.log('[2] changed', date)
setEmptyDate(date)
}}
placeholder="Select a date"
label="Birthday"
/>
</View>
<View style={[a.w_full]}>
<LabelText>3. Empty value with placeholder and maximumDate</LabelText>
<DateField
testID="dateEmptyMax"
value={emptyDate}
onChangeDate={date => {
console.log('[3] changed', date)
setEmptyDate(date)
}}
placeholder="Select a date"
maximumDate={utils.toSimpleDateString(new Date())}
label="Date of birth"
/>
</View>
<View style={[a.w_full]}>
<LabelText>
4. onConfirm vs onChangeDate (check the console)
</LabelText>
<DateField
testID="dateConfirm"
value={confirmDate}
onChangeDate={date => {
console.log('[4] changed', date)
setConfirmDate(date)
}}
onConfirm={date => console.log('[4] confirmed', date)}
label="Input"
/>
</View>
<H3>InternationalPhoneCodeSelect</H3>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>