diff --git a/package.json b/package.json index 47af1da5ca..00a775cf9c 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "@emotion-icons/material-outlined": "3.8.0", "@emotion/react": "^11.1.5", "@emotion/styled": "^11.1.5", + "@floating-ui/react-dom-interactions": "^0.6.0", "@seznam/compose-react-refs": "^1.0.5", "aria-hidden": "^1.1.3", "date-fns": "^2.6.0", diff --git a/src/__tests__/__snapshots__/index.test.ts.snap b/src/__tests__/__snapshots__/index.test.ts.snap index f5458ff9a0..2786c0430c 100644 --- a/src/__tests__/__snapshots__/index.test.ts.snap +++ b/src/__tests__/__snapshots__/index.test.ts.snap @@ -134,6 +134,7 @@ Array [ "TitleBar", "Toast", "ToastProvider", + "Tooltip", "UnorderedList", "VideoPlayer", "Visible", diff --git a/src/button/types.ts b/src/button/types.ts index ff9e01e456..cc749f468b 100644 --- a/src/button/types.ts +++ b/src/button/types.ts @@ -34,4 +34,4 @@ export const isButtonLink = ( props: ButtonOrButtonLinkProps, ): props is ButtonLinkProps => (props as ButtonLinkProps).href !== undefined; -export type IconButtonProps = {'aria-label': string} & ButtonOrButtonLinkProps; +export type IconButtonProps = {'aria-label'?: string} & ButtonOrButtonLinkProps; diff --git a/src/icon-button/__tests__/icon-button.test.tsx b/src/icon-button/__tests__/icon-button.test.tsx index 115844facf..17c6934331 100644 --- a/src/icon-button/__tests__/icon-button.test.tsx +++ b/src/icon-button/__tests__/icon-button.test.tsx @@ -1,5 +1,9 @@ -import React from 'react'; -import {renderToFragmentWithTheme} from '../../test/test-utils'; +import React, {createRef} from 'react'; +import {act} from '@testing-library/react'; +import { + renderToFragmentWithTheme, + renderWithTheme, +} from '../../test/test-utils'; import {IconButton} from '..'; import {ButtonSize, IconButtonProps} from '../../button'; import {IconFilledEmail} from '../../icons'; @@ -74,6 +78,25 @@ describe('IconButton', () => { expect(fragment).toMatchSnapshot(); }); + test('focus can be triggered with ref', async () => { + const iconButtonRef = createRef(); + + const props = { + ref: iconButtonRef, + 'aria-label': 'Test icon button', + children: , + }; + + renderWithTheme(IconButton, props); + + await act(async () => { + if (iconButtonRef && iconButtonRef.current) { + iconButtonRef.current.focus(); + } + }); + expect(iconButtonRef.current).toHaveFocus(); + }); + test('renders icon button with logical prop overrides', () => { const props: IconButtonProps = { 'aria-label': 'Test icon button', diff --git a/src/icon-button/icon-button.tsx b/src/icon-button/icon-button.tsx index be61f0a03e..7949fa53ab 100644 --- a/src/icon-button/icon-button.tsx +++ b/src/icon-button/icon-button.tsx @@ -6,7 +6,10 @@ import defaults from './defaults'; import stylePresets from './style-presets'; import {withOwnTheme} from '../utils/with-own-theme'; -const ThemelessIconButton = ({overrides = {}, ...props}: IconButtonProps) => { +const ThemelessIconButton = React.forwardRef< + HTMLButtonElement | HTMLAnchorElement, + IconButtonProps +>(({overrides = {}, ...props}, ref) => { const theme = useTheme(); const {size = ButtonSize.Small} = props; @@ -15,8 +18,10 @@ const ThemelessIconButton = ({overrides = {}, ...props}: IconButtonProps) => { ...filterOutFalsyProperties(overrides), }; - return + .emotion-0 { + margin: 0; + pointer-events: none; + z-index: 80; + background-color: #0A0A0A; + color: #FFFFFF; + border-radius: 8px; + font-family: "Poppins",sans-serif; + font-size: 12px; + line-height: 1.5; + font-weight: 500; + letter-spacing: 0; + padding-inline: 8px; + padding-block: 8px; +} + + + +`; + +exports[`Tooltip should render correct styles: with overrides 1`] = ` + + + .emotion-0 { + margin: 0; + padding-inline: 8px; + padding-block: 16px; + pointer-events: none; + z-index: 70; + max-width: 80px; + min-width: 50px; + background-color: #EF1703; + border-radius: 0; + color: #0A0A0A; + font-family: "Poppins",sans-serif; + font-size: 14px; + line-height: 1.5; + font-weight: 500; + letter-spacing: 0; + padding-inline: 8px; + padding-block: 16px; +} + + + +`; diff --git a/src/tooltip/__tests__/tooltip.stories.tsx b/src/tooltip/__tests__/tooltip.stories.tsx new file mode 100644 index 0000000000..fc863e359d --- /dev/null +++ b/src/tooltip/__tests__/tooltip.stories.tsx @@ -0,0 +1,630 @@ +import * as React from 'react'; +import {Button, ButtonSize} from '../../button'; +import {GridLayout, GridLayoutItem} from '../../grid-layout'; +import {StorybookSubHeading} from '../../test/storybook-comps'; +import {createTheme, ThemeProvider} from '../../theme'; +import {styled} from '../../utils'; +import {Tooltip} from '../tooltip'; +import {IconFilledTwitter} from '../../icons'; +import {IconButton} from '../../icon-button'; +import {Flow, Stack} from '../../stack'; +import {LinkInline, LinkStandalone} from '../../link'; + +export default { + title: 'NewsKit Light/tooltip', + component: () => 'None', +}; + +const StyledDiv = styled.div` + margin-left: 200px; + margin-top: 48px; +`; + +const Container = styled.div` + max-width: 600px; + margin: 50px auto; +`; + +const myCustomTheme = createTheme({ + name: 'my-custom-modal-theme', + overrides: { + stylePresets: { + tooltipCustom: { + base: { + backgroundColor: '{{colors.red080}}', + borderRadius: '{{borders.borderRadiusDefault}}', + color: '{{colors.inkInverse}}', + }, + }, + }, + }, +}); + +export const StoryTooltip = () => ( + + Tooltip with icon button + + + + + + Tooltip with button + + + + Tooltip with inline link + + Inline link + + Tooltip with standalone link + + Standalone link + + + When title is empty, no tooltip is displayed + + + + + When title is not a string + Lorem ipsum dolor sit amet} + placement="right" + trigger={['focus', 'hover']} + > + + + + When title is long and without maxWidth + + + + + + + + When title is long and with maxWidth + + + + + + + +); +StoryTooltip.storyName = 'tooltip'; +StoryTooltip.parameters = { + eyes: {include: false}, +}; + +export const StoryTooltipPlacements = () => ( + <> + Tooltip Placements + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +StoryTooltipPlacements.storyName = 'tooltip-placements'; +StoryTooltipPlacements.parameters = { + eyes: {include: false}, +}; + +export const StoryTooltipTriggers = () => ( + <> + Triggered by focus only + + + + Triggered by hover & focus + + + + +); +StoryTooltipTriggers.storyName = 'tooltip-triggers'; +StoryTooltipTriggers.parameters = { + eyes: {include: false}, +}; + +export const StoryTooltipDefaultOpen = () => ( + <> + Tooltip default open + + + + +); +StoryTooltipDefaultOpen.storyName = 'tooltip-default-open'; + +export const StoryTooltipControlled = () => { + const [open, setOpen] = React.useState(false); + return ( + <> + Tooltip Controlled + + + +

+ External state control - click the button below to show/hide the + tooltip. +

+ + + ); +}; +StoryTooltipControlled.storyName = 'tooltip-controlled'; +StoryTooltipControlled.parameters = { + eyes: {include: false}, +}; + +export const StoryTooltipOverrides = () => ( + <> + Tooltip Overrides + + + + + + +); +StoryTooltipOverrides.storyName = 'tooltip-overrides'; + +export const StoryTooltipPlacementsVisualTest = () => ( + <> + Tooltip Visual + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +StoryTooltipPlacementsVisualTest.storyName = 'tooltip-placements-visual-test'; diff --git a/src/tooltip/__tests__/tooltip.test.tsx b/src/tooltip/__tests__/tooltip.test.tsx new file mode 100644 index 0000000000..d882f27f58 --- /dev/null +++ b/src/tooltip/__tests__/tooltip.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import {fireEvent} from '@testing-library/react'; +import {renderWithTheme} from '../../test/test-utils'; +import {Tooltip} from '..'; +import {TriggerType} from '../types'; +import {Button} from '../../button'; +import {createTheme} from '../../theme'; + +describe('Tooltip', () => { + const defaultProps = { + children: , + title: 'hello', + defaultOpen: true, + }; + + // Mocking ResizeObserver + const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + disconnect: jest.fn(), + })); + + beforeAll(() => { + // @ts-ignore + global.ResizeObserver = mockResizeObserver; + }); + + describe('should render correct styles:', () => { + test('default', () => { + const {getByRole, asFragment} = renderWithTheme(Tooltip, defaultProps); + expect(getByRole('tooltip').textContent).toBe('hello'); + expect(getByRole('tooltip')).toHaveStyle({ + position: 'absolute', + }); + expect(asFragment()).toMatchSnapshot(); + }); + test('not render if title is an empty string', () => { + const {queryByRole} = renderWithTheme(Tooltip, { + children: , + title: '', + }); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + // Cannot test the exact position with unit tests but will be covered in visual tests + test('with different placement', () => { + const {getByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + placement: 'bottom', + }); + expect(getByRole('tooltip')).toHaveStyle({ + position: 'absolute', + }); + }); + test('with overrides', () => { + const myCustomTheme = createTheme({ + name: 'my-custom-tooltip-theme', + overrides: { + stylePresets: { + tooltipCustom: { + base: { + backgroundColor: '{{colors.red060}}', + borderRadius: '{{borders.borderRadiusSharp}}', + color: '{{colors.inkContrast}}', + }, + }, + }, + }, + }); + const {asFragment} = renderWithTheme( + Tooltip, + { + ...defaultProps, + overrides: { + minWidth: '50px', + maxWidth: '80px', + zIndex: 70, + paddingBlock: 'space040', + paddingInline: 'space020', + stylePreset: 'tooltipCustom', + typographyPreset: 'utilityLabel020', + }, + }, + myCustomTheme, + ); + expect(asFragment()).toMatchSnapshot(); + }); + }); + + describe('with different triggers:', () => { + test('opens on mouseover by default', () => { + const {getByRole, queryByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + defaultOpen: false, + }); + const button = getByRole('button'); + fireEvent.mouseEnter(button); + expect(queryByRole('tooltip')).toBeInTheDocument(); + }); + test('closes on mouseleave', () => { + const {getByRole, queryByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + defaultOpen: false, + }); + const button = getByRole('button'); + + fireEvent.mouseEnter(button); + fireEvent.mouseLeave(button); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + test('opens on focus', () => { + const {getByRole, queryByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + defaultOpen: false, + trigger: 'focus' as TriggerType, + }); + const button = getByRole('button'); + fireEvent.focus(button); + expect(queryByRole('tooltip')).toBeInTheDocument(); + }); + + test('closes on blur', () => { + const {getByRole, queryByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + defaultOpen: false, + trigger: 'focus' as TriggerType, + }); + const button = getByRole('button'); + fireEvent.focus(button); + fireEvent.blur(button); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('will not open on focus when focus trigger is not passed', () => { + const {getByRole, queryByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + defaultOpen: false, + }); + const button = getByRole('button'); + fireEvent.focus(button); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('dismisses with escape key', () => { + const {queryByRole} = renderWithTheme(Tooltip, defaultProps); + expect(queryByRole('tooltip')).toBeInTheDocument(); + fireEvent.keyDown(document.body, {key: 'Escape'}); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + }); + + describe('pass the correct a11y attributes:', () => { + test('have role tooltip when used as a description', () => { + const {queryByRole} = renderWithTheme(Tooltip, defaultProps); + expect(queryByRole('tooltip')).toBeInTheDocument(); + }); + test('do not have role tooltip when used as a label', () => { + const {queryByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + asLabel: true, + }); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + test('can describe the child when open and remove aria attribute when closed', () => { + const {getByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + defaultOpen: false, + }); + const button = getByRole('button'); + fireEvent.mouseEnter(button); + expect(button.hasAttribute('aria-describedby')).toBe(true); + fireEvent.mouseLeave(button); + expect(button.hasAttribute('aria-describedby')).toBe(false); + }); + test('can describe with exotic title when open and remove aria attribute when closed', () => { + const {getByRole} = renderWithTheme(Tooltip, { + children: , + title:
the title
, + defaultOpen: false, + }); + const button = getByRole('button'); + fireEvent.mouseEnter(button); + expect(button.hasAttribute('aria-describedby')).toBe(true); + fireEvent.mouseLeave(button); + expect(button.hasAttribute('aria-describedby')).toBe(false); + }); + test('should label the child when closed', () => { + const {getByRole} = renderWithTheme(Tooltip, { + ...defaultProps, + defaultOpen: false, + asLabel: true, + }); + const button = getByRole('button'); + expect(button.hasAttribute('aria-label')).toBe(true); + }); + test('should label the child when open with an exotic title', () => { + const {getByRole} = renderWithTheme(Tooltip, { + children: , + title:
the title
, + defaultOpen: false, + asLabel: true, + }); + const button = getByRole('button'); + expect(button.hasAttribute('aria-labelledby')).toBe(false); + fireEvent.mouseEnter(button); + expect(button.hasAttribute('aria-labelledby')).toBe(true); + }); + }); + + test('should be controllable', () => { + const Component = () => { + const [open, setOpen] = React.useState(false); + return ( + <> + + + + + + ); + }; + + const {getByTestId, queryByRole} = renderWithTheme(Component); + + const button = getByTestId('outside-control'); + fireEvent.click(button); + expect(queryByRole('tooltip')).toBeInTheDocument(); + }); +}); diff --git a/src/tooltip/defaults.ts b/src/tooltip/defaults.ts new file mode 100644 index 0000000000..dcf631967d --- /dev/null +++ b/src/tooltip/defaults.ts @@ -0,0 +1,9 @@ +export default { + tooltip: { + zIndex: 80, + paddingBlock: 'spaceInset020', + paddingInline: 'spaceInset020', + stylePreset: 'tooltip', + typographyPreset: 'utilityLabel010', + }, +}; diff --git a/src/tooltip/index.ts b/src/tooltip/index.ts new file mode 100644 index 0000000000..0ef19c2f76 --- /dev/null +++ b/src/tooltip/index.ts @@ -0,0 +1,2 @@ +export * from './tooltip'; +export * from './types'; diff --git a/src/tooltip/style-presets.ts b/src/tooltip/style-presets.ts new file mode 100644 index 0000000000..e25f42eebc --- /dev/null +++ b/src/tooltip/style-presets.ts @@ -0,0 +1,11 @@ +import {StylePreset} from '../theme/types'; + +export default { + tooltip: { + base: { + backgroundColor: '{{colors.interface060}}', + color: '{{colors.inkInverse}}', + borderRadius: '{{borders.borderRadiusDefault}}', + }, + }, +} as Record; diff --git a/src/tooltip/styled.tsx b/src/tooltip/styled.tsx new file mode 100644 index 0000000000..c5e334442b --- /dev/null +++ b/src/tooltip/styled.tsx @@ -0,0 +1,20 @@ +import {TooltipProps} from './types'; +import { + getTypographyPreset, + getStylePreset, + styled, + getResponsiveSpace, + getResponsiveSize, +} from '../utils/style'; +import {logicalProps} from '../utils/logical-properties'; +import {TextBlock} from '../text-block'; + +export const StyledTooltip = styled(TextBlock)>` + pointer-events: none; + ${getResponsiveSpace('zIndex', 'tooltip', '', 'zIndex')}; + ${getResponsiveSize('maxWidth', 'tooltip', '', 'maxWidth')}; + ${getResponsiveSize('minWidth', 'tooltip', '', 'minWidth')}; + ${getStylePreset('tooltip', '')}; + ${getTypographyPreset('tooltip', '')}; + ${logicalProps('tooltip', '')} +`; diff --git a/src/tooltip/tooltip.tsx b/src/tooltip/tooltip.tsx new file mode 100644 index 0000000000..b56e8eac26 --- /dev/null +++ b/src/tooltip/tooltip.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { + autoUpdate, + useFloating, + useInteractions, + useHover, + useFocus, + useRole, + useDismiss, + useId, +} from '@floating-ui/react-dom-interactions'; +import composeRefs from '@seznam/compose-react-refs'; +import {TooltipProps} from './types'; +import {withOwnTheme} from '../utils/with-own-theme'; +import {StyledTooltip} from './styled'; +import defaults from './defaults'; +import stylePresets from './style-presets'; +import {useControlled} from '../utils/hooks'; + +const ThemelessTooltip: React.FC = ({ + children, + title, + placement = 'top', + trigger = 'hover', + open: openProp, + defaultOpen, + asLabel, + overrides, + ...props +}) => { + const [open, setOpen] = useControlled({ + controlledValue: openProp, + defaultValue: Boolean(defaultOpen), + }); + + const {x, y, reference, floating, strategy, context} = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + }); + + const {getReferenceProps, getFloatingProps} = useInteractions([ + useHover(context, { + enabled: trigger.includes('hover'), + }), + useFocus(context, {enabled: trigger.includes('focus')}), + useRole(context, {enabled: !asLabel, role: 'tooltip'}), + useDismiss(context), + ]); + + const id = useId(); + + const isTitleString = typeof title === 'string'; + + const labelOrDescProps: { + 'aria-label'?: string | null; + 'aria-labelledby'?: string | null; + 'aria-describedby'?: string | null; + } = {}; + + // If tooltip is used as a label, add aria-label or aria-labelledby to childrenProps and id to StyledTooltip; + // aria-label is used when title is string; aria-labelledby is used when it's not a string; + // Because of above, 'aria-describedby' has different id for reference and floating, hence manually set below as well; + if (asLabel) { + labelOrDescProps['aria-label'] = isTitleString ? title : null; + labelOrDescProps['aria-labelledby'] = open && !isTitleString ? id : null; + } else { + labelOrDescProps['aria-describedby'] = open ? id : null; + } + + const childrenProps = { + ...labelOrDescProps, + ...children.props, + }; + + if (!title) { + return children; + } + + return ( + <> + {React.cloneElement( + children, + getReferenceProps({ + ref: composeRefs(reference, children.ref), + ...childrenProps, + }), + )} + {open && ( + + {title} + + )} + + ); +}; + +export const Tooltip = withOwnTheme(ThemelessTooltip)({ + defaults, + stylePresets, +}); diff --git a/src/tooltip/types.ts b/src/tooltip/types.ts new file mode 100644 index 0000000000..95bda72b30 --- /dev/null +++ b/src/tooltip/types.ts @@ -0,0 +1,26 @@ +import React from 'react'; +import {Placement} from '@floating-ui/react-dom-interactions'; +import {MQ} from '../utils/style'; +import {LogicalPaddingProps} from '../utils/logical-properties'; + +export type TriggerType = 'hover' | 'focus'; + +export interface TooltipProps + extends Omit, 'title' | 'defaultValue'> { + children: React.ReactElement & { + ref?: React.Ref; + }; + title: React.ReactNode; + open?: boolean; + defaultOpen?: boolean; + trigger?: TriggerType | TriggerType[]; + placement?: Placement; + asLabel?: boolean; + overrides?: { + zIndex?: number; + maxWidth?: MQ; + minWidth?: MQ; + stylePreset?: MQ; + typographyPreset?: MQ; + } & LogicalPaddingProps; +} diff --git a/yarn.lock b/yarn.lock index fde6936db7..9fb4bdea3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1977,6 +1977,35 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@floating-ui/core@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-0.7.0.tgz#f2442168d65c22daeb48cfb730cbc3a29a846ac7" + integrity sha512-W7+i5Suhhvv97WDTW//KqUA43f/2a4abprM1rWqtLM9lIlJ29tbFI8h232SvqunXon0WmKNEKVjbOsgBhTnbLw== + +"@floating-ui/dom@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-0.5.0.tgz#e4efd9e609ff3ee6778fbe4273930d6d1c220f2e" + integrity sha512-PS75dnMg4GdWjDFOiOs15cDzYJpukRNHqQn0ugrBlsrpk2n+y8bwZ24XrsdLSL7kxshmxxr2nTNycLnmRIvV7g== + dependencies: + "@floating-ui/core" "^0.7.0" + +"@floating-ui/react-dom-interactions@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.6.0.tgz#a328b4d349b4d706e45c3a6a7dce592f9cf4bd27" + integrity sha512-8XzQuQStUNztHvg+Rj6MdUjBsOKIb6Oe0eIs1w9LfssOT0ZEPPHVsCZgLiWoyDaotF6pirLGAXkOfQxPM7VBRQ== + dependencies: + "@floating-ui/react-dom" "^0.7.0" + aria-hidden "^1.1.3" + use-isomorphic-layout-effect "^1.1.1" + +"@floating-ui/react-dom@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-0.7.0.tgz#8f867b6fbf87dbfde6f55fd5f5c2c53cfdd1c873" + integrity sha512-mpYGykTqwtBYT+ZTQQ2OfZ6wXJNuUgmqqD9ooCgbMRgvul6InFOTtWYvtujps439hmOFiVPm4PoBkEEn5imidg== + dependencies: + "@floating-ui/dom" "^0.5.0" + use-isomorphic-layout-effect "^1.1.1" + "@gar/promisify@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.2.tgz#30aa825f11d438671d585bd44e7fd564535fc210" @@ -18051,6 +18080,11 @@ use-isomorphic-layout-effect@^1.0.0: resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz#7bb6589170cd2987a152042f9084f9effb75c225" integrity sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ== +use-isomorphic-layout-effect@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" + integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + use-latest@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.0.tgz#a44f6572b8288e0972ec411bdd0840ada366f232"