diff --git a/packages/ffe-core/scripts/lib/renderLessVarsToCSSProps.js b/packages/ffe-core/scripts/lib/renderLessVarsToCSSProps.js index e411987f5c..e69de29bb2 100644 --- a/packages/ffe-core/scripts/lib/renderLessVarsToCSSProps.js +++ b/packages/ffe-core/scripts/lib/renderLessVarsToCSSProps.js @@ -1,23 +0,0 @@ -const fs = require('fs').promises; -const less = require('less'); - -/** - * Takes less variables from a given file and generates a css stylesheet with - * these variables as custom properties on the :root pseudo-class. - */ -module.exports = async inputFile => { - const data = await fs.readFile(inputFile, 'utf8'); - const root = await less.parse(data, { filename: inputFile }); - - const propertyNames = root.rules - .filter(r => r.variable) - .map(n => n.name.slice(1)); - - const { css } = await less.render( - `@import '${inputFile}';` + - `@props: ${propertyNames.join(', ')};` + - `:root, :host { each(@props, { --@{value}: @@value; }); }`, - ); - - return css; -}; diff --git a/packages/ffe-datepicker-react/package.json b/packages/ffe-datepicker-react/package.json index 2a90751c23..3c5c27f721 100644 --- a/packages/ffe-datepicker-react/package.json +++ b/packages/ffe-datepicker-react/package.json @@ -30,6 +30,7 @@ "@sb1/ffe-icons-react": "^11.0.14", "@types/lodash.debounce": "^4.0.9", "classnames": "^2.3.1", + "date-fns": "^4.1.0", "lodash.debounce": "^4.0.8", "uuid": "^9.0.0" }, diff --git a/packages/ffe-datepicker-react/src/calendar/Calendar.tsx b/packages/ffe-datepicker-react/src/calendar/Calendar.tsx index c6ee7d8a75..37645851a3 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,42 @@ export interface CalendarProps { focusOnMount?: boolean; } -interface State { - calendar: SimpleCalendar; - isFocusingHeader: boolean; -} +// interface State { +// calendar: SimpleCalendar; +// isFocusingHeader: boolean; +// } + +export const Calendar: React.FC = props => { + const { visibleMonth, visibleYear, month, updateMonth, year, setYear } = + 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 +75,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 +119,35 @@ 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()); + console.log(pickedDate); + if (calendar.isDateWithinDateRange(pickedDate)) { + calendar.selectTimestamp(date.timestamp); + /* props.onDatePicked(calendar.selected());*/ + updateMonth(pickedDate.month); + setYear(pickedDate.year.toString()); } } + const nextMonth = (evt: React.MouseEvent) => { + updateMonth((parseInt(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) => { + updateMonth((parseInt(month) - 1 + 12) % 12); + }; - renderDate(calendarButtonState: CalendarButtonState, index: number) { - const { calendar } = this.state; + const changeMonth = (evt: React.ChangeEvent) => { + updateMonth(parseInt(evt.target.value)); + }; + function renderDate( + calendarButtonState: CalendarButtonState, + index: number, + ) { if (calendarButtonState.isNonClickableDate) { return ( { return ( ); } - 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/ClickableDate.tsx b/packages/ffe-datepicker-react/src/calendar/ClickableDate.tsx index 98a9a1f529..654c88fb80 100644 --- a/packages/ffe-datepicker-react/src/calendar/ClickableDate.tsx +++ b/packages/ffe-datepicker-react/src/calendar/ClickableDate.tsx @@ -1,10 +1,11 @@ import React, { Component } from 'react'; import classNames from 'classnames'; import { CalendarButtonState } from '../datelogic/types'; +import i18n from '../i18n/i18n'; interface ClickableDateProps { calendarButtonState: CalendarButtonState; - month: string; + month: number; year: number; headers: string; onClick: (date: CalendarButtonState) => void; @@ -62,7 +63,11 @@ export class ClickableDate extends Component { year, } = this.props; - const monthName = locale === 'en' ? month : month.toLowerCase(); + const monthName = + locale === 'en' + ? i18n[locale][`MONTH_${month + 1}`] + : i18n[locale][`MONTH_${month + 1}`]?.toLowerCase(); + //const monthName = locale === 'en' ? month : month.toLowerCase(); return ( ; 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 { + visibleYear, + visibleMonth, + setVisibleMonth, + setVisibleYear, + //updateMonth, + //setYear, + } = useCalendar(); + const arrowBackIosIcon = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiB3aWR0aD0iMjQiPjxwYXRoIGQ9Im0zNjcuMzg0LTQ4MCAzMDEuMzA4IDMwMS4zMDhxMTEuOTIzIDExLjkyMyAxMS42MTUgMjguMDc3LS4zMDggMTYuMTUzLTEyLjIzMSAyOC4wNzYtMTEuOTIyIDExLjkyMy0yOC4wNzYgMTEuOTIzdC0yOC4wNzYtMTEuOTIzTDMwNS4wNzgtNDI4Ljc3cS0xMC44NDctMTAuODQ2LTE2LjA3Ny0yNC4zMDctNS4yMzEtMTMuNDYyLTUuMjMxLTI2LjkyMyAwLTEzLjQ2MSA1LjIzMS0yNi45MjMgNS4yMy0xMy40NjEgMTYuMDc3LTI0LjMwN2wzMDYuODQ2LTMwNi44NDZxMTEuOTIyLTExLjkyMyAyOC4zODQtMTEuNjE2IDE2LjQ2MS4zMDggMjguMzg0IDEyLjIzMSAxMS45MjMgMTEuOTIzIDExLjkyMyAyOC4wNzYgMCAxNi4xNTQtMTEuOTIzIDI4LjA3N0wzNjcuMzg0LTQ4MFoiLz48L3N2Zz4='; @@ -49,9 +63,51 @@ export const Header: React.FC = ({ className="ffe-calendar__title" id={`${datepickerId}-title`} > -
- {month} - {year} +
+ + + setVisibleMonth(parseInt(e.target.value)) + } + > + {getAllMonths('nb').map( + (monthOption, index) => { + return ( + + ); + }, + )} + + + + + setVisibleYear(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'; +import { DatePicker } from '../v2/DatePicker'; + +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..d3a1c4155b --- /dev/null +++ b/packages/ffe-datepicker-react/src/datepicker/DatepickerInner.tsx @@ -0,0 +1,315 @@ +import React, { useCallback, useEffect, useRef, useState } 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 { isDateInputWithTwoDigitYear, validateDate } from '../util/dateUtil'; +import debounce from 'lodash.debounce'; +import { 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 { day, updateDay, month, updateMonth, 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') { + updateDay(parseInt(keyValue)); + const newValue: number = parseInt(keyValue); + + const shouldFocusYear = + (day === 'dd' && newValue >= 4) || + (day === '00' && newValue >= 1) || + (day === '03' && newValue <= 1) || + newValue >= 4; + if (shouldFocusYear) { + monthRef.current?.focus(); + return; + } + //check if the user has typed two numbers? Or, if the user has typed a number and then a number that is greater than 3 + } else if (field === 'month') { + const newValue: number = parseInt(keyValue); + const shouldFocusYear = + (month === 'mm' && newValue >= 2) || + (month === '0' && newValue >= 1) || + newValue >= 3; + updateMonth(newValue); + if (shouldFocusYear || month === '12' || month === '11') { //month isn't updated yet + 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') { + updateMonth(2); + //updateMonth(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.length === 1 ? `0${day}` : day} + + . + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + monthRef.current?.focus()} + onKeyDown={e => handleKeyDown(e, 'month')} + ref={monthRef} + > + {month.length === 1 ? `0${month}` : 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-react/src/v2/CalenderWrapper.tsx b/packages/ffe-datepicker-react/src/v2/CalenderWrapper.tsx new file mode 100644 index 0000000000..7703c132d8 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/CalenderWrapper.tsx @@ -0,0 +1,47 @@ +import React, { useImperativeHandle, useRef, useState } from 'react'; + +export interface CalendarWrapperProps { + children: React.ReactNode; +} + +export type CalendarWrapperHandle = { + readonly open: ({ left, top }: { left: number; top: number }) => void; + readonly close: () => void; +}; + +export const CalendarWrapper = React.forwardRef< + CalendarWrapperHandle, + CalendarWrapperProps +>(({ children }, ref) => { + const dialogRef = useRef(null); + const [style, setStyle] = useState(); + + useImperativeHandle(ref, () => ({ + open: ({ top, left }) => { + setStyle({ + '--top': `${top}px`, + '--left': `${left}px`, + } as React.CSSProperties); + dialogRef.current?.showModal(); + }, + close: () => { + dialogRef.current?.close(); + }, + })); + + return ( + { + const target = event.target as HTMLDialogElement; + if (target.nodeName === 'DIALOG') { + target.close(); + } + }} + > + {children} + + ); +}); diff --git a/packages/ffe-datepicker-react/src/v2/DatePicker.tsx b/packages/ffe-datepicker-react/src/v2/DatePicker.tsx new file mode 100644 index 0000000000..40892d3d20 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/DatePicker.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { DatePickerProvider } from './DatePickerContext'; +import { DatePickerComp } from './DatePickerComp'; + +interface Props { + locale?: 'nb' | 'nn' | 'en'; +} + +export const DatePicker: React.FC = ({ locale = 'nb' }) => { + return ( + + + + ); +}; diff --git a/packages/ffe-datepicker-react/src/v2/DatePickerComp.tsx b/packages/ffe-datepicker-react/src/v2/DatePickerComp.tsx new file mode 100644 index 0000000000..a2ceefb0a3 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/DatePickerComp.tsx @@ -0,0 +1,69 @@ +import React, { useContext, useRef, useState } from 'react'; +import { DatePickerContext } from './DatePickerContext'; +import { SpinnButton } from './SpinnButton'; +import { PadZero } from './PadZero'; +import { Button } from '../button'; +import { Calendar } from './calendar'; +import { CalendarWrapper, CalendarWrapperHandle } from './CalenderWrapper'; + +export const DatePickerComp: React.FC = () => { + const containerRef = useRef(null); + const monthRef = useRef(null); + const yearRef = useRef(null); + const { day, setDay, year, setYear, month, setMonth, locale } = + useContext(DatePickerContext); + const calendarRef = useRef(null); + + return ( +
+
+ { + setDay(value, () => + monthRef.current?.focus({ preventScroll: true }), + ); + }} + maxLength={2} + > + {day ? : 'dd'} + + . + { + setMonth(value, () => + yearRef.current?.focus({ preventScroll: true }), + ); + }} + maxLength={2} + > + {month ? : 'mm'} + + . + { + setYear(value); + }} + maxLength={4} + > + {year ? year : 'yyyy'} + +
+
+ ); +}; diff --git a/packages/ffe-datepicker-react/src/v2/DatePickerContext.tsx b/packages/ffe-datepicker-react/src/v2/DatePickerContext.tsx new file mode 100644 index 0000000000..04c61349a2 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/DatePickerContext.tsx @@ -0,0 +1,101 @@ +import React, { createContext, useState } from 'react'; +import { Locale } from './types'; + +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; +} + +export const DatePickerContext = createContext({ + day: null, + month: null, + year: null, + setDay: () => null, + setMonth: () => null, + setYear: () => null, + locale: 'nb', +}); + +interface Props { + locale: Locale; + children: React.ReactNode; +} + +const MONTHS_PER_YEAR = 12; +const MAX_DAYS = 31; + +export const DatePickerProvider: React.FC = ({ children, locale }) => { + const [day, setDay] = useState(); + const [month, setMonth] = useState(); + const [year, setYear] = useState(); + + const getTotal = (numbers: (number | undefined)[]) => { + const validNumbers = numbers.filter(it => typeof it === 'number'); + return validNumbers + .map( + (it, index) => + it * Math.pow(10, validNumbers.length - index - 1), + ) + .reduce((acc, curr) => acc + curr, 0); + }; + + return ( + { + const numbers = value.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: (value, focusNext) => { + const numbers = value.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: value => { + setYear(getTotal(value.slice(-4))); + + /* if (year === 'yyyy' || `${year}${value}`.length > 4) { + setYear(`${value}`); + } else { + setYear(`${year}${value}`); + }*/ + }, + locale, + }} + > + {children} + + ); +}; diff --git a/packages/ffe-datepicker-react/src/v2/PadZero.tsx b/packages/ffe-datepicker-react/src/v2/PadZero.tsx new file mode 100644 index 0000000000..0bae17ad0d --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/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/v2/SpinnButton.tsx b/packages/ffe-datepicker-react/src/v2/SpinnButton.tsx new file mode 100644 index 0000000000..886c026152 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/SpinnButton.tsx @@ -0,0 +1,40 @@ +import React, { useRef } from 'react'; + +interface Props { + onChange: (value: number[]) => void; + children: React.ReactNode; + maxLength: number; +} + +export const SpinnButton = React.forwardRef( + ({ onChange, maxLength, children }, ref) => { + const history = useRef([]); + + return ( + { + history.current = []; + }} + ref={ref} + onKeyDown={evt => { + evt.stopPropagation(); + + if (/\d/.test(evt.key)) { + history.current = + history.current.length === maxLength + ? (history.current = [parseInt(evt.key)]) + : history.current.concat(parseInt(evt.key)); + onChange(history.current); + } else if (evt.key === 'Backspace') { + history.current = []; + onChange(history.current); + } + }} + > + {children} + + ); + }, +); diff --git a/packages/ffe-datepicker-react/src/v2/calendar/Calendar.tsx b/packages/ffe-datepicker-react/src/v2/calendar/Calendar.tsx new file mode 100644 index 0000000000..2910fee6bc --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/calendar/Calendar.tsx @@ -0,0 +1,154 @@ +import React, { useState } from 'react'; +import { + lastDayOfMonth, + eachDayOfInterval, + startOfMonth, + getWeekOfMonth, + getWeeksInMonth, +} from 'date-fns'; +import { Locale } from '../types'; +import { txt } from './texts'; +import { parseNsISO8601 } from '../date'; +import { Day } from './Day'; + +export interface CalendarProps { + locale?: Locale; + initialValue?: `${string}.${string}.${string}` | null; +} + +const DAYS_IN_WEEK = 7; + +const norwegianIndex: Record = { + 0: 6, + 1: 0, + 2: 1, + 3: 2, + 4: 3, + 5: 4, + 6: 5, +}; + +export const Calendar: React.FC = ({ + locale = 'nb', + initialValue = '10.05.2023', +}) => { + const startOfWeek = locale === 'en' ? 0 : 1; + + const [value, setValue] = useState( + initialValue ? parseNsISO8601(initialValue) : null, + ); + + const currentMonth = value ?? new Date(); + + const weeksInMonth = Array.from( + Array(getWeeksInMonth(currentMonth)).keys(), + ); + const days = eachDayOfInterval({ + start: startOfMonth(currentMonth), + end: lastDayOfMonth(currentMonth), + }); + + const refs = Array.from( + Array(weeksInMonth.length * DAYS_IN_WEEK).keys(), + ).map(() => React.createRef()); + + const handeKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + } + if (e.key === 'ArrowDown') { + } + + if (e.key === 'ArrowRight') { + } + if (e.key === 'ArrowLeft') { + } + + console.log(e.key); + }; + + return ( +
+
+
+
+ {txt[locale].daysShort.map(it => ( + + {it} + + ))} +
+
+
+ {weeksInMonth.map(weekNumberInMonth => { + const daysOfWeek = days.filter( + dayInMonth => + getWeekOfMonth(dayInMonth, { + weekStartsOn: startOfWeek, + }) === + weekNumberInMonth + 1, + { startOfWeek }, + ); + + return ( +
+ {Array.from(Array(DAYS_IN_WEEK).keys()).map( + dayInWeekIndex => { + const dayOfWeek = daysOfWeek.find( + it => + (locale === 'en' + ? it.getDay() + : norwegianIndex[ + it.getDay() + ]) === dayInWeekIndex, + ); + + if (!dayOfWeek) { + return ( + + ); + } + + return ( + { + setValue( + new Date( + currentMonth.getFullYear(), + currentMonth.getMonth(), + dayOfWeek.getDate(), + ), + ); + }} + isSelected={ + dayOfWeek.getDate() === + value?.getDate() + } + ref={ + refs[ + weekNumberInMonth * + DAYS_IN_WEEK + + dayInWeekIndex + ] + } + > + {dayOfWeek.getDate()} + + ); + }, + )} +
+ ); + })} +
+
+
+ ); +}; diff --git a/packages/ffe-datepicker-react/src/v2/calendar/ClickableDate.tsx b/packages/ffe-datepicker-react/src/v2/calendar/ClickableDate.tsx new file mode 100644 index 0000000000..4ba623d3ef --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/calendar/ClickableDate.tsx @@ -0,0 +1,87 @@ +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { CalendarButtonState } from '../datelogic/types'; +import i18n from '..//i18n'; + +interface ClickableDateProps { + calendarButtonState: CalendarButtonState; + month: number; + year: number; + headers: string; + onClick: (date: CalendarButtonState) => void; + locale: 'nb' | 'nn' | 'en'; + dateButtonRef?: React.RefObject; + isFocusingHeader: boolean; + focusOnMount?: boolean; +} + +export class ClickableDate extends Component { + componentDidMount() { + if (this.props.focusOnMount) { + this.focusIfNeeded(); + } + } + + componentDidUpdate() { + this.focusIfNeeded(); + } + + focusIfNeeded() { + const { calendarButtonState, isFocusingHeader, dateButtonRef } = + this.props; + if (calendarButtonState.isFocus && !isFocusingHeader) { + dateButtonRef?.current?.focus(); + } + } + + dateClassName() { + const { calendarButtonState, isFocusingHeader } = this.props; + const { isEnabled, isFocus, isSelected, isToday } = calendarButtonState; + + return classNames({ + 'ffe-calendar__date': true, + 'ffe-calendar__date--today': isToday, + 'ffe-calendar__date--focus': isFocus && !isFocusingHeader, + 'ffe-calendar__date--disabled': !isEnabled, + 'ffe-calendar__date--selected': isSelected, + 'ffe-calendar__date--disabled-focus': !isEnabled && isFocus, + }); + } + + tabIndex() { + return this.props.calendarButtonState.isFocus ? 0 : -1; + } + + render() { + const { + calendarButtonState, + headers, + onClick, + locale, + dateButtonRef, + month, + year, + } = this.props; + + const monthName = ''; + //const monthName = locale === 'en' ? month : month.toLowerCase(); + + return ( + onClick(calendarButtonState)} + > + + + ); + } +} diff --git a/packages/ffe-datepicker-react/src/v2/calendar/Day.tsx b/packages/ffe-datepicker-react/src/v2/calendar/Day.tsx new file mode 100644 index 0000000000..2bdd130d1f --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/calendar/Day.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import classNames from 'classnames'; + +interface Props + extends Omit< + React.ComponentProps<'span'>, + 'tabIndex' | 'role' | 'className' + > { + isSelected: boolean; + children: number; +} + +export const Day = React.forwardRef( + ({ children, isSelected, ...rest }, ref) => { + return ( + + {children} + + ); + }, +); diff --git a/packages/ffe-datepicker-react/src/v2/calendar/Header.tsx b/packages/ffe-datepicker-react/src/v2/calendar/Header.tsx new file mode 100644 index 0000000000..4b525f5ee3 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/calendar/Header.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Icon } from '@sb1/ffe-icons-react'; + +interface HeaderProps { + datepickerId: string; + month: string; + nextMonthHandler: React.MouseEventHandler; + nextMonthLabel: string; + previousMonthHandler: React.MouseEventHandler; + previousMonthLabel: string; + year: number; + prevMonthButtonElement: React.RefObject; + nextMonthButtonElement: React.RefObject; +} + +export const Header: React.FC = ({ + datepickerId, + month, + nextMonthHandler, + nextMonthLabel, + previousMonthHandler, + previousMonthLabel, + year, + prevMonthButtonElement, + nextMonthButtonElement, +}) => { + const arrowBackIosIcon = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiB3aWR0aD0iMjQiPjxwYXRoIGQ9Im0zNjcuMzg0LTQ4MCAzMDEuMzA4IDMwMS4zMDhxMTEuOTIzIDExLjkyMyAxMS42MTUgMjguMDc3LS4zMDggMTYuMTUzLTEyLjIzMSAyOC4wNzYtMTEuOTIyIDExLjkyMy0yOC4wNzYgMTEuOTIzdC0yOC4wNzYtMTEuOTIzTDMwNS4wNzgtNDI4Ljc3cS0xMC44NDctMTAuODQ2LTE2LjA3Ny0yNC4zMDctNS4yMzEtMTMuNDYyLTUuMjMxLTI2LjkyMyAwLTEzLjQ2MSA1LjIzMS0yNi45MjMgNS4yMy0xMy40NjEgMTYuMDc3LTI0LjMwN2wzMDYuODQ2LTMwNi44NDZxMTEuOTIyLTExLjkyMyAyOC4zODQtMTEuNjE2IDE2LjQ2MS4zMDggMjguMzg0IDEyLjIzMSAxMS45MjMgMTEuOTIzIDExLjkyMyAyOC4wNzYgMCAxNi4xNTQtMTEuOTIzIDI4LjA3N0wzNjcuMzg0LTQ4MFoiLz48L3N2Zz4='; + + return ( +
+
+ +
+
+ {month} + {year} +
+
+ +
+
+ ); +}; diff --git a/packages/ffe-datepicker-react/src/v2/calendar/index.ts b/packages/ffe-datepicker-react/src/v2/calendar/index.ts new file mode 100644 index 0000000000..729eaa95d4 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/calendar/index.ts @@ -0,0 +1,2 @@ +export { Calendar } from './Calendar'; +export type { CalendarProps } from './Calendar'; diff --git a/packages/ffe-datepicker-react/src/v2/calendar/texts.ts b/packages/ffe-datepicker-react/src/v2/calendar/texts.ts new file mode 100644 index 0000000000..0f4cfbf249 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/calendar/texts.ts @@ -0,0 +1,13 @@ +const nb = { + daysShort: ['Man', 'Tir', 'Ons', 'Tor', 'Fre', 'Lør', 'Søn'], +}; + +const nn = { + daysShort: ['Man', 'Tir', 'Ons', 'Tor', 'Fre', 'Lør', 'Søn'], +}; + +const en = { + daysShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], +}; + +export const txt = { nb, nn, en }; diff --git a/packages/ffe-datepicker-react/src/v2/date.ts b/packages/ffe-datepicker-react/src/v2/date.ts new file mode 100644 index 0000000000..745d1bef42 --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/date.ts @@ -0,0 +1,5 @@ +import { parse as parseFNS } from 'date-fns'; +export const NS_ISO_8601_DATE_FORMAT = 'dd.MM.yyyy'; + +export const parseNsISO8601 = (date: string): Date => + parseFNS(date, NS_ISO_8601_DATE_FORMAT, new Date()); diff --git a/packages/ffe-datepicker-react/src/v2/types.ts b/packages/ffe-datepicker-react/src/v2/types.ts new file mode 100644 index 0000000000..83a3cfb50a --- /dev/null +++ b/packages/ffe-datepicker-react/src/v2/types.ts @@ -0,0 +1 @@ +export type Locale = 'nb' | 'nn' | 'en'; diff --git a/packages/ffe-datepicker/less/calendar.less b/packages/ffe-datepicker/less/calendar.less index 62034cd3ec..24ce46d7cb 100644 --- a/packages/ffe-datepicker/less/calendar.less +++ b/packages/ffe-datepicker/less/calendar.less @@ -1,5 +1,42 @@ @import (reference) '@sb1/ffe-core/less/typography'; +.ffe-calendar { + border: solid 2px var(--ffe-g-border-color); + padding: var(--ffe-spacing-2xs); + /* overflow-y: auto;*/ +} + +.ffe-calendar__days { + display: grid; + grid-template-columns: repeat(7, 1fr); + + [role='rowgroup']:first-child { + grid-area: 1 / 1 / span 1 / span 7; + } + + [role='row'], + [role='rowgroup'] { + grid-column: 1 / 8; + display: grid; + place-items: center; + grid-template-columns: subgrid; + } +} + +.ffe-calendar__day-name { + background: red; + padding: var(--ffe-spacing-2xs); +} + +.ffe-calendar__day-number { + padding: var(--ffe-spacing-2xs); + + &--selected { + background: green; + } +} + +/* .ffe-calendar { border: solid 2px var(--ffe-g-border-color); border-radius: var(--ffe-g-border-radius); @@ -8,11 +45,10 @@ overflow-y: auto; &--datepicker { - position: absolute; - transform: translateY(var(--ffe-spacing-xs)); - left: 0; - z-index: 9999; - width: fit-content; + !* position: absolute; + transform: translateY(var(--ffe-spacing-xs));*! + !* left: 0;*! + !* z-index: 9999;*! &--above { top: inherit; @@ -25,12 +61,12 @@ text-align: center; padding: var(--ffe-spacing-sm) var(--ffe-spacing-2xs) var(--ffe-spacing-2xs); - width: 100%; + !* width: 100%;*! } &__header-inner-wrapper { display: flex; - justify-content: center; + justify-content: space-between; align-items: center; } @@ -63,6 +99,10 @@ } } + &__month-label { + display: flex; + } + &__icon-prev.ffe-icons, &__icon-next.ffe-icons { color: var(--ffe-v-datepicker-icon-color); @@ -88,7 +128,7 @@ } &__grid { - width: 100%; + !* width: 100%;*! border-spacing: 0.5em 0; &:focus { @@ -103,6 +143,7 @@ border-bottom: 1px solid var(--ffe-v-datepicker-weekday-border-color); color: var(--ffe-v-datepicker-weekday-color); + white-space: nowrap; } &__day { @@ -186,3 +227,4 @@ } } } +*/ diff --git a/packages/ffe-datepicker/less/dateinput.less b/packages/ffe-datepicker/less/dateinput.less index ff898d198e..aa0a9b605a 100644 --- a/packages/ffe-datepicker/less/dateinput.less +++ b/packages/ffe-datepicker/less/dateinput.less @@ -1,54 +1,46 @@ .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); - } + &[aria-invalid='true'] { + border-color: var(--ffe-g-error-color); + border-style: solid; + } + + &.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; } } } .ffe-datepicker { - display: inline-block; position: relative; - + display: grid; + grid-template-columns: 1fr auto; &--full-width { display: block; } - &--wrapper { - display: grid; - grid-template-columns: 1fr auto; - } - &__button { background-color: transparent; border: 2px solid transparent; @@ -60,6 +52,7 @@ transition: all var(--ffe-transition-duration) var(--ffe-ease); width: 56px; cursor: pointer; + z-index: 1; &:focus, &:active { @@ -84,4 +77,19 @@ } } } + + &__dialog::backdrop { + background-color: transparent; + } + + &__dialog { + --left: 0; + --top: 0; + padding: 0; + margin: 0; + left: var(--left); + top: var(--top); + border: none; + width: fit-content; + } }