Skip to content

Commit

Permalink
feat(datepicker): Enable custom date-formats (#3415)
Browse files Browse the repository at this point in the history
* feat(datepicker): Enable custom date-formats

* fix(datepicker): Handle external null-values
  • Loading branch information
yusijs authored May 3, 2024
1 parent 5cbda66 commit 2f91ed9
Show file tree
Hide file tree
Showing 13 changed files with 199 additions and 17 deletions.
3 changes: 3 additions & 0 deletions packages/eds-core-react/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ expect.extend(toHaveNoViolations)
// Workaround for jest-axe error: https://github.com/nickcolley/jest-axe/issues/147
const { getComputedStyle } = window
window.getComputedStyle = (elt) => getComputedStyle(elt)

HTMLElement.prototype.showPopover = jest.fn()
HTMLElement.prototype.hidePopover = jest.fn()
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ const mockResizeObserver = jest.fn(() => ({

beforeAll(() => {
window.ResizeObserver = mockResizeObserver
HTMLDivElement.prototype.showPopover = jest.fn()
HTMLDivElement.prototype.hidePopover = jest.fn()

//https://github.com/TanStack/virtual/issues/641
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { axe } from 'jest-axe'
import { DatePicker } from './DatePicker'
import userEvent from '@testing-library/user-event'
import { I18nProvider } from 'react-aria'
import { useState } from 'react'

describe('DatePicker', () => {
it('Can render', () => {
Expand All @@ -27,6 +28,24 @@ describe('DatePicker', () => {
expect(screen.getByText('dd')).toHaveAttribute('aria-disabled', 'true')
})

it('should be nullable from outside', async () => {
const Comp = () => {
const [date, setDate] = useState<Date | null>(new Date(2024, 4, 1))
return (
<I18nProvider locale={'en-US'}>
<button type={'button'} onClick={() => setDate(null)}>
Reset
</button>
<DatePicker label={'Datepicker'} value={date} />
</I18nProvider>
)
}
render(<Comp />)
expect(screen.getByRole('presentation')).toHaveTextContent('05/01/2024')
await userEvent.click(screen.getByText('Reset'))
expect(screen.getByRole('presentation')).toHaveTextContent('mm/dd/yyyy')
})

it('Should be possible to type', async () => {
const onChange = jest.fn()

Expand Down Expand Up @@ -195,6 +214,6 @@ describe('DatePicker', () => {
</I18nProvider>,
)

expect(screen.getByRole('presentation')).toHaveTextContent('2024年05月04日')
expect(screen.getByRole('presentation')).toHaveTextContent('2024年05年04年')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Autocomplete } from '../Autocomplete'
import { NativeSelect } from '../Select'
import { action } from '@storybook/addon-actions'
import { CalendarDate } from '@internationalized/date'
import { I18nProvider } from 'react-aria'

const meta: Meta<typeof DatePicker> = {
title: 'Inputs/Dates/Datepicker',
Expand Down Expand Up @@ -46,7 +47,7 @@ export const Introduction: StoryFn = (props: DatePickerProps) => {
<DatePicker
{...props}
onChange={(v) => {
const str = v.toISOString()
const str = v?.toISOString()
action('onChange')(str)
}}
/>
Expand Down Expand Up @@ -82,6 +83,67 @@ export const DateTime: StoryFn = () => {
)
}

export const CustomDisplayFormat: StoryFn = () => {
const [val, setValue] = useState(new Date())
return (
<DatePicker
formatOptions={{
year: 'numeric',
month: 'long',
day: '2-digit',
}}
value={val}
onChange={(v) => {
setValue(v)
action('onChange')(v?.toISOString())
}}
/>
)
}

export const CustomLocale: StoryFn = () => {
const [val, setValue] = useState(new Date())
const [locale, setLocale] = useState('en-US')
const locales = [
{ value: 'en-US', label: 'English' },
{ value: 'uk', label: 'Ukrainian' },
{ value: 'sv-SE', label: 'Swedish' },
{ value: 'zh-Hans', label: 'Chinese (Simplified)' },
{ value: 'zh-Hant', label: 'Chinese (Traditional)' },
]
return (
<div>
<NativeSelect
id={'locale-picker'}
label={'Select locale'}
onChange={(e) => setLocale(e.currentTarget.value)}
value={locale}
>
{locales.map((l) => (
<option value={l.value} key={l.value}>
{l.label}
</option>
))}
</NativeSelect>{' '}
<br />
<I18nProvider locale={locale}>
<DatePicker
formatOptions={{
year: 'numeric',
month: 'long',
day: '2-digit',
}}
value={val}
onChange={(v) => {
setValue(v)
action('onChange')(v?.toISOString())
}}
/>
</I18nProvider>
</div>
)
}

const minusOneMonth = new Date()
minusOneMonth.setMonth(minusOneMonth.getMonth() - 1)
const plusOneMonth = new Date()
Expand Down
22 changes: 20 additions & 2 deletions packages/eds-core-react/src/components/Datepicker/DatePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { forwardRef, RefObject, useCallback, useRef, useState } from 'react'
import {
forwardRef,
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { DatePickerProps } from './props'
import { Calendar } from './calendars/Calendar'
import { DateField } from './fields/DateField'
Expand Down Expand Up @@ -42,11 +49,17 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
granularity,
disabled: isDisabled,
readOnly: isReadOnly,
formatOptions,
...props
}: DatePickerProps,
forwardedRef: RefObject<HTMLDivElement>,
) => {
timezone = timezone ?? defaultTimezone
formatOptions = formatOptions ?? {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}
const [innerValue, setInnerValue] = useState<
CalendarDate | CalendarDateTime
>(() => {
Expand Down Expand Up @@ -133,8 +146,13 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
}
: undefined

// innerValue is used as a fallback, especially for uncontrolled inputs, so it needs to be reset when value / defaultValue is reset
useEffect(() => {
if (!defaultValue && !value) setInnerValue(null)
}, [defaultValue, value])

return (
<DatePickerProvider timezone={timezone}>
<DatePickerProvider timezone={timezone} formatOptions={formatOptions}>
<FieldWrapper
isOpen={isOpen}
readonly={fieldProps.isReadOnly}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { forwardRef, RefObject, useCallback, useRef, useState } from 'react'
import {
forwardRef,
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { DateRangePickerProps } from './props'
import { RangeCalendar } from './calendars/RangeCalendar'
import { calendar_date_range, warning_outlined } from '@equinor/eds-icons'
Expand Down Expand Up @@ -35,13 +42,19 @@ export const DateRangePicker = forwardRef(
Header,
timezone,
defaultValue,
formatOptions,
disabled: isDisabled,
readOnly: isReadOnly,
...props
}: DateRangePickerProps,
forwardedRef: RefObject<HTMLDivElement>,
) => {
timezone = timezone ?? defaultTimezone
formatOptions = formatOptions ?? {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}
const [innerValue, setInnerValue] = useState<RangeValue<DateValue>>(() => {
const initialValue = value ?? defaultValue
if (initialValue) {
Expand Down Expand Up @@ -134,8 +147,15 @@ export const DateRangePicker = forwardRef(
? Object.values(formattedValue).join(' - ')
: null

// innerValue is used as a fallback, especially for uncontrolled inputs, so it needs to be reset when value / defaultValue is reset
useEffect(() => {
const val = defaultValue ?? value
if (!defaultValue && !value) setInnerValue(null)
if (!val?.from && !val?.to) setInnerValue(null)
}, [defaultValue, value])

return (
<DatePickerProvider timezone={timezone}>
<DatePickerProvider timezone={timezone} formatOptions={formatOptions}>
<FieldWrapper
isOpen={isOpen}
color={state.isInvalid ? 'warning' : props.variant}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ Datepicker for selecting a single date, built on [react-aria](https://react-spec

## Usage

### Custom display format

In some cases it's useful to display the date in a different format than the default. This can be done by passing a `formatOptions` prop, which uses the [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) options.

For example, to display the date as `dd MMMM yyyy`:
```
<DatePicker
formatOptions={{ day: '2-digit', month: 'long', year: 'numeric' }} />
```

<Canvas of={ComponentStories.CustomDisplayFormat} />

### Custom locale

In some cases it's useful to display the date in a different locale than the default, e.g. if your app has a locale-picker.
This can be done by wrapping the component with [I18nProvider](https://react-spectrum.adobe.com/react-aria/I18nProvider.html) from react-aria, which supports all the locales that Intl.DateTimeFormat does.

```
<I18nProvider locale={'en-US'}>
<DatePicker />
</I18nProvider>
```

<Canvas of={ComponentStories.CustomLocale} />

### Min / Max values

Limits on min/max can be set to restrict the dates that can be selected. In this example, it's set as the current month excluding first/last date
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import {
DateFieldState,
DateSegment as IDateSegment,
} from '@react-stately/datepicker'
import { KeyboardEvent, useRef } from 'react'
import { useDateSegment } from 'react-aria'
import { KeyboardEvent, useRef, useState } from 'react'
import { useDateFormatter, useDateSegment } from 'react-aria'
import styled from 'styled-components'
import { tokens } from '@equinor/eds-tokens'
import { useDatePickerContext } from '../utils/context'

const Segment = styled.div<{
$placeholder: boolean
Expand All @@ -32,12 +33,29 @@ export function DateSegment({
state: DateFieldState
segment: IDateSegment
}) {
const { formatOptions, timezone } = useDatePickerContext()
const formatter = useDateFormatter(formatOptions)
const parts = state.value
? formatter.formatToParts(state.value.toDate(timezone))
: []
const part = parts.find((p) => p.type === segment.type)
const value = part?.value ?? segment.text

const [focus, setFocus] = useState(false)
const ref = useRef(null)
const { segmentProps } = useDateSegment(segment, state, ref)

return (
<Segment
{...segmentProps}
onFocus={(e) => {
setFocus(true)
segmentProps.onFocus(e)
}}
onBlur={(e) => {
setFocus(false)
segmentProps.onBlur(e)
}}
$invalid={state.isInvalid}
$disabled={state.isDisabled}
$placeholder={segment.isPlaceholder}
Expand All @@ -53,9 +71,11 @@ export function DateSegment({
ref={ref}
className={`segment ${segment.isPlaceholder ? 'placeholder' : ''}`}
>
{segment.isPlaceholder || segment.type === 'literal'
? segment.text
: segment.text.padStart(segment.type === 'year' ? 4 : 2, '0')}
{focus
? segment.isPlaceholder || segment.type === 'literal'
? segment.text
: segment.text.padStart(segment.type === 'year' ? 4 : 2, '0')
: value}
</Segment>
)
}
6 changes: 6 additions & 0 deletions packages/eds-core-react/src/components/Datepicker/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MutableRefObject, ReactNode } from 'react'
import { CalendarState, RangeCalendarState } from '@react-stately/calendar'
import { Variants } from '../types'
import { HelperTextProps } from '../InputWrapper/HelperText'
import { DateFormatterOptions } from 'react-aria'

type DateRange = { from: Date | null; to: Date | null }

Expand Down Expand Up @@ -83,6 +84,11 @@ export type DatePickerProps = Partial<{
* Granularity of the time field if enabled
*/
granularity?: 'hour' | 'minute' | 'second'
/**
* Format options for the datepicker input field
* Only applies when input is blurred
*/
formatOptions?: DateFormatterOptions
}>

export type DateTimePickerProps = Omit<
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createContext, ReactNode } from 'react'
import { createContext, ReactNode, useContext } from 'react'
import { DateFormatterOptions } from 'react-aria'

type DatePickerContextValue = {
timezone: string
formatOptions?: DateFormatterOptions
}

const intl = new Intl.DateTimeFormat()
Expand All @@ -13,13 +15,18 @@ const DatePickerContext = createContext<DatePickerContextValue>({

export const DatePickerProvider = ({
timezone,
formatOptions,
children,
}: DatePickerContextValue & { children: ReactNode }) => {
return (
<DatePickerContext.Provider
value={{ timezone: timezone ?? defaultTimezone }}
value={{ timezone: timezone ?? defaultTimezone, formatOptions }}
>
{children}
</DatePickerContext.Provider>
)
}

export const useDatePickerContext = () => useContext(DatePickerContext)
export const useTimezone = () => useDatePickerContext().timezone
export const useCustomFormat = () => useDatePickerContext().formatOptions

This file was deleted.

3 changes: 3 additions & 0 deletions packages/eds-data-grid-react/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ expect.extend(toHaveNoViolations)
// Workaround for jest-axe error: https://github.com/nickcolley/jest-axe/issues/147
const { getComputedStyle } = window
window.getComputedStyle = (elt) => getComputedStyle(elt)

HTMLElement.prototype.showPopover = jest.fn()
HTMLElement.prototype.hidePopover = jest.fn()
3 changes: 3 additions & 0 deletions packages/eds-lab-react/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom'
import 'jest-styled-components'

HTMLElement.prototype.showPopover = jest.fn()
HTMLElement.prototype.hidePopover = jest.fn()

0 comments on commit 2f91ed9

Please sign in to comment.