Skip to content

Commit

Permalink
feat(MenuV2): add search feature in menu
Browse files Browse the repository at this point in the history
  • Loading branch information
matthprost committed Dec 19, 2024
1 parent dcafecf commit b4b3bcc
Show file tree
Hide file tree
Showing 17 changed files with 1,009 additions and 583 deletions.
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

0 comments on commit b4b3bcc

Please sign in to comment.