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