Add placeholder, empty-value, and onConfirm support to DateField (#10993)

Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eric Bailey
2026-06-25 18:04:54 -05:00
committed by GitHub
parent fcf17a482f
commit 308693762c
7 changed files with 162 additions and 18 deletions
+34 -1
View File
@@ -164,12 +164,45 @@ related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file. its own looks more like a single component file.
### Documentation and Tests Within Features ### Comments
Comment code when necessary to explain the “why” behind something; avoid Comment code when necessary to explain the “why” behind something; avoid
comments that simply describe the code. Avoid Unicode characters in comments, comments that simply describe the code. Avoid Unicode characters in comments,
e.g., use `-` not `—`. e.g., use `-` not `—`.
Always use docblock (`/** */`) syntax for comments that document a type, type
member, method, function, or variable. These are the comments a reader expects
to find attached to a named declaration, and the docblock form makes that intent
clear and surfaces nicely in editor tooltips.
```tsx
type DateFieldProps = {
/**
* An empty string renders the placeholder and opens the picker at today (or
* maximumDate, if earlier).
*/
value: string | Date
}
/**
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
*/
export function DateField() {}
```
More generally, any multiline comment should use the `/* */` block syntax rather
than stacked `//` lines. Reserve `//` for short, single-line comments.
```tsx
/*
* The picker requires a valid date, so when value is empty we fall back to
* maximumDate (if set) or today.
*/
const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
```
### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends any important implementation details. The `/Component/index.tsx` pattern lends
@@ -16,6 +16,8 @@ export function DateField({
value, value,
inputRef, inputRef,
onChangeDate, onChangeDate,
onConfirm,
placeholder,
label, label,
isInvalid, isInvalid,
testID, testID,
@@ -26,14 +28,28 @@ export function DateField({
const t = useTheme() const t = useTheme()
const [open, setOpen] = useState(false) 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( const onChangeInternal = useCallback(
(date: Date) => { (date: Date) => {
setOpen(false) setOpen(false)
const formatted = toSimpleDateString(date) const formatted = toSimpleDateString(date)
onChangeDate(formatted) onChangeDate(formatted)
onConfirm?.(formatted)
}, },
[onChangeDate, setOpen], [onChangeDate, onConfirm, setOpen],
) )
useImperativeHandle( useImperativeHandle(
@@ -63,6 +79,7 @@ export function DateField({
<DateFieldButton <DateFieldButton
label={label} label={label}
value={value} value={value}
placeholder={placeholder}
onPress={onPress} onPress={onPress}
isInvalid={isInvalid} isInvalid={isInvalid}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
@@ -77,7 +94,7 @@ export function DateField({
theme={t.scheme} theme={t.scheme}
// @ts-ignore TODO // @ts-ignore TODO
buttonColor={t.name === 'light' ? '#000000' : '#ffffff'} buttonColor={t.name === 'light' ? '#000000' : '#ffffff'}
date={new Date(value)} date={initialDate}
onConfirm={onChangeInternal} onConfirm={onChangeInternal}
onCancel={onCancel} onCancel={onCancel}
mode="date" mode="date"
@@ -14,12 +14,14 @@ import {Text} from '#/components/Typography'
export function DateFieldButton({ export function DateFieldButton({
label, label,
value, value,
placeholder,
onPress, onPress,
isInvalid, isInvalid,
accessibilityHint, accessibilityHint,
}: { }: {
label: string label: string
value: string | Date value: string | Date
placeholder?: string
onPress: () => void onPress: () => void
isInvalid?: boolean isInvalid?: boolean
accessibilityHint?: string accessibilityHint?: string
@@ -78,20 +80,18 @@ export function DateFieldButton({
a.align_center, a.align_center,
hovered ? chromeHover : {}, hovered ? chromeHover : {},
focused || pressed ? chromeFocus : {}, focused || pressed ? chromeFocus : {},
isInvalid || isInvalid ? chromeError : {}, isInvalid ? chromeError : {},
(isInvalid || isInvalid) && (hovered || focused) isInvalid && (hovered || focused) ? chromeErrorHover : {},
? chromeErrorHover
: {},
]}> ]}>
<TextField.Icon icon={CalendarDays} /> <TextField.Icon icon={CalendarDays} />
<Text <Text
style={[ style={[
a.text_md, a.text_md,
a.pl_xs, a.pl_xs,
t.atoms.text, value === '' ? t.atoms.text_contrast_low : t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875}, {lineHeight: a.text_md.fontSize * 1.1875},
]}> ]}>
{i18n.date(value, {timeZone: 'UTC'})} {value === '' ? placeholder : i18n.date(value, {timeZone: 'UTC'})}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
+33 -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 {Keyboard, View} from 'react-native'
import DatePicker from 'react-native-date-picker' import DatePicker from 'react-native-date-picker'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
@@ -28,6 +28,8 @@ export function DateField({
value, value,
inputRef, inputRef,
onChangeDate, onChangeDate,
onConfirm,
placeholder,
testID, testID,
label, label,
isInvalid, isInvalid,
@@ -38,10 +40,23 @@ export function DateField({
const t = useTheme() const t = useTheme()
const control = Dialog.useDialogControl() 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( const onChangeInternal = useCallback(
(date: Date | undefined) => { (date: Date | undefined) => {
if (date) { if (date) {
const formatted = toSimpleDateString(date) const formatted = toSimpleDateString(date)
setDraft(formatted)
onChangeDate(formatted) onChangeDate(formatted)
} }
}, },
@@ -53,13 +68,14 @@ export function DateField({
() => ({ () => ({
focus: () => { focus: () => {
Keyboard.dismiss() Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open() control.open()
}, },
blur: () => { blur: () => {
control.close() control.close()
}, },
}), }),
[control], [control, value, fallbackDate],
) )
return ( return (
@@ -67,8 +83,10 @@ export function DateField({
<DateFieldButton <DateFieldButton
label={label} label={label}
value={value} value={value}
placeholder={placeholder}
onPress={() => { onPress={() => {
Keyboard.dismiss() Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open() control.open()
}} }}
isInvalid={isInvalid} isInvalid={isInvalid}
@@ -85,7 +103,7 @@ export function DateField({
<DatePicker <DatePicker
timeZoneOffsetInMinutes={0} timeZoneOffsetInMinutes={0}
theme={t.scheme} theme={t.scheme}
date={new Date(toSimpleDateString(value))} date={new Date(draft)}
onDateChange={onChangeInternal} onDateChange={onChangeInternal}
mode="date" mode="date"
locale={i18n.locale} locale={i18n.locale}
@@ -102,7 +120,18 @@ export function DateField({
</View> </View>
<Button <Button
label={_(msg`Done`)} label={_(msg`Done`)}
onPress={() => control.close()} onPress={() => {
/*
* Commit the currently shown date even if the user never
* scrolled (onDateChange only fires on scroll). This keeps
* onChangeDate firing alongside onConfirm, matching Android and
* web, so an empty field confirmed without scrolling does not
* report a date via onConfirm while onChangeDate stays silent.
*/
onChangeDate(draft)
onConfirm?.(draft)
control.close()
}}
size="large" size="large"
color="primary" color="primary"
variant="solid"> variant="solid">
+4 -2
View File
@@ -36,6 +36,7 @@ export function DateField({
value, value,
inputRef, inputRef,
onChangeDate, onChangeDate,
onConfirm,
label, label,
isInvalid, isInvalid,
testID, testID,
@@ -49,16 +50,17 @@ export function DateField({
if (date) { if (date) {
const formatted = toSimpleDateString(date) const formatted = toSimpleDateString(date)
onChangeDate(formatted) onChangeDate(formatted)
onConfirm?.(formatted)
} }
}, },
[onChangeDate], [onChangeDate, onConfirm],
) )
return ( return (
<TextField.Root isInvalid={isInvalid}> <TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={CalendarDays} /> <TextField.Icon icon={CalendarDays} />
<Input <Input
value={toSimpleDateString(value)} value={value === '' ? '' : toSimpleDateString(value)}
inputRef={inputRef as React.Ref<TextInput>} inputRef={inputRef as React.Ref<TextInput>}
label={label} label={label}
onChange={handleOnChange} onChange={handleOnChange}
+15
View File
@@ -3,8 +3,23 @@ export type DateFieldRef = {
blur: () => void blur: () => void
} }
export type DateFieldProps = { export type DateFieldProps = {
/**
* An empty string renders the placeholder and opens the picker at today (or
* maximumDate, if earlier).
*/
value: string | Date value: string | Date
onChangeDate: (date: string) => void 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 label: string
inputRef?: React.Ref<DateFieldRef> inputRef?: React.Ref<DateFieldRef>
isInvalid?: boolean 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 {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea' 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 SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
@@ -28,6 +28,8 @@ export function Forms() {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [date, setDate] = useState('2001-01-01') 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 [countryCode, setCountryCode] = useState<CountryCode>('US')
const [phoneNumber, setPhoneNumber] = useState('') const [phoneNumber, setPhoneNumber] = useState('')
const [lang, setLang] = useState('en') const [lang, setLang] = useState('en')
@@ -175,17 +177,63 @@ export function Forms() {
<H3>DateField</H3> <H3>DateField</H3>
<View style={[a.w_full]}> <View style={[a.w_full]}>
<LabelText>Date</LabelText> <LabelText>1. Date</LabelText>
<DateField <DateField
testID="date" testID="date"
value={date} value={date}
onChangeDate={date => { onChangeDate={date => {
console.log(date) console.log('[1] changed', date)
setDate(date) setDate(date)
}} }}
label="Input" label="Input"
/> />
</View> </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> <H3>InternationalPhoneCodeSelect</H3>
<View style={[a.flex_row, a.gap_sm, a.align_center]}> <View style={[a.flex_row, a.gap_sm, a.align_center]}>