Skip to content

Commit

Permalink
feat(ui-calendar,ui-date-input): improve DateInput2 onChange callback…
Browse files Browse the repository at this point in the history
…, add date formatting option, extend docs
  • Loading branch information
balzss committed Aug 14, 2024
1 parent f35f7dc commit 4e2c23c
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 31 deletions.
2 changes: 1 addition & 1 deletion packages/ui-calendar/src/Calendar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ class Calendar extends Component<CalendarProps, CalendarState> {
return DateTime.browserTimeZone()
}

// date is returned es a ISO string, like 2021-09-14T22:00:00.000Z
// date is returned as an ISO string, like 2021-09-14T22:00:00.000Z
handleDayClick = (event: MouseEvent<any>, { date }: { date: string }) => {
if (this.props.onDateSelected) {
const parsedDate = DateTime.parse(date, this.locale(), this.timezone())
Expand Down
104 changes: 103 additions & 1 deletion packages/ui-date-input/src/DateInput2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ This component is an updated version of [`DateInput`](/#DateInput) that's easier
render(<Example />)
```

### With custom validation
### Date validation

By default `DateInput2` only does date validation if the `invalidDateErrorMessage` prop is provided. This uses the browser's `Date` object to try an parse the user provided date and displays the error message if it fails. Validation is only triggered on the blur event of the input field.

If you want to do a more complex validation than the above (e.g. only allow a subset of dates) you can use the `onRequestValidateDate` prop to pass a validation function. This function will run on blur or on selecting the date from the picker. The result of the internal validation will be passed to this function. Then you have to set the error messages accordingly. Check the following example for more details:

```js
---
Expand Down Expand Up @@ -163,3 +167,101 @@ const Example = () => {

render(<Example />)
```

### Date formatting

The display format of the dates can be set via the `formatDate` property. It will be applied if the user clicks on a date in the date picker of after blur event from the input field.
Something to pay attention to is that the date string passed back in the callback function **is in UTC timezone**.

```js
---
type: example
---
const Example = () => {
const [value1, setValue1] = useState('')
const [value2, setValue2] = useState('')
const [value3, setValue3] = useState('')

const shortDateFormatFn = (dateString, locale, timezone) => {
return new Date(dateString).toLocaleDateString(locale, {
month: 'numeric',
year: 'numeric',
day: 'numeric',
timeZone: timezone,
})
}

const isoDateFormatFn = (dateString, locale, timezone) => {
// this is a simple way to get ISO8601 date in a specific timezone but should not be used in production
// please use a proper date library instead like date-fns, luxon or dayjs
const localeDate = new Date(dateString).toLocaleDateString('sv', {
month: 'numeric',
year: 'numeric',
day: 'numeric',
timeZone: timezone,
})

return localeDate
}

return (
<div style={{display: 'flex', flexDirection: 'column', gap: '1.5rem'}}>
<DateInput2
renderLabel="Default format"
screenReaderLabels={{
calendarIcon: 'Calendar',
nextMonthButton: 'Next month',
prevMonthButton: 'Previous month'
}}
isInline
width="20rem"
value={value1}
onChange={(e, value) => setValue1(value)}
withYearPicker={{
screenReaderLabel: 'Year picker',
startYear: 1900,
endYear: 2024
}}
/>
<DateInput2
renderLabel="Short format in current locale"
screenReaderLabels={{
calendarIcon: 'Calendar',
nextMonthButton: 'Next month',
prevMonthButton: 'Previous month'
}}
isInline
width="20rem"
value={value2}
onChange={(e, value) => setValue2(value)}
formatDate={shortDateFormatFn}
withYearPicker={{
screenReaderLabel: 'Year picker',
startYear: 1900,
endYear: 2024
}}
/>
<DateInput2
renderLabel="ISO8601"
screenReaderLabels={{
calendarIcon: 'Calendar',
nextMonthButton: 'Next month',
prevMonthButton: 'Previous month'
}}
isInline
width="20rem"
value={value3}
onChange={(e, value) => setValue3(value)}
formatDate={isoDateFormatFn}
withYearPicker={{
screenReaderLabel: 'Year picker',
startYear: 1900,
endYear: 2024
}}
/>
</div>
)
}

render(<Example />)
```
63 changes: 37 additions & 26 deletions packages/ui-date-input/src/DateInput2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,22 @@ import type { Moment } from '@instructure/ui-i18n'

function parseDate(dateString: string): string {
const date = new Date(dateString)
// return empty string if not a valid date
return isNaN(date.getTime()) ? '' : date.toISOString()
}

function defaultDateFormatter(
dateString: string,
locale: string,
timezone: string
) {
return new Date(dateString).toLocaleDateString(locale, {
month: 'long',
year: 'numeric',
day: 'numeric',
timeZone: timezone
})
}

/**
---
category: components
Expand All @@ -73,6 +85,8 @@ const DateInput2 = ({
locale,
timezone,
placeholder,
formatDate = defaultDateFormatter,
// margin, TODO enable this prop
...rest
}: DateInput2Props) => {
const [selectedDate, setSelectedDate] = useState<string>('')
Expand All @@ -83,7 +97,7 @@ const DateInput2 = ({
const localeContext = useContext(ApplyLocaleContext)

useEffect(() => {
// when `value` is changed, validation runs again and removes the error message if validation passes
// when `value` is changed, validation removes the error message if passes
// but it's NOT adding error message if validation fails for better UX
validateInput(true)
}, [value])
Expand All @@ -92,27 +106,31 @@ const DateInput2 = ({
setInputMessages(messages || [])
}, [messages])

const handleInputChange = (e: SyntheticEvent, value: string) => {
onChange?.(e, value)
// blur event formats the input which should trigger parsing
useEffect(() => {
setSelectedDate(parseDate(value || ''))
}, [])

const handleInputChange = (
e: SyntheticEvent,
newValue: string,
parsedDate: string = ''
) => {
// blur event formats the input which shouldn't trigger parsing
if (e.type !== 'blur') {
setSelectedDate(parseDate(value))
setSelectedDate(parseDate(newValue))
}
onChange?.(e, newValue, parsedDate)
}

const handleDateSelected = (
dateString: string,
_momentDate: Moment,
e: SyntheticEvent
) => {
const formattedDate = new Date(dateString).toLocaleDateString(getLocale(), {
month: 'long',
year: 'numeric',
day: 'numeric',
timeZone: getTimezone()
})
handleInputChange(e, formattedDate)
setSelectedDate(dateString)
const formattedDate = formatDate(dateString, getLocale(), getTimezone())
const parsedDate = parseDate(dateString)
setSelectedDate(parsedDate)
handleInputChange(e, formattedDate, parsedDate)
setShowPopover(false)
onRequestValidateDate?.(dateString, true)
}
Expand Down Expand Up @@ -163,16 +181,8 @@ const DateInput2 = ({
const handleBlur = (e: SyntheticEvent) => {
const isInputValid = validateInput(false)
if (isInputValid && selectedDate) {
const formattedDate = new Date(selectedDate).toLocaleDateString(
getLocale(),
{
month: 'long',
year: 'numeric',
day: 'numeric',
timeZone: getTimezone()
}
)
handleInputChange(e, formattedDate)
const formattedDate = formatDate(selectedDate, getLocale(), getTimezone())
handleInputChange(e, formattedDate, selectedDate)
}
onRequestValidateDate?.(value, isInputValid)
onBlur?.(e)
Expand All @@ -181,6 +191,7 @@ const DateInput2 = ({
return (
<TextInput
{...passthroughProps(rest)}
// margin={'large'} TODO add this prop to TextInput
renderLabel={renderLabel}
onChange={handleInputChange}
onBlur={handleBlur}
Expand Down Expand Up @@ -219,8 +230,8 @@ const DateInput2 = ({
onDateSelected={handleDateSelected}
selectedDate={selectedDate}
visibleMonth={selectedDate}
locale={locale}
timezone={timezone}
locale={getLocale()}
timezone={getTimezone()}
role="listbox"
renderNextMonthButton={
<IconButton
Expand Down
27 changes: 24 additions & 3 deletions packages/ui-date-input/src/DateInput2/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import type { SyntheticEvent, InputHTMLAttributes } from 'react'
import { controllable } from '@instructure/ui-prop-types'
import { FormPropTypes } from '@instructure/ui-form-field'
import type { FormMessage } from '@instructure/ui-form-field'
import type { OtherHTMLAttributes, Renderable, PropValidators } from '@instructure/shared-types'
import type {
OtherHTMLAttributes,
Renderable,
PropValidators
} from '@instructure/shared-types'

type DateInput2OwnProps = {
/**
Expand Down Expand Up @@ -56,7 +60,11 @@ type DateInput2OwnProps = {
/**
* Callback fired when the input changes.
*/
onChange?: (event: React.SyntheticEvent, value: string) => void
onChange?: (
event: React.SyntheticEvent,
inputValue: string,
dateString: string
) => void
/**
* Callback executed when the input fires a blur event.
*/
Expand Down Expand Up @@ -157,6 +165,18 @@ type DateInput2OwnProps = {
startYear: number
endYear: number
}

/**
* Formatting function for how the date should be displayed inside the input field. It will be applied if the user clicks on a date in the date picker of after blur event from the input field.
*/
formatDate?: (isoDate: string, locale: string, timezone: string) => string

/**
* Valid values are `0`, `none`, `auto`, `xxx-small`, `xx-small`, `x-small`,
* `small`, `medium`, `large`, `x-large`, `xx-large`. Apply these values via
* familiar CSS-like shorthand. For example: `margin="small auto large"`.
*/
// margin?: Spacing TODO enable this prop
}

type PropKeys = keyof DateInput2OwnProps
Expand Down Expand Up @@ -189,7 +209,8 @@ const propTypes: PropValidators<PropKeys> = {
]),
locale: PropTypes.string,
timezone: PropTypes.string,
withYearPicker: PropTypes.object
withYearPicker: PropTypes.object,
formatDate: PropTypes.func
}

export type { DateInput2Props }
Expand Down

0 comments on commit 4e2c23c

Please sign in to comment.