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;