diff --git a/packages/ffe-datepicker-react/src/calendar/Calendar.tsx b/packages/ffe-datepicker-react/src/calendar/Calendar.tsx index c6ee7d8a75..965c88fade 100644 --- a/packages/ffe-datepicker-react/src/calendar/Calendar.tsx +++ b/packages/ffe-datepicker-react/src/calendar/Calendar.tsx @@ -1,7 +1,4 @@ -import React, { Component } from 'react'; -import { v4 as uuid } from 'uuid'; -import { ClickableDate } from './ClickableDate'; -import { NonClickableDate } from './NonClickableDate'; +import React, { useEffect, useId, useState } from 'react'; import { Header } from './Header'; import { getSimpleDateFromString, @@ -9,6 +6,9 @@ import { } from '../datelogic/simpledate'; import { SimpleCalendar } from '../datelogic/simplecalendar'; import { CalendarButtonState } from '../datelogic/types'; +import { useCalendar } from '../datelogic/CalendarContext'; +import { NonClickableDate } from './NonClickableDate'; +import { ClickableDate } from './ClickableDate'; export interface CalendarProps { calendarClassName?: string; @@ -21,67 +21,41 @@ export interface CalendarProps { focusOnMount?: boolean; } -interface State { - calendar: SimpleCalendar; - isFocusingHeader: boolean; -} +// interface State { +// calendar: SimpleCalendar; +// isFocusingHeader: boolean; +// } + +export const Calendar: React.FC = props => { + const { month, setMonth } = useCalendar(); + const datepickerId = `ffe-calendar-${useId()}`; + const [isFocusingHeader, setIsFocusingHeader] = useState(false); -export class Calendar extends Component { - private readonly datepickerId: string; + const clickableDateRef = React.createRef(); + const prevMonthButtonElementRef = React.createRef(); + const nextMonthButtonElementRef = React.createRef(); - constructor(props: CalendarProps) { - super(props); + const [calendar, setCalendar] = useState( + new SimpleCalendar( + getSimpleDateFromString(props?.selectedDate), + props.minDate, + props.maxDate, + props.locale, + ), + ); - this.state = { - calendar: new SimpleCalendar( + useEffect(() => { + setCalendar( + new SimpleCalendar( getSimpleDateFromString(props?.selectedDate), props.minDate, props.maxDate, props.locale, ), - isFocusingHeader: false, - }; - - this.datepickerId = `ffe-calendar-${uuid()}`; - - this.keyDown = this.keyDown.bind(this); - this.mouseClick = this.mouseClick.bind(this); - this.nextMonth = this.nextMonth.bind(this); - this.previousMonth = this.previousMonth.bind(this); - - this.renderDate = this.renderDate.bind(this); - this.renderWeek = this.renderWeek.bind(this); - this.renderDay = this.renderDay.bind(this); - } - - clickableDateRef = React.createRef(); - prevMonthButtonElementRef = React.createRef(); - nextMonthButtonElementRef = React.createRef(); - - /* eslint-disable react/no-did-update-set-state */ - componentDidUpdate(prevProps: CalendarProps) { - if (prevProps.selectedDate !== this.props.selectedDate) { - this.setState( - { - calendar: new SimpleCalendar( - getSimpleDateFromString(this.props.selectedDate), - this.props.minDate, - this.props.maxDate, - this.props.locale, - ), - }, - this.forceUpdate, - ); - } - } - - shouldComponentUpdate(nextProps: CalendarProps) { - return nextProps.selectedDate !== this.props.selectedDate; - } - - keyDown(event: React.KeyboardEvent) { - const calendar = this.state.calendar; + ); + }, [props.selectedDate, props.minDate, props.maxDate, props.locale]); + function keyDown(event: React.KeyboardEvent) { const scrollableEvents: string[] = [ 'PageUp', 'PageDown', @@ -100,12 +74,12 @@ export class Calendar extends Component { case 'Enter': if (calendar.isDateWithinDateRange(calendar.focusedDate)) { calendar.selectFocusedDate(); - this.props.onDatePicked(calendar.selected()); + props.onDatePicked(calendar.selected()); } event.preventDefault(); break; case 'Escape': - this.props.escKeyHandler?.(event); + props.escKeyHandler?.(event); break; case 'Tab': break; @@ -144,33 +118,32 @@ export class Calendar extends Component { default: return; } - - this.forceUpdate(); + //this.forceUpdate(); } - mouseClick(date: CalendarButtonState) { + function mouseClick(date: CalendarButtonState) { const pickedDate = getSimpleDateFromTimestamp(date.timestamp); - if (this.state.calendar.isDateWithinDateRange(pickedDate)) { - this.state.calendar.selectTimestamp(date.timestamp); - this.props.onDatePicked(this.state.calendar.selected()); + if (calendar.isDateWithinDateRange(pickedDate)) { + calendar.selectTimestamp(date.timestamp); + props.onDatePicked(calendar.selected()); } } + const nextMonth = (evt: React.MouseEvent) => { + setMonth((month + 1) % 12); + }; - nextMonth(evt: React.MouseEvent) { - evt.preventDefault(); - this.state.calendar.nextMonth(); - this.forceUpdate(); - } - - previousMonth(evt: React.MouseEvent) { - evt.preventDefault(); - this.state.calendar.previousMonth(); - this.forceUpdate(); - } + const previousMonth = (evt: React.MouseEvent) => { + setMonth((month - 1 + 12) % 12); + }; - renderDate(calendarButtonState: CalendarButtonState, index: number) { - const { calendar } = this.state; + const changeMonth = (evt: React.ChangeEvent) => { + setMonth(parseInt(evt.target.value)); + }; + function renderDate( + calendarButtonState: CalendarButtonState, + index: number, + ) { if (calendarButtonState.isNonClickableDate) { return ( { calendarButtonState={calendarButtonState} month={calendar.focusedMonth} year={calendar.focusedYear} - headers={`header__${this.datepickerId}__${index}`} + headers={`header__${datepickerId}__${index}`} key={calendarButtonState.timestamp} - onClick={this.mouseClick} - locale={this.props.locale} + onClick={mouseClick} + locale={props.locale} dateButtonRef={ - calendarButtonState.isFocus - ? this.clickableDateRef - : undefined + calendarButtonState.isFocus ? clickableDateRef : undefined } - isFocusingHeader={this.state.isFocusingHeader} - focusOnMount={this.props.focusOnMount} + isFocusingHeader={isFocusingHeader} + focusOnMount={props.focusOnMount} /> ); } - renderWeek(week: { dates: CalendarButtonState[]; number: number }) { + function renderWeek(week: { + dates: CalendarButtonState[]; + number: number; + }) { return ( - {week.dates.map(this.renderDate)} + {week.dates.map(renderDate)} ); } - renderDay(day: { name: string; shortName: string }, index: number) { + function renderDay( + newDay: { name: string; shortName: string }, + index: number, + ) { return ( - {day.shortName} + {newDay.shortName} ); } - focusTrap = (event: React.KeyboardEvent) => { + const focusTrap = (event: React.KeyboardEvent) => { const activeElement = document.activeElement; if (event.key === 'Tab') { event.preventDefault(); if (event.shiftKey) { - if (activeElement === this.clickableDateRef.current) { - this.nextMonthButtonElementRef.current?.focus(); - this.setState({ isFocusingHeader: true }); + if (activeElement === clickableDateRef.current) { + nextMonthButtonElementRef.current?.focus(); + setIsFocusingHeader(true); } - if (activeElement === this.nextMonthButtonElementRef.current) { - this.prevMonthButtonElementRef.current?.focus(); + if (activeElement === nextMonthButtonElementRef.current) { + prevMonthButtonElementRef.current?.focus(); } - if (activeElement === this.prevMonthButtonElementRef.current) { - this.clickableDateRef.current?.focus(); - this.setState({ isFocusingHeader: false }); - this.forceUpdate(); + if (activeElement === prevMonthButtonElementRef.current) { + clickableDateRef.current?.focus(); + setIsFocusingHeader(false); + //this.forceUpdate(); } } else { - if (activeElement === this.clickableDateRef.current) { - this.prevMonthButtonElementRef.current?.focus(); - this.setState({ isFocusingHeader: true }); + if (activeElement === clickableDateRef.current) { + prevMonthButtonElementRef.current?.focus(); + setIsFocusingHeader(true); } - if (activeElement === this.prevMonthButtonElementRef.current) { - this.nextMonthButtonElementRef.current?.focus(); + if (activeElement === prevMonthButtonElementRef.current) { + nextMonthButtonElementRef.current?.focus(); } - if (activeElement === this.nextMonthButtonElementRef.current) { - this.clickableDateRef.current?.focus(); - this.setState({ isFocusingHeader: false }); - this.forceUpdate(); + if (activeElement === nextMonthButtonElementRef.current) { + clickableDateRef.current?.focus(); + setIsFocusingHeader(false); + //this.forceUpdate(); } } } }; - render() { - const { calendar } = this.state; - - /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ - return ( + return ( +
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
-
+ -
-
- - {calendar.dayNames.map(this.renderDay)} - - - {calendar.visibleDates.map(this.renderWeek)} - -
-
+ + {calendar.dayNames.map(renderDay)} + + {calendar.visibleDates.map(renderWeek)} +
- ); - /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ - } -} +
+ ); +}; diff --git a/packages/ffe-datepicker-react/src/calendar/Header.tsx b/packages/ffe-datepicker-react/src/calendar/Header.tsx index 4b525f5ee3..efe5257c2c 100644 --- a/packages/ffe-datepicker-react/src/calendar/Header.tsx +++ b/packages/ffe-datepicker-react/src/calendar/Header.tsx @@ -1,29 +1,36 @@ import React from 'react'; import { Icon } from '@sb1/ffe-icons-react'; +import { Dropdown } from '@sb1/ffe-dropdown-react'; +import { getAllMonths, getAllYears } from '../datelogic/simplecalendar'; +import { useCalendar } from '../datelogic/CalendarContext'; interface HeaderProps { datepickerId: string; - month: string; nextMonthHandler: React.MouseEventHandler; nextMonthLabel: string; previousMonthHandler: React.MouseEventHandler; previousMonthLabel: string; - year: number; + changeMonthHandler: (evt: React.ChangeEvent) => void; + startYear?: number; + endYear?: number; prevMonthButtonElement: React.RefObject; nextMonthButtonElement: React.RefObject; } export const Header: React.FC = ({ datepickerId, - month, nextMonthHandler, nextMonthLabel, previousMonthHandler, previousMonthLabel, - year, + changeMonthHandler, + startYear = new Date().getFullYear() - 100, + endYear = new Date().getFullYear(), prevMonthButtonElement, nextMonthButtonElement, }) => { + const { month, year, setMonth, setYear } = useCalendar(); + const arrowBackIosIcon = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiB3aWR0aD0iMjQiPjxwYXRoIGQ9Im0zNjcuMzg0LTQ4MCAzMDEuMzA4IDMwMS4zMDhxMTEuOTIzIDExLjkyMyAxMS42MTUgMjguMDc3LS4zMDggMTYuMTUzLTEyLjIzMSAyOC4wNzYtMTEuOTIyIDExLjkyMy0yOC4wNzYgMTEuOTIzdC0yOC4wNzYtMTEuOTIzTDMwNS4wNzgtNDI4Ljc3cS0xMC44NDctMTAuODQ2LTE2LjA3Ny0yNC4zMDctNS4yMzEtMTMuNDYyLTUuMjMxLTI2LjkyMyAwLTEzLjQ2MSA1LjIzMS0yNi45MjMgNS4yMy0xMy40NjEgMTYuMDc3LTI0LjMwN2wzMDYuODQ2LTMwNi44NDZxMTEuOTIyLTExLjkyMyAyOC4zODQtMTEuNjE2IDE2LjQ2MS4zMDggMjguMzg0IDEyLjIzMSAxMS45MjMgMTEuOTIzIDExLjkyMyAyOC4wNzYgMCAxNi4xNTQtMTEuOTIzIDI4LjA3N0wzNjcuMzg0LTQ4MFoiLz48L3N2Zz4='; @@ -49,9 +56,51 @@ export const Header: React.FC = ({ className="ffe-calendar__title" id={`${datepickerId}-title`} > -
- {month} - {year} +
+ + + setMonth(parseInt(e.target.value)) + } + > + {getAllMonths('nb').map( + (monthOption, index) => { + return ( + + ); + }, + )} + + + + + setYear(parseInt(e.target.value)) + } + > + {getAllYears(startYear, endYear).map( + (yearOption, index) => { + return ( + + ); + }, + )} + +
- {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} - /> - )} - - - ); - } -} +import { CalendarProvider } from '../datelogic/CalendarContext'; +import React from 'react'; +import { DatepickerInner, DatepickerProps } from './DatepickerInner'; + +export const Datepicker: React.FC = props => { + return ( + + + + ); +}; diff --git a/packages/ffe-datepicker-react/src/datepicker/DatepickerInner.tsx b/packages/ffe-datepicker-react/src/datepicker/DatepickerInner.tsx new file mode 100644 index 0000000000..2876ca4a8e --- /dev/null +++ b/packages/ffe-datepicker-react/src/datepicker/DatepickerInner.tsx @@ -0,0 +1,311 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import classNames from 'classnames'; +import { v4 as uuid } from 'uuid'; +import { Calendar } from '../calendar'; +import { Button } from '../button'; +import { getSimpleDateFromString } from '../datelogic/simpledate'; +import { validateDate, isDateInputWithTwoDigitYear } from '../util/dateUtil'; +import debounce from 'lodash.debounce'; +import { CalendarProvider, useCalendar } from '../datelogic/CalendarContext'; + +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; + value: string; + fullWidth?: boolean; +} + +export const DatepickerInner: React.FC = props => { + const { + 'aria-invalid': ariaInvalidProp, + calendarAbove, + locale = 'nb', + maxDate: maxDateProp, + minDate: minDateProp, + onChange, + value, + fullWidth, + } = props; + + const [displayDatePicker, setDisplayDatePicker] = useState(false); + const [minDate, setMinDate] = useState(minDateProp); + const [maxDate, setMaxDate] = useState(maxDateProp); + const [lastValidDate, setLastValidDate] = useState(''); + const [calendarActiveDate, setCalendarActiveDate] = useState( + validateDate(value) ? value : '', + ); + // const [ariaInvalidState, setAriaInvalidState] = useState< + // boolean | undefined + // >(false); + const [day, setDay] = useState( + getSimpleDateFromString(calendarActiveDate)?.day.toString() ?? 'dd', + ); + const { month, setMonth, year, setYear } = useCalendar(); + + const datepickerId = useRef(uuid()); + const buttonRef = useRef(null); + const dayRef = useRef(null); + const monthRef = useRef(null); + const yearRef = useRef(null); + + const debounceCalendar = useCallback( + debounce((newValue: any) => { + if (newValue !== lastValidDate && validateDate(newValue)) { + setCalendarActiveDate(newValue); + setLastValidDate(newValue); + } + }, 250), + [lastValidDate], + ); + + useEffect(() => { + return () => { + debounceCalendar.cancel(); + }; + }, [debounceCalendar]); + + const validateDateIntervals = () => { + const dateString = `${day}.${month}.${year}`; + getSimpleDateFromString(dateString, date => { + //setAriaInvalidState(false); + + 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 onInputBlur = () => { + validateDateIntervals(); + }; + + const handleKeyDown = ( + evt: React.KeyboardEvent, + field: 'day' | 'month' | 'year', + ) => { + if (evt.key === 'Enter') { + evt.preventDefault(); + validateDateIntervals(); + } else if (/\d/.test(evt.key)) { + evt.preventDefault(); + evt.stopPropagation(); + const keyValue = evt.key; + if (field === 'day') { + if (day === 'dd' && parseInt(keyValue) > 3) { + setDay(`0${keyValue}`); + monthRef.current?.focus(); + return; + } + setDay(prev => + prev === 'dd' ? keyValue : (prev + keyValue).slice(-2), + ); + if (dayRef.current && (day + keyValue).length === 2) { + monthRef.current?.focus(); + } + } else if (field === 'month') { + // setMonth( prev => + // prev === 'mm' ? keyValue : (prev + keyValue).slice(-2), + // ); + setMonth(5); + if (monthRef.current && (month + keyValue).length === 2) { + yearRef.current?.focus(); + } + } else if (field === 'year') { + // setYear(prev => + // prev === 'yyyy' ? keyValue : (prev + keyValue).slice(-4), + // ); + setYear(2021); + } + } else if (evt.key === 'Backspace') { + evt.preventDefault(); + if (field === 'day') { + setDay(prev => (prev.length > 1 ? prev.slice(0, -1) : 'dd')); + } else if (field === 'month') { + setMonth(2); + //setMonth(prev => (prev.length > 1 ? prev.slice(0, -1) : 'mm')); + } else if (field === 'year') { + setYear(202); + //setYear(prev => (prev.length > 1 ? prev.slice(0, -1) : 'yyyy')); + } + } + }; + + 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); + }; + + const addFlagOnClickEventClickHandler = (evt: React.MouseEvent) => { + const nativeEvent = evt.nativeEvent as any; + nativeEvent.__datepickerID = datepickerId.current; + }; + + const datePickedHandler = (date: string) => { + onChange(date); + setDisplayDatePicker(false); + setCalendarActiveDate(date); + buttonRef.current?.focus(); + }; + + const calendarClassName = classNames( + 'ffe-calendar ffe-calendar--datepicker', + { 'ffe-calendar--datepicker--above': calendarAbove }, + ); + + const datepickerClassName = classNames('ffe-datepicker', { + 'ffe-datepicker--full-width': fullWidth, + }); + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + dayRef.current?.focus()} + onKeyDown={e => handleKeyDown(e, 'day')} + ref={dayRef} + > + {day} + + . + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + monthRef.current?.focus()} + onKeyDown={e => handleKeyDown(e, 'month')} + ref={monthRef} + > + {month} + + . + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + yearRef.current?.focus()} + onKeyDown={e => handleKeyDown(e, 'year')} + ref={yearRef} + > + {year} + +
+
+ {displayDatePicker && ( + , + // ) => { + // if (evt.key === 'Escape') { + // closeCalendarSetInputFocus(); + // } + // }} + locale={locale} + maxDate={maxDate} + minDate={minDate} + onDatePicked={datePickedHandler} + selectedDate={calendarActiveDate} + focusOnMount={true} + /> + )} +
+
+ ); +}; diff --git a/packages/ffe-datepicker/less/calendar.less b/packages/ffe-datepicker/less/calendar.less index 3c2939fe3c..392fd5a673 100644 --- a/packages/ffe-datepicker/less/calendar.less +++ b/packages/ffe-datepicker/less/calendar.less @@ -28,7 +28,7 @@ &__header-inner-wrapper { display: flex; - justify-content: center; + justify-content: space-between; align-items: center; } @@ -61,6 +61,10 @@ } } + &__month-label { + display: flex; + } + &__icon-prev.ffe-icons, &__icon-next.ffe-icons { color: var(--ffe-v-datepicker-icon-color); diff --git a/packages/ffe-datepicker/less/dateinput.less b/packages/ffe-datepicker/less/dateinput.less index ff898d198e..143038e687 100644 --- a/packages/ffe-datepicker/less/dateinput.less +++ b/packages/ffe-datepicker/less/dateinput.less @@ -1,37 +1,49 @@ .ffe-dateinput { position: relative; display: inline-block; + grid-column: 1 e('/') 3; + grid-row: 1 e('/') -1; + min-width: 210px; &--full-width { display: block; } + // + //@media (hover: hover) and (pointer: fine) { + // &:hover { + // color: var(--ffe-g-primary-color); + // } + //} - @media (hover: hover) and (pointer: fine) { - &:hover { - color: var(--ffe-g-primary-color); - } + &[aria-invalid='true'] { + border-color: var(--ffe-g-error-color); + border-style: solid; + } + + //@media (hover: hover) and (pointer: fine) { + // /* stylelint-disable selector-max-specificity */ + // &:focus + .ffe-datepicker__button:hover { + // border-color: red !important; + // box-shadow: 0 0 0 2px var(--ffe-v-datepicker-border-hover-color); + // } + // /* stylelint-enable selector-max-specificity */ + //} + + &.ffe-input-field { + display: flex; + align-items: center; } &__field { - min-width: 160px; - grid-column: 1 e('/') 3; - grid-row: 1 e('/') -1; - &::-ms-clear { - display: none; - } + padding-block: var(--ffe-spacing-2xs); - &[aria-invalid='true'] { - border-color: var(--ffe-g-error-color); - border-style: solid; + &:focus { + background-color: var(--ffe-farge-frost-30); + outline: none; } - @media (hover: hover) and (pointer: fine) { - /* stylelint-disable selector-max-specificity */ - &:focus + .ffe-datepicker__button:hover { - border-color: transparent; - box-shadow: 0 0 0 2px var(--ffe-v-datepicker-border-hover-color); - } - /* stylelint-enable selector-max-specificity */ + &::-ms-clear { + display: none; } } } @@ -60,6 +72,7 @@ transition: all var(--ffe-transition-duration) var(--ffe-ease); width: 56px; cursor: pointer; + z-index: 1; &:focus, &:active {