Skip to content

Commit

Permalink
fix(ui-calendar,ui-date-input): fix year picker for non latin based l…
Browse files Browse the repository at this point in the history
…ocales; return iso date string in onRequestValidateDate
  • Loading branch information
balzss committed Aug 5, 2024
1 parent 9e4db0a commit d7df0e8
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 61 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 37 additions & 13 deletions packages/ui-calendar/src/Calendar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,22 +145,34 @@ class Calendar extends Component<CalendarProps, CalendarState> {
}

get hasPrevMonth() {
// this is needed for locales that doesn't use the latin script for numbers e.g.: arabic
const yearNumber = Number(
this.state.visibleMonth
.clone()
.locale('en')
.subtract({ months: 1 })
.format('YYYY')
)
return (
!this.props.withYearPicker ||
(this.props.withYearPicker &&
Number(
this.state.visibleMonth.clone().subtract({ months: 1 }).format('YYYY')
) >= this.props.withYearPicker.startYear)
yearNumber >= this.props.withYearPicker.startYear)
)
}

get hasNextMonth() {
// this is needed for locales that doesn't use the latin script for numbers e.g.: arabic
const yearNumber = Number(
this.state.visibleMonth
.clone()
.locale('en')
.subtract({ months: 1 })
.format('YYYY')
)
return (
!this.props.withYearPicker ||
(this.props.withYearPicker &&
Number(
this.state.visibleMonth.clone().add({ months: 1 }).format('YYYY')
) <= this.props.withYearPicker.endYear)
yearNumber <= this.props.withYearPicker.endYear)
)
}

Expand Down Expand Up @@ -227,16 +239,21 @@ class Calendar extends Component<CalendarProps, CalendarState> {

handleYearChange = (
e: React.SyntheticEvent<Element, Event>,
year: number
year: string
) => {
const { withYearPicker } = this.props
const { visibleMonth } = this.state
const yearNumber = Number(
DateTime.parse(year, this.locale(), this.timezone())
.locale('en')
.format('YYYY')
)
const newDate = visibleMonth.clone()
if (withYearPicker?.onRequestYearChange) {
withYearPicker.onRequestYearChange(e, year)
withYearPicker.onRequestYearChange(e, yearNumber)
return
}
newDate.year(year)
newDate.year(yearNumber)
this.setState({ visibleMonth: newDate })
}

Expand All @@ -261,12 +278,19 @@ class Calendar extends Component<CalendarProps, CalendarState> {
...(prevButton || nextButton ? [styles?.navigationWithButtons] : [])
]

const yearList: number[] = []
const yearList: string[] = []

if (withYearPicker) {
const { startYear, endYear } = withYearPicker
for (let year = endYear; year >= startYear!; year--) {
yearList.push(year)
// add the years to the list with the correct locale
yearList.push(
DateTime.parse(
year.toString(),
this.locale(),
this.timezone()
).format('YYYY')
)
}
}

Expand Down Expand Up @@ -295,7 +319,7 @@ class Calendar extends Component<CalendarProps, CalendarState> {
width="90px"
renderLabel=""
assistiveText={withYearPicker.screenReaderLabel}
value={Number(visibleMonth.format('YYYY'))}
value={visibleMonth.format('YYYY')}
onChange={(
e: React.SyntheticEvent<Element, Event>,
{
Expand All @@ -304,7 +328,7 @@ class Calendar extends Component<CalendarProps, CalendarState> {
value?: string | number | undefined
id?: string | undefined
}
) => this.handleYearChange(e, Number(value))}
) => this.handleYearChange(e, `${value}`)}
>
{yearList.map((year) => (
<SimpleSelect.Option key={year} id={`opt-${year}`} value={year}>
Expand Down
76 changes: 35 additions & 41 deletions packages/ui-date-input/src/DateInput2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
/** @jsx jsx */
import { useState, useEffect, useContext } from 'react'
import type { SyntheticEvent } from 'react'
import moment from 'moment-timezone'
import { Calendar } from '@instructure/ui-calendar'
import { IconButton } from '@instructure/ui-buttons'
import {
Expand All @@ -43,25 +42,12 @@ import { jsx } from '@instructure/emotion'
import { propTypes } from './props'
import type { DateInput2Props } from './props'
import type { FormMessage } from '@instructure/ui-form-field'
import type { Moment } from '@instructure/ui-i18n'

function isValidDate(dateString: string): boolean {
return !isNaN(new Date(dateString).getTime())
}

function isValidMomentDate(
dateString: string,
locale: string,
timezone: string
): boolean {
return moment
.tz(
dateString,
[moment.ISO_8601, 'llll', 'LLLL', 'lll', 'LLL', 'll', 'LL', 'l', 'L'],
locale,
true,
timezone
)
.isValid()
function parseDate(dateString: string): string {
const date = new Date(dateString)
// return empty string if not a valid date
return isNaN(date.getTime()) ? '' : date.toISOString()
}

/**
Expand Down Expand Up @@ -97,6 +83,8 @@ const DateInput2 = ({
const localeContext = useContext(ApplyLocaleContext)

useEffect(() => {
// when `value` is changed, validation runs again and removes the error message if validation passes
// but it's NOT adding error message if validation fails for better UX
validateInput(true)
}, [value])

Expand All @@ -106,11 +94,15 @@ const DateInput2 = ({

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

const handleDateSelected = (
dateString: string,
_momentDate: any, // real type is Moment but used `any` to avoid importing the moment lib
_momentDate: Moment,
e: SyntheticEvent
) => {
const formattedDate = new Date(dateString).toLocaleDateString(getLocale(), {
Expand All @@ -120,30 +112,29 @@ const DateInput2 = ({
timeZone: getTimezone()
})
handleInputChange(e, formattedDate)
setSelectedDate(dateString)
setShowPopover(false)
onRequestValidateDate?.(formattedDate, true)
onRequestValidateDate?.(dateString, true)
}

// onlyRemoveError is used to remove the error msg immediately when the user inputs a valid date (and don't wait for blur event)
const validateInput = (onlyRemoveError = false): boolean => {
// TODO `isValidDate` and `isValidMomentDate` basically have the same functionality but the latter is a bit more strict (e.g.: `33` is only valid in `isValidMomentDate`)
// in the future we should get rid of moment but currently Calendar is using it for validation too so we can only remove it simultaneously
// otherwise DateInput could pass invalid dates to Calendar and break it
if (
(isValidDate(value || '') &&
isValidMomentDate(value || '', getLocale(), getTimezone())) ||
value === ''
) {
setSelectedDate(value || '')
// don't validate empty input
if (!value || parseDate(value) || selectedDate) {
setInputMessages(messages || [])
return true
}
if (!onlyRemoveError && typeof invalidDateErrorMessage === 'string') {
setInputMessages((messages) => [
// only show error if there is no user provided validation callback
if (
!onlyRemoveError &&
typeof invalidDateErrorMessage === 'string' &&
!onRequestValidateDate
) {
setInputMessages([
{
type: 'error',
text: invalidDateErrorMessage
},
...messages
}
])
}

Expand Down Expand Up @@ -171,13 +162,16 @@ const DateInput2 = ({

const handleBlur = (e: SyntheticEvent) => {
const isInputValid = validateInput(false)
if (isInputValid && value) {
const formattedDate = new Date(value).toLocaleDateString(getLocale(), {
month: 'long',
year: 'numeric',
day: 'numeric',
timeZone: getTimezone()
})
if (isInputValid && selectedDate) {
const formattedDate = new Date(selectedDate).toLocaleDateString(
getLocale(),
{
month: 'long',
year: 'numeric',
day: 'numeric',
timeZone: getTimezone()
}
)
handleInputChange(e, formattedDate)
}
onRequestValidateDate?.(value, isInputValid)
Expand Down
14 changes: 10 additions & 4 deletions packages/ui-date-input/src/DateInput2/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@
*/

import PropTypes from 'prop-types'
import type { SyntheticEvent } from 'react'
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 { Renderable, PropValidators } from '@instructure/shared-types'
import type { OtherHTMLAttributes, Renderable, PropValidators } from '@instructure/shared-types'

type DateInput2Props = {
type DateInput2OwnProps = {
/**
* Specifies the input label.
*/
Expand Down Expand Up @@ -159,7 +159,13 @@ type DateInput2Props = {
}
}

type PropKeys = keyof DateInput2Props
type PropKeys = keyof DateInput2OwnProps

type DateInput2Props = DateInput2OwnProps &
OtherHTMLAttributes<
DateInput2OwnProps,
InputHTMLAttributes<DateInput2OwnProps & Element>
>

const propTypes: PropValidators<PropKeys> = {
renderLabel: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
Expand Down
1 change: 1 addition & 0 deletions packages/ui-date-input/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@
export { DateInput } from './DateInput'
export { DateInput2 } from './DateInput2'
export type { DateInputProps } from './DateInput/props'
export type { DateInput2Props } from './DateInput2/props'

0 comments on commit d7df0e8

Please sign in to comment.