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
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
comments that simply describe the code. Avoid Unicode characters in comments,
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
within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends
@@ -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>
+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 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,18 @@ export function DateField({
</View>
<Button
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"
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]}>