From b4b3bcc181b93a33ae20dbda4097ffade9f872c8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 19 Dec 2024 12:47:42 +0100 Subject: [PATCH] feat(MenuV2): add search feature in menu --- .changeset/healthy-avocados-give.md | 5 + .../__stories__/AdvancedUsage.stories.tsx | 2 + .../ui/src/components/MenuV2/MenuContent.tsx | 198 +++++ .../ui/src/components/MenuV2/MenuProvider.tsx | 46 ++ .../FunctionDisclosure.stories.tsx | 2 +- .../MenuV2/__stories__/Searchable.stories.tsx | 63 ++ .../MenuV2/__stories__/index.stories.tsx | 1 + .../__snapshots__/index.test.tsx.snap | 106 ++- .../MenuV2/__tests__/index.test.tsx | 97 +++ .../MenuV2/{ => components}/Group.tsx | 2 +- .../MenuV2/{ => components}/Item.tsx | 35 +- .../ui/src/components/MenuV2/constants.ts | 5 + packages/ui/src/components/MenuV2/helpers.ts | 30 + packages/ui/src/components/MenuV2/index.tsx | 225 +----- packages/ui/src/components/MenuV2/types.ts | 65 ++ .../ui/src/components/Tabs/TabMenuItem.tsx | 4 +- .../__snapshots__/index.test.tsx.snap | 706 +++++++++--------- 17 files changed, 1009 insertions(+), 583 deletions(-) create mode 100644 .changeset/healthy-avocados-give.md create mode 100644 packages/ui/src/components/MenuV2/MenuContent.tsx create mode 100644 packages/ui/src/components/MenuV2/MenuProvider.tsx create mode 100644 packages/ui/src/components/MenuV2/__stories__/Searchable.stories.tsx rename packages/ui/src/components/MenuV2/{ => components}/Group.tsx (94%) rename packages/ui/src/components/MenuV2/{ => components}/Item.tsx (84%) create mode 100644 packages/ui/src/components/MenuV2/constants.ts create mode 100644 packages/ui/src/components/MenuV2/helpers.ts create mode 100644 packages/ui/src/components/MenuV2/types.ts diff --git a/.changeset/healthy-avocados-give.md b/.changeset/healthy-avocados-give.md new file mode 100644 index 0000000000..54a9de5908 --- /dev/null +++ b/.changeset/healthy-avocados-give.md @@ -0,0 +1,5 @@ +--- +"@ultraviolet/ui": minor +--- + +New prop `searchable` and `hideOnClickItem` in `` component diff --git a/packages/ui/src/components/Breadcrumbs/__stories__/AdvancedUsage.stories.tsx b/packages/ui/src/components/Breadcrumbs/__stories__/AdvancedUsage.stories.tsx index 1e83b9e608..98c6d38bac 100644 --- a/packages/ui/src/components/Breadcrumbs/__stories__/AdvancedUsage.stories.tsx +++ b/packages/ui/src/components/Breadcrumbs/__stories__/AdvancedUsage.stories.tsx @@ -38,6 +38,8 @@ export const AdvancedUsage: StoryFn< diff --git a/packages/ui/src/components/MenuV2/MenuContent.tsx b/packages/ui/src/components/MenuV2/MenuContent.tsx new file mode 100644 index 0000000000..f15f1ab7f0 --- /dev/null +++ b/packages/ui/src/components/MenuV2/MenuContent.tsx @@ -0,0 +1,198 @@ +import styled from '@emotion/styled' +import type { ButtonHTMLAttributes, MouseEvent, ReactNode, Ref } from 'react' +import { + cloneElement, + forwardRef, + isValidElement, + useCallback, + useEffect, + useId, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { Popup } from '../Popup' +import { SearchInput } from '../SearchInput' +import { Stack } from '../Stack' +import { useMenu } from './MenuProvider' +import { SIZES } from './constants' +import { searchChildren } from './helpers' +import type { MenuProps } from './types' + +const StyledPopup = styled(Popup, { + shouldForwardProp: prop => !['size', 'searchable'].includes(prop), +})<{ size: keyof typeof SIZES; searchable: boolean }>` + background-color: ${({ theme }) => theme.colors.other.elevation.background.raised}; + box-shadow: ${({ theme }) => `${theme.shadows.raised[0]}, ${theme.shadows.raised[1]}`}; + padding: 0; + + &[data-has-arrow='true'] { + &::after { + border-color: ${({ theme }) => theme.colors.other.elevation.background.raised} + transparent transparent transparent; + } + } + + width: ${({ size }) => SIZES[size]}; + max-width: none; + ${({ searchable }) => (searchable ? `min-width: 20rem` : null)}; + padding: ${({ theme }) => `${theme.space['0.25']} 0`}; +` + +const MenuList = styled(Stack)` + overflow-y: auto; + overflow-x: hidden; + &:after, + &:before { + border: solid transparent; + border-width: 9px; + content: ' '; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + } + + &:after { + border-color: transparent; + } + &:before { + border-color: transparent; + } + background-color: ${({ theme }) => + theme.colors.other.elevation.background.raised}; + color: ${({ theme }) => theme.colors.neutral.text}; + border-radius: ${({ theme }) => theme.radii.default}; + position: relative; +` + +const StyledSearchInput = styled(SearchInput)` + padding: ${({ theme }) => theme.space['1']}; +` + +export const Menu = forwardRef( + ( + { + id, + ariaLabel = 'Menu', + children, + disclosure, + hasArrow = false, + placement = 'bottom', + className, + 'data-testid': dataTestId, + maxHeight, + maxWidth, + portalTarget = document.body, + size = 'small', + triggerMethod = 'click', + dynamicDomRendering, + align, + searchable = false, + }: MenuProps, + ref: Ref, + ) => { + const { isVisible, setIsVisible } = useMenu() + const searchInputRef = useRef(null) + const [localChild, setLocalChild] = useState() + const popupRef = useRef(null) + const disclosureRef = useRef(null) + const tempId = useId() + const finalId = `menu-${id ?? tempId}` + + // if you need dialog inside your component, use function, otherwise component is fine + const target = isValidElement>( + disclosure, + ) + ? disclosure + : disclosure({ visible: isVisible }) + const innerRef = useRef(target as unknown as HTMLButtonElement) + useImperativeHandle(ref, () => innerRef.current) + + const finalDisclosure = cloneElement(target, { + onClick: (event: MouseEvent) => { + target.props.onClick?.(event) + setIsVisible(!isVisible) + }, + 'aria-haspopup': 'dialog', + 'aria-expanded': isVisible, + // @ts-expect-error not sure how to fix this + ref: disclosureRef, + }) + + const onSearch = useCallback( + (value: string) => { + if (typeof children === 'object') { + setLocalChild(searchChildren(children, value)) + } + }, + [children], + ) + + useEffect(() => { + if (isVisible && searchable) { + setTimeout(() => { + searchInputRef.current?.focus() + }, 50) + } + }, [isVisible, searchable]) + + const finalChild = useMemo(() => { + if (typeof children === 'function') { + return children({ toggle: () => setIsVisible(!isVisible) }) + } + + if (searchable && localChild) { + return localChild + } + + return children + }, [children, isVisible, localChild, searchable, setIsVisible]) + + return ( + setIsVisible(false)} + tabIndex={-1} + maxHeight={maxHeight ?? '480px'} + maxWidth={maxWidth} + searchable={searchable} + size={size} + text={ +
+ {searchable && typeof children !== 'function' ? ( + + ) : null} + + {finalChild} + +
+ } + portalTarget={portalTarget} + dynamicDomRendering={dynamicDomRendering} + align={align} + > + {finalDisclosure} +
+ ) + }, +) diff --git a/packages/ui/src/components/MenuV2/MenuProvider.tsx b/packages/ui/src/components/MenuV2/MenuProvider.tsx new file mode 100644 index 0000000000..7d288bc08c --- /dev/null +++ b/packages/ui/src/components/MenuV2/MenuProvider.tsx @@ -0,0 +1,46 @@ +import type { Dispatch, ReactNode, SetStateAction } from 'react' +import { createContext, useContext, useMemo, useState } from 'react' + +type MenuContextProps = { + hideOnClickItem: boolean + isVisible: boolean + setIsVisible: Dispatch> +} + +const MenuContext = createContext({ + hideOnClickItem: false, + isVisible: false, + setIsVisible: () => {}, +}) + +export const useMenu = () => { + const context = useContext(MenuContext) + if (!context) throw new Error('useMenu must be used in MenuProvider') + + return context +} + +type MenuProviderProps = { + hideOnClickItem?: boolean + children: ReactNode + visible?: boolean +} + +export const MenuProvider = ({ + hideOnClickItem = false, + children, + visible = false, +}: MenuProviderProps) => { + const [isVisible, setIsVisible] = useState(visible) + + const values = useMemo( + () => ({ + hideOnClickItem, + isVisible, + setIsVisible, + }), + [hideOnClickItem, isVisible], + ) + + return {children} +} diff --git a/packages/ui/src/components/MenuV2/__stories__/FunctionDisclosure.stories.tsx b/packages/ui/src/components/MenuV2/__stories__/FunctionDisclosure.stories.tsx index bfe512166f..1137bb1bc9 100644 --- a/packages/ui/src/components/MenuV2/__stories__/FunctionDisclosure.stories.tsx +++ b/packages/ui/src/components/MenuV2/__stories__/FunctionDisclosure.stories.tsx @@ -1,7 +1,7 @@ import type { StoryFn } from '@storybook/react' -import type { DisclosureProps } from '..' import { MenuV2 } from '..' import { Button } from '../../Button' +import type { DisclosureProps } from '../types' const CustomDisclosure = ({ visible }: DisclosureProps) => ( diff --git a/packages/ui/src/components/MenuV2/__stories__/Searchable.stories.tsx b/packages/ui/src/components/MenuV2/__stories__/Searchable.stories.tsx new file mode 100644 index 0000000000..8f09d2e620 --- /dev/null +++ b/packages/ui/src/components/MenuV2/__stories__/Searchable.stories.tsx @@ -0,0 +1,63 @@ +import type { StoryFn } from '@storybook/react' +import { MenuV2 } from '..' +import { AvatarV2 } from '../../AvatarV2' +import { Button } from '../../Button' +import { Stack } from '../../Stack' + +export const Searchable: StoryFn = () => ( + + } + > + + + + Default Project + + + + + + Project 1 + + + + + + Project 2 + + + +) + +Searchable.decorators = [ + StoryComponent => ( +
+ +
+ ), +] diff --git a/packages/ui/src/components/MenuV2/__stories__/index.stories.tsx b/packages/ui/src/components/MenuV2/__stories__/index.stories.tsx index 2ddd99d1ec..02e7de9273 100644 --- a/packages/ui/src/components/MenuV2/__stories__/index.stories.tsx +++ b/packages/ui/src/components/MenuV2/__stories__/index.stories.tsx @@ -30,6 +30,7 @@ export { Sentiments } from './Sentiments.stories' export { Borderless } from './Borderless.stories' export { Group } from './Group.stories' export { Active } from './Active.stories' +export { Searchable } from './Searchable.stories' export { LongMenu } from './LongMenu.stories' export { TriggerMethod } from './TriggerMethod.stories' export { WithModal } from './WithModal.stories' diff --git a/packages/ui/src/components/MenuV2/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/MenuV2/__tests__/__snapshots__/index.test.tsx.snap index 23868f11b9..277ba62a88 100644 --- a/packages/ui/src/components/MenuV2/__tests__/__snapshots__/index.test.tsx.snap +++ b/packages/ui/src/components/MenuV2/__tests__/__snapshots__/index.test.tsx.snap @@ -800,8 +800,8 @@ exports[`Menu > placement > renders bottom 1`] = ` data-testid="testing" >
@@ -847,8 +847,8 @@ exports[`Menu > placement > renders left 1`] = ` data-testid="testing" >
@@ -894,8 +894,8 @@ exports[`Menu > placement > renders right 1`] = ` data-testid="testing" >
@@ -941,8 +941,8 @@ exports[`Menu > placement > renders top 1`] = ` data-testid="testing" >
@@ -1144,6 +1144,84 @@ exports[`Menu > renders with visible=false 1`] = ` `; +exports[`Menu > should search on simple childs 1`] = ` + + .emotion-0 { + display: inherit; +} + +.emotion-0[data-container-full-width="true"] { + width: 100%; +} + +.emotion-0 { + display: inherit; +} + +.emotion-0[data-container-full-width="true"] { + width: 100%; +} + +
+
+ +
+
+
+`; + +exports[`Menu > should search on simple complex childs 1`] = ` + + .emotion-0 { + display: inherit; +} + +.emotion-0[data-container-full-width="true"] { + width: 100%; +} + +.emotion-0 { + display: inherit; +} + +.emotion-0[data-container-full-width="true"] { + width: 100%; +} + +
+
+ +
+
+
+`; + exports[`Menu > sizes > renders large 1`] = ` .emotion-0 { @@ -1174,8 +1252,8 @@ exports[`Menu > sizes > renders large 1`] = ` data-testid="testing" >
@@ -1221,8 +1299,8 @@ exports[`Menu > sizes > renders medium 1`] = ` data-testid="testing" >
@@ -1268,8 +1346,8 @@ exports[`Menu > sizes > renders small 1`] = ` data-testid="testing" >
diff --git a/packages/ui/src/components/MenuV2/__tests__/index.test.tsx b/packages/ui/src/components/MenuV2/__tests__/index.test.tsx index d11300ba2f..c2baef9fe0 100644 --- a/packages/ui/src/components/MenuV2/__tests__/index.test.tsx +++ b/packages/ui/src/components/MenuV2/__tests__/index.test.tsx @@ -101,6 +101,103 @@ describe('Menu', () => { }) }) + test('should hideOnClickItem', async () => { + renderWithTheme( + } + > + Test + , + ) + const menuButton = screen.getByRole('button') + // Open Menu + await userEvent.click(menuButton) + const dialog = screen.getByRole('dialog') + + await waitFor(() => { + expect(dialog).toBeVisible() + }) + + const item = screen.getByRole('menuitem') + await userEvent.click(item) + + await waitFor(() => { + expect(dialog).not.toBeVisible() + }) + }) + + test('should search on simple childs', async () => { + const { asFragment } = renderWithTheme( + } + > + Disk + Ram + , + ) + const menuButton = screen.getByRole('button') + // Open Menu + await userEvent.click(menuButton) + const dialog = screen.getByRole('dialog') + + await waitFor(() => { + expect(dialog).toBeVisible() + }) + + expect(asFragment()).toMatchSnapshot() + + const searchInput = screen.getByRole('textbox') + await userEvent.type(searchInput, 'Disk') + + const items = screen.getAllByRole('menuitem') + expect(items.length).toBe(1) + expect(items[0]).toHaveTextContent('Disk') + }) + + test('should search on simple complex childs', async () => { + const { asFragment } = renderWithTheme( + } + > + +
+
Volume type:
+
Disk
+
+
+ +
+
Memory type:
+
Ram
+
+
+
, + ) + const menuButton = screen.getByRole('button') + // Open Menu + await userEvent.click(menuButton) + const dialog = screen.getByRole('dialog') + + await waitFor(() => { + expect(dialog).toBeVisible() + }) + + expect(asFragment()).toMatchSnapshot() + + const searchInput = screen.getByRole('textbox') + await userEvent.type(searchInput, 'Disk') + + const items = screen.getAllByRole('menuitem') + expect(items.length).toBe(1) + expect(items[0]).toHaveTextContent('Disk') + }) + describe('placement', () => { test('renders top', () => shouldMatchEmotionSnapshot( diff --git a/packages/ui/src/components/MenuV2/Group.tsx b/packages/ui/src/components/MenuV2/components/Group.tsx similarity index 94% rename from packages/ui/src/components/MenuV2/Group.tsx rename to packages/ui/src/components/MenuV2/components/Group.tsx index 417b489b90..e3ad31512a 100644 --- a/packages/ui/src/components/MenuV2/Group.tsx +++ b/packages/ui/src/components/MenuV2/components/Group.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled' import type { ReactNode } from 'react' -import { Text } from '../Text' +import { Text } from '../../Text' const Container = styled.span` padding: ${({ theme }) => `${theme.space['0.5']} ${theme.space['1.5']}`}; diff --git a/packages/ui/src/components/MenuV2/Item.tsx b/packages/ui/src/components/MenuV2/components/Item.tsx similarity index 84% rename from packages/ui/src/components/MenuV2/Item.tsx rename to packages/ui/src/components/MenuV2/components/Item.tsx index c131264a1d..24b0cb9b3c 100644 --- a/packages/ui/src/components/MenuV2/Item.tsx +++ b/packages/ui/src/components/MenuV2/components/Item.tsx @@ -1,8 +1,9 @@ import type { Theme } from '@emotion/react' import styled from '@emotion/styled' -import type { MouseEventHandler, ReactNode, Ref } from 'react' -import { forwardRef } from 'react' -import { Tooltip } from '../Tooltip' +import type { MouseEvent, MouseEventHandler, ReactNode, Ref } from 'react' +import { forwardRef, useCallback } from 'react' +import { Tooltip } from '../../Tooltip' +import { useMenu } from '../MenuProvider' type MenuItemSentiment = 'neutral' | 'primary' | 'danger' @@ -130,6 +131,21 @@ const Item = forwardRef( }, ref, ) => { + const { hideOnClickItem, setIsVisible } = useMenu() + + const onClickHandle = useCallback( + (event: MouseEvent) => { + if (disabled) return undefined + onClick?.(event) + if (hideOnClickItem) { + setIsVisible(false) + } + + return undefined + }, + [disabled, hideOnClickItem, onClick, setIsVisible], + ) + if (href && !disabled) { return ( @@ -139,11 +155,7 @@ const Item = forwardRef( borderless href={href} ref={ref as Ref} - onClick={ - disabled - ? undefined - : (onClick as MouseEventHandler) - } + onClick={onClickHandle} role="menuitem" disabled={disabled} sentiment={sentiment} @@ -165,7 +177,12 @@ const Item = forwardRef( ref={ref as Ref} role="menuitem" disabled={disabled} - onClick={onClick} + onClick={event => { + onClick?.(event) + if (hideOnClickItem) { + setIsVisible(false) + } + }} borderless={borderless} sentiment={sentiment} className={className} diff --git a/packages/ui/src/components/MenuV2/constants.ts b/packages/ui/src/components/MenuV2/constants.ts new file mode 100644 index 0000000000..e0473ad9c0 --- /dev/null +++ b/packages/ui/src/components/MenuV2/constants.ts @@ -0,0 +1,5 @@ +export const SIZES = { + small: '180px', + medium: '280px', + large: '380px', +} diff --git a/packages/ui/src/components/MenuV2/helpers.ts b/packages/ui/src/components/MenuV2/helpers.ts new file mode 100644 index 0000000000..778244b748 --- /dev/null +++ b/packages/ui/src/components/MenuV2/helpers.ts @@ -0,0 +1,30 @@ +import { Children, type ReactNode, isValidElement } from 'react' + +/** + * Search inside a children (React Element) recursively until a result is found + */ +export const searchChildren = ( + children: ReactNode, + searchString: string, +): ReactNode[] => { + const matches: ReactNode[] = [] + const searchRegex = new RegExp(searchString, 'i') + + Children.forEach(children, child => { + if (typeof child === 'string' && child.match(searchRegex)) { + matches.push(child) + } else if (isValidElement(child)) { + const childProps = child.props as { children: ReactNode } + + // Recursively search the children of this element + const childMatches = searchChildren(childProps.children, searchString) + + if (childMatches.length > 0) { + // If any matches are found within this child's children, push the entire child element + matches.push(child) + } + } + }) + + return matches +} diff --git a/packages/ui/src/components/MenuV2/index.tsx b/packages/ui/src/components/MenuV2/index.tsx index 56c89c753a..1c7e7a6d17 100644 --- a/packages/ui/src/components/MenuV2/index.tsx +++ b/packages/ui/src/components/MenuV2/index.tsx @@ -1,209 +1,22 @@ -import styled from '@emotion/styled' -import type { - ButtonHTMLAttributes, - ComponentProps, - MouseEvent, - ReactElement, - ReactNode, - Ref, -} from 'react' -import { - cloneElement, - forwardRef, - isValidElement, - useId, - useImperativeHandle, - useRef, - useState, -} from 'react' -import { Popup } from '../Popup' -import { Stack } from '../Stack' -import { Group } from './Group' -import Item from './Item' - -const SIZES = { - small: '180px', - medium: '280px', - large: '380px', -} - -export type DisclosureProps = { visible: boolean } - -type DisclosureElement = - | (( - disclosure: DisclosureProps, - ) => ReactElement>) - | (ReactElement> & { - ref?: Ref - }) - -const StyledPopup = styled(Popup, { - shouldForwardProp: prop => !['size'].includes(prop), -})<{ size: keyof typeof SIZES }>` - background-color: ${({ theme }) => theme.colors.other.elevation.background.raised}; - box-shadow: ${({ theme }) => `${theme.shadows.raised[0]}, ${theme.shadows.raised[1]}`}; - padding: 0; - - &[data-has-arrow='true'] { - &::after { - border-color: ${({ theme }) => theme.colors.other.elevation.background.raised} - transparent transparent transparent; - } - } - - width: ${({ size }) => SIZES[size]}; - max-width: none; - padding: ${({ theme }) => `${theme.space['0.25']} 0`}; -` - -const MenuList = styled(Stack)` - overflow-y: auto; - overflow-x: hidden; - &:after, - &:before { - border: solid transparent; - border-width: 9px; - content: ' '; - height: 0; - width: 0; - position: absolute; - pointer-events: none; - } - - &:after { - border-color: transparent; - } - &:before { - border-color: transparent; - } - background-color: ${({ theme }) => - theme.colors.other.elevation.background.raised}; - color: ${({ theme }) => theme.colors.neutral.text}; - border-radius: ${({ theme }) => theme.radii.default}; - position: relative; -` - -type ChildMenuProps = { - toggle: () => void -} - -type MenuProps = { - id?: string - ariaLabel?: string - children?: ReactNode | (({ toggle }: ChildMenuProps) => ReactNode) - className?: string - disclosure: DisclosureElement - hasArrow?: boolean - visible?: boolean - 'data-testid'?: string - maxHeight?: string - /** - * @deprecated: use `size` instead - */ - maxWidth?: string - /** - * By default, the portal target is children container or document.body if children is a function. You can override this - * behavior by setting a portalTarget prop. - */ - portalTarget?: HTMLElement - size?: keyof typeof SIZES - /** - * The behavior of the menu when it is opened. If set to `click`, the menu will open when the user clicks on the disclosure. - * If set to `hover`, the menu will open when the user hovers over the disclosure. - */ - triggerMethod?: 'click' | 'hover' -} & Pick< - ComponentProps, - 'placement' | 'dynamicDomRendering' | 'align' -> - -const FwdMenu = forwardRef( +import type { Ref } from 'react' +import { forwardRef } from 'react' +import { Menu } from './MenuContent' +import { MenuProvider } from './MenuProvider' +import { Group } from './components/Group' +import Item from './components/Item' +import type { MenuProps } from './types' + +const Component = forwardRef( ( - { - id, - ariaLabel = 'Menu', - children, - disclosure, - hasArrow = false, - placement = 'bottom', - visible = false, - className, - 'data-testid': dataTestId, - maxHeight, - maxWidth, - portalTarget = document.body, - size = 'small', - triggerMethod = 'click', - dynamicDomRendering, - align, - }: MenuProps, - ref: Ref, - ) => { - const [isVisible, setIsVisible] = useState(visible) - const popupRef = useRef(null) - const disclosureRef = useRef(null) - const tempId = useId() - const finalId = `menu-${id ?? tempId}` - - // if you need dialog inside your component, use function, otherwise component is fine - const target = isValidElement>( - disclosure, - ) - ? disclosure - : disclosure({ visible: isVisible }) - const innerRef = useRef(target as unknown as HTMLButtonElement) - useImperativeHandle(ref, () => innerRef.current) - - const finalDisclosure = cloneElement(target, { - onClick: (event: MouseEvent) => { - target.props.onClick?.(event) - setIsVisible(!isVisible) - }, - 'aria-haspopup': 'dialog', - 'aria-expanded': isVisible, - // @ts-expect-error not sure how to fix this - ref: disclosureRef, - }) - - return ( - setIsVisible(false)} - tabIndex={-1} - maxHeight={maxHeight ?? '480px'} - maxWidth={maxWidth} - size={size} - text={ - - {typeof children === 'function' - ? children({ toggle: () => setIsVisible(!isVisible) }) - : children} - - } - portalTarget={portalTarget} - dynamicDomRendering={dynamicDomRendering} - align={align} - > - {finalDisclosure} - - ) - }, + { children, visible, hideOnClickItem, ...props }: MenuProps, + ref: Ref, + ) => ( + + + {children} + + + ), ) -/** - * A menu is a widget that offers a list of choices to the user, such as a set of actions or functions. - * A menu is usually opened, or made visible, by activating a menu button, choosing an item in a menu that opens a - * sub menu, or by invoking a command, such as `Shift + F10` on Windows, that opens a context specific menu. - * When a user activates a choice in a menu, the menu usually closes unless the choice opened a submenu. - */ -export const MenuV2 = Object.assign(FwdMenu, { Item, Group }) +export const MenuV2 = Object.assign(Component, { Item, Group }) diff --git a/packages/ui/src/components/MenuV2/types.ts b/packages/ui/src/components/MenuV2/types.ts new file mode 100644 index 0000000000..aa8fdcad7b --- /dev/null +++ b/packages/ui/src/components/MenuV2/types.ts @@ -0,0 +1,65 @@ +import type { + ButtonHTMLAttributes, + ComponentProps, + ReactElement, + ReactNode, + Ref, +} from 'react' +import type { Popup } from '../Popup' +import type { SIZES } from './constants' + +type ChildMenuProps = { + toggle: () => void +} + +type DisclosureElement = + | (( + disclosure: DisclosureProps, + ) => ReactElement>) + | (ReactElement> & { + ref?: Ref + }) + +export type DisclosureProps = { visible: boolean } + +export type MenuProps = { + id?: string + ariaLabel?: string + /* + * CHILDREN AS FUNCTION IS DEPRECATED: use `hideOnClickWithin` prop instead + */ + children?: ReactNode | (({ toggle }: ChildMenuProps) => ReactNode) + className?: string + disclosure: DisclosureElement + hasArrow?: boolean + visible?: boolean + 'data-testid'?: string + maxHeight?: string + /** + * @deprecated: use `size` instead + */ + maxWidth?: string + /** + * By default, the portal target is children container or document.body if children is a function. You can override this + * behavior by setting a portalTarget prop. + */ + portalTarget?: HTMLElement + size?: keyof typeof SIZES + /** + * The behavior of the menu when it is opened. If set to `click`, the menu will open when the user clicks on the disclosure. + * If set to `hover`, the menu will open when the user hovers over the disclosure. + */ + triggerMethod?: 'click' | 'hover' + /** + * If set to true, the menu will be searchable. This will add a search input at the top of the menu. + * This doesn't work when children is a function. + */ + searchable?: boolean + /** + * When set to true the menu will automatically close when a `MenuV2.Item` is clicked. + */ + hideOnClickItem?: boolean +} & Pick< + ComponentProps, + 'placement' | 'dynamicDomRendering' | 'align' +> diff --git a/packages/ui/src/components/Tabs/TabMenuItem.tsx b/packages/ui/src/components/Tabs/TabMenuItem.tsx index 938fbcb451..9ccdb69162 100644 --- a/packages/ui/src/components/Tabs/TabMenuItem.tsx +++ b/packages/ui/src/components/Tabs/TabMenuItem.tsx @@ -1,10 +1,10 @@ import styled from '@emotion/styled' import type { ComponentProps } from 'react' import { useMemo } from 'react' -import Item from '../MenuV2/Item' +import { MenuV2 } from '../MenuV2' import { useTabsContext } from './TabsContext' -const StyledMenuItem = styled(Item)` +const StyledMenuItem = styled(MenuV2.Item)` &[aria-selected='true'] { color: ${({ theme }) => theme.colors.primary.text}; } diff --git a/packages/ui/src/components/Tabs/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/Tabs/__tests__/__snapshots__/index.test.tsx.snap index cd5d25fe22..aafa6f9cdd 100644 --- a/packages/ui/src/components/Tabs/__tests__/__snapshots__/index.test.tsx.snap +++ b/packages/ui/src/components/Tabs/__tests__/__snapshots__/index.test.tsx.snap @@ -2435,31 +2435,33 @@ exports[`Tabs > renders correctly with Tabs with prop 1`] = ` role="dialog" style="opacity: 1; animation: none;" > -
@@ -2984,399 +2986,401 @@ exports[`Tabs > renders correctly with Tabs with prop 1`] = ` role="dialog" style="opacity: 1; animation: none;" > - - -
-
- -
-
- -
-
- - - - - - - - - - - - - -
+
- + Blabla + + + + +
@@ -3575,31 +3579,33 @@ exports[`Tabs > renders correctly with Tabs with prop 1`] = ` role="dialog" style="opacity: 1; animation: none;" > -