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