From 1ea20b3a053a22ea007d7305888e3ffd3168aec0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 10 Jan 2024 14:33:11 -0600 Subject: [PATCH] Date inputs --- bskyweb/templates/base.html | 4 + .../com/forms/InputDate/index.android.tsx | 162 ++++++++++++++++++ src/view/com/forms/InputDate/index.tsx | 77 +++++++++ src/view/com/forms/InputDate/index.web.tsx | 154 +++++++++++++++++ src/view/com/forms/InputDate/utils.ts | 16 ++ src/view/com/forms/types.ts | 22 +++ .../com/util/hooks/useInteractionState.ts | 21 +++ src/view/screens/DebugNew.tsx | 16 ++ web/index.html | 4 + 9 files changed, 476 insertions(+) create mode 100644 src/view/com/forms/InputDate/index.android.tsx create mode 100644 src/view/com/forms/InputDate/index.tsx create mode 100644 src/view/com/forms/InputDate/index.web.tsx create mode 100644 src/view/com/forms/InputDate/utils.ts create mode 100644 src/view/com/forms/types.ts create mode 100644 src/view/com/util/hooks/useInteractionState.ts diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 55c7c9fd86..ffc2adf0f8 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -39,6 +39,10 @@ height: calc(100% + env(safe-area-inset-top)); } + input::-webkit-date-and-time-value { + text-align: left; + } + /* Color theming */ :root { --text: black; diff --git a/src/view/com/forms/InputDate/index.android.tsx b/src/view/com/forms/InputDate/index.android.tsx new file mode 100644 index 0000000000..76ffa2e56e --- /dev/null +++ b/src/view/com/forms/InputDate/index.android.tsx @@ -0,0 +1,162 @@ +import React from 'react' +import {View, TextInputProps, TextStyle, Pressable} from 'react-native' +import DateTimePicker, { + BaseProps as DateTimePickerProps, +} from '@react-native-community/datetimepicker' + +import {Logo} from '#/view/icons/Logo' +import {useTheme, atoms, tokens} from '#/alf' +import {Text} from '#/view/com/Typography' +import {useInteractionState} from '#/view/com/util/hooks/useInteractionState' +import {BaseProps} from '#/view/com/forms/types' +import { + localizeDate, + toSimpleDateString, +} from '#/view/com/forms/InputDate/utils' + +type Props = Omit & BaseProps + +export * as utils from '#/view/com/forms/InputDate/utils' + +export function InputDate({ + value: initialValue, + onChange, + testID, + label, + hasError, + accessibilityLabel, + accessibilityHint, + ...props +}: Props) { + const labelId = React.useId() + const t = useTheme() + const [open, setOpen] = React.useState(false) + const [value, setValue] = React.useState(initialValue) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const {inputStyles, iconStyles} = React.useMemo(() => { + const input: TextStyle[] = [ + { + paddingLeft: 40, + }, + ] + const icon: TextStyle[] = [] + + if (hasError) { + input.push({ + borderColor: tokens.color.red_200, + }) + icon.push({ + color: tokens.color.red_400, + }) + } + + if (focused) { + input.push({ + borderColor: t.atoms.border_contrast_500.borderColor, + }) + + if (hasError) { + input.push({ + borderColor: tokens.color.red_500, + }) + } + } + + return {inputStyles: input, iconStyles: icon} + }, [t, focused, hasError]) + + const onChangeInternal = React.useCallback< + Required['onChange'] + >( + (_event, date) => { + setOpen(false) + + if (date) { + const formatted = toSimpleDateString(date) + onChange(formatted) + setValue(formatted) + } + }, + [onChange, setOpen, setValue], + ) + + return ( + + {label && ( + + {label} + + )} + + setOpen(true)} + onFocus={onFocus} + onBlur={onBlur} + style={[ + { + paddingTop: atoms.pt_md.paddingTop + 2, + }, + atoms.w_full, + atoms.px_lg, + atoms.pb_md, + atoms.rounded_sm, + t.atoms.bg_contrast_100, + ...inputStyles, + ]}> + {localizeDate(value)} + + + + + + + {open && ( + + )} + + ) +} diff --git a/src/view/com/forms/InputDate/index.tsx b/src/view/com/forms/InputDate/index.tsx new file mode 100644 index 0000000000..6b3c8b40b9 --- /dev/null +++ b/src/view/com/forms/InputDate/index.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import {View, TextInputProps} from 'react-native' +import DateTimePicker, { + DateTimePickerEvent, +} from '@react-native-community/datetimepicker' + +import {useTheme, atoms} from '#/alf' +import {Text} from '#/view/com/Typography' +import {BaseProps} from '#/view/com/forms/types' +import {toSimpleDateString} from '#/view/com/forms/InputDate/utils' + +type Props = Omit & BaseProps + +export * as utils from '#/view/com/forms/InputDate/utils' + +/** + * Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date + * changes in the same format. + * + * For dates of unknown format, convert with the + * `utils.toSimpleDateString(Date)` export of this file. + */ +export function InputDate({ + value: initialValue, + onChange, + testID, + label, + accessibilityLabel, + accessibilityHint, +}: Props) { + const labelId = React.useId() + const t = useTheme() + const [value, setValue] = React.useState(initialValue) + + const onChangeInternal = React.useCallback( + (event: DateTimePickerEvent, date: Date | undefined) => { + if (date) { + const formatted = toSimpleDateString(date) + onChange(formatted) + setValue(formatted) + } + }, + [onChange], + ) + + return ( + + {label && ( + + {label} + + )} + + + + ) +} diff --git a/src/view/com/forms/InputDate/index.web.tsx b/src/view/com/forms/InputDate/index.web.tsx new file mode 100644 index 0000000000..994a7f509f --- /dev/null +++ b/src/view/com/forms/InputDate/index.web.tsx @@ -0,0 +1,154 @@ +import React from 'react' +import {View, TextStyle} from 'react-native' +// @ts-ignore +import {unstable_createElement} from 'react-native-web' + +import {Logo} from '#/view/icons/Logo' +import {useTheme, atoms, tokens} from '#/alf' +import {Text} from '#/view/com/Typography' +import {useInteractionState} from '#/view/com/util/hooks/useInteractionState' + +import {BaseProps} from '#/view/com/forms/types' +import {toSimpleDateString} from '#/view/com/forms/InputDate/utils' + +type Props = BaseProps + +export * as utils from '#/view/com/forms/InputDate/utils' + +export function InputDate({ + label, + hasError, + testID, + value: initialValue, + onChange, + accessibilityLabel, + accessibilityHint, + ...props +}: Props) { + const labelId = React.useId() + const t = useTheme() + const [value, setValue] = React.useState(initialValue) + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const {inputStyles, iconStyles} = React.useMemo(() => { + const input: TextStyle[] = [ + { + paddingLeft: 40, + }, + ] + const icon: TextStyle[] = [] + + if (hasError) { + input.push({ + borderColor: tokens.color.red_200, + }) + icon.push({ + color: tokens.color.red_400, + }) + } + + if (hovered || focused) { + input.push({ + borderColor: t.atoms.border_contrast_500.borderColor, + }) + + if (hasError) { + input.push({ + borderColor: tokens.color.red_500, + }) + } + } + + return {inputStyles: input, iconStyles: icon} + }, [t, hovered, focused, hasError]) + + const handleOnChange = React.useCallback( + (e: any) => { + const date = e.currentTarget.valueAsDate + + if (date) { + const formatted = toSimpleDateString(date) + onChange(formatted) + setValue(formatted) + } + }, + [onChange, setValue], + ) + + return ( + + {label && ( + + {label} + + )} + + {unstable_createElement('input', { + ...props, + testID: `${testID}-datepicker`, + 'aria-labelledby': labelId, + 'aria-label': label, + accessibilityLabel: accessibilityLabel, + accessibilityHint: accessibilityHint, + type: 'date', + value: value, + onFocus: onFocus, + onBlur: onBlur, + onChange: handleOnChange, + onMouseEnter: onHoverIn, + onMouseLeave: onHoverOut, + style: [ + { + outline: 0, + border: 0, + appearance: 'none', + boxSizing: 'border-box', + lineHeight: atoms.text_md.lineHeight * 1.1875, + paddingTop: atoms.pt_md.paddingTop - 1, + }, + atoms.w_full, + atoms.px_lg, + atoms.pb_md, + atoms.rounded_sm, + atoms.text_md, + t.atoms.bg_contrast_100, + t.atoms.text, + ...inputStyles, + ], + })} + + + + + + ) +} diff --git a/src/view/com/forms/InputDate/utils.ts b/src/view/com/forms/InputDate/utils.ts new file mode 100644 index 0000000000..c787272fe8 --- /dev/null +++ b/src/view/com/forms/InputDate/utils.ts @@ -0,0 +1,16 @@ +import {getLocales} from 'expo-localization' + +const LOCALE = getLocales()[0] + +// we need the date in the form yyyy-MM-dd to pass to the input +export function toSimpleDateString(date: Date | string): string { + const _date = typeof date === 'string' ? new Date(date) : date + return _date.toISOString().split('T')[0] +} + +export function localizeDate(date: Date | string): string { + const _date = typeof date === 'string' ? new Date(date) : date + return new Intl.DateTimeFormat(LOCALE.languageTag, { + timeZone: 'UTC', + }).format(_date) +} diff --git a/src/view/com/forms/types.ts b/src/view/com/forms/types.ts new file mode 100644 index 0000000000..9a4217579b --- /dev/null +++ b/src/view/com/forms/types.ts @@ -0,0 +1,22 @@ +import {AccessibilityProps, TextInputProps} from 'react-native' + +export type RequiredAccessibilityProps = Required + +export type BaseProps = Omit< + AccessibilityProps, + 'accessibilityLabel' | 'accessibilityHint' +> & + Pick< + RequiredAccessibilityProps, + 'accessibilityLabel' | 'accessibilityHint' + > & { + value: T + onChange: (value: T) => void + testID: string + label?: string + hasError?: boolean + /** + * **NOTE:** Available only on web + */ + autoFocus?: TextInputProps['autoFocus'] + } diff --git a/src/view/com/util/hooks/useInteractionState.ts b/src/view/com/util/hooks/useInteractionState.ts new file mode 100644 index 0000000000..653b1c10e6 --- /dev/null +++ b/src/view/com/util/hooks/useInteractionState.ts @@ -0,0 +1,21 @@ +import React from 'react' + +export function useInteractionState() { + const [state, setState] = React.useState(false) + + const onIn = React.useCallback(() => { + setState(true) + }, [setState]) + const onOut = React.useCallback(() => { + setState(false) + }, [setState]) + + return React.useMemo( + () => ({ + state, + onIn, + onOut, + }), + [state, onIn, onOut], + ) +} diff --git a/src/view/screens/DebugNew.tsx b/src/view/screens/DebugNew.tsx index d52e7a6e00..35f0e415d2 100644 --- a/src/view/screens/DebugNew.tsx +++ b/src/view/screens/DebugNew.tsx @@ -10,6 +10,7 @@ import {Button, ButtonText} from '#/view/com/Button' import {Link} from '#/view/com/Link' import {Text, H1, H2, H3, H4, H5, H6} from '#/view/com/Typography' import {InputText} from '#/view/com/forms/InputText' +import {InputDate, utils} from '#/view/com/forms/InputDate' import {Logo} from '#/view/icons/Logo' function ThemeSelector() { @@ -226,6 +227,21 @@ function Forms() { icon={Logo} suffix={() => .bksy.social} /> + + console.log(date)} + accessibilityLabel="Date" + accessibilityHint="Enter a date" + /> + console.log(date)} + accessibilityLabel="Date" + accessibilityHint="Enter a date" + /> ) } diff --git a/web/index.html b/web/index.html index 18b985bff5..70072f2ae2 100644 --- a/web/index.html +++ b/web/index.html @@ -43,6 +43,10 @@ height: calc(100% + env(safe-area-inset-top)); } + input::-webkit-date-and-time-value { + text-align: left; + } + /* Color theming */ :root { --text: black;