diff --git a/.changeset/clever-candles-train.md b/.changeset/clever-candles-train.md new file mode 100644 index 0000000000..1a7f9db7e5 --- /dev/null +++ b/.changeset/clever-candles-train.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": patch +--- + +:recycle: Refactor event-handling i datepicker-hooks diff --git a/@navikt/core/react/src/date/DateInput.tsx b/@navikt/core/react/src/date/DateInput.tsx index d854a8170c..87b7534c66 100644 --- a/@navikt/core/react/src/date/DateInput.tsx +++ b/@navikt/core/react/src/date/DateInput.tsx @@ -3,7 +3,7 @@ import cl from "clsx"; import React, { forwardRef, InputHTMLAttributes } from "react"; import { BodyShort, Button, ErrorMessage, Label, omit } from ".."; import { FormFieldProps, useFormField } from "../form/useFormField"; -import { useDateInputContext } from "./hooks"; +import { useDateInputContext } from "./context"; export interface DateInputProps extends FormFieldProps, diff --git a/@navikt/core/react/src/date/context/index.ts b/@navikt/core/react/src/date/context/index.ts new file mode 100644 index 0000000000..e28f19c83f --- /dev/null +++ b/@navikt/core/react/src/date/context/index.ts @@ -0,0 +1,5 @@ +export { useDateInputContext, DateContext } from "./useDateInputContext"; +export { + useSharedMonthContext, + SharedMonthProvider, +} from "./useSharedMonthContext"; diff --git a/@navikt/core/react/src/date/hooks/useDateInputContext.tsx b/@navikt/core/react/src/date/context/useDateInputContext.tsx similarity index 100% rename from @navikt/core/react/src/date/hooks/useDateInputContext.tsx rename to @navikt/core/react/src/date/context/useDateInputContext.tsx diff --git a/@navikt/core/react/src/date/hooks/useSharedMonthContext.tsx b/@navikt/core/react/src/date/context/useSharedMonthContext.tsx similarity index 100% rename from @navikt/core/react/src/date/hooks/useSharedMonthContext.tsx rename to @navikt/core/react/src/date/context/useSharedMonthContext.tsx diff --git a/@navikt/core/react/src/date/datepicker/DatePicker.tsx b/@navikt/core/react/src/date/datepicker/DatePicker.tsx index 92b78d8b6d..ecb7838006 100644 --- a/@navikt/core/react/src/date/datepicker/DatePicker.tsx +++ b/@navikt/core/react/src/date/datepicker/DatePicker.tsx @@ -13,14 +13,14 @@ import { } from "react-day-picker"; import { omit, Popover, useId } from "../.."; import { DateInputType, DatePickerInput } from "../DateInput"; -import { DateContext } from "../hooks"; +import { DateContext } from "../context"; import { getLocaleFromString, labels } from "../utils"; import { Caption, DropdownCaption } from "./caption"; import DatePickerStandalone, { DatePickerStandaloneType, } from "./DatePickerStandalone"; import { DayButton } from "./DayButton"; -import { Head } from "./Head"; +import { TableHead } from "./TableHead"; export type ConditionalModeProps = | { @@ -221,7 +221,7 @@ export const DatePicker = forwardRef( components={{ Caption: dropdownCaption ? DropdownCaption : Caption, Day: DayButton, - Head: Head, + Head: TableHead, }} className={cl("navds-date", className)} classNames={{ diff --git a/@navikt/core/react/src/date/datepicker/DatePickerStandalone.tsx b/@navikt/core/react/src/date/datepicker/DatePickerStandalone.tsx index 8b2e407451..d7b5af9de3 100644 --- a/@navikt/core/react/src/date/datepicker/DatePickerStandalone.tsx +++ b/@navikt/core/react/src/date/datepicker/DatePickerStandalone.tsx @@ -13,7 +13,7 @@ import { omit } from "../.."; import { getLocaleFromString, labels } from "../utils"; import { Caption, DropdownCaption } from "./caption"; import { ConditionalModeProps, DatePickerDefaultProps } from "./DatePicker"; -import { Head } from "./Head"; +import { TableHead } from "./TableHead"; interface DatePickerStandaloneDefaultProps extends Omit< @@ -100,7 +100,7 @@ export const DatePickerStandalone: DatePickerStandaloneType = forwardRef< selected={selected ?? selectedDates} components={{ Caption: dropdownCaption ? DropdownCaption : Caption, - Head: Head, + Head: TableHead, }} className="navds-date" classNames={{ vhidden: "navds-sr-only" }} diff --git a/@navikt/core/react/src/date/datepicker/Head.tsx b/@navikt/core/react/src/date/datepicker/TableHead.tsx similarity index 89% rename from @navikt/core/react/src/date/datepicker/Head.tsx rename to @navikt/core/react/src/date/datepicker/TableHead.tsx index 44acf7db66..f59690647f 100644 --- a/@navikt/core/react/src/date/datepicker/Head.tsx +++ b/@navikt/core/react/src/date/datepicker/TableHead.tsx @@ -2,7 +2,7 @@ import React from "react"; import { HeadRow, useDayPicker } from "react-day-picker"; /** Render the table head. */ -export function Head(): JSX.Element { +export function TableHead(): JSX.Element { const { classNames, styles, components } = useDayPicker(); const HeadRowComponent = components?.HeadRow ?? HeadRow; return ( diff --git a/@navikt/core/react/src/date/datepicker/datepicker.stories.tsx b/@navikt/core/react/src/date/datepicker/datepicker.stories.tsx index 0ae2395e19..0f7bffc0e9 100644 --- a/@navikt/core/react/src/date/datepicker/datepicker.stories.tsx +++ b/@navikt/core/react/src/date/datepicker/datepicker.stories.tsx @@ -12,27 +12,6 @@ const disabledDays = [ export default { title: "ds-react/Datepicker", component: DatePicker, - argTypes: { - size: { - control: { - type: "radio", - options: ["medium", "small"], - }, - }, - locale: { - control: { - type: "radio", - options: ["nb", "nn", "en"], - }, - }, - mode: { - defaultValue: "single", - control: { - type: "radio", - options: ["single", "multiple", "range"], - }, - }, - }, }; export const Default = { @@ -123,6 +102,27 @@ export const Default = { inputfield: true, standalone: false, openOnFocus: true, + mode: "single", + }, + argTypes: { + size: { + control: { + type: "radio", + options: ["medium", "small"], + }, + }, + locale: { + control: { + type: "radio", + options: ["nb", "nn", "en"], + }, + }, + mode: { + control: { + type: "radio", + options: ["single", "multiple", "range"], + }, + }, }, }; diff --git a/@navikt/core/react/src/date/hooks/index.ts b/@navikt/core/react/src/date/hooks/index.ts index f9ba9a0d6d..4870fa53ac 100644 --- a/@navikt/core/react/src/date/hooks/index.ts +++ b/@navikt/core/react/src/date/hooks/index.ts @@ -4,8 +4,3 @@ export { useRangeDatepicker as UNSAFE_useRangeDatepicker } from "./useRangeDatep export type { RangeValidationT } from "./useRangeDatepicker"; export { useMonthpicker as UNSAFE_useMonthpicker } from "./useMonthPicker"; export type { MonthValidationT } from "./useMonthPicker"; -export { useDateInputContext, DateContext } from "./useDateInputContext"; -export { - useSharedMonthContext, - SharedMonthProvider, -} from "./useSharedMonthContext"; diff --git a/@navikt/core/react/src/date/hooks/useDatepicker.tsx b/@navikt/core/react/src/date/hooks/useDatepicker.tsx index 2382545c1b..ba4320bb65 100644 --- a/@navikt/core/react/src/date/hooks/useDatepicker.tsx +++ b/@navikt/core/react/src/date/hooks/useDatepicker.tsx @@ -1,6 +1,6 @@ import differenceInCalendarDays from "date-fns/differenceInCalendarDays"; import isWeekend from "date-fns/isWeekend"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import { DayClickEventHandler, isMatch } from "react-day-picker"; import { DateInputProps } from "../DateInput"; import { DatePickerProps } from "../datepicker/DatePicker"; @@ -10,6 +10,8 @@ import { isValidDate, parseDate, } from "../utils"; +import { useEscape } from "./useEscape"; +import { useOutsideClickHandler } from "./useOutsideClickHandler"; export interface UseDatepickerOptions extends Pick< @@ -134,7 +136,7 @@ export const useDatepicker = ( const locale = getLocaleFromString(_locale); const inputRef = useRef(null); - const daypickerRef = useRef(null); + const [daypickerRef, setDaypickerRef] = useState(); const [defaultSelected, setDefaultSelected] = useState(_defaultSelected); @@ -148,45 +150,21 @@ export const useDatepicker = ( : ""; const [inputValue, setInputValue] = useState(defaultInputValue); + useOutsideClickHandler(open, setOpen, [ + daypickerRef, + inputRef.current, + inputRef.current?.nextSibling, + ]); + + useEscape(open, setOpen, inputRef); + const updateDate = (date?: Date) => { onDateChange?.(date); setSelectedDay(date); }; - const updateValidation = (val: Partial = {}) => { - const msg = getValidationMessage(val); - onValidate?.(msg); - }; - - const handleFocusIn = useCallback( - (e) => { - /* Workaround for shadow-dom users (open) */ - const composed = e.composedPath?.()?.[0]; - if (!e?.target || !e?.target?.nodeType || !composed) { - return; - } - - ![ - daypickerRef.current, - inputRef.current, - inputRef.current?.nextSibling, - ].some( - (element) => element?.contains(e.target) || element?.contains(composed) - ) && - open && - setOpen(false); - }, - [open] - ); - - useEffect(() => { - window.addEventListener("focusin", handleFocusIn); - window.addEventListener("pointerdown", handleFocusIn); - return () => { - window?.removeEventListener?.("focusin", handleFocusIn); - window?.removeEventListener?.("pointerdown", handleFocusIn); - }; - }, [handleFocusIn]); + const updateValidation = (val: Partial = {}) => + onValidate?.(getValidationMessage(val)); const reset = () => { updateDate(defaultSelected); @@ -300,24 +278,6 @@ export const useDatepicker = ( setMonth(defaultMonth ?? day); }; - const handleClose = useCallback(() => { - setOpen(false); - inputRef.current && inputRef.current.focus(); - }, []); - - const escape = useCallback( - (e) => open && e.key === "Escape" && handleClose(), - [handleClose, open] - ); - - useEffect(() => { - window.addEventListener("keydown", escape, false); - - return () => { - window.removeEventListener("keydown", escape, false); - }; - }, [escape]); - const datepickerProps = { month, onMonthChange: (month) => setMonth(month), @@ -331,7 +291,7 @@ export const useDatepicker = ( onOpenToggle: () => setOpen((x) => !x), disabled, disableWeekends, - ref: daypickerRef, + ref: setDaypickerRef, }; const inputProps = { diff --git a/@navikt/core/react/src/date/hooks/useEscape.tsx b/@navikt/core/react/src/date/hooks/useEscape.tsx new file mode 100644 index 0000000000..97365423bc --- /dev/null +++ b/@navikt/core/react/src/date/hooks/useEscape.tsx @@ -0,0 +1,25 @@ +import React, { useCallback, useEffect } from "react"; + +export const useEscape = ( + open: boolean, + setOpen: React.Dispatch>, + focusRef: any +) => { + const handleClose = useCallback(() => { + setOpen(false); + focusRef?.current && focusRef.current.focus(); + }, [focusRef, setOpen]); + + const escape = useCallback( + (e) => open && e.key === "Escape" && handleClose(), + [handleClose, open] + ); + + useEffect(() => { + window.addEventListener("keydown", escape, false); + + return () => { + window.removeEventListener("keydown", escape, false); + }; + }, [escape]); +}; diff --git a/@navikt/core/react/src/date/hooks/useMonthPicker.tsx b/@navikt/core/react/src/date/hooks/useMonthPicker.tsx index afb70782d7..a25866aac7 100644 --- a/@navikt/core/react/src/date/hooks/useMonthPicker.tsx +++ b/@navikt/core/react/src/date/hooks/useMonthPicker.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import { DateInputProps } from "../DateInput"; import { MonthPickerProps } from "../monthpicker/MonthPicker"; import { @@ -8,6 +8,8 @@ import { isValidDate, parseDate, } from "../utils"; +import { useEscape } from "./useEscape"; +import { useOutsideClickHandler } from "./useOutsideClickHandler"; export interface UseMonthPickerOptions extends Pick< @@ -118,7 +120,7 @@ export const useMonthpicker = ( const locale = getLocaleFromString(_locale); const inputRef = useRef(null); - const monthpickerRef = useRef(null); + const [monthpickerRef, setMonthpickerRef] = useState(); // Initialize states const [year, setYear] = useState(defaultSelected ?? defaultYear ?? today); @@ -131,44 +133,21 @@ export const useMonthpicker = ( const [inputValue, setInputValue] = useState(defaultInputValue); + useOutsideClickHandler(open, setOpen, [ + monthpickerRef, + inputRef.current, + inputRef.current?.nextSibling, + ]); + + useEscape(open, setOpen, inputRef); + const updateMonth = (date?: Date) => { onMonthChange?.(date); setSelectedMonth(date); }; - const updateValidation = (val: Partial = {}) => { - const msg = getValidationMessage(val); - onValidate?.(msg); - }; - - const handleFocusIn = useCallback( - (e) => { - /* Workaround for shadow-dom users (open) */ - const composed = e.composedPath?.()?.[0]; - if (!e?.target || !e?.target?.nodeType || !composed) { - return; - } - ![ - monthpickerRef.current, - inputRef.current, - inputRef.current?.nextSibling, - ].some( - (element) => element?.contains(e.target) || element?.contains(composed) - ) && - open && - setOpen(false); - }, - [open] - ); - - useEffect(() => { - window.addEventListener("focusin", handleFocusIn); - window.addEventListener("pointerdown", handleFocusIn); - return () => { - window?.removeEventListener?.("focusin", handleFocusIn); - window?.removeEventListener?.("pointerdown", handleFocusIn); - }; - }, [handleFocusIn]); + const updateValidation = (val: Partial = {}) => + onValidate?.(getValidationMessage(val)); const reset = () => { updateMonth(defaultSelected); @@ -294,24 +273,6 @@ export const useMonthpicker = ( setYear(month); }; - const handleClose = useCallback(() => { - setOpen(false); - inputRef.current && inputRef.current.focus(); - }, []); - - const escape = useCallback( - (e) => open && e.key === "Escape" && handleClose(), - [handleClose, open] - ); - - useEffect(() => { - window.addEventListener("keydown", escape, false); - - return () => { - window.removeEventListener("keydown", escape, false); - }; - }, [escape]); - const monthpickerProps = { year, onYearChange: (y?: Date) => setYear(y ?? today), @@ -323,7 +284,7 @@ export const useMonthpicker = ( open, onOpenToggle: () => setOpen((x) => !x), disabled, - ref: monthpickerRef, + ref: setMonthpickerRef, }; const inputProps = { diff --git a/@navikt/core/react/src/date/hooks/useOutsideClickHandler.tsx b/@navikt/core/react/src/date/hooks/useOutsideClickHandler.tsx new file mode 100644 index 0000000000..3ab218e0ce --- /dev/null +++ b/@navikt/core/react/src/date/hooks/useOutsideClickHandler.tsx @@ -0,0 +1,34 @@ +import React, { useCallback, useEffect } from "react"; + +export const useOutsideClickHandler = ( + open: boolean, + setOpen: React.Dispatch>, + refs: Array +) => { + const handleFocusIn = useCallback( + (e) => { + const composed = e.composedPath?.()?.[0]; + if (!e?.target || !e?.target?.nodeType || !composed) { + return; + } + if ( + !refs.some( + (element) => + element?.contains(e.target) || element?.contains(composed) + ) + ) { + open && setOpen(false); + } + }, + [open, refs, setOpen] + ); + + useEffect(() => { + window.addEventListener("focusin", handleFocusIn); + window.addEventListener("pointerdown", handleFocusIn); + return () => { + window?.removeEventListener?.("focusin", handleFocusIn); + window?.removeEventListener?.("pointerdown", handleFocusIn); + }; + }, [handleFocusIn]); +}; diff --git a/@navikt/core/react/src/date/hooks/useRangeDatepicker.tsx b/@navikt/core/react/src/date/hooks/useRangeDatepicker.tsx index a0faed36f0..3f91af1253 100644 --- a/@navikt/core/react/src/date/hooks/useRangeDatepicker.tsx +++ b/@navikt/core/react/src/date/hooks/useRangeDatepicker.tsx @@ -1,7 +1,7 @@ import differenceInCalendarDays from "date-fns/differenceInCalendarDays"; import isBefore from "date-fns/isBefore"; import isWeekend from "date-fns/isWeekend"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import { DateRange, isMatch } from "react-day-picker"; import { DateInputProps } from "../DateInput"; import { DatePickerProps } from "../datepicker/DatePicker"; @@ -12,6 +12,8 @@ import { parseDate, } from "../utils"; import { DateValidationT, UseDatepickerOptions } from "./useDatepicker"; +import { useEscape } from "./useEscape"; +import { useOutsideClickHandler } from "./useOutsideClickHandler"; export type RangeValidationT = { from: DateValidationT; @@ -211,7 +213,7 @@ export const useRangeDatepicker = ( const inputRefTo = useRef(null); const inputRefFrom = useRef(null); - const datePickerRef = useRef(null); + const [daypickerRef, setDaypickerRef] = useState(); const [defaultSelected, setDefaultSelected] = useState(_defaultSelected); @@ -241,6 +243,20 @@ export const useRangeDatepicker = ( const [open, setOpen] = useState(false); + useOutsideClickHandler(open, setOpen, [ + daypickerRef, + inputRefTo.current, + inputRefFrom.current, + inputRefTo.current?.nextSibling, + inputRefFrom.current?.nextSibling, + ]); + + useEscape( + open, + setOpen, + selectedRange?.from && !selectedRange?.to ? inputRefTo : inputRefFrom + ); + const updateRange = (range?: DateRange) => { onRangeChange?.(range); setSelectedRange(range); @@ -255,37 +271,6 @@ export const useRangeDatepicker = ( onValidate?.(msg); }; - const handleFocusIn = useCallback( - (e) => { - /* Workaround for shadow-dom users (open) */ - const composed = e.composedPath?.()?.[0]; - if (!e?.target || !e?.target?.nodeType || !composed) { - return; - } - ![ - datePickerRef.current, - inputRefTo.current, - inputRefFrom.current, - inputRefTo.current?.nextSibling, - inputRefFrom.current?.nextSibling, - ].some( - (element) => element?.contains(e.target) || element?.contains(composed) - ) && - open && - setOpen(false); - }, - [open] - ); - - useEffect(() => { - window.addEventListener("focusin", handleFocusIn); - window.addEventListener("pointerdown", handleFocusIn); - return () => { - window?.removeEventListener?.("focusin", handleFocusIn); - window?.removeEventListener?.("pointerdown", handleFocusIn); - }; - }, [handleFocusIn]); - const reset = () => { updateRange(defaultSelected ?? { from: undefined, to: undefined }); setMonth(defaultSelected ? defaultSelected?.from : defaultMonth ?? today); @@ -527,28 +512,6 @@ export const useRangeDatepicker = ( : toChange(e.target.value, day, isBefore, isAfter); }; - const handleClose = useCallback(() => { - setOpen(false); - if (selectedRange?.from && !selectedRange?.to) { - inputRefTo?.current?.focus(); - } else { - inputRefFrom?.current?.focus(); - } - }, [selectedRange]); - - const escape = useCallback( - (e) => open && e.key === "Escape" && handleClose(), - [handleClose, open] - ); - - useEffect(() => { - window.addEventListener("keydown", escape, false); - - return () => { - window.removeEventListener("keydown", escape, false); - }; - }, [escape]); - const datepickerProps = { month: month, onMonthChange: (month) => setMonth(month), @@ -563,7 +526,7 @@ export const useRangeDatepicker = ( onOpenToggle: () => setOpen((x) => !x), disabled, disableWeekends, - ref: datePickerRef, + ref: setDaypickerRef, }; const fromInputProps = { diff --git a/@navikt/core/react/src/date/monthpicker/MonthButton.tsx b/@navikt/core/react/src/date/monthpicker/MonthButton.tsx index 9dc986b1c8..b7ddf8bbb5 100644 --- a/@navikt/core/react/src/date/monthpicker/MonthButton.tsx +++ b/@navikt/core/react/src/date/monthpicker/MonthButton.tsx @@ -6,7 +6,7 @@ import isSameMonth from "date-fns/isSameMonth"; import setYear from "date-fns/setYear"; import React, { useEffect, useRef } from "react"; import { useDayPicker } from "react-day-picker"; -import { useSharedMonthContext } from "../hooks"; +import { useSharedMonthContext } from "../context"; import { dateIsInCurrentMonth, isMatch, nextEnabled } from "../utils"; interface MonthType { diff --git a/@navikt/core/react/src/date/monthpicker/MonthCaption.tsx b/@navikt/core/react/src/date/monthpicker/MonthCaption.tsx index 1eed035ba8..e4319b5868 100644 --- a/@navikt/core/react/src/date/monthpicker/MonthCaption.tsx +++ b/@navikt/core/react/src/date/monthpicker/MonthCaption.tsx @@ -6,7 +6,7 @@ import startOfYear from "date-fns/startOfYear"; import React from "react"; import { useDayPicker } from "react-day-picker"; import { Button, Select } from "../.."; -import { useSharedMonthContext } from "../hooks"; +import { useSharedMonthContext } from "../context"; import { hasNextYear, labelNextYear, labelPrevYear } from "../utils"; export const MonthCaption = () => { diff --git a/@navikt/core/react/src/date/monthpicker/MonthPicker.tsx b/@navikt/core/react/src/date/monthpicker/MonthPicker.tsx index 6110074383..1366620d75 100644 --- a/@navikt/core/react/src/date/monthpicker/MonthPicker.tsx +++ b/@navikt/core/react/src/date/monthpicker/MonthPicker.tsx @@ -3,7 +3,7 @@ import React, { forwardRef, useRef, useState } from "react"; import { RootProvider } from "react-day-picker"; import { Popover, useId } from "../.."; import { DateInputType, MonthPickerInput } from "../DateInput"; -import { DateContext, SharedMonthProvider } from "../hooks"; +import { DateContext, SharedMonthProvider } from "../context"; import { getLocaleFromString, Matcher } from "../utils"; import MonthCaption from "./MonthCaption"; import MonthPickerStandalone, { diff --git a/@navikt/core/react/src/date/monthpicker/MonthPickerStandalone.tsx b/@navikt/core/react/src/date/monthpicker/MonthPickerStandalone.tsx index 49eb196e97..ad3788036f 100644 --- a/@navikt/core/react/src/date/monthpicker/MonthPickerStandalone.tsx +++ b/@navikt/core/react/src/date/monthpicker/MonthPickerStandalone.tsx @@ -1,7 +1,7 @@ import cl from "clsx"; import React, { forwardRef, useState } from "react"; import { RootProvider } from "react-day-picker"; -import { SharedMonthProvider } from "../hooks"; +import { SharedMonthProvider } from "../context"; import { getLocaleFromString } from "../utils"; import MonthCaption from "./MonthCaption"; import { MonthPickerProps } from "./MonthPicker"; diff --git a/@navikt/core/react/src/date/monthpicker/MonthSelector.tsx b/@navikt/core/react/src/date/monthpicker/MonthSelector.tsx index c104ab1b4f..e464cd4c27 100644 --- a/@navikt/core/react/src/date/monthpicker/MonthSelector.tsx +++ b/@navikt/core/react/src/date/monthpicker/MonthSelector.tsx @@ -4,7 +4,7 @@ import setYear from "date-fns/setYear"; import startOfMonth from "date-fns/startOfMonth"; import React, { useState } from "react"; import { BodyShort } from "../.."; -import { useSharedMonthContext } from "../hooks"; +import { useSharedMonthContext } from "../context"; import { isMatch } from "../utils"; import MonthButton from "./MonthButton";