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: Improve Dropdown Component #2492

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
32 changes: 16 additions & 16 deletions apps/site/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { NextResponse, NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
const { pathname } = new URL(request.url)
if (pathname.startsWith('/docs')) {
return NextResponse.redirect(
`https://developers.vtex.com/docs/guides/faststore${pathname.replace(
'/docs',
''
)}`
)
}
if (pathname.startsWith('/components')) {
return NextResponse.redirect(
`https://developers.vtex.com/docs/guides/faststore${pathname
.replace('/components', '')
.replace(/\/([^\/]*)\//, '/$1-')}`
)
}
// const { pathname } = new URL(request.url)
// if (pathname.startsWith('/docs')) {
// return NextResponse.redirect(
// `https://developers.vtex.com/docs/guides/faststore${pathname.replace(
// '/docs',
// ''
// )}`
// )
// }
// if (pathname.startsWith('/components')) {
// return NextResponse.redirect(
// `https://developers.vtex.com/docs/guides/faststore${pathname
// .replace('/components', '')
// .replace(/\/([^\/]*)\//, '/$1-')}`
// )
// }
Comment on lines +4 to +19
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncomment before the merge

return NextResponse.next()
}
106 changes: 101 additions & 5 deletions apps/site/pages/components/molecules/dropdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { OverviewSection } from 'site/components/OverviewSection'
import path from 'path'
import { useSSG } from 'nextra/ssg'
import { getComponentPropsFrom } from 'site/components/utilities/propsSection'
import { useState } from 'react'

export const getStaticProps = () => {
const dropdownPath = path.resolve(__filename)
Expand Down Expand Up @@ -88,10 +89,8 @@ Displays a set of actions/items to the user, usually used to show a menu of opti
<Tab>
<OverviewSection>
<Dropdown>
<DropdownButton icon={<Icon name="CaretDown" />}>
{'Dropdown'}
</DropdownButton>
<DropdownMenu>
<DropdownButton icon={<Icon name="CaretDown" />}>Dropdown</DropdownButton>
<DropdownMenu size="regular" align="left">
<DropdownItem icon={<Icon name="ArrowElbowDownRight" />}>
{'Dropdown Item 1'}
</DropdownItem>
Expand All @@ -107,7 +106,7 @@ Displays a set of actions/items to the user, usually used to show a menu of opti
<DropdownButton icon={<Icon name="CaretDown" />}>
{'Dropdown Small'}
</DropdownButton>
<DropdownMenu size="small">
<DropdownMenu size="small" align="center">
<DropdownItem icon={<Icon name="ArrowElbowDownRight" />}>
{'Dropdown Item 1'}
</DropdownItem>
Expand Down Expand Up @@ -186,6 +185,8 @@ Follow the instructions in the [Importing FastStore UI component styles](/docs/c

## Usage

#### Default

<Tabs items={['Example', 'Code']} defaultIndex="0">
<Tab>
<OverviewSection>
Expand Down Expand Up @@ -231,6 +232,101 @@ Follow the instructions in the [Importing FastStore UI component styles](/docs/c
</Tab>
</Tabs>

#### Controlled

export const ControlledDropdown = () => {
const [isOpen, setIsOpen] = useState(false)
return (
<Dropdown isOpen={isOpen} onDismiss={() => {setIsOpen(false)}}>
<DropdownButton onClick={()=> setIsOpen(true)}>Controlled Dropdown</DropdownButton>
<DropdownMenu>
<DropdownItem>A - Dropdown item as child</DropdownItem>
<DropdownItem>B - Dropdown item as child</DropdownItem>
<DropdownItem>C - Dropdown item as child</DropdownItem>
<DropdownItem>D - Dropdown item as child</DropdownItem>
ArthurTriis1 marked this conversation as resolved.
Show resolved Hide resolved
</DropdownMenu>
</Dropdown>
)
}

<Tabs items={['Example', 'Code']} defaultIndex="0">
<Tab>
<OverviewSection>
<ControlledDropdown/>
</OverviewSection>
</Tab>
<Tab>
```tsx
<OverviewSection>
const [isOpen, setIsOpen] = useState(false)
return (
<Dropdown isOpen={isOpen} onDismiss={() => {setIsOpen(false)}}>
<DropdownButton>Controlled Dropdown</DropdownButton>
<DropdownMenu>
<DropdownItem>A - Dropdown item as child</DropdownItem>
<DropdownItem>B - Dropdown item as child</DropdownItem>
<DropdownItem>C - Dropdown item as child</DropdownItem>
<DropdownItem>D - Dropdown item as child</DropdownItem>
ArthurTriis1 marked this conversation as resolved.
Show resolved Hide resolved
</DropdownMenu>
</Dropdown>
</OverviewSection>
```
</Tab>
</Tabs>

#### As Child

<Tabs items={['Example', 'Code']} defaultIndex="0">
<Tab>
<OverviewSection>
<Dropdown>
<DropdownButton asChild>
<button>{'Dropdown with trigger as child'}</button>
</DropdownButton>
<DropdownMenu style={{ backgroundColor: "white"}}>
<DropdownItem asChild>
<button>A - Dropdown item as child</button>
</DropdownItem>
<DropdownItem asChild>
<button>B - Dropdown item as child</button>
</DropdownItem>
<DropdownItem asChild>
<button>C - Dropdown item as child</button>
</DropdownItem>
<DropdownItem asChild>
<button>D - Dropdown item as child</button>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</OverviewSection>
</Tab>
<Tab>
```tsx
<OverviewSection>
<Dropdown>
<DropdownButton asChild>
<button>{'Dropdown with trigger as child'}</button>
</DropdownButton>
<DropdownMenu style={{ backgroundColor: "white"}}>
<DropdownItem asChild>
<button>A - Dropdown item as child</button>
</DropdownItem>
<DropdownItem asChild>
<button>B - Dropdown item as child</button>
</DropdownItem>
<DropdownItem asChild>
<button>C - Dropdown item as child</button>
</DropdownItem>
<DropdownItem asChild>
<button>D - Dropdown item as child</button>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</OverviewSection>
```
</Tab>
</Tabs>

---

## Props
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/atoms/Button/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { default } from './Button'
export type { ButtonProps } from './Button'
export type { ButtonProps, Variant, Size, IconPosition } from './Button'
ArthurTriis1 marked this conversation as resolved.
Show resolved Hide resolved
51 changes: 32 additions & 19 deletions packages/components/src/molecules/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,49 +20,62 @@ export interface DropdownProps {

const Dropdown = ({
children,
isOpen: isOpenDefault = false,
isOpen: isOpenControlled,
onDismiss,
id = 'fs-dropdown',
}: PropsWithChildren<DropdownProps>) => {
const [isOpen, setIsOpen] = useState(isOpenDefault)
const dropdownItemsRef = useRef<HTMLButtonElement[]>([])
const [isOpenInternal, setIsOpenInternal] = useState(false)
const dropdownItemsRef = useRef<HTMLElement[]>([])
const selectedDropdownItemIndexRef = useRef(0)
const dropdownButtonRef = useRef<HTMLButtonElement>(null)
const dropdownTriggerRef = useRef<HTMLElement | null>(null)

const isOpen = isOpenControlled ?? isOpenInternal

const close = useCallback(() => {
setIsOpen(false)
setIsOpenInternal(false)
onDismiss?.()
}, [onDismiss])

const open = () => {
setIsOpen(true)
setIsOpenInternal(true)
}

const toggle = useCallback(() => {
setIsOpen((old) => {
if (old) {
setIsOpenInternal((currentIsOpen) => {
if (currentIsOpen) {
onDismiss?.()
dropdownButtonRef.current?.focus()
dropdownTriggerRef.current?.focus()
}

return !old
return !currentIsOpen
})
}, [onDismiss])

const addDropdownTriggerRef = useCallback(<T extends HTMLElement = HTMLElement>(ref: T) => {
dropdownTriggerRef.current = ref
}, [])

useEffect(() => {
setIsOpen(isOpenDefault)
}, [isOpenDefault])
setIsOpenInternal(isOpenControlled ?? false)
}, [isOpenControlled])

useEffect(() => {
isOpen && dropdownItemsRef?.current[0]?.focus()
if(isOpen) {
dropdownItemsRef?.current[0]?.focus()
document.body.style.overflow = 'hidden'

return
}

document.body.style.overflow = 'auto'
}, [isOpen])

useEffect(() => {
let firstClick = true

const event = (e: MouseEvent) => {
const someItemWasClicked = dropdownItemsRef?.current.some(
(item) => e.target === item
const wasSomeItemClicked = dropdownItemsRef?.current.some(
(item) => e.target === item || item.contains(e.target as Node)
)

if (firstClick) {
Expand All @@ -71,7 +84,7 @@ const Dropdown = ({
return
}

!someItemWasClicked && close()
!wasSomeItemClicked && close()
}

if (isOpen) {
Expand All @@ -91,13 +104,13 @@ const Dropdown = ({
close,
open,
toggle,
dropdownButtonRef,
onDismiss,
dropdownTriggerRef,
addDropdownTriggerRef,
selectedDropdownItemIndexRef,
dropdownItemsRef,
id,
}
}, [close, id, isOpen, onDismiss, toggle])
}, [isOpen, close, toggle, addDropdownTriggerRef, id])

return (
<DropdownContext.Provider value={value}>
Expand Down
77 changes: 45 additions & 32 deletions packages/components/src/molecules/Dropdown/DropdownButton.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,64 @@
import React, { forwardRef, useImperativeHandle, AriaAttributes } from 'react'
import Button, { ButtonProps } from '../../atoms/Button'

import { useDropdown } from './hooks/useDropdown'
import React, { cloneElement, forwardRef, ReactNode } from 'react'
import Button, { ButtonProps, IconPosition } from '../../atoms/Button'
import { useDropdownTrigger } from './hooks/useDropdownTrigger'
ArthurTriis1 marked this conversation as resolved.
Show resolved Hide resolved

export interface DropdownButtonProps
extends Omit<ButtonProps, 'variant' | 'inverse'> {
extends Omit<ButtonProps, 'variant' | 'inverse' | 'icon' | 'iconPosition' > {
/**
* ID to find this component in testing tools (e.g.: cypress, testing library, and jest).
*/
testId?: string
/**
* For accessibility purposes, add an ARIA label to the element when it doesn't have a label.
* Replace the default rendered element with the provided child element, merging their props and behavior.
*/
asChild?: boolean
/**
* Boolean that represents a loading state.
*/
loading?: boolean
/**
* Specifies a label for loading state.
*/
loadingLabel?: string
/**
* @deprecated
* A React component that will be rendered as an icon.
*/
icon?: ReactNode
/**
* @deprecated
* Specifies where the icon should be positioned
*/
'aria-label'?: AriaAttributes['aria-label']
iconPosition?: IconPosition
ArthurTriis1 marked this conversation as resolved.
Show resolved Hide resolved
}
ArthurTriis1 marked this conversation as resolved.
Show resolved Hide resolved

const DropdownButton = forwardRef<HTMLButtonElement, DropdownButtonProps>(
function DropdownButton(
{
testId = 'fs-dropdown-button',
'aria-label': ariaLabel,
children,
...otherProps
},
ref
{ testId = 'fs-dropdown-button', children, asChild = false, ...otherProps },
triggerRef
) {
const { toggle, dropdownButtonRef, isOpen, id } = useDropdown()
const triggerProps = useDropdownTrigger({ triggerRef })

useImperativeHandle(ref, () => dropdownButtonRef!.current!, [
dropdownButtonRef,
])
const asChildrenTrigger = React.isValidElement(children)
? cloneElement(children, { ...triggerProps, ...children.props })
: children;

return (
<Button
data-fs-dropdown-button
onClick={toggle}
data-testid={testId}
ref={dropdownButtonRef}
aria-label={ariaLabel}
aria-expanded={isOpen}
aria-haspopup="menu"
aria-controls={id}
variant="tertiary"
{...otherProps}
>
{children}
</Button>
<>
{asChild ? (
asChildrenTrigger
) : (
<Button
data-fs-dropdown-button
data-testid={testId}
variant="tertiary"
{...triggerProps}
{...otherProps}
>
{children}
</Button>
)}
</>
)
}
)
Expand Down
Loading
Loading