Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(MenuV2): add search feature in menu #4604

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/healthy-avocados-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ultraviolet/ui": minor
---

New prop `searchable` and `hideOnClickItem` in `<MenuV2 />` component
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export const AdvancedUsage: StoryFn<
</MenuV2>
<MenuV2
align="start"
searchable
hideOnClickItem
disclosure={
<Breadcrumbs.Item>
<Stack direction="row" gap={1} alignItems="center">
Expand Down
198 changes: 198 additions & 0 deletions packages/ui/src/components/MenuV2/MenuContent.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement | null>,
) => {
const { isVisible, setIsVisible } = useMenu()
const searchInputRef = useRef<HTMLInputElement>(null)
const [localChild, setLocalChild] = useState<ReactNode[]>()
const popupRef = useRef<HTMLDivElement>(null)
const disclosureRef = useRef<HTMLButtonElement>(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<ButtonHTMLAttributes<HTMLButtonElement>>(
disclosure,
)
? disclosure
: disclosure({ visible: isVisible })
const innerRef = useRef(target as unknown as HTMLButtonElement)
useImperativeHandle(ref, () => innerRef.current)

const finalDisclosure = cloneElement(target, {
onClick: (event: MouseEvent<HTMLButtonElement>) => {
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 (
<StyledPopup
debounceDelay={triggerMethod === 'hover' ? 250 : 0}
hideOnClickOutside
aria-label={ariaLabel}
className={className}
visible={triggerMethod === 'click' ? isVisible : undefined}
placement={placement}
hasArrow={hasArrow}
data-has-arrow={hasArrow}
role="dialog"
id={finalId}
ref={popupRef}
onClose={() => setIsVisible(false)}
tabIndex={-1}
maxHeight={maxHeight ?? '480px'}
maxWidth={maxWidth}
searchable={searchable}
size={size}
text={
<div>
{searchable && typeof children !== 'function' ? (
<StyledSearchInput
size="small"
onSearch={onSearch}
ref={searchInputRef}
/>
) : null}
<MenuList
data-testid={dataTestId}
className={className}
role="menu"
>
{finalChild}
</MenuList>
</div>
}
portalTarget={portalTarget}
dynamicDomRendering={dynamicDomRendering}
align={align}
>
{finalDisclosure}
</StyledPopup>
)
},
)
46 changes: 46 additions & 0 deletions packages/ui/src/components/MenuV2/MenuProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<boolean>>
}

const MenuContext = createContext<MenuContextProps>({
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 <MenuContext.Provider value={values}>{children}</MenuContext.Provider>
}
Original file line number Diff line number Diff line change
@@ -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) => (
<Button>{visible ? 'MenuV2 (is opened)' : 'MenuV2 (is closed)'}</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof MenuV2> = () => (
<MenuV2
align="start"
searchable
hideOnClickItem
disclosure={
<Button
icon="dots-horizontal"
sentiment="neutral"
variant="ghost"
size="small"
/>
}
>
<MenuV2.Item sentiment="primary" active>
<Stack direction="row" gap={1} alignItems="center">
<AvatarV2
variant="colors"
colors={['#BF95F9', '#3D1862']}
shape="circle"
size="xsmall"
/>
Default Project
</Stack>
</MenuV2.Item>
<MenuV2.Item>
<Stack direction="row" gap={1} alignItems="center">
<AvatarV2
variant="colors"
colors={['#FFBFAB', '#822F15']}
shape="circle"
size="xsmall"
/>
Project 1
</Stack>
</MenuV2.Item>
<MenuV2.Item>
<Stack direction="row" gap={1} alignItems="center">
<AvatarV2
variant="colors"
colors={['#FF9EC1', '#740D32']}
shape="circle"
size="xsmall"
/>
Project 2
</Stack>
</MenuV2.Item>
</MenuV2>
)

Searchable.decorators = [
StoryComponent => (
<div style={{ height: '80px', width: 'min-content' }}>
<StoryComponent />
</div>
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading