diff --git a/package-lock.json b/package-lock.json index 49748079..b57bd6f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "storybook": "^8.0.4", "ts-jest": "^29.1.2", "typescript": "^5.4.3", + "usehooks-ts": "^3.0.2", "vite": "^5.2.6", "vite-plugin-classname": "^0.0.3", "vite-plugin-dts": "^3.7.3", @@ -23056,6 +23057,21 @@ "requires-port": "^1.0.0" } }, + "node_modules/usehooks-ts": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.0.2.tgz", + "integrity": "sha512-qJScCj8YOxa8RV3Iz2T+2IsydLG0EID5FouTGE7aNFEpFlCXmRrnJiPCESDArKr1FLTaUQSfDQ43UDn7yMLExw==", + "dev": true, + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json index 64e78044..117bd68c 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "storybook": "^8.0.4", "ts-jest": "^29.1.2", "typescript": "^5.4.3", + "usehooks-ts": "^3.0.2", "vite": "^5.2.6", "vite-plugin-classname": "^0.0.3", "vite-plugin-dts": "^3.7.3", @@ -117,6 +118,17 @@ "moduleNameMapper": { "\\.(css|less|scss|sass)$": "identity-obj-proxy", "\\.(gif|ttf|eot|svg|png)$": "/test/__mocks__/fileMock.js" - } + }, + "collectCoverageFrom": [ + "./src/components/*/*.{ts,tsx}", + "./src/hooks/*.{ts,tsx}", + "!./src/hooks/index.ts", + "!./node_modules/**", + "!./dist/**", + "!./stories/**", + "!/vite.config.ts", + "!./.storybook/**", + "!./coverage/**" + ] } } diff --git a/src/components/Button/Button.scss b/src/components/Button/Button.scss index f769f380..18639e92 100644 --- a/src/components/Button/Button.scss +++ b/src/components/Button/Button.scss @@ -24,6 +24,16 @@ $black-outline-hover: rgba(0, 0, 0, 0.08); cursor: not-allowed; } + &__hover--disabled { + pointer-events: none; + cursor: default; + + &:hover { + background-color: inherit; + color: inherit; + } + } + &__size { &--sm { height: 2.4rem; @@ -196,4 +206,5 @@ $black-outline-hover: rgba(0, 0, 0, 0.08); } } } + } diff --git a/src/components/Button/__test__/Button.spec.tsx b/src/components/Button/__test__/Button.spec.tsx index 87504bd2..df8186d7 100644 --- a/src/components/Button/__test__/Button.spec.tsx +++ b/src/components/Button/__test__/Button.spec.tsx @@ -1,92 +1,109 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { Button } from '..'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Button } from ".."; -describe('Button component', () => { - it('renders without crashing', () => { +describe("Button component", () => { + it("renders without crashing", () => { render(); - expect(screen.getByRole('button', { name: /test button/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /test button/i })).toBeInTheDocument(); }); - it('applies the "contained" variant classes when variant is "contained"', () => { + it("applies the contained variant classes when variant is contained", () => { const { container } = render(); - expect(container.firstChild).toHaveClass('deriv-button__variant--contained'); + expect(container.firstChild).toHaveClass("deriv-button__variant--contained"); }); - it('applies the "ghost" variant classes when variant is "ghost"', () => { + it("applies the ghost variant classes when variant is ghost", () => { const { container } = render(); - expect(container.firstChild).toHaveClass('deriv-button__variant--ghost'); + expect(container.firstChild).toHaveClass("deriv-button__variant--ghost"); }); - it('applies the "outlined" variant classes when variant is "outlined"', () => { + it("applies the outlined variant classes when variant is outlined", () => { const { container } = render(); - expect(container.firstChild).toHaveClass('deriv-button__variant--outlined'); + expect(container.firstChild).toHaveClass("deriv-button__variant--outlined"); }); - it('applies the correct color class based on the "color" prop', () => { + it("applies the correct color class based on the color prop", () => { const { container } = render(); - expect(container.firstChild).toHaveClass('deriv-button__color--primary'); + expect(container.firstChild).toHaveClass("deriv-button__color--primary"); }); - it('applies full width class when "isFullWidth" prop is true', () => { + it("applies full width class when isFullWidth prop is true", () => { const { container } = render(); - expect(container.firstChild).toHaveClass('deriv-button__full-width'); + expect(container.firstChild).toHaveClass("deriv-button__full-width"); }); - it('does not apply full width class when "isFullWidth" prop is false', () => { + it("does not apply full width class when isFullWidth prop is false", () => { const { container } = render(); - expect(container.firstChild).not.toHaveClass('deriv-button__full-width'); + expect(container.firstChild).not.toHaveClass("deriv-button__full-width"); }); - it('shows loader when "isLoading" prop is true', () => { + it("shows loader when isLoading prop is true", () => { render(); - expect(screen.getByTestId('dt_derivs-loader')).toBeInTheDocument(); + expect(screen.getByTestId("dt_derivs-loader")).toBeInTheDocument(); }); - it('does not show loader when "isLoading" prop is false', () => { + it("does not show loader when isLoading prop is false", () => { render(); - expect(screen.queryByTestId('loader')).toBeNull(); + expect(screen.queryByTestId("loader")).toBeNull(); }); - it('disables the button when "disabled" prop is true', () => { + it("disables the button when disabled prop is true", () => { render(); - expect(screen.getByRole('button', { name: /test button/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /test button/i })).toBeDisabled(); }); - it('disables the button when "isLoading" prop is true', () => { + it("disables the button when isLoading prop is true", () => { render(); - expect(screen.getByRole('button', { name: /test button/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /test button/i })).toBeDisabled(); }); - it('applies the correct size class based on the "size" prop', () => { + it("applies the correct size class based on the size prop", () => { const { container } = render(); - expect(container.firstChild).toHaveClass('deriv-button__size--lg'); + expect(container.firstChild).toHaveClass("deriv-button__size--lg"); }); - it('applies the correct rounded class based on the "rounded" prop', () => { + it("applies the correct rounded class based on the rounded prop", () => { const { container } = render(); - expect(container.firstChild).toHaveClass('deriv-button__rounded--md'); + expect(container.firstChild).toHaveClass("deriv-button__rounded--md"); }); - it('shows the icon when provided and not loading', () => { + it("shows the icon when provided and not loading", () => { const Icon = () => Icon; render(); - expect(screen.getByText('Icon')).toBeInTheDocument(); + expect(screen.getByText("Icon")).toBeInTheDocument(); }); - it('does not show the icon when "isLoading" prop is true', () => { + it("does not show the icon when isLoading prop is true", () => { const Icon = () => Icon; render(); - expect(screen.queryByText('Icon')).toBeNull(); + expect(screen.queryByText("Icon")).toBeNull(); }); - it('renders children text when not loading', () => { + it("renders children text when not loading", () => { render(); - expect(screen.getByRole('button', { name: /test button/i })).toHaveTextContent('Test Button'); + expect(screen.getByRole("button", { name: /test button/i })).toHaveTextContent("Test Button"); }); - it('does not render children text when "isLoading" prop is true', () => { + it("does not render children text when isLoading prop is true", () => { render(); - expect(screen.queryByText('Test Button')).toBeNull(); + expect(screen.queryByText("Test Button")).toBeNull(); }); + + it("hover styles are applied when hideHoverStyles is true", () => { + render( + + ); + const button = screen.getByRole("button"); + expect(button).toHaveClass("deriv-button__hover--disabled"); + }); + + it("hover styles are not applied when hideHoverStyles is false", () => { + render( + + ); + const button = screen.getByRole("button"); + expect(button).not.toHaveClass("deriv-button__hover--disabled"); + }); + }); diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 89e3a77f..faeaff75 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -15,6 +15,7 @@ interface ButtonProps extends ComponentProps<"button"> { isLoading?: boolean; rounded?: Extract; size?: Extract; + hideHoverStyles?:boolean; textSize?: ComponentProps["size"]; variant?: TVariant; } @@ -65,6 +66,7 @@ export const Button = ({ isLoading = false, rounded = "sm", size = "md", + hideHoverStyles=false, textSize, variant = "contained", ...rest @@ -80,6 +82,7 @@ export const Button = ({ ButtonRounded[rounded], { "deriv-button__full-width": isFullWidth, + "deriv-button__hover--disabled": hideHoverStyles, }, className, )} diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index 78f58ced..b58cfaff 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -1,6 +1,6 @@ import React, { ReactNode, useRef, useState } from "react"; import clsx from "clsx"; -import { useOnClickOutside } from "../../hooks/useOnClickOutside"; +import { useOnClickOutside } from "usehooks-ts"; import "./Tooltip.scss"; type TooltipPositionType = "top" | "bottom" | "left" | "right"; @@ -45,7 +45,7 @@ export const Tooltip = ({ } }; - const handleClickOutside = (event: globalThis.MouseEvent) => { + const handleClickOutside = (event: MouseEvent | TouchEvent | FocusEvent) => { if ( triggerAction === "click" && active && diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 68559283..d1a6a3e0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1 @@ export {useDevice} from './useDevice' -export {useOnClickOutside} from './useOnClickOutside' diff --git a/src/hooks/useDevice.ts b/src/hooks/useDevice.ts index ab008407..92cb487d 100644 --- a/src/hooks/useDevice.ts +++ b/src/hooks/useDevice.ts @@ -1,4 +1,4 @@ -import { useMediaQuery } from "./useMediaQuery"; +import { useMediaQuery } from "usehooks-ts"; /** A custom hook to check for the client device and determine the layout to be rendered */ export const useDevice = () => { diff --git a/src/hooks/useEventListener.ts b/src/hooks/useEventListener.ts deleted file mode 100644 index 4c561a68..00000000 --- a/src/hooks/useEventListener.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Code from: https://usehooks-typescript.com/react-hook/use-event-listener -import { RefObject, useEffect, useRef } from "react"; -import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; - -// MediaQueryList Event based useEventListener interface -function useEventListener( - eventName: K, - handler: (event: MediaQueryListEventMap[K]) => void, - element: RefObject, - options?: boolean | AddEventListenerOptions, -): void; - -// Window Event based useEventListener interface -function useEventListener( - eventName: K, - handler: (event: WindowEventMap[K]) => void, - element?: undefined, - options?: boolean | AddEventListenerOptions, -): void; - -// Element Event based useEventListener interface -function useEventListener< - K extends keyof HTMLElementEventMap, - T extends HTMLElement = HTMLDivElement, ->( - eventName: K, - handler: (event: HTMLElementEventMap[K]) => void, - element: RefObject, - options?: boolean | AddEventListenerOptions, -): void; - -// Document Event based useEventListener interface -function useEventListener( - eventName: K, - handler: (event: DocumentEventMap[K]) => void, - element: RefObject, - options?: boolean | AddEventListenerOptions, -): void; - -function useEventListener< - KW extends keyof WindowEventMap, - KH extends keyof HTMLElementEventMap, - KM extends keyof MediaQueryListEventMap, - T extends HTMLElement | MediaQueryList | void = void, ->( - eventName: KW | KH | KM, - handler: ( - event: - | WindowEventMap[KW] - | HTMLElementEventMap[KH] - | MediaQueryListEventMap[KM] - | Event, - ) => void, - element?: RefObject, - options?: boolean | AddEventListenerOptions, -) { - // Create a ref that stores handler - const savedHandler = useRef(handler); - - useIsomorphicLayoutEffect(() => { - savedHandler.current = handler; - }, [handler]); - - useEffect(() => { - // Define the listening target - const targetElement: T | Window = element?.current ?? window; - - if (!(targetElement && targetElement.addEventListener)) return; - - // Create event listener that calls handler function stored in ref - const listener: typeof handler = (event) => savedHandler.current(event); - - targetElement.addEventListener(eventName, listener, options); - - // Remove event listener on cleanup - return () => { - targetElement.removeEventListener(eventName, listener, options); - }; - }, [eventName, element, options]); -} - -export { useEventListener }; diff --git a/src/hooks/useIsomorphicLayoutEffect.ts b/src/hooks/useIsomorphicLayoutEffect.ts deleted file mode 100644 index 4090f6ac..00000000 --- a/src/hooks/useIsomorphicLayoutEffect.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Code from: https://usehooks-typescript.com/react-hook/use-event-listener -import { useEffect, useLayoutEffect } from "react"; - -export const useIsomorphicLayoutEffect = - typeof window !== "undefined" ? useLayoutEffect : useEffect; diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts deleted file mode 100644 index 02c3497d..00000000 --- a/src/hooks/useMediaQuery.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Code from: https://usehooks-ts.com/react-hook/use-media-query -import { useEffect, useState } from "react"; - -const getMatches = (query: string): boolean => { - // Prevents SSR issues - if (typeof window !== "undefined") { - return window.matchMedia(query).matches; - } - return false; -}; - -/** Can be used to retrieve window dimensions with this React Hook which also works onResize. (Source: https://usehooks-ts.com/react-hook/use-media-query) */ -export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(getMatches(query)); - - useEffect(() => { - function handleChange() { - setMatches(getMatches(query)); - } - - const matchMedia = window.matchMedia(query); - - // Triggered at the first client-side load and if query changes - handleChange(); - - // Use deprecated `addListener` and `removeListener` to support Safari < 14 (#135) - if (matchMedia.addListener) { - matchMedia.addListener(handleChange); - } else { - matchMedia.addEventListener("change", handleChange); - } - - return () => { - if (matchMedia.removeListener) { - matchMedia.removeListener(handleChange); - } else { - matchMedia.removeEventListener("change", handleChange); - } - }; - }, [query]); - - return matches; -} diff --git a/src/hooks/useOnClickOutside.ts b/src/hooks/useOnClickOutside.ts deleted file mode 100644 index 4fa3c42d..00000000 --- a/src/hooks/useOnClickOutside.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code from: https://usehooks-typescript.com/react-hook/use-on-click-outside -import { RefObject } from "react"; -import { useEventListener } from "./useEventListener"; - -type Handler = (event: MouseEvent) => void; - -export function useOnClickOutside( - ref: RefObject, - handler: Handler, - mouseEvent: "mousedown" | "mouseup" = "mousedown", -): void { - useEventListener(mouseEvent, (event) => { - const el = ref?.current; - - // Do nothing if clicking ref's element or descendent elements - if (!el || el.contains(event.target as Node)) { - return; - } - - handler(event); - }); -} diff --git a/src/main.ts b/src/main.ts index 6a5d303b..87bfc34e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,5 +20,5 @@ export { Text } from "./components/Text"; export { TextArea } from "./components/TextArea"; export { ToggleSwitch } from "./components/ToggleSwitch"; export { Tooltip } from "./components/Tooltip"; -export { useDevice, useOnClickOutside } from "./hooks"; +export { useDevice } from "./hooks"; export { VerticalTab, VerticalTabItems } from "./components/VerticalTab"; diff --git a/stories/Button.stories.ts b/stories/Button.stories.ts index 939c2b9c..393f657d 100644 --- a/stories/Button.stories.ts +++ b/stories/Button.stories.ts @@ -15,6 +15,7 @@ const meta = { isLoading: false, disabled: false, size: "md", + hideHoverStyles:false, isFullWidth: false, rounded: "sm", type: "button", @@ -35,6 +36,10 @@ const meta = { options: ["true", "false"], control: { type: "boolean" }, }, + hideHoverStyles: { + options: ["true", "false"], + control: { type: "boolean" }, + }, rounded: { options: ["sm", "md", "lg"], control: { type: "radio" }, @@ -85,6 +90,14 @@ export const ContainedPrimary: Story = { args: { ...meta.args }, }; +export const ContainedPrimaryWithNoHover: Story = { + name: "Contained (Primary No Hover Style)", + args: { + ...meta.args, + hideHoverStyles:true, + }, +}; + export const ContainedPrimaryLight: Story = { name: "Contained (Primary Light)", args: {