diff --git a/packages/ffe-datepicker-react/src/datelogic/types.ts b/packages/ffe-datepicker-react/src/datelogic/types.ts index 8b69dc1095..c0bddf319f 100644 --- a/packages/ffe-datepicker-react/src/datelogic/types.ts +++ b/packages/ffe-datepicker-react/src/datelogic/types.ts @@ -7,3 +7,11 @@ export type CalendarButtonState = { isSelected: boolean; isEnabled: boolean; }; + +export type Locale = 'nb' | 'nn' | 'en'; + +type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + +export const isMonth = (thing: any): thing is Month => { + return typeof thing === 'number' && thing >= 1 && thing <= 12; +}; diff --git a/packages/ffe-datepicker-react/src/datepicker/Datepicker.spec.tsx b/packages/ffe-datepicker-react/src/datepicker/Datepicker.spec.tsx index 8c1d43e79a..1b66525e5d 100644 --- a/packages/ffe-datepicker-react/src/datepicker/Datepicker.spec.tsx +++ b/packages/ffe-datepicker-react/src/datepicker/Datepicker.spec.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import { Datepicker, DatepickerProps } from './Datepicker'; +import { Datepicker, DatepickerProviderProps } from './Datepicker'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const defaultProps = { value: '', onChange: () => {}, + locale: 'nb' as const, + labelId: 'datepicker-label', }; -const renderDatePicker = (props?: Partial) => +const renderDatePicker = (props?: Partial) => render(); describe('', () => { @@ -23,7 +25,7 @@ describe('', () => { it('contains a single DateInput component', () => { renderDatePicker(); - expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('group')).toBeInTheDocument(); }); it('does not contain a Calendar component', () => { @@ -31,6 +33,32 @@ describe('', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); + it('responds to arrow up and down', async () => { + renderDatePicker(); + const [dayInput] = screen.getAllByRole('spinbutton'); + await userEvent.type(dayInput, '{arrowup}'); + expect(dayInput).toHaveValue(1); + await userEvent.type(dayInput, '{arrowdown}'); + expect(dayInput).toHaveValue(31); + }); + + it('triggers onchange when arrows are used', async () => { + const onChange = jest.fn(); + renderDatePicker({ onChange }); + const [dayInput] = screen.getAllByRole('spinbutton'); + await userEvent.type(dayInput, '{arrowup}'); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('reponds to arrow left and right', async () => { + renderDatePicker(); + const [dayInput, monthInput] = screen.getAllByRole('spinbutton'); + await userEvent.type(dayInput, '{arrowright}'); + expect(monthInput).toHaveFocus(); + await userEvent.type(monthInput, '{arrowleft}'); + expect(dayInput).toHaveFocus(); + }); + describe('with click on button', () => { const user = userEvent.setup(); it('contains a Calendar', async () => { @@ -54,8 +82,8 @@ describe('', () => { it('calls onChange method', async () => { const onChange = jest.fn(); renderDatePicker({ onChange }); - const input = screen.getByRole('textbox'); - await user.type(input, '1'); + const [dayInput] = screen.getAllByRole('spinbutton'); + await user.type(dayInput, '4'); expect(onChange).toHaveBeenCalledTimes(1); }); }); @@ -81,33 +109,24 @@ describe('', () => { describe('ariaInvalid', () => { it('has correct aria-invalid value if given prop', () => { renderDatePicker({ ariaInvalid: true }); - const input = screen.getByRole('textbox'); - expect(input.getAttribute('aria-invalid')).toBe('true'); + const [date, month, year] = + screen.getAllByRole('spinbutton'); + expect(date.getAttribute('aria-invalid')).toBe('true'); + expect(month.getAttribute('aria-invalid')).toBe('true'); + expect(year.getAttribute('aria-invalid')).toBe('true'); }); it('has correct aria-describedby if aria-describedby given as input prop', () => { - const inputProps = { - 'aria-describedby': 'test', - }; renderDatePicker({ ariaInvalid: true, - inputProps, + ariaDescribedby: 'test', }); - const input = screen.getByRole('textbox'); - expect(input.getAttribute('aria-describedby')).toBe('test'); - }); - }); - describe('inputProps', () => { - it('is passed on to input field', () => { - const inputProps = { - className: 'customClass', - id: 'custom-input-id', - }; - renderDatePicker({ inputProps }); - const input = screen.getByRole('textbox'); - expect(input.classList.contains('customClass')).toBe(true); - expect(input.getAttribute('id')).toBe('custom-input-id'); + const [date, month, year] = + screen.getAllByRole('spinbutton'); + expect(date.getAttribute('aria-describedby')).toBe('test'); + expect(month.getAttribute('aria-describedby')).toBe('test'); + expect(year.getAttribute('aria-describedby')).toBe('test'); }); }); @@ -126,43 +145,14 @@ describe('', () => { }); }); - describe('try to be smart in which century to place an input of two digit years', () => { - const user = userEvent.setup(); - it('defaults to the 20th century', async () => { - const onChange = jest.fn(); - renderDatePicker({ onChange, value: '101099' }); - - const input = screen.getByRole('textbox'); - await user.type(input, '{Tab}'); - expect(onChange).toHaveBeenCalledWith('10.10.2099'); - }); - - it('assumes last century if maxDate is today-ish', async () => { - const onChange = jest.fn(); - renderDatePicker({ - maxDate: '02.02.2022', - onChange, - value: '111199', - }); - - const input = screen.getByRole('textbox'); - await user.type(input, '{Tab}'); - - expect(onChange).toHaveBeenCalledWith('11.11.1999'); - }); - - it('assumes this century if maxDate is today-ish but input is rather close to it', async () => { - const onChange = jest.fn(); - renderDatePicker({ - maxDate: '02.02.2022', - onChange, - value: '121220', - }); - - const input = screen.getByRole('textbox'); - await user.type(input, '{Tab}'); + describe('with value', () => { + it('has correct value in input field', () => { + renderDatePicker({ value: '01.01.2021' }); - expect(onChange).toHaveBeenCalledWith('12.12.2020'); + const [date, month, year] = screen.getAllByRole('spinbutton'); + expect(date).toHaveValue(1); + expect(month).toHaveValue(1); + expect(year).toHaveValue(2021); }); }); }); diff --git a/packages/ffe-datepicker-react/src/datepicker/Datepicker.stories.tsx b/packages/ffe-datepicker-react/src/datepicker/Datepicker.stories.tsx index 0c85bc9db9..c892253c01 100644 --- a/packages/ffe-datepicker-react/src/datepicker/Datepicker.stories.tsx +++ b/packages/ffe-datepicker-react/src/datepicker/Datepicker.stories.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Datepicker } from './Datepicker'; +import { Datepicker, DatepickerProviderProps } from './Datepicker'; import type { StoryObj, Meta } from '@storybook/react'; import { InputGroup } from '@sb1/ffe-form-react'; @@ -14,17 +14,105 @@ type Story = StoryObj; export const Standard: Story = { args: { locale: 'nb', - maxDate: '31.12.2016', - minDate: '01.01.2016', + maxDate: '31.12.2025', + minDate: '01.01.2024', + labelId: 'datepicker-label', }, - render: function Render({ value, onChange, ...args }) { - const [date, setDate] = useState('01.01.2016'); + render: function Render({ + value, + onChange, + ...args + }: DatepickerProviderProps) { + const [date, setDate] = useState('01.12.2024'); + + return ( + + {inputProps => ( + + )} + + ); + }, +}; + +export const FieldMessageString: Story = { + args: { + ...Standard.args, + }, + render: function Render({ + value, + onChange, + ...args + }: DatepickerProviderProps) { + const [date, setDate] = useState('01.12.2024'); + + return ( + + {inputProps => ( + + )} + + ); + }, +}; + +export const FullWidth: Story = { + args: { + ...Standard.args, + fullWidth: true, + }, + render: function Render({ + value, + onChange, + ...args + }: DatepickerProviderProps) { + const [date, setDate] = useState('01.12.2024'); + return ( - + + + ); + }, +}; + +export const CalendarAbove: Story = { + args: { + ...Standard.args, + calendarAbove: true, + }, + render: function Render({ + value, + onChange, + ...args + }: DatepickerProviderProps) { + const [date, setDate] = useState('01.12.2024'); + + return ( + + ); diff --git a/packages/ffe-datepicker-react/src/datepicker/Datepicker.tsx b/packages/ffe-datepicker-react/src/datepicker/Datepicker.tsx index b9f1a1676e..e87e8dea80 100644 --- a/packages/ffe-datepicker-react/src/datepicker/Datepicker.tsx +++ b/packages/ffe-datepicker-react/src/datepicker/Datepicker.tsx @@ -1,327 +1,20 @@ -import React, { Component } from 'react'; -import classNames from 'classnames'; -import { v4 as uuid } from 'uuid'; -import { Calendar } from '../calendar'; -import { DateInput } from '../input'; -import { Button } from '../button'; -import { getSimpleDateFromString } from '../datelogic/simpledate'; -import { validateDate, isDateInputWithTwoDigitYear } from '../util/dateUtil'; -import debounce from 'lodash.debounce'; +import React from 'react'; +import { DatepickerProvider } from './DatepickerContext'; +import { DatepickerComp, DatepickerProps } from './DatepickerComp'; +import { Locale } from '../datelogic/types'; -export interface DatepickerProps { - 'aria-invalid'?: React.ComponentProps<'input'>['aria-invalid']; - ariaInvalid?: string | boolean; - calendarAbove?: boolean; - id?: string; - inputProps?: Omit< - React.ComponentPropsWithRef<'input'>, - 'aria-invalid' | 'value' | 'id' - >; - locale?: 'nb' | 'nn' | 'en'; - maxDate?: string | null; - minDate?: string | null; - onChange: (date: string) => void; +export interface DatepickerProviderProps extends DatepickerProps { value: string; - fullWidth?: boolean; -} - -interface DatepickerState { - lastValidDate: string; - displayDatePicker: boolean; - minDate?: string | null; - maxDate?: string | null; - calendarActiveDate: string; - ariaInvalid?: boolean; -} - -export class Datepicker extends Component { - private readonly datepickerId: string; - - constructor(props: DatepickerProps) { - super(props); - - this.state = { - displayDatePicker: false, - minDate: props.minDate, - maxDate: props.maxDate, - lastValidDate: '', - calendarActiveDate: validateDate(props.value) ? props.value : '', - }; - - this.datepickerId = uuid(); - - this.openCalendar = this.openCalendar.bind(this); - this.closeCalendar = this.closeCalendar.bind(this); - this.closeCalendarSetInputFocus = - this.closeCalendarSetInputFocus.bind(this); - this.calendarButtonClickHandler = - this.calendarButtonClickHandler.bind(this); - this.addFlagOnClickEventClickHandler = - this.addFlagOnClickEventClickHandler.bind(this); - this.globalClickHandler = this.globalClickHandler.bind(this); - this.escKeyHandler = this.escKeyHandler.bind(this); - this.datePickedHandler = this.datePickedHandler.bind(this); - this.onInputKeydown = this.onInputKeydown.bind(this); - this.onInputBlur = this.onInputBlur.bind(this); - } - - locale = this.props.locale ?? 'nb'; - buttonRef = React.createRef(); - dateInputRef = this.props.inputProps?.ref ?? React.createRef(); - - debounceCalendar = debounce((value: any) => { - if (value !== this.state.lastValidDate && validateDate(value)) { - this.setState({ calendarActiveDate: value, lastValidDate: value }); - } - }, 250); - - componentWillUnmount() { - this.removeGlobalEventListeners(); - this.debounceCalendar.cancel(); - } - - /* eslint-disable react/no-did-update-set-state */ - componentDidUpdate(prevProps: DatepickerProps, prevState: DatepickerState) { - const valueChangedAndDatepickerIsToggled = - prevProps.value !== this.props.value && - prevState.displayDatePicker && - !this.state.displayDatePicker; - if ( - (this.props.minDate && this.props.minDate !== this.state.minDate) || - (this.props.maxDate && this.props.maxDate !== this.state.maxDate) - ) { - this.setState( - { minDate: this.props.minDate, maxDate: this.props.maxDate }, - this.validateDateIntervals, - ); - } - - if (valueChangedAndDatepickerIsToggled) { - this.validateDateIntervals(); - } - - this.debounceCalendar(this.props.value); - } - - validateDateIntervals() { - this.setState((prevState, props) => { - let nextState = {}; - const { onChange, value } = props; - - getSimpleDateFromString(value, date => { - nextState = { - ariaInvalid: false, - }; - - const maxDate = prevState.maxDate - ? getSimpleDateFromString(prevState.maxDate) - : null; - - // SimpleDate.fromString assumes years written as two digits - // are in the 20th century. This can be unwanted behaviour - // when asking for dates in the past, like birthdates. - // This little hack should catch most of these cases. - if ( - maxDate?.isBefore(date) && - isDateInputWithTwoDigitYear(value) - ) { - date.adjust({ period: 'Y', offset: -100 }); - } - - const formattedDate = date.format(); - - if (formattedDate !== value) { - onChange(formattedDate); - } - - nextState = { - ...nextState, - lastValidDate: formattedDate, - }; - }); - - return nextState; - }); - } - - onInputBlur(e: React.FocusEvent) { - this.props.inputProps?.onBlur?.(e); - this.validateDateIntervals(); - } - - onInputKeydown(evt: React.KeyboardEvent) { - this.props.inputProps?.onKeyDown?.(evt); - if (evt.key === 'Enter') { - evt.preventDefault(); - this.validateDateIntervals(); - } - } - - escKeyHandler(evt: KeyboardEvent) { - if (evt.key === 'Escape') { - this.closeCalendarSetInputFocus(); - } - } - - globalClickHandler(evt: MouseEvent) { - if ( - this.state.displayDatePicker && - // @ts-ignore - evt.__datepickerID !== this.datepickerId - ) { - this.closeCalendar(); - } - } - - calendarButtonClickHandler() { - this.validateDateIntervals(); - - if (!this.state.displayDatePicker) { - this.openCalendar(); - } else { - this.closeCalendar(); - } - } - - /** - * Adds a flag on the click event so that the globalClickHandler() - * can determine whether or not the ID matches. Makes it so that only one datepicker can be open at the same time - */ - addFlagOnClickEventClickHandler(evt: React.MouseEvent) { - // @ts-ignore - // eslint-disable-next-line no-param-reassign - evt.nativeEvent.__datepickerID = this.datepickerId; - } - - datePickedHandler(date: string) { - this.props.onChange(date); - this.removeGlobalEventListeners(); - this.setState( - { - displayDatePicker: false, - calendarActiveDate: date, - }, - () => this.buttonRef.current?.focus(), - ); - } - - openCalendar() { - this.setState({ - displayDatePicker: true, - }); - this.removeGlobalEventListeners(); - this.addGlobalEventListeners(); - } - - closeCalendar() { - this.removeGlobalEventListeners(); - this.setState({ displayDatePicker: false }); - this.validateDateIntervals(); - } - - closeCalendarSetInputFocus() { - this.removeGlobalEventListeners(); - this.setState( - { - displayDatePicker: false, - }, - () => this.buttonRef.current?.focus(), - ); - this.validateDateIntervals(); - } - - addGlobalEventListeners() { - window.addEventListener('click', this.globalClickHandler); - window.addEventListener('keyup', this.escKeyHandler); - } - - removeGlobalEventListeners() { - window.removeEventListener('click', this.globalClickHandler); - window.removeEventListener('keyup', this.escKeyHandler); - } - - ariaInvalid() { - const ariaInvalid = - this.props['aria-invalid'] || this.props.ariaInvalid; - - return [null, undefined].every(val => val !== ariaInvalid) - ? String(ariaInvalid) - : String(this.state.ariaInvalid); - } - - render() { - const { inputProps = {}, onChange, value, fullWidth } = this.props; - - const { minDate, maxDate } = this.state; - - if (this.state.ariaInvalid && !inputProps['aria-describedby']) { - inputProps['aria-describedby'] = - `date-input-validation-${this.datepickerId}`; - } - - const calendarClassName = classNames( - 'ffe-calendar ffe-calendar--datepicker', - { 'ffe-calendar--datepicker--above': this.props.calendarAbove }, - ); - - const datepickerClassName = classNames('ffe-datepicker', { - 'ffe-datepicker--full-width': fullWidth, - }); - - return ( -
- {/* - * This element is not an actual button, but the onClick is something that happens under the hood, - * that the user is not aware of. So it is not a semantic button for the user. - * Thus we do not want to add a role, so we suppress the linting rule. - */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
-
- { - this.props.inputProps?.onChange?.(evt); - onChange(evt.target.value); - }} - onKeyDown={this.onInputKeydown} - ref={this.dateInputRef} - value={value} - locale={this.locale} - /> -
- {this.state.displayDatePicker && ( - { - if (evt.key === 'Escape') { - this.closeCalendarSetInputFocus(); - } - }} - locale={this.locale} - maxDate={maxDate} - minDate={minDate} - onDatePicked={this.datePickedHandler} - selectedDate={this.state.calendarActiveDate} - focusOnMount={true} - /> - )} -
-
- ); - } + locale: Locale; } +export const Datepicker: React.FC = ({ + locale = 'nb' as const, + value, + ...props +}: DatepickerProviderProps) => { + return ( + + + + ); +}; diff --git a/packages/ffe-datepicker-react/src/datepicker/DatepickerComp.tsx b/packages/ffe-datepicker-react/src/datepicker/DatepickerComp.tsx new file mode 100644 index 0000000000..39c0b8d0cd --- /dev/null +++ b/packages/ffe-datepicker-react/src/datepicker/DatepickerComp.tsx @@ -0,0 +1,363 @@ +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { DatepickerContext } from './DatepickerContext'; +import { SpinButton } from './SpinButton'; +import { PadZero } from './PadZero'; +import { Button } from '../button'; +import { v4 as uuid } from 'uuid'; +import { Calendar } from '../calendar'; +import { isDateInputWithTwoDigitYear, validateDate } from '../util/dateUtil'; +import debounce from 'lodash.debounce'; +import classNames from 'classnames'; +import { getSimpleDateFromString } from '../datelogic/simpledate'; +import { ErrorFieldMessage } from '@sb1/ffe-form-react/src/message'; +import i18n from '../i18n/i18n'; +import { isMonth } from '../datelogic/types'; + +export interface DatepickerProps { + 'aria-invalid'?: React.ComponentProps<'input'>['aria-invalid']; + ariaInvalid?: React.ComponentProps<'input'>['aria-invalid']; + 'aria-describedby'?: React.ComponentProps<'input'>['aria-describedby']; + ariaDescribedby?: React.ComponentProps<'input'>['aria-describedby']; + /** Blur used for e.g. validating the date. Triggered on blur of the last field, i.e. year. */ + onBlur?: (evt: React.FocusEvent) => void; + calendarAbove?: boolean; + id?: string; + maxDate?: string | null; + minDate?: string | null; + onChange: (date: string) => void; + fullWidth?: boolean; + fieldMessage?: string | null; + /** Id of the label describing the datepicker. Required for UU-compatibility */ + labelId: string; +} + +export const DatepickerComp: React.FC = ({ + ariaInvalid: ariaInvalidState, + 'aria-invalid': ariaInvalidProp, + ariaDescribedby: ariaDescribedbyState, + 'aria-describedby': ariaDescribedbyProp, + onBlur, + calendarAbove, + id, + maxDate: maxDateProp, + minDate: minDateProp, + onChange, + fullWidth, + fieldMessage, + labelId, +}) => { + const { + day, + setDay, + year, + setYear, + month, + setMonth, + locale, + calendarActiveDate, + setCalendarActiveDate, + } = useContext(DatepickerContext); + + const [displayDatePicker, setDisplayDatePicker] = useState(false); + const [minDate, setMinDate] = useState(minDateProp); + const [maxDate, setMaxDate] = useState(maxDateProp); + const [lastValidDate, setLastValidDate] = useState(''); + const datepickerId = useRef(uuid()); + const buttonRef = useRef(null); + const dayRef = useRef(null); + const monthRef = useRef(null); + const yearRef = useRef(null); + + const getFieldMessageId = () => { + return fieldMessage + ? `${datepickerId.current}-fieldmessage` + : undefined; + }; + + const fieldMessageId = getFieldMessageId(); + const debounceCalendar = useCallback( + debounce((newValue: any) => { + if (newValue !== lastValidDate && validateDate(newValue)) { + setCalendarActiveDate(newValue); + setLastValidDate(newValue); + } + }, 250), + [lastValidDate], + ); + + const hasMessage = !!fieldMessage; + + useEffect(() => { + return () => { + debounceCalendar.cancel(); + }; + }, [debounceCalendar]); + + const validateDateIntervals = () => { + const dateString = `${day}.${month}.${year}`; + getSimpleDateFromString(dateString, date => { + const maxDateValidated = maxDateProp + ? getSimpleDateFromString(maxDateProp) + : null; + + if ( + maxDateValidated?.isBefore(date) && + isDateInputWithTwoDigitYear(dateString) + ) { + date.adjust({ period: 'Y', offset: -100 }); + } + + const formattedDate = date.format(); + + if (formattedDate !== dateString) { + onChange(formattedDate); + } + setLastValidDate(formattedDate); + }); + }; + + useEffect(() => { + if (minDateProp !== minDate || maxDateProp !== maxDate) { + setMinDate(minDateProp); + setMaxDate(maxDateProp); + validateDateIntervals(); + } + debounceCalendar(`${day}.${month}.${year}`); + }, [minDateProp, maxDateProp, day, month, year, debounceCalendar]); + + const closeCalendarSetInputFocus = () => { + setDisplayDatePicker(false); + buttonRef.current?.focus(); + validateDateIntervals(); + }; + + const escKeyHandler = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + closeCalendarSetInputFocus(); + } + }; + + const globalClickHandler = (evt: MouseEvent) => { + if ( + displayDatePicker && + (evt as any).__datepickerID !== datepickerId.current + ) { + closeCalendarSetInputFocus(); + } + }; + + const addGlobalEventListeners = () => { + window.addEventListener('click', globalClickHandler); + window.addEventListener('keyup', escKeyHandler); + }; + + const removeGlobalEventListeners = () => { + window.removeEventListener('click', globalClickHandler); + window.removeEventListener('keyup', escKeyHandler); + }; + + useEffect(() => { + if (displayDatePicker) { + addGlobalEventListeners(); + } else { + removeGlobalEventListeners(); + } + return () => { + removeGlobalEventListeners(); + }; + }, [displayDatePicker]); + + const calendarButtonClickHandler = () => { + validateDateIntervals(); + setDisplayDatePicker(!displayDatePicker); + }; + + /** + * Adds a flag on the click event so that the globalClickHandler() + * can determine whether the ID matches. Makes it so that only one datepicker can be open at the same time + */ + const addFlagOnClickEventClickHandler = (evt: React.MouseEvent) => { + const nativeEvent = evt.nativeEvent as any; + nativeEvent.__datepickerID = datepickerId.current; + }; + + const focusSpinButton = (evt: React.MouseEvent) => { + if ( + evt.target !== yearRef.current && + evt.target !== buttonRef.current && + evt.target !== dayRef.current && + evt.target !== monthRef.current + ) { + dayRef.current?.focus(); + } + }; + + const datePickedHandler = (date: string) => { + const simpleDate = getSimpleDateFromString(date); + if (simpleDate) { + setDay([simpleDate.date]); + setMonth([simpleDate.month + 1]); + setYear([simpleDate.year]); + onChange(date); + setDisplayDatePicker(false); + setCalendarActiveDate(date); + buttonRef.current?.focus(); + } + }; + + const ariaInvalid = () => { + const ariaInvalidTotal = ariaInvalidProp === 'true' || ariaInvalidState; + const isAriaInvalid = + ariaInvalidTotal === 'true' || ariaInvalidTotal === true + ? 'true' + : undefined; + + return isAriaInvalid as unknown as React.ComponentPropsWithRef<'span'>['aria-invalid']; + }; + + const ariaDescribedby = () => { + const ariaDescribedbyTotal = + ariaDescribedbyProp ?? ariaDescribedbyState; + + return ariaDescribedbyTotal as unknown as React.ComponentPropsWithRef<'span'>['aria-describedby']; + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/no-noninteractive-element-interactions +
{ + addFlagOnClickEventClickHandler(e); + focusSpinButton(e); + }} + role={'group'} + id={id} + > +
+ { + onChange(`${newValue}.${month}.${year}`); + return allowFocusNext + ? setDay(newValue, () => + monthRef.current?.focus({ + preventScroll: true, + }), + ) + : setDay(newValue); + }} + nextSpinButton={monthRef} + maxLength={2} + aria-invalid={ariaInvalid()} + aria-valuenow={typeof day === 'number' ? day : undefined} + aria-valuetext={`${day}`} + aria-label={i18n[locale].DAY} + aria-describedby={ariaDescribedby()} + aria-labelledby={labelId} + > + {day ? : 'dd'} + + . + { + onChange(`${newValue}.${month}.${year}`); + return allowFocusNext + ? setMonth(newValue, () => + yearRef.current?.focus({ + preventScroll: true, + }), + ) + : setMonth(newValue); + }} + nextSpinButton={yearRef} + prevSpinButton={dayRef} + maxLength={2} + aria-invalid={ariaInvalid()} + aria-valuenow={ + typeof month === 'number' ? month : undefined + } + aria-valuetext={ + isMonth(month) + ? `${month} - ${i18n[locale][`MONTH_${month}`]}` + : undefined + } + aria-label={i18n[locale].MONTH} + aria-describedby={ariaDescribedby()} + aria-labelledby={labelId} + > + {month ? : 'mm'} + + . + { + onChange(`${newValue}.${month}.${year}`); + setYear(newValue); + }} + prevSpinButton={monthRef} + maxLength={4} + aria-invalid={ariaInvalid()} + aria-valuetext={`${year}`} + aria-valuenow={typeof year === 'number' ? year : undefined} + aria-label={i18n[locale].YEAR} + onBlur={onBlur} + aria-describedby={ariaDescribedby()} + aria-labelledby={labelId} + > + {year ? year : 'yyyy'} + +
+
+ ); +}; diff --git a/packages/ffe-datepicker-react/src/datepicker/DatepickerContext.tsx b/packages/ffe-datepicker-react/src/datepicker/DatepickerContext.tsx new file mode 100644 index 0000000000..aa60c2c79f --- /dev/null +++ b/packages/ffe-datepicker-react/src/datepicker/DatepickerContext.tsx @@ -0,0 +1,120 @@ +import React, { createContext, useState } from 'react'; +import { Locale } from '../datelogic/types'; +import { validateDate } from '../util/dateUtil'; +import { getSimpleDateFromString } from '../datelogic/simpledate'; + +interface DatepickerContextInterface { + day?: number | null; + month?: number | null; + year?: number | null; + setDay: (value: number[], focusNext?: () => void) => void; + setMonth: (value: number[], focusNext?: () => void) => void; + setYear: (value: number[]) => void; + locale: Locale; + calendarActiveDate?: string; + setCalendarActiveDate: (date: string) => void; +} + +export const DatepickerContext = createContext({ + day: null, + month: null, + year: null, + setDay: () => null, + setMonth: () => null, + setYear: () => null, + locale: 'nb', + calendarActiveDate: '', + setCalendarActiveDate: () => null, +}); + +interface Props { + locale: Locale; + value?: string; + children: React.ReactNode; +} + +const MONTHS_PER_YEAR = 12; +const MAX_DAYS = 31; + +export const DatepickerProvider: React.FC = ({ + children, + value = '', + locale, +}) => { + const newDate = validateDate(value) ? getSimpleDateFromString(value) : ''; + const [day, setDay] = useState( + newDate ? newDate.date : null, + ); + const [month, setMonth] = useState( + newDate ? newDate.month + 1 : null, + ); + const [year, setYear] = useState( + newDate ? newDate.year : null, + ); + const [calendarActiveDate, setCalendarActiveDate] = useState( + newDate?.toString() ?? '', + ); + + const getTotal = (numbers: (number | undefined)[]) => { + const validNumbers = numbers.filter(it => typeof it === 'number'); + return validNumbers + .map( + (it, index) => + (it ?? 1) * Math.pow(10, validNumbers.length - index - 1), + ) + .reduce((acc, curr) => acc + curr, 0); + }; + + return ( + { + const numbers = newValue.slice(-2); + const [first, second] = numbers; + const total = getTotal(numbers); + if (total > MAX_DAYS) { + focusNext?.(); + } else if (first > 3) { + setDay(total); + focusNext?.(); + } else { + setDay(total); + if (second !== undefined) { + focusNext?.(); + } + } + }, + setMonth: (newValue, focusNext = undefined) => { + const numbers = newValue.slice(-2); + const [first, second] = numbers; + const total = getTotal(numbers); + + if (total > MONTHS_PER_YEAR) { + focusNext?.(); + } else if (first > 1) { + setMonth(total); + focusNext?.(); + } else { + setMonth(total); + if (second !== undefined) { + focusNext?.(); + } + } + }, + setYear: newValue => { + setYear(getTotal(newValue.slice(-4))); + }, + calendarActiveDate, + setCalendarActiveDate: date => { + setCalendarActiveDate(date); + }, + locale, + }} + > + {children} + + ); +}; diff --git a/packages/ffe-datepicker-react/src/datepicker/PadZero.tsx b/packages/ffe-datepicker-react/src/datepicker/PadZero.tsx new file mode 100644 index 0000000000..0bae17ad0d --- /dev/null +++ b/packages/ffe-datepicker-react/src/datepicker/PadZero.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +interface Props { + value: number; +} + +export const PadZero: React.FC = ({ value }) => { + if (value < 10) { + return `0${value}`; + } + return value; +}; diff --git a/packages/ffe-datepicker-react/src/datepicker/SpinButton.tsx b/packages/ffe-datepicker-react/src/datepicker/SpinButton.tsx new file mode 100644 index 0000000000..0f1ef4c10b --- /dev/null +++ b/packages/ffe-datepicker-react/src/datepicker/SpinButton.tsx @@ -0,0 +1,85 @@ +import React, { useRef } from 'react'; +import classNames from 'classnames'; + +interface SpinButtonProps extends React.ComponentPropsWithoutRef<'span'> { + className?: string; + onSpinButtonChange: (value: number[], allowFocusNext?: boolean) => void; + children: React.ReactNode; + maxLength: number; + min: number; + max: number; + value?: number; + nextSpinButton?: React.RefObject; + prevSpinButton?: React.RefObject; +} + +export const SpinButton = React.forwardRef( + ( + { + className, + onSpinButtonChange, + maxLength, + min, + max, + value, + nextSpinButton, + prevSpinButton, + children, + ...rest + }, + ref, + ) => { + const history = useRef([]); + + const handleKeyDown = (evt: React.KeyboardEvent) => { + evt.stopPropagation(); + + if (/\d/.test(evt.key)) { + history.current = + history.current.length === maxLength + ? (history.current = [parseInt(evt.key)]) + : history.current.concat(parseInt(evt.key)); + onSpinButtonChange(history.current); + } else if (evt.key === 'Backspace') { + history.current = []; + onSpinButtonChange(history.current); + } else if (evt.key === 'ArrowUp') { + let newValue = (value ?? 0) + 1; + if (newValue && newValue !== null && newValue > max) { + newValue = min; + } + onSpinButtonChange([newValue], false); + } else if (evt.key === 'ArrowDown') { + let newValue = (value ?? 0) - 1; + if (newValue < min) { + newValue = max; + } + onSpinButtonChange([newValue], false); + } else if (evt.key === 'ArrowLeft') { + prevSpinButton?.current?.focus(); + } else if (evt.key === 'ArrowRight') { + nextSpinButton?.current?.focus(); + } + }; + + return ( + { + history.current = []; + }} + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={value} + ref={ref} + onKeyDown={handleKeyDown} + {...rest} + > + {children} + + ); + }, +); diff --git a/packages/ffe-datepicker-react/src/datepicker/index.ts b/packages/ffe-datepicker-react/src/datepicker/index.ts index 1172e84dfc..f0ef5d42c9 100644 --- a/packages/ffe-datepicker-react/src/datepicker/index.ts +++ b/packages/ffe-datepicker-react/src/datepicker/index.ts @@ -1,2 +1,2 @@ export { Datepicker } from './Datepicker'; -export type { DatepickerProps } from './Datepicker'; +export type { DatepickerProps } from './DatepickerComp'; diff --git a/packages/ffe-datepicker-react/src/i18n/en.ts b/packages/ffe-datepicker-react/src/i18n/en.ts index 05133f7262..77a7ad27ca 100644 --- a/packages/ffe-datepicker-react/src/i18n/en.ts +++ b/packages/ffe-datepicker-react/src/i18n/en.ts @@ -1,4 +1,7 @@ export default { + DAY: 'Day', + MONTH: 'Month', + YEAR: 'Year', DAY_1_SHORT: 'Mon', DAY_2_SHORT: 'Tue', DAY_3_SHORT: 'Wed', diff --git a/packages/ffe-datepicker-react/src/i18n/nb.ts b/packages/ffe-datepicker-react/src/i18n/nb.ts index a3f6ab9f6d..f250aeec80 100644 --- a/packages/ffe-datepicker-react/src/i18n/nb.ts +++ b/packages/ffe-datepicker-react/src/i18n/nb.ts @@ -1,4 +1,7 @@ export default { + DAY: 'Dag', + MONTH: 'Måned', + YEAR: 'År', DAY_1_SHORT: 'Man', DAY_2_SHORT: 'Tir', DAY_3_SHORT: 'Ons', diff --git a/packages/ffe-datepicker-react/src/i18n/nn.ts b/packages/ffe-datepicker-react/src/i18n/nn.ts index 5c03b41de3..81dfd6f956 100644 --- a/packages/ffe-datepicker-react/src/i18n/nn.ts +++ b/packages/ffe-datepicker-react/src/i18n/nn.ts @@ -1,4 +1,7 @@ export default { + DAY: 'Dag', + MONTH: 'Måned', + YEAR: 'År', DAY_1_SHORT: 'Man', DAY_2_SHORT: 'Tir', DAY_3_SHORT: 'Ons',