diff --git a/package-lock.json b/package-lock.json index 78fb28e2e1f89d63d41d5285d30e762c36e8f5b1..57dc9ec5ab190f3d487fe1a33a0f6a3817e2929a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49710,17 +49710,17 @@ "@sikt/sds-button": "^4.0.1", "@sikt/sds-core": "^4.2.0", "@sikt/sds-form": "^3.0.1", + "@sikt/sds-hooks": "^0.1.0", "@sikt/sds-icons": "^2.0.2", "@sikt/sds-input": "^4.0.2", "react-aria-components": "^1.5.0" }, "peerDependencies": { - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", "clsx": "^2.1.0", - "react": "^18.0.0", - "react-dom": "^18.0.0", - "usehooks-ts": "^3.1.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, "packages/input-datepicker/node_modules/@sikt/sds-core/node_modules/@sikt/sds-tokens": { diff --git a/packages/dialog/src/Dialog.test.tsx b/packages/dialog/src/Dialog.test.tsx index 170493dec31f41dc5c68c1e3abe4b386f628c3e3..42e2c44c9a2d51ee989b5147b84932e6bfebede5 100644 --- a/packages/dialog/src/Dialog.test.tsx +++ b/packages/dialog/src/Dialog.test.tsx @@ -98,13 +98,13 @@ describe("Dialog", () => { const user = userEvent.setup(); const handleClose = jest.fn(); const headingText = "Test Heading"; - const closeButtonText = "Close"; + const closeButtonText = "Close ARIA"; render( <Dialog open onClose={handleClose} heading={headingText} - closeButtonLabel={closeButtonText} + closeButtonAriaLabel={closeButtonText} footer={[<button key="primary">Click me!</button>]} dismissable > @@ -125,7 +125,9 @@ describe("Dialog", () => { expect(footerButton).toBeInTheDocument(); // Check if the close button is rendered correctly - const closeButton = screen.getByRole("button", { name: closeButtonText }); + const closeButton = screen.getByRole("button", { + name: closeButtonText, + }); expect(closeButton).toBeInTheDocument(); await user.click(closeButton); @@ -156,7 +158,6 @@ describe("Dialog", () => { rerender( <Dialog closeButtonLabel={closeButtonText} - dismissable open onClose={handleClose} heading="Test heading" @@ -244,4 +245,26 @@ describe("Dialog", () => { await user.keyboard("[Escape]"); expect(handleClose).toHaveBeenCalledTimes(0); }); + + it("closes dialog when click outside wrapper", async () => { + const user = userEvent.setup(); + const handleClose = jest.fn(); + render( + <Dialog + footer={[<button key="primary">Click me!</button>]} + open + onClose={handleClose} + heading="Test heading" + dismissable + closeButtonLabel="Close dialog" + data-testid="test" + > + <p>Dialog Content</p> + </Dialog>, + ); + + await user.click(screen.getByTestId("test")); + + expect(handleClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/dialog/src/Dialog.tsx b/packages/dialog/src/Dialog.tsx index 3f96d00c7098770cfdcaa02c0f15fb05186669b6..725ed8311b945984ceeff20559a7f00d4f494f05 100644 --- a/packages/dialog/src/Dialog.tsx +++ b/packages/dialog/src/Dialog.tsx @@ -1,6 +1,6 @@ import { Button } from "@sikt/sds-button"; import { Heading1, Paragraph } from "@sikt/sds-core"; -import { useWindowResize } from "@sikt/sds-hooks"; +import { useClickOutside, useKeydown, useWindowResize } from "@sikt/sds-hooks"; import { XIcon } from "@sikt/sds-icons"; import { clsx } from "clsx/lite"; import { @@ -42,12 +42,12 @@ export type DialogProps = DialogBaseProps & closeButtonAriaLabel?: never; } | { - dismissable: true; + dismissable?: true; closeButtonLabel: string; closeButtonAriaLabel?: never; } | { - dismissable: true; + dismissable?: true; closeButtonLabel?: never; closeButtonAriaLabel: string; } @@ -65,34 +65,23 @@ export const Dialog = ({ onClose, open, subheading, + ...rest }: DialogProps) => { const dialogRef = useRef<HTMLDialogElement>(null); + const wrapperRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null); const [isScrolling, setIsScrolling] = useState<boolean>(false); + const [isOpen, setIsOpen] = useState<boolean>(open); - const handleBackdropClick = (event: MouseEvent) => { - if (dismissable) { - const bounds = ( - event.target as HTMLDialogElement - ).getBoundingClientRect(); - if ( - bounds.left > event.clientX || - bounds.right < event.clientX || - bounds.top > event.clientY || - bounds.bottom < event.clientY - ) - onClose(); + const handleBackdropClick = () => { + if (dismissable && isOpen) { + onClose(); } }; - const handleEscapeKey = (event: KeyboardEvent) => { - if (event.key === "Escape") { - if (dismissable) { - onClose(); - } else { - event.preventDefault(); - event.stopPropagation(); - } + const handleEscapeKey = () => { + if (dismissable && isOpen) { + onClose(); } }; @@ -100,23 +89,15 @@ export const Dialog = ({ if (dialogRef.current) { if (open) { dialogRef.current.showModal(); - dialogRef.current.addEventListener("click", handleBackdropClick); - document.addEventListener("keydown", handleEscapeKey); - document.body.style.overflow = "hidden"; } else { dialogRef.current.close(); - dialogRef.current.removeEventListener("click", handleBackdropClick); - document.removeEventListener("keydown", handleEscapeKey); - document.body.style.overflow = "unset"; } } - return () => { - if (dialogRef.current) { - dialogRef.current.removeEventListener("click", handleBackdropClick); - } + setIsOpen(open); + document.body.style.overflow = open ? "hidden" : "unset"; - document.removeEventListener("keydown", handleEscapeKey); + return () => { document.body.style.overflow = "unset"; }; }, [open]); @@ -133,6 +114,8 @@ export const Dialog = ({ const headingId = `${id}-heading`; const contentId = `${id}-content`; + useClickOutside(wrapperRef, handleBackdropClick); + useKeydown(null, "Escape", handleEscapeKey); useWindowResize(checkScroll, { throttleTime: 200 }); useEffect(checkScroll, [children]); @@ -147,40 +130,43 @@ export const Dialog = ({ aria-describedby={contentLabel ? undefined : contentId} aria-label={contentLabel} ref={dialogRef} + {...rest} > - <header className="sds-dialog__header"> - <div - id={headingId} - data-testid="headings" - className="sds-dialog__heading" - > - <Heading1 variant="medium">{heading}</Heading1> - {subheading !== undefined && <Paragraph>{subheading}</Paragraph>} - </div> + <div ref={wrapperRef}> + <header className="sds-dialog__header"> + <div + id={headingId} + data-testid="headings" + className="sds-dialog__heading" + > + <Heading1 variant="medium">{heading}</Heading1> + {subheading !== undefined && <Paragraph>{subheading}</Paragraph>} + </div> - {dismissable && ( - <Button - variant="transparent" - icon={<XIcon />} - className="sds-dialog__close-button" - onClick={onClose} - aria-label={closeButtonLabel ? undefined : closeButtonAriaLabel} + {dismissable && ( + <Button + variant="transparent" + icon={<XIcon />} + className="sds-dialog__close-button" + onClick={onClose} + aria-label={closeButtonLabel ? undefined : closeButtonAriaLabel} + > + {closeButtonLabel} + </Button> + )} + </header> + <div className="sds-dialog__content-wrapper" ref={contentRef}> + <div + id={contentId} + data-testid="content" + className="sds-dialog__content" > - {closeButtonLabel} - </Button> - )} - </header> - <div className="sds-dialog__content-wrapper" ref={contentRef}> - <div - id={contentId} - data-testid="content" - className="sds-dialog__content" - > - {children} + {children} + </div> + {footer !== undefined && ( + <div className="sds-dialog__footer">{footer}</div> + )} </div> - {footer !== undefined && ( - <div className="sds-dialog__footer">{footer}</div> - )} </div> </dialog> ); diff --git a/packages/hooks/index.ts b/packages/hooks/index.ts index f65bcd4b7ba757834aca5d369d934ac81e886a30..08e9f763ec02ee1075e4b01999f9d2b70f18d923 100644 --- a/packages/hooks/index.ts +++ b/packages/hooks/index.ts @@ -1,3 +1,5 @@ +export { useClickOutside } from "./src/useClickOutside/useClickOutside"; +export { useKeydown } from "./src/useKeydown/useKeydown"; export { useWindowResize, type useWindowResizeOptions, diff --git a/packages/hooks/src/useClickOutside/useClickOutside.test.tsx b/packages/hooks/src/useClickOutside/useClickOutside.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ff9bce2b04f4da330c74a1f7027249e6093fe03 --- /dev/null +++ b/packages/hooks/src/useClickOutside/useClickOutside.test.tsx @@ -0,0 +1,37 @@ +import { renderHook } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { useClickOutside } from "./useClickOutside"; + +describe("useClickOutside", () => { + it("should call callback on click outside", async () => { + const user = userEvent.setup(); + const ref = { current: document.createElement("div") }; + const callback = jest.fn(); + + renderHook(() => { + useClickOutside(ref, callback); + }); + + expect(callback).not.toHaveBeenCalled(); + + await user.click(document.body); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should not call callback on click inside", async () => { + const user = userEvent.setup(); + const ref = { current: document.createElement("div") }; + const callback = jest.fn(); + + renderHook(() => { + useClickOutside(ref, callback); + }); + + expect(callback).not.toHaveBeenCalled(); + + await user.click(ref.current); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hooks/src/useClickOutside/useClickOutside.tsx b/packages/hooks/src/useClickOutside/useClickOutside.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c900690760dc631b4c38ac2a763a314da6a6836e --- /dev/null +++ b/packages/hooks/src/useClickOutside/useClickOutside.tsx @@ -0,0 +1,20 @@ +import { RefObject, useEffect } from "react"; + +export const useClickOutside = ( + ref: RefObject<HTMLElement>, + callback: (event: MouseEvent) => void, +) => { + useEffect(() => { + const listener = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(event); + } + }; + + document.addEventListener("click", listener); + + return () => { + document.removeEventListener("click", listener); + }; + }, [ref, callback]); +}; diff --git a/packages/hooks/src/useKeydown/useKeydown.test.tsx b/packages/hooks/src/useKeydown/useKeydown.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fd69edefff23a244603492e2e569415fef99d563 --- /dev/null +++ b/packages/hooks/src/useKeydown/useKeydown.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, renderHook } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { useKeydown } from "./useKeydown"; + +describe("useKeydown", () => { + it("should call callback on keydown with correct key", async () => { + const ref = { current: document.createElement("button") }; + const callback = jest.fn(); + + renderHook(() => { + useKeydown(ref, "Escape", callback); + }); + + expect(callback).not.toHaveBeenCalled(); + + fireEvent.keyDown(ref.current, { key: "Escape" }); + + expect(callback).toHaveBeenCalledWith(expect.any(KeyboardEvent)); + }); + + it("should not call callback on keydown with incorrect key", async () => { + const ref = { current: document.createElement("div") }; + const callback = jest.fn(); + + renderHook(() => { + useKeydown(ref, "Escape", callback); + }); + + expect(callback).not.toHaveBeenCalled(); + + fireEvent.keyDown(ref.current, { key: "Enter" }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("should call callback on document keydown with correct key", async () => { + const user = userEvent.setup(); + const callback = jest.fn(); + + renderHook(() => { + useKeydown(null, "Escape", callback); + }); + + expect(callback).not.toHaveBeenCalled(); + + await user.keyboard("{Escape}"); + + expect(callback).toHaveBeenCalledWith(expect.any(KeyboardEvent)); + }); +}); diff --git a/packages/hooks/src/useKeydown/useKeydown.tsx b/packages/hooks/src/useKeydown/useKeydown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2bcf1632bd49ed1f150a34b66cd6a728ba3d6f53 --- /dev/null +++ b/packages/hooks/src/useKeydown/useKeydown.tsx @@ -0,0 +1,26 @@ +import { RefObject, useEffect } from "react"; + +export const useKeydown = ( + ref: RefObject<HTMLElement> | null, + key: string, + callback: (event: KeyboardEvent) => void, +) => { + useEffect(() => { + const targetElement = ref?.current ?? document; + + const listener = (event: KeyboardEvent) => { + if (event.key === key) { + event.preventDefault(); + callback(event); + } + }; + + // @ts-expect-error: Type '(event: KeyboardEvent) => void' is not assignable to type 'EventListener'. + targetElement.addEventListener("keydown", listener); + + return () => { + // @ts-expect-error: Type '(event: KeyboardEvent) => void' is not assignable to type 'EventListener'. + targetElement.removeEventListener("keydown", listener); + }; + }, [ref, key, callback]); +}; diff --git a/packages/input-datepicker/package.json b/packages/input-datepicker/package.json index e92fc4a13fcc3362ecba22acc1aa1d55662cb4dc..2f2d6418c0af3b31c0681a65cd72b7a6f8de9a66 100644 --- a/packages/input-datepicker/package.json +++ b/packages/input-datepicker/package.json @@ -38,16 +38,16 @@ "@sikt/sds-button": "^4.0.1", "@sikt/sds-core": "^4.2.0", "@sikt/sds-form": "^3.0.1", + "@sikt/sds-hooks": "^0.1.0", "@sikt/sds-icons": "^2.0.2", "@sikt/sds-input": "^4.0.2", "react-aria-components": "^1.5.0" }, "peerDependencies": { - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", "clsx": "^2.1.0", - "react": "^18.0.0", - "react-dom": "^18.0.0", - "usehooks-ts": "^3.1.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } } diff --git a/packages/input-datepicker/src/InputDatepicker.tsx b/packages/input-datepicker/src/InputDatepicker.tsx index 9465baf01409518850a4a9349a2d17c0f6c0cd58..8b28a74c308aea9d93e0e86802864096ce1df039 100644 --- a/packages/input-datepicker/src/InputDatepicker.tsx +++ b/packages/input-datepicker/src/InputDatepicker.tsx @@ -1,6 +1,7 @@ import { I18nProvider } from "@react-aria/i18n"; import { Button, ButtonProps } from "@sikt/sds-button"; import { HelpText, Label } from "@sikt/sds-form"; +import { useClickOutside, useKeydown } from "@sikt/sds-hooks"; import { CalendarBlankIcon, CaretLeftIcon, @@ -15,7 +16,8 @@ import { useRef, useState, useContext, - MouseEvent, + MouseEvent as ReactMouseEvent, + KeyboardEvent as ReactKeyboardEvent, } from "react"; import { Calendar, @@ -33,7 +35,6 @@ import { Text, DatePickerStateContext, } from "react-aria-components"; -import { useEventListener, useOnClickOutside } from "usehooks-ts"; import "./input-datepicker.pcss"; interface InputDatepickerBaseProps @@ -67,7 +68,7 @@ export type InputDatepickerProps = InputDatepickerBaseProps & const DatepickerClearButton = (clearActionProps?: ClearActionProps) => { const state = useContext(DatePickerStateContext); - const handleClearClick = (event: MouseEvent<HTMLButtonElement>) => { + const handleClearClick = (event: ReactMouseEvent<HTMLButtonElement>) => { if (clearActionProps?.onClick) { clearActionProps.onClick(event); } @@ -75,11 +76,9 @@ const DatepickerClearButton = (clearActionProps?: ClearActionProps) => { state && state.setValue(null); }; - const handleClearKeyDown = ( - event: React.KeyboardEvent<HTMLButtonElement>, - ) => { + const handleClearKeydown = (event: ReactKeyboardEvent<HTMLButtonElement>) => { if (event.key === " " || event.key === "Enter") { - handleClearClick(event as unknown as MouseEvent<HTMLButtonElement>); + handleClearClick(event as unknown as ReactMouseEvent<HTMLButtonElement>); } }; @@ -91,7 +90,7 @@ const DatepickerClearButton = (clearActionProps?: ClearActionProps) => { iconVariant="only" className="sds-input__clear" onClick={handleClearClick} - onKeyUp={handleClearKeyDown} + onKeyDown={handleClearKeydown} icon={<XIcon />} aria-label={clearActionProps?.["aria-label"] ?? "Tøm datofelt"} type={clearActionProps?.type ?? "button"} @@ -124,22 +123,17 @@ export const InputDatepicker = forwardRef<HTMLDivElement, InputDatepickerProps>( const helpTextId = `${id}-help-text`; const [calendarOpen, setCalendarOpen] = useState(false); - const onEscapeKey = (event: KeyboardEvent) => { - if (event.key === "Escape") { - if ("current" in calendarRef && calendarRef.current) { - setCalendarOpen(false); - (inputRef.current?.firstChild as HTMLElement).focus(); - } - } + const handleEscapeKeydown = () => { + setCalendarOpen(false); + (inputRef.current?.firstChild as HTMLElement).focus(); }; - useEventListener("keyup", onEscapeKey, calendarRef); - const handleClickOutside = () => { setCalendarOpen(false); }; - useOnClickOutside(calendarRef, handleClickOutside); + useClickOutside(calendarRef, handleClickOutside); + useKeydown(calendarRef, "Escape", handleEscapeKeydown); return ( <I18nProvider locale={lang}> @@ -191,7 +185,7 @@ export const InputDatepicker = forwardRef<HTMLDivElement, InputDatepickerProps>( onClick={() => { setCalendarOpen(!calendarOpen); }} - onKeyUp={(event) => { + onKeyDown={(event) => { if (event.key === " " || event.key === "Enter") { setCalendarOpen(!calendarOpen); }