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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user