From e4fdb478babe147289b27664f2d6f0b5b64e4215 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 14 Oct 2022 16:12:26 +0100 Subject: [PATCH] feat(PPDSC-2448): export hooks (#404) * feat: export withOwnTheme * feat(PPDSC-2448): export most used hooks * fix(PPDSC-2448): update tests * fix(PPDSC-2448): resolve conflicts * fix(PPDSC-2448): update docs in storybook * fix(PPDSC-2448): update storybook and tests * fix(PPDSC-2448): design reviews * fix(PPDSC-2448): test * fix(PPDSC-2448): more design comments * fix(PPDSC-2448): resolve comments * fix(PPDSC-2448): update screen reader only * fix(PPDSC-2448): e2e test --- .storybook/preview.js | 1 + cypress/components/use-media-query.spec.js | 17 +- site/helpers/use-media-query.tsx | 25 --- site/pages/components/utils/hooks.mdx | 68 +++++- site/pages/theme/foundation/motion.tsx | 9 +- .../static/examples/hooks/use-controlled.tsx | 33 +++ .../examples/hooks/use-intersection.tsx | 18 ++ .../static/examples/hooks/use-keypress.tsx | 32 +++ .../examples/hooks/use-resize-observer.tsx | 18 ++ .../__snapshots__/index.test.ts.snap | 5 + src/index.ts | 1 - .../__tests__/screen-reader-only.stories.tsx | 27 ++- .../__tests__/use-controlled.stories.tsx | 87 ++++++++ .../__tests__/use-intersection.stories.tsx | 76 +++++++ .../hooks/__tests__/use-keypress.stories.tsx | 148 ++++++++++++ .../__tests__/use-resize-observer.stories.tsx | 74 ++++++ .../__tests__/use-breakpoint-key.stories.tsx | 61 +++++ .../use-media-query-object.stories.tsx | 69 ++++++ .../__tests__/use-media-query-object.test.tsx | 210 ++++++++++++++++++ .../__tests__/use-media-query.stories.tsx | 115 ++++++---- .../__tests__/use-media-query.test.tsx | 201 ++--------------- src/utils/hooks/use-media-query/index.tsx | 3 +- .../use-media-query-object.tsx | 62 ++++++ .../hooks/use-media-query/use-media-query.tsx | 83 ++----- src/utils/index.ts | 5 + 25 files changed, 1111 insertions(+), 337 deletions(-) delete mode 100644 site/helpers/use-media-query.tsx create mode 100644 site/public/static/examples/hooks/use-controlled.tsx create mode 100644 site/public/static/examples/hooks/use-intersection.tsx create mode 100644 site/public/static/examples/hooks/use-keypress.tsx create mode 100644 site/public/static/examples/hooks/use-resize-observer.tsx create mode 100644 src/utils/hooks/__tests__/use-controlled.stories.tsx create mode 100644 src/utils/hooks/__tests__/use-intersection.stories.tsx create mode 100644 src/utils/hooks/__tests__/use-keypress.stories.tsx create mode 100644 src/utils/hooks/__tests__/use-resize-observer.stories.tsx create mode 100644 src/utils/hooks/use-media-query/__tests__/use-breakpoint-key.stories.tsx create mode 100644 src/utils/hooks/use-media-query/__tests__/use-media-query-object.stories.tsx create mode 100644 src/utils/hooks/use-media-query/__tests__/use-media-query-object.test.tsx create mode 100644 src/utils/hooks/use-media-query/use-media-query-object.tsx diff --git a/.storybook/preview.js b/.storybook/preview.js index 31a6295383..9bf94ae3bf 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -24,6 +24,7 @@ const unlimitedScenarios = [ 'popover', 'audio-player-composable', 'text-area', + 'useIntersection', ]; const BackgroundColor = styled.div` diff --git a/cypress/components/use-media-query.spec.js b/cypress/components/use-media-query.spec.js index c32cded66c..848e9625c4 100644 --- a/cypress/components/use-media-query.spec.js +++ b/cypress/components/use-media-query.spec.js @@ -6,20 +6,27 @@ describe('useMediaQuery hooks', () => { {vp: 1200, value: 'lg'}, {vp: 1600, value: 'xl'}, ]; - beforeEach(() => { - cy.visit('?name=hooks'); - cy.wait(50); - }); + it('useMediaQuery', () => { + cy.visit('?name=usemediaquery'); + cy.viewport(320, 480); + cy.get('[data-testid="use-media-query"]').contains('sm: yes'); + cy.viewport(1600, 1600); + cy.get('[data-testid="use-media-query"]').contains('xl: yes'); + }); it('useMediaQueryObject', () => { + cy.visit('?name=usemediaqueryobject'); viewPorts.forEach(({vp, value}) => { cy.viewport(vp, 480); + cy.wait(50); cy.get('[data-testid="use-media-query-object"]').contains(value); }); }); - it('useBreakpoint', () => { + it('useBreakpointKey', () => { + cy.visit('?name=usebreakpointkey'); viewPorts.forEach(({vp, value}) => { cy.viewport(vp, 480); + cy.wait(50); cy.get('[data-testid="use-breakpoint-key"]').contains(value); }); }); diff --git a/site/helpers/use-media-query.tsx b/site/helpers/use-media-query.tsx deleted file mode 100644 index a5250de1f7..0000000000 --- a/site/helpers/use-media-query.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {useState, useEffect} from 'react'; - -export default function useMediaQuery(query: string) { - const getInitialState = () => - typeof window !== 'undefined' && window.matchMedia(query).matches; - - const [isMatched, setIsMatched] = useState(getInitialState); - - useEffect(() => { - const mediaQueryList = - typeof window !== 'undefined' && window.matchMedia(query); - const listener = (event: MediaQueryListEvent) => { - setIsMatched(event.matches); - }; - if (mediaQueryList) { - mediaQueryList.addEventListener('change', listener); - } - return () => { - if (mediaQueryList) { - mediaQueryList.removeEventListener('change', listener); - } - }; - }, [query]); - return isMatched; -} diff --git a/site/pages/components/utils/hooks.mdx b/site/pages/components/utils/hooks.mdx index ce793eb728..c6114a436c 100644 --- a/site/pages/components/utils/hooks.mdx +++ b/site/pages/components/utils/hooks.mdx @@ -1,5 +1,5 @@ import Layout from '../../../components/layout'; -import {Code} from '../../../components/code'; +import {Code, CodeFromFile} from '../../../components/code'; import {LegacyBlock} from '../../../components/legacy-block'; import Prop from '../../../components/prop'; @@ -9,8 +9,6 @@ export default Layout; ## useMediaQueryObject -### Overview - `useMediaQueryObject` hook handles scenarios in which you want to render component based on media query breakpoints. This hooks also responds to the window resizing and returns the appropriate value for the new window size. @@ -51,8 +49,6 @@ const dividerStylePreset = useMediaQueryObject(stylePresets); ## useBreakpointKey -### Overview - `useBreakpointKey` has a similar utility as `useMediaQueryObject`, it's intended usage is where you want to know the currently active breakpoint key `xs | sm | md | lg | xl`. ### Example @@ -71,3 +67,65 @@ const dividerStylePreset = useMediaQueryObject(stylePresets); // the + +## useMediaQuery + +`useMediaQuery` is a custom hook used to help detect whether a single media query matches. + +Learn more about the API and background (https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) + +### Example + + + {`import {useMediaQuery} from 'newskit'; +const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); +// return true when reduce motion is detected`} + + +## useControlled + +`useControlled` is a custom hook used to allow any component handle controlled and uncontrolled modes, and provide control over its internal state. +Most NewsKit components use the useControlled for seamlessly managing both controlled or uncontrolled state scenarios. + +With useControlled, you can pass an initial state (using defaultValue) implying the component is uncontrolled, +or you can pass a controlled value (using controlledValue) implying the component is controlled. + +### Example + + + +## useIntersection + +`useIntersection` is a custom hook that detects visibility of a component on the viewport using the IntersectionObserver API (https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) natively present in the browser. + +It is currently being used in the lazy-loading of our image component. + +It takes optionally `rootMargin` and `disabled` arguments and returns the full IntersectionObserver's entry object. + +### Example + + + +## useResizeObserver + +`useResizeObserver` is a custom hook that utilizes the resizeObserver API (https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to return an element's size. + +It takes a ref and returns the width and the height from the observed element. + +It also takes an optional callback which allows you to access the full DOMRect object if required. + +### Example + + + +## useKeypress + +`useKeypress` is a custom hook that detects when the user is pressing one single key or multiple keys. + +It takes a key or an array of keys, a call back function and some optional arguments like `enabled`, `eventType`, `target` and `preventDefault`; + +This hook is currently being used in the audio player, modal & drawer. + +### Example + + diff --git a/site/pages/theme/foundation/motion.tsx b/site/pages/theme/foundation/motion.tsx index 12753df5bd..2564117bba 100644 --- a/site/pages/theme/foundation/motion.tsx +++ b/site/pages/theme/foundation/motion.tsx @@ -1,5 +1,10 @@ import React, {useEffect} from 'react'; -import {newskitLightTheme, InlineMessage, UnorderedList} from 'newskit'; +import { + newskitLightTheme, + InlineMessage, + UnorderedList, + useMediaQuery, +} from 'newskit'; import {FoundationPageTemplate} from '../../../templates/foundation-page-template'; import { ContentSection, @@ -16,8 +21,6 @@ import {UsageKind} from '../../../components/usage-card'; import {Link} from '../../../components/link'; import {getTokenType} from '../../../utils/get-token-type'; -import useMediaQuery from '../../../helpers/use-media-query'; - const TOKENS_DESCRIPTION: {[key in string]: string} = { motionTimingLinear: 'Has the same even speed from start to end.', motionTimingEaseIn: diff --git a/site/public/static/examples/hooks/use-controlled.tsx b/site/public/static/examples/hooks/use-controlled.tsx new file mode 100644 index 0000000000..cbc971a93b --- /dev/null +++ b/site/public/static/examples/hooks/use-controlled.tsx @@ -0,0 +1,33 @@ +const Component = ({ + value: valueProp, + defaultValue, + onClick, +}: { + value?: number; + defaultValue?: number; + onClick?: () => void; +}) => { + const [value, setValue] = useControlled({ + defaultValue, + controlledValue: valueProp, + }); + + const handleOnClick = () => { + setValue(value! + 1); + + if (onClick) { + onClick(); + } + }; + + return ( + <> + + {value} + + ); +}; +export const UncontrolledComponent = () => ; +export const ControlledComponent = () => ( + setValue(value + 1)} /> +); diff --git a/site/public/static/examples/hooks/use-intersection.tsx b/site/public/static/examples/hooks/use-intersection.tsx new file mode 100644 index 0000000000..6140f71221 --- /dev/null +++ b/site/public/static/examples/hooks/use-intersection.tsx @@ -0,0 +1,18 @@ +const Section = ({title}: {title: string}) => { + const [ref, isIntersected] = useIntersection({rootMargin: '120px'}); + + const isVisible = isIntersected; + + console.log(`Render Section ${title}`, {isVisible}); + + return {title}; +}; + +export const Component = () => ( + <> + {Array.from({length: 5}).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+ ))} + +); diff --git a/site/public/static/examples/hooks/use-keypress.tsx b/site/public/static/examples/hooks/use-keypress.tsx new file mode 100644 index 0000000000..418cf04c42 --- /dev/null +++ b/site/public/static/examples/hooks/use-keypress.tsx @@ -0,0 +1,32 @@ +export const Component = () => { + const [onPressL, setOnPressL] = React.useState(false); + const [onPressShiftAndF, setonPressShiftAndF] = React.useState(false); + const [onPressAOrH, setonPressAOrH] = React.useState(false); + + const onPressSingle = React.useCallback(() => { + setOnPressL(true); + }, [setOnPressL]); + + const onPressMulti = React.useCallback(() => { + setonPressShiftAndF(true); + }, [setonPressShiftAndF]); + + const onPressEitherKey = React.useCallback(() => { + setonPressAOrH(true); + }, [setonPressAOrH]); + + useKeypress('l', onPressSingle, {eventType: 'keydown'}); + useKeypress('shift + f', onPressMulti, {eventType: 'keydown'}); + useKeypress(['a', 'h'], onPressEitherKey, {eventType: 'keydown'}); + + return ( + <> + Press L for love + {onPressL && 🧡} + Press SHIFT + F for fox + {onPressShiftAndF && 🦊} + Press A or H for happy face + {onPressAOrH && 😊} + + ); + }; \ No newline at end of file diff --git a/site/public/static/examples/hooks/use-resize-observer.tsx b/site/public/static/examples/hooks/use-resize-observer.tsx new file mode 100644 index 0000000000..cc05a44e81 --- /dev/null +++ b/site/public/static/examples/hooks/use-resize-observer.tsx @@ -0,0 +1,18 @@ +export const Component = () => { + const [, setDimensions] = React.useState({top: 0, left: 0}); + const ref = React.useRef(null); + + // Optional callback to access the full DOMRect object if required. + const optionalCallback = (entry: DOMRectReadOnly) => + setDimensions({top: entry.x, left: entry.left}); + + // Access the width and the height returned from the observed element. + const [width, height] = useResizeObserver(ref, optionalCallback); + return ( + <> + + {width} x {height} + + + ); +}; diff --git a/src/__tests__/__snapshots__/index.test.ts.snap b/src/__tests__/__snapshots__/index.test.ts.snap index 2d956d4e26..63c708bef4 100644 --- a/src/__tests__/__snapshots__/index.test.ts.snap +++ b/src/__tests__/__snapshots__/index.test.ts.snap @@ -226,9 +226,14 @@ Array [ "useAudioPlayerContext", "useBreakpointKey", "useClientSide", + "useControlled", "useFormContext", "useInstrumentation", + "useIntersection", + "useKeypress", + "useMediaQuery", "useMediaQueryObject", + "useResizeObserver", "useTheme", "withDefaultProps", "withInstrumentation", diff --git a/src/index.ts b/src/index.ts index 8bdb3072b4..9c5eff2f9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,6 @@ export * from './toast'; export * from './tooltip'; export * from './typography'; export * from './unordered-list'; -export * from './utils/hooks/use-media-query'; export * from './utils'; export * from './video-player'; export * from './volume-control'; diff --git a/src/screen-reader-only/__tests__/screen-reader-only.stories.tsx b/src/screen-reader-only/__tests__/screen-reader-only.stories.tsx index 04b57232d9..912c33ba7d 100644 --- a/src/screen-reader-only/__tests__/screen-reader-only.stories.tsx +++ b/src/screen-reader-only/__tests__/screen-reader-only.stories.tsx @@ -1,25 +1,32 @@ import * as React from 'react'; import {ScreenReaderOnly} from '../screen-reader-only'; -import {StorybookHeading} from '../../test/storybook-comps'; import {getSSRId} from '../../utils/get-ssr-id'; +import {LinkStandalone} from '../../link'; const srOnly = getSSRId(); -export default { - title: 'Utilities/Screen Reader Only', - component: () => 'None', -}; - export const StoryScreenReaderOnly = () => ( <> - Screen reader only - + Google - + The best known search engine ); -StoryScreenReaderOnly.storyName = 'Default'; +StoryScreenReaderOnly.storyName = 'ScreenReaderOnly'; StoryScreenReaderOnly.parameters = {eyes: {include: false}}; + +export default { + title: 'Utilities/ScreenReaderOnly', + component: ScreenReaderOnly, + parameters: { + nkDocs: { + title: 'Screen reader', + url: 'https://newskit.co.uk/components/visibility/', + description: + 'ScreenReaderOnly wraps an element making sure that it is not visible to the user, but still readable by a screen reader.', + }, + }, +}; diff --git a/src/utils/hooks/__tests__/use-controlled.stories.tsx b/src/utils/hooks/__tests__/use-controlled.stories.tsx new file mode 100644 index 0000000000..a0a0ddc35e --- /dev/null +++ b/src/utils/hooks/__tests__/use-controlled.stories.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import {IconFilledAddCircle} from '../../../icons'; +import {useControlled} from '../index'; +import {IconButton} from '../../../icon-button'; +import {Stack} from '../../../stack'; +import {StorybookCase, StorybookPage} from '../../../test/storybook-comps'; +import {TextBlock} from '../../../text-block'; + +const twoCols = { + xs: 'repeat(1, minmax(150px, max-content))', + sm: 'repeat(2, minmax(150px, max-content))', +}; + +export const StoryUseControlled = () => { + const Component = ({ + value: valueProp, + defaultValue, + onClick, + }: { + value?: number; + defaultValue?: number; + onClick?: () => void; + }) => { + const [value, setValue] = useControlled({ + defaultValue, + controlledValue: valueProp, + }); + + const handleOnClick = () => { + setValue(value! + 1); + + if (onClick) { + onClick(); + } + }; + + const title = valueProp ? 'controlled' : 'uncontrolled'; + + return ( + + + + + + {value} + + + ); + }; + + const [externalValue, setExternalValue] = React.useState(0); + + return ( + + + + + + setExternalValue(externalValue + 1)} + /> + + + ); +}; +StoryUseControlled.storyName = 'useControlled'; +StoryUseControlled.parameters = { + eyes: {include: false}, +}; + +export default { + title: 'Utilities/useControlled', + component: useControlled, + parameters: { + nkDocs: { + title: 'Hooks', + url: 'https://newskit.co.uk/components/utils/hooks/', + description: + 'useControlled is a custom hook used to allow any component handle controlled and uncontrolled modes, and provide control over its internal state.', + }, + }, +}; diff --git a/src/utils/hooks/__tests__/use-intersection.stories.tsx b/src/utils/hooks/__tests__/use-intersection.stories.tsx new file mode 100644 index 0000000000..88e241c62f --- /dev/null +++ b/src/utils/hooks/__tests__/use-intersection.stories.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import {useIntersection} from '../index'; +import {InlineMessage} from '../../../inline-message'; +import { + getColorCssFromTheme, + getTypographyPresetFromTheme, + styled, +} from '../../style'; +import {IconFilledInfo} from '../../../icons'; +import {StorybookCase, StorybookPage} from '../../../test/storybook-comps'; + +const StyledDiv = styled.div` + min-height: 60vh; + width: 100%; + display: flex; + border: 1px dashed; + padding: 12px; + ${getColorCssFromTheme('borderColor', 'inkNegative')}; + ${getColorCssFromTheme('color', 'inkBase')}; + ${getTypographyPresetFromTheme('utilityLabel020')}; +`; + +export const StoryUseIntersection = () => { + const Section = ({title}: {title: string}) => { + const [ref, isVisible] = useIntersection({rootMargin: '120px'}); + console.log(`Render Section ${title}`, {isVisible}); + + return {title}; + }; + return ( + <> + + + } + overrides={{ + marginBlockEnd: 'space050', + }} + > + In order to view how useIntersection works, please check out console + + + {Array.from({length: 5}).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+ ))} + + + + ); +}; +StoryUseIntersection.storyName = 'useIntersection'; +StoryUseIntersection.parameters = { + eyes: {include: false}, +}; + +export default { + title: 'Utilities/useIntersection', + component: useIntersection, + parameters: { + nkDocs: { + title: 'Hooks', + url: 'https://newskit.co.uk/components/utils/hooks/', + description: + 'useIntersection is a custom hook that detects visibility of a component on the viewport using the IntersectionObserver API natively present in the browser.', + }, + }, +}; diff --git a/src/utils/hooks/__tests__/use-keypress.stories.tsx b/src/utils/hooks/__tests__/use-keypress.stories.tsx new file mode 100644 index 0000000000..38c9fb0286 --- /dev/null +++ b/src/utils/hooks/__tests__/use-keypress.stories.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import {Story as StoryType} from '@storybook/react'; +import {useKeypress} from '../index'; + +import { + StorybookCase, + StorybookPage, + StorybookParah, +} from '../../../test/storybook-comps'; +import {getColorCssFromTheme, styled} from '../../style'; +import {IconFilledStarOutline} from '../../../icons'; +import {Flag} from '../../../flag'; +import {CreateThemeArgs, ThemeProvider} from '../../../theme'; +import {createCustomThemeWithBaseThemeSwitch} from '../../../test/theme-select-object'; +import {Stack} from '../../../stack'; + +const StyledDiv = styled.div` + margin: 15px; + width: 200px; + height: 100px; +`; + +const IconDiv = styled.div` + ${getColorCssFromTheme('color', 'inkBase')}; +`; + +const twoFlagCols = { + xs: 'repeat(1, minmax(150px, max-content))', + sm: 'repeat(2, minmax(150px, max-content))', +}; + +const useKeyPressCustomTheme: CreateThemeArgs = { + name: 'keypress-theme', + overrides: { + stylePresets: { + flagSolidNeutral: { + base: { + color: '{{colors.inkBase}}', + backgroundColor: '{{colors.interface020}}', + borderRadius: '{{borders.borderRadiusRounded010}}', + whiteSpace: 'nowrap', + }, + }, + }, + }, +}; +const Key = ({children}: {children: string}) => ( + + {children} + +); + +export const StoryUseKeyPress = () => { + const [customStylePreset, setCustomStylePreset] = React.useState( + 'inkBrand010', + ); + + const onPressSingle = React.useCallback(() => { + setCustomStylePreset('inkNegative'); + }, [setCustomStylePreset]); + + const onPressMulti = React.useCallback(() => { + setCustomStylePreset('inkBrand010'); + }, [setCustomStylePreset]); + + const onPressEitherKey = React.useCallback(() => { + setCustomStylePreset('inkPositive'); + }, [setCustomStylePreset]); + + useKeypress('r', onPressSingle, {eventType: 'keydown'}); + useKeypress('shift + b', onPressMulti, {eventType: 'keydown'}); + useKeypress(['g', 's'], onPressEitherKey, {eventType: 'keydown'}); + + return ( + <> + + + + + + r + + Red star + + + shift + + + b + + + Blue star + + + g + / + s + + + Green star + + + ); +}; +StoryUseKeyPress.storyName = 'useKeypress'; +StoryUseKeyPress.parameters = { + eyes: {include: false}, +}; + +export default { + title: 'Utilities/useKeypress', + component: useKeypress, + parameters: { + nkDocs: { + title: 'Hooks', + url: 'https://newskit.co.uk/components/utils/hooks/', + description: + 'useKeypress is a custom hook that detects when the user is pressing one single key or multiple keys.', + }, + }, + decorators: [ + (Story: StoryType, context: {globals: {backgrounds: {value: string}}}) => ( + + + + ), + ], +}; diff --git a/src/utils/hooks/__tests__/use-resize-observer.stories.tsx b/src/utils/hooks/__tests__/use-resize-observer.stories.tsx new file mode 100644 index 0000000000..85f67db6af --- /dev/null +++ b/src/utils/hooks/__tests__/use-resize-observer.stories.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import {useResizeObserver} from '../index'; + +import {getBorderCssFromTheme, getColorCssFromTheme, styled} from '../../style'; +import {InlineMessage} from '../../../inline-message'; +import {IconFilledInfo} from '../../../icons'; +import {TextBlock} from '../../../text-block'; +import {StorybookPage} from '../../../test/storybook-comps'; + +const StyledDiv = styled.div` + padding: 10px; + ${getColorCssFromTheme('color', 'inkBase')}; + ${getColorCssFromTheme('borderColor', 'inkBrand020')}; + ${getBorderCssFromTheme('borderRadius', 'borderRadiusRounded020')}; + width: 250px; + border: 1px solid; + resize: both; + overflow: auto; +`; + +export const StoryUseResizeObserver = () => { + const [, setDimensions] = React.useState({top: 0, left: 0}); + const ref = React.useRef(null); + + // Optional callback to access the full DOMRect object if required. + const optionalCallback = (entry: DOMRectReadOnly) => + setDimensions({top: entry.x, left: entry.left}); + + // Access the width and the height returned from the observed element. + const [width, height] = useResizeObserver(ref, optionalCallback); + return ( + + + } + overrides={{marginBlockEnd: 'space050'}} + > + Resize the input to return the element’s size. + + + + Width: {width}px + + + Height: {height}px + + + + ); +}; +StoryUseResizeObserver.storyName = 'useResizeObserver'; +StoryUseResizeObserver.parameters = { + eyes: {include: false}, +}; + +export default { + title: 'Utilities/useResizeObserver', + component: useResizeObserver, + parameters: { + nkDocs: { + title: 'Hooks', + url: 'https://newskit.co.uk/components/utils/hooks/', + description: `useResizeObserver is a custom hook that utilizes the resizeObserver API to return an element's size.`, + }, + }, +}; diff --git a/src/utils/hooks/use-media-query/__tests__/use-breakpoint-key.stories.tsx b/src/utils/hooks/use-media-query/__tests__/use-breakpoint-key.stories.tsx new file mode 100644 index 0000000000..4c40fa449c --- /dev/null +++ b/src/utils/hooks/use-media-query/__tests__/use-breakpoint-key.stories.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import {useBreakpointKey} from '../index'; + +import { + getColorCssFromTheme, + getTypographyPresetFromTheme, + styled, +} from '../../../style'; +import {IconFilledInfo} from '../../../../icons'; +import {InlineMessage} from '../../../../inline-message'; +import {StorybookPage} from '../../../../test/storybook-comps'; + +const StyledDiv = styled.div` + ${getColorCssFromTheme('backgroundColor', 'red010')}; + ${getTypographyPresetFromTheme('utilityLabel030')} + color: #3b3b3b; + text-align: center; + height: 64px; + line-height: 64px; +`; + +export const StoryUseBreakpointKey = () => { + const bp = useBreakpointKey(); + + return ( + + + } + overrides={{marginBlockEnd: 'space050'}} + > + Resize the browser window to return the active breakpoint key. + + {bp || 'unknown'} + + ); +}; +StoryUseBreakpointKey.storyName = 'useBreakpointKey'; +StoryUseBreakpointKey.parameters = { + eyes: {include: false}, +}; +export default { + title: 'Utilities/useBreakpointKey', + component: useBreakpointKey, + parameters: { + nkDocs: { + title: 'Hooks', + url: 'https://newskit.co.uk/components/utils/hooks/', + description: + 'useBreakpointKey is a custom hook that returns the active breakpoint key: xs | sm | md | lg | xl', + }, + }, +}; diff --git a/src/utils/hooks/use-media-query/__tests__/use-media-query-object.stories.tsx b/src/utils/hooks/use-media-query/__tests__/use-media-query-object.stories.tsx new file mode 100644 index 0000000000..bbed8c70b3 --- /dev/null +++ b/src/utils/hooks/use-media-query/__tests__/use-media-query-object.stories.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import {useMediaQueryObject} from '../index'; +import {MQ} from '../../../style/types'; +import { + getColorCssFromTheme, + getTypographyPresetFromTheme, + styled, +} from '../../../style'; +import {IconFilledInfo} from '../../../../icons'; +import {InlineMessage} from '../../../../inline-message'; +import {StorybookPage} from '../../../../test/storybook-comps'; + +const StyledDiv = styled.div` + ${getColorCssFromTheme('backgroundColor', 'red010')}; + ${getTypographyPresetFromTheme('utilityLabel030')} + text-align: center; + height: 64px; + line-height: 64px; + color: #3b3b3b; +`; + +export const StoryUseMediaQueryObject = () => { + const mediaQueryObject: MQ = { + xs: 'xs', + sm: 'sm', + md: 'md', + lg: 'lg', + xl: 'xl', + }; + const bp = useMediaQueryObject(mediaQueryObject); + + return ( + + + } + overrides={{marginBlockEnd: 'space050'}} + > + Resize the browser window to return the active media query object. + + {bp} + + ); +}; +StoryUseMediaQueryObject.storyName = 'useMediaQueryObject'; +StoryUseMediaQueryObject.parameters = { + eyes: {include: false}, +}; + +export default { + title: 'Utilities/useMediaQueryObject', + component: useMediaQueryObject, + parameters: { + nkDocs: { + title: 'Hooks', + url: 'https://newskit.co.uk/components/utils/hooks/', + description: + 'useMediaQueryObject hook handles scenarios in which you want to render component based on media query breakpoints.', + }, + }, +}; diff --git a/src/utils/hooks/use-media-query/__tests__/use-media-query-object.test.tsx b/src/utils/hooks/use-media-query/__tests__/use-media-query-object.test.tsx new file mode 100644 index 0000000000..f3aa9c361d --- /dev/null +++ b/src/utils/hooks/use-media-query/__tests__/use-media-query-object.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import {act, render} from '@testing-library/react'; +import {renderWithTheme} from '../../../../test/test-utils'; +import { + useMediaQueryObject, + MediaQueryProvider, + useBreakpointKey, +} from '../index'; +import {MQContext, withMediaQueryProvider} from '../context'; +import { + BreakpointKeys, + newskitLightTheme, + ThemeProvider, +} from '../../../../theme'; +import {MQ} from '../../../style/types'; + +const MockMediaQueryProvider = ({ + mq = 'xs', + children, +}: { + mq?: BreakpointKeys; + children: React.ReactElement; +}) => { + const state = { + xs: false, + sm: false, + md: false, + lg: false, + xl: false, + [mq]: true, + }; + return {children}; +}; + +const TestComponent = ({size}: {size: MQ}) => { + const width = useMediaQueryObject(size); + return
; +}; + +const WithProvider = ({mq, size}: {mq?: BreakpointKeys; size: MQ}) => ( + + + +); +const TestComponentUseBreakpointKey = () => { + const bpKey = useBreakpointKey() as BreakpointKeys; + return
{bpKey}
; +}; + +const WithProviderUseBreakpointKey = ({mq}: {mq?: BreakpointKeys}) => ( + + + +); + +describe('useMediaQueryObject', () => { + it('non MQ value', () => { + const size = '20px'; + const {getByTestId} = renderWithTheme(WithProvider, {size}); + expect(getByTestId('el')).toHaveStyle('width: 20px'); + }); + + it('use object with single key value', () => { + const size = {sm: '20px'}; + const {getByTestId} = renderWithTheme(WithProvider, {size}); + expect(getByTestId('el')).toHaveStyle('width: 20px'); + }); + + it('use object with all mq keys', () => { + const size = { + xs: '10px', + sm: '20px', + md: '30px', + lg: '40px', + xl: '50px', + }; + const {getByTestId} = renderWithTheme(WithProvider, {size, mq: 'sm'}); + expect(getByTestId('el')).toHaveStyle('width: 20px'); + }); + + it('use object without all keys', () => { + const size = { + xs: '10px', + sm: '20px', + xl: '50px', + }; + const {getByTestId} = renderWithTheme(WithProvider, {size, mq: 'md'}); + + expect(getByTestId('el')).toHaveStyle('width: 20px'); + }); + + it('use object without xs value', () => { + const size = { + sm: '20px', + xl: '50px', + }; + const {getByTestId} = renderWithTheme(WithProvider, { + size, + mq: 'xs', + }); + expect(getByTestId('el')).toHaveStyle('width: 20px'); + }); + + it('change media query size', () => { + const size = { + sm: '20px', + xl: '50px', + }; + const {rerender, getByTestId} = renderWithTheme(WithProvider, { + size, + mq: 'xs', + }); + expect(getByTestId('el')).toHaveStyle('width: 20px'); + + // change media query + rerender(); + + expect(getByTestId('el')).toHaveStyle('width: 50px'); + }); +}); + +describe('useBreakpointKey', () => { + it('get correct MQ key', () => { + const {rerender, getByText} = renderWithTheme( + WithProviderUseBreakpointKey, + { + mq: 'lg', + }, + ); + expect(getByText('lg')).toBeDefined(); + + // change media query + rerender(); + expect(getByText('xl')).toBeDefined(); + }); +}); + +const getMediaQuery = (bp: BreakpointKeys): string => { + const mqs = { + xs: 'screen and (max-width: 479px)', + sm: 'screen and (min-width: 480px) and (max-width: 767px)', + md: 'screen and (min-width: 768px) and (max-width: 1023px)', + lg: 'screen and (min-width: 1024px) and (max-width: 1439px)', + xl: 'screen and (min-width: 1440px)', + }; + return mqs[bp]; +}; +describe('MediaQueryProvider', () => { + let matchMedia: MatchMediaMock; + + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + const Component = ({children}: {children: React.ReactElement}) => ( + {children} + ); + + it('renders children correctly', () => { + const {getByText} = renderWithTheme(Component, { + children:
test
, + }); + const child = getByText('test'); + expect(child).toBeDefined(); + }); + + it('State is updated when media query changes', () => { + const {getByText} = renderWithTheme(Component, { + children: , + }); + + const child = getByText('xs'); + expect(child).toBeDefined(); + + act(() => { + // change media query + matchMedia.useMediaQuery(getMediaQuery('sm')); + }); + + const child1 = getByText('sm'); + expect(child1).toBeDefined(); + }); + + it('adds provider to component via withMediaQueryProvider', () => { + const ComponentWithProvider = withMediaQueryProvider( + TestComponentUseBreakpointKey, + ); + + // we use render here because we don't want to use the default MediaQueryProvider + // that comes with NewsKitProvider which a is part of renderWithTheme + const {getByText} = render(, { + wrapper: ({children}) => ( + {children} + ), + }); + + const child = getByText('sm'); + expect(child).toBeDefined(); + }); +}); diff --git a/src/utils/hooks/use-media-query/__tests__/use-media-query.stories.tsx b/src/utils/hooks/use-media-query/__tests__/use-media-query.stories.tsx index d1d57019e4..17daa27161 100644 --- a/src/utils/hooks/use-media-query/__tests__/use-media-query.stories.tsx +++ b/src/utils/hooks/use-media-query/__tests__/use-media-query.stories.tsx @@ -1,55 +1,86 @@ import * as React from 'react'; -import {useMediaQueryObject, useBreakpointKey} from '../index'; -import {StorybookHeading} from '../../../../test/storybook-comps'; -import {MQ} from '../../../style/types'; -import {styled} from '../../../style'; +import {useMediaQuery} from '../index'; -const StyledDiv = styled.div` - border: 2px solid black; - color: #fff; - font-weight: bold; +import { + getColorCssFromTheme, + getTypographyPresetFromTheme, + styled, +} from '../../../style'; +import {InlineMessage} from '../../../../inline-message'; +import {IconFilledInfo} from '../../../../icons'; +import {StorybookCase, StorybookPage} from '../../../../test/storybook-comps'; + +interface WrapperProps { + active: boolean; +} + +const StyledDiv = styled.div` + ${({active}) => + active + ? getColorCssFromTheme('backgroundColor', 'green010') + : getColorCssFromTheme('backgroundColor', 'red010')} + ${getTypographyPresetFromTheme('utilityLabel030')} text-align: center; - background: orange; + height: 64px; + line-height: 64px; + color: #3b3b3b; + margin-bottom: 24px; `; -export default { - title: 'Utilities/Hooks', - component: () => 'None', -}; - -export const StoryUseMediaQueryObject = () => { - const mediaQueryObject: MQ = { - xs: 'xs', - sm: 'sm', - md: 'md', - lg: 'lg', - xl: 'xl', - }; - const bp = useMediaQueryObject(mediaQueryObject); +export const StoryUseMediaQuery = () => { + const small = useMediaQuery('screen and (max-width : 767px)'); + const medium = useMediaQuery( + 'screen and (min-width : 768px) and (max-width : 1023px)', + ); + const large = useMediaQuery( + 'screen and (min-width : 1024px) and (max-width : 1439px)', + ); + const extraLarge = useMediaQuery('screen and (min-width : 1440px)'); return ( - <> - useMediaQueryObject - {bp} - + + + } + overrides={{marginBlockEnd: 'space050'}} + > + Resize the browser window to return the active media query. + + +
+ sm: {small ? 'yes' : 'no'} + md: {medium ? 'yes' : 'no'} + lg: {large ? 'yes' : 'no'} + + xl: {extraLarge ? 'yes' : 'no'} + +
+
+
); }; -StoryUseMediaQueryObject.storyName = 'useMediaQueryObject'; -StoryUseMediaQueryObject.parameters = { +StoryUseMediaQuery.storyName = 'useMediaQuery'; +StoryUseMediaQuery.parameters = { eyes: {include: false}, }; -export const StoryUseBreakpointKey = () => { - const bp = useBreakpointKey(); - - return ( - <> - useBreakpointKey - {bp || 'unknown'} - - ); -}; -StoryUseBreakpointKey.storyName = 'useBreakpointKey'; -StoryUseBreakpointKey.parameters = { - eyes: {include: false}, +export default { + title: 'Utilities/useMediaQuery', + component: useMediaQuery, + parameters: { + nkDocs: { + title: 'Hooks', + url: 'https://newskit.co.uk/components/utils/hooks/', + description: + 'useMediaQuery is a custom hook used to help detect whether a single media query matches', + }, + }, }; diff --git a/src/utils/hooks/use-media-query/__tests__/use-media-query.test.tsx b/src/utils/hooks/use-media-query/__tests__/use-media-query.test.tsx index f3aa9c361d..cb0ace402f 100644 --- a/src/utils/hooks/use-media-query/__tests__/use-media-query.test.tsx +++ b/src/utils/hooks/use-media-query/__tests__/use-media-query.test.tsx @@ -1,155 +1,10 @@ -import React from 'react'; -import '@testing-library/jest-dom'; +import {act, renderHook} from '@testing-library/react'; import MatchMediaMock from 'jest-matchmedia-mock'; -import {act, render} from '@testing-library/react'; -import {renderWithTheme} from '../../../../test/test-utils'; -import { - useMediaQueryObject, - MediaQueryProvider, - useBreakpointKey, -} from '../index'; -import {MQContext, withMediaQueryProvider} from '../context'; -import { - BreakpointKeys, - newskitLightTheme, - ThemeProvider, -} from '../../../../theme'; -import {MQ} from '../../../style/types'; +import {useMediaQuery} from '../index'; -const MockMediaQueryProvider = ({ - mq = 'xs', - children, -}: { - mq?: BreakpointKeys; - children: React.ReactElement; -}) => { - const state = { - xs: false, - sm: false, - md: false, - lg: false, - xl: false, - [mq]: true, - }; - return {children}; -}; - -const TestComponent = ({size}: {size: MQ}) => { - const width = useMediaQueryObject(size); - return
; -}; - -const WithProvider = ({mq, size}: {mq?: BreakpointKeys; size: MQ}) => ( - - - -); -const TestComponentUseBreakpointKey = () => { - const bpKey = useBreakpointKey() as BreakpointKeys; - return
{bpKey}
; -}; - -const WithProviderUseBreakpointKey = ({mq}: {mq?: BreakpointKeys}) => ( - - - -); - -describe('useMediaQueryObject', () => { - it('non MQ value', () => { - const size = '20px'; - const {getByTestId} = renderWithTheme(WithProvider, {size}); - expect(getByTestId('el')).toHaveStyle('width: 20px'); - }); - - it('use object with single key value', () => { - const size = {sm: '20px'}; - const {getByTestId} = renderWithTheme(WithProvider, {size}); - expect(getByTestId('el')).toHaveStyle('width: 20px'); - }); - - it('use object with all mq keys', () => { - const size = { - xs: '10px', - sm: '20px', - md: '30px', - lg: '40px', - xl: '50px', - }; - const {getByTestId} = renderWithTheme(WithProvider, {size, mq: 'sm'}); - expect(getByTestId('el')).toHaveStyle('width: 20px'); - }); - - it('use object without all keys', () => { - const size = { - xs: '10px', - sm: '20px', - xl: '50px', - }; - const {getByTestId} = renderWithTheme(WithProvider, {size, mq: 'md'}); - - expect(getByTestId('el')).toHaveStyle('width: 20px'); - }); - - it('use object without xs value', () => { - const size = { - sm: '20px', - xl: '50px', - }; - const {getByTestId} = renderWithTheme(WithProvider, { - size, - mq: 'xs', - }); - expect(getByTestId('el')).toHaveStyle('width: 20px'); - }); - - it('change media query size', () => { - const size = { - sm: '20px', - xl: '50px', - }; - const {rerender, getByTestId} = renderWithTheme(WithProvider, { - size, - mq: 'xs', - }); - expect(getByTestId('el')).toHaveStyle('width: 20px'); - - // change media query - rerender(); - - expect(getByTestId('el')).toHaveStyle('width: 50px'); - }); -}); - -describe('useBreakpointKey', () => { - it('get correct MQ key', () => { - const {rerender, getByText} = renderWithTheme( - WithProviderUseBreakpointKey, - { - mq: 'lg', - }, - ); - expect(getByText('lg')).toBeDefined(); - - // change media query - rerender(); - expect(getByText('xl')).toBeDefined(); - }); -}); - -const getMediaQuery = (bp: BreakpointKeys): string => { - const mqs = { - xs: 'screen and (max-width: 479px)', - sm: 'screen and (min-width: 480px) and (max-width: 767px)', - md: 'screen and (min-width: 768px) and (max-width: 1023px)', - lg: 'screen and (min-width: 1024px) and (max-width: 1439px)', - xl: 'screen and (min-width: 1440px)', - }; - return mqs[bp]; -}; -describe('MediaQueryProvider', () => { - let matchMedia: MatchMediaMock; +let matchMedia: MatchMediaMock; +describe('useMediaQuery', () => { beforeAll(() => { matchMedia = new MatchMediaMock(); }); @@ -162,49 +17,25 @@ describe('MediaQueryProvider', () => { matchMedia.clear(); }); - const Component = ({children}: {children: React.ReactElement}) => ( - {children} - ); + it('should return false with no matching media query', () => { + matchMedia.useMediaQuery(`(min-width: 0px)`); + const {result} = renderHook(() => useMediaQuery('test')); - it('renders children correctly', () => { - const {getByText} = renderWithTheme(Component, { - children:
test
, - }); - const child = getByText('test'); - expect(child).toBeDefined(); + expect(result.current).toEqual(false); }); - it('State is updated when media query changes', () => { - const {getByText} = renderWithTheme(Component, { - children: , - }); + it('should return false on a no matching breakpoint and true when query becomes matching', () => { + matchMedia.useMediaQuery('(prefers-reduced-motion: reduce)'); + const {result} = renderHook(() => + useMediaQuery('(prefers-reduced-motion: no-preference)'), + ); - const child = getByText('xs'); - expect(child).toBeDefined(); + expect(result.current).toEqual(false); act(() => { // change media query - matchMedia.useMediaQuery(getMediaQuery('sm')); + matchMedia.useMediaQuery('(prefers-reduced-motion: no-preference)'); }); - - const child1 = getByText('sm'); - expect(child1).toBeDefined(); - }); - - it('adds provider to component via withMediaQueryProvider', () => { - const ComponentWithProvider = withMediaQueryProvider( - TestComponentUseBreakpointKey, - ); - - // we use render here because we don't want to use the default MediaQueryProvider - // that comes with NewsKitProvider which a is part of renderWithTheme - const {getByText} = render(, { - wrapper: ({children}) => ( - {children} - ), - }); - - const child = getByText('sm'); - expect(child).toBeDefined(); + expect(result.current).toEqual(true); }); }); diff --git a/src/utils/hooks/use-media-query/index.tsx b/src/utils/hooks/use-media-query/index.tsx index 6f03766d2a..0745ef5686 100644 --- a/src/utils/hooks/use-media-query/index.tsx +++ b/src/utils/hooks/use-media-query/index.tsx @@ -1,2 +1,3 @@ export {MediaQueryProvider} from './context'; -export {useBreakpointKey, useMediaQueryObject} from './use-media-query'; +export {useBreakpointKey, useMediaQueryObject} from './use-media-query-object'; +export {useMediaQuery} from './use-media-query'; diff --git a/src/utils/hooks/use-media-query/use-media-query-object.tsx b/src/utils/hooks/use-media-query/use-media-query-object.tsx new file mode 100644 index 0000000000..524aedb815 --- /dev/null +++ b/src/utils/hooks/use-media-query/use-media-query-object.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import {useTheme} from '../../../theme'; +import {BreakpointKeys} from '../../../theme/types'; +import {MQ, MQPartial} from '../../style/types'; +import {MQContext} from './context'; +import {BreakpointState} from './types'; +import {getCurrentBreakpointKey, sortBreakpointKeys} from './utils'; + +const useMediaQueryContext = (): BreakpointState => { + const context = React.useContext(MQContext) as BreakpointState; + return context; +}; + +export function useBreakpointKey() { + const breakpointState = useMediaQueryContext(); + const currentBreakpoint = getCurrentBreakpointKey(breakpointState); + return currentBreakpoint; +} + +export function useMediaQueryObject(mqObject: MQ): T | undefined { + const breakpointsState = useMediaQueryContext(); + const theme = useTheme(); + const {breakpoints} = theme; + const breakpointsKeys = Object.keys(breakpoints) as BreakpointKeys[]; + + // when the param is simple type like string | number + if (typeof mqObject !== 'object') return mqObject; + + const valuesPerBreakpoint = mqObject as MQPartial; + + // when the props has only one key like {xs: 10px} + if (Object.keys(valuesPerBreakpoint).length === 1) { + const breakpoint = Object.keys(valuesPerBreakpoint)[0] as BreakpointKeys; + return valuesPerBreakpoint[breakpoint]; + } + + const currentBreakpoint = getCurrentBreakpointKey(breakpointsState); + + if (valuesPerBreakpoint[currentBreakpoint] !== undefined) + return valuesPerBreakpoint[currentBreakpoint]; + + // get the smallest breakpoint as "base", and pre-fill the rest based on the base + const baseBreakpoint = sortBreakpointKeys( + Object.keys(valuesPerBreakpoint) as BreakpointKeys[], + breakpointsKeys, + )[0]; + + let baseValue = valuesPerBreakpoint[baseBreakpoint]; + const valuesForAllBreakpoints = breakpointsKeys.reduce( + (acc, breakpointKey) => { + if (acc[breakpointKey] === undefined) { + return {...acc, [breakpointKey]: baseValue}; + } + + baseValue = acc[breakpointKey]; + return acc; + }, + valuesPerBreakpoint, + ); + + return valuesForAllBreakpoints[currentBreakpoint]; +} diff --git a/src/utils/hooks/use-media-query/use-media-query.tsx b/src/utils/hooks/use-media-query/use-media-query.tsx index 524aedb815..cd3f90cd2c 100644 --- a/src/utils/hooks/use-media-query/use-media-query.tsx +++ b/src/utils/hooks/use-media-query/use-media-query.tsx @@ -1,62 +1,25 @@ -import React from 'react'; -import {useTheme} from '../../../theme'; -import {BreakpointKeys} from '../../../theme/types'; -import {MQ, MQPartial} from '../../style/types'; -import {MQContext} from './context'; -import {BreakpointState} from './types'; -import {getCurrentBreakpointKey, sortBreakpointKeys} from './utils'; - -const useMediaQueryContext = (): BreakpointState => { - const context = React.useContext(MQContext) as BreakpointState; - return context; -}; - -export function useBreakpointKey() { - const breakpointState = useMediaQueryContext(); - const currentBreakpoint = getCurrentBreakpointKey(breakpointState); - return currentBreakpoint; -} - -export function useMediaQueryObject(mqObject: MQ): T | undefined { - const breakpointsState = useMediaQueryContext(); - const theme = useTheme(); - const {breakpoints} = theme; - const breakpointsKeys = Object.keys(breakpoints) as BreakpointKeys[]; - - // when the param is simple type like string | number - if (typeof mqObject !== 'object') return mqObject; - - const valuesPerBreakpoint = mqObject as MQPartial; - - // when the props has only one key like {xs: 10px} - if (Object.keys(valuesPerBreakpoint).length === 1) { - const breakpoint = Object.keys(valuesPerBreakpoint)[0] as BreakpointKeys; - return valuesPerBreakpoint[breakpoint]; - } - - const currentBreakpoint = getCurrentBreakpointKey(breakpointsState); - - if (valuesPerBreakpoint[currentBreakpoint] !== undefined) - return valuesPerBreakpoint[currentBreakpoint]; - - // get the smallest breakpoint as "base", and pre-fill the rest based on the base - const baseBreakpoint = sortBreakpointKeys( - Object.keys(valuesPerBreakpoint) as BreakpointKeys[], - breakpointsKeys, - )[0]; - - let baseValue = valuesPerBreakpoint[baseBreakpoint]; - const valuesForAllBreakpoints = breakpointsKeys.reduce( - (acc, breakpointKey) => { - if (acc[breakpointKey] === undefined) { - return {...acc, [breakpointKey]: baseValue}; +import {useState, useEffect} from 'react'; + +export function useMediaQuery(query: string) { + const getInitialState = () => + typeof window !== 'undefined' && window.matchMedia(query).matches; + + const [isMatched, setIsMatched] = useState(getInitialState); + + useEffect(() => { + const mediaQueryList = + typeof window !== 'undefined' && window.matchMedia(query); + const listener = (event: MediaQueryListEvent) => { + setIsMatched(event.matches); + }; + if (mediaQueryList) { + mediaQueryList.addEventListener('change', listener); + } + return () => { + if (mediaQueryList) { + mediaQueryList.removeEventListener('change', listener); } - - baseValue = acc[breakpointKey]; - return acc; - }, - valuesPerBreakpoint, - ); - - return valuesForAllBreakpoints[currentBreakpoint]; + }; + }, [query]); + return isMatched; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 2a152f09d5..8d9c01a021 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,3 +8,8 @@ export * from './style'; export * from './with-default-props'; export * from './with-own-theme'; export * from './get-transition-duration'; +export * from './hooks/use-controlled'; +export * from './hooks/use-media-query'; +export * from './hooks/use-intersection'; +export * from './hooks/use-resize-observer'; +export * from './hooks/use-keypress';