diff --git a/apps/site/middleware.ts b/apps/site/middleware.ts index 5fcf566fd4..b3167eaec0 100644 --- a/apps/site/middleware.ts +++ b/apps/site/middleware.ts @@ -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-')}` + // ) + // } return NextResponse.next() } diff --git a/apps/site/pages/components/molecules/dropdown.mdx b/apps/site/pages/components/molecules/dropdown.mdx index 3945e39c96..998a53eca8 100644 --- a/apps/site/pages/components/molecules/dropdown.mdx +++ b/apps/site/pages/components/molecules/dropdown.mdx @@ -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) @@ -88,10 +89,8 @@ Displays a set of actions/items to the user, usually used to show a menu of opti - }> - {'Dropdown'} - - + }>Dropdown + }> {'Dropdown Item 1'} @@ -107,7 +106,7 @@ Displays a set of actions/items to the user, usually used to show a menu of opti }> {'Dropdown Small'} - + }> {'Dropdown Item 1'} @@ -186,6 +185,8 @@ Follow the instructions in the [Importing FastStore UI component styles](/docs/c ## Usage +#### Default + @@ -231,6 +232,101 @@ Follow the instructions in the [Importing FastStore UI component styles](/docs/c +#### Controlled + +export const ControlledDropdown = () => { + const [isOpen, setIsOpen] = useState(false) + return ( + {setIsOpen(false)}}> + setIsOpen(true)}>Controlled Dropdown + + A - Dropdown item + B - Dropdown item + C - Dropdown item + D - Dropdown item + + + ) +} + + + + + + + + + ```tsx + + const [isOpen, setIsOpen] = useState(false) + return ( + {setIsOpen(false)}}> + Controlled Dropdown + + A - Dropdown item + B - Dropdown item + C - Dropdown item + D - Dropdown item + + + + ``` + + + +#### As Child + + + + + + + + + + + + + + + + + + + + + + + + + + + ```tsx + + + + + + + + + + + + + + + + + + + + + + ``` + + + --- ## Props diff --git a/packages/components/src/atoms/Button/index.ts b/packages/components/src/atoms/Button/index.ts index c378f002c0..8dbc1909ca 100644 --- a/packages/components/src/atoms/Button/index.ts +++ b/packages/components/src/atoms/Button/index.ts @@ -1,2 +1,2 @@ export { default } from './Button' -export type { ButtonProps } from './Button' +export type { ButtonProps, Variant as ButtonVariant, Size as ButtonSize, IconPosition as ButtonIconPosition } from './Button' diff --git a/packages/components/src/molecules/Dropdown/Dropdown.tsx b/packages/components/src/molecules/Dropdown/Dropdown.tsx index aacec6e08a..40c58ef362 100644 --- a/packages/components/src/molecules/Dropdown/Dropdown.tsx +++ b/packages/components/src/molecules/Dropdown/Dropdown.tsx @@ -20,38 +20,47 @@ export interface DropdownProps { const Dropdown = ({ children, - isOpen: isOpenDefault = false, + isOpen: isOpenControlled, onDismiss, id = 'fs-dropdown', }: PropsWithChildren) => { - const [isOpen, setIsOpen] = useState(isOpenDefault) - const dropdownItemsRef = useRef([]) + const [isOpenInternal, setIsOpenInternal] = useState(false) + const dropdownItemsRef = useRef([]) const selectedDropdownItemIndexRef = useRef(0) - const dropdownButtonRef = useRef(null) + const dropdownTriggerRef = useRef(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( + (ref: T) => { + dropdownTriggerRef.current = ref + }, + [] + ) + useEffect(() => { - setIsOpen(isOpenDefault) - }, [isOpenDefault]) + setIsOpenInternal(isOpenControlled ?? false) + }, [isOpenControlled]) useEffect(() => { isOpen && dropdownItemsRef?.current[0]?.focus() @@ -61,8 +70,8 @@ const Dropdown = ({ 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) { @@ -71,7 +80,7 @@ const Dropdown = ({ return } - !someItemWasClicked && close() + !wasSomeItemClicked && close() } if (isOpen) { @@ -91,13 +100,13 @@ const Dropdown = ({ close, open, toggle, - dropdownButtonRef, - onDismiss, + dropdownTriggerRef, + addDropdownTriggerRef, selectedDropdownItemIndexRef, dropdownItemsRef, id, } - }, [close, id, isOpen, onDismiss, toggle]) + }, [isOpen, close, toggle, addDropdownTriggerRef, id]) return ( diff --git a/packages/components/src/molecules/Dropdown/DropdownButton.tsx b/packages/components/src/molecules/Dropdown/DropdownButton.tsx index 6ca2305cde..115d1b8aaa 100644 --- a/packages/components/src/molecules/Dropdown/DropdownButton.tsx +++ b/packages/components/src/molecules/Dropdown/DropdownButton.tsx @@ -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, ButtonIconPosition } from '../../atoms/Button' +import { useDropdownTrigger } from './hooks/useDropdownTrigger' export interface DropdownButtonProps - extends Omit { + extends Omit { /** * 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?: ButtonIconPosition } const DropdownButton = forwardRef( 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 ( - + <> + {asChild ? ( + asChildrenTrigger + ) : ( + + )} + ) } ) diff --git a/packages/components/src/molecules/Dropdown/DropdownItem.tsx b/packages/components/src/molecules/Dropdown/DropdownItem.tsx index 7fd3652fb3..2c48c4bc6d 100644 --- a/packages/components/src/molecules/Dropdown/DropdownItem.tsx +++ b/packages/components/src/molecules/Dropdown/DropdownItem.tsx @@ -1,7 +1,7 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react' -import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react' +import React, { cloneElement, forwardRef } from 'react' -import { useDropdown } from './hooks/useDropdown' +import { useDropdownItem } from './hooks/useDropdownItem' export interface DropdownItemProps extends ButtonHTMLAttributes { @@ -10,63 +10,55 @@ export interface DropdownItemProps */ testId?: string /** + * @deprecated * A React component that will be rendered as an icon. */ icon?: ReactNode + /** + * Replace the default rendered element with the one passed as a child, merging their props and behavior. + * */ + asChild?: boolean + /** + * Emit onDismiss event when the component is clicked. + */ + dismissOnClick?: boolean } const DropdownItem = forwardRef( function Button( - { children, icon, onClick, testId = 'fs-dropdown-item', ...otherProps }, + { + children, + asChild, + icon, + onClick, + dismissOnClick = true, + testId = 'fs-dropdown-item', + ...otherProps + }, ref ) { - const { dropdownItemsRef, selectedDropdownItemIndexRef, close } = - useDropdown() - - const [dropdownItemIndex, setDropdownItemIndex] = useState(0) - const dropdownItemRef = useRef() - - const addToRefs = (el: HTMLButtonElement) => { - if (el && !dropdownItemsRef?.current.includes(el)) { - dropdownItemsRef?.current.push(el) - setDropdownItemIndex( - dropdownItemsRef?.current.findIndex((element) => element === el) ?? 0 - ) - } - - dropdownItemRef.current = el - } - - const onFocusItem = () => { - selectedDropdownItemIndexRef!.current = dropdownItemIndex - dropdownItemsRef?.current[selectedDropdownItemIndexRef!.current]?.focus() - } - - const handleOnClickItem = ( - event: React.MouseEvent - ) => { - onClick?.(event) - close?.() - } + const itemProps = useDropdownItem({ ref, onClick, dismissOnClick }) - useImperativeHandle(ref, () => dropdownItemRef.current!, []) + const asChildrenItem = React.isValidElement(children) + ? cloneElement(children, { ...itemProps, ...children.props }) + : children return ( - + <> + {asChild ? ( + asChildrenItem + ) : ( + + )} + ) } ) diff --git a/packages/components/src/molecules/Dropdown/DropdownMenu.tsx b/packages/components/src/molecules/Dropdown/DropdownMenu.tsx index fad2237f23..b213f9cf96 100644 --- a/packages/components/src/molecules/Dropdown/DropdownMenu.tsx +++ b/packages/components/src/molecules/Dropdown/DropdownMenu.tsx @@ -22,17 +22,19 @@ export interface DropdownMenuProps extends ModalContentProps { * @see aria-labelledby https://www.w3.org/TR/wai-aria-1.1/#aria-labelledby */ 'aria-labelledby'?: AriaAttributes['aria-label'] - /** * This function is called whenever the user hits "Escape" or clicks outside * the dialog. */ onDismiss?: (event: MouseEvent | KeyboardEvent) => void - - /** + /** * Specifies the size variant. */ size?: 'small' | 'regular' + /** + * Alignment for the dropdown + */ + align?: 'left' | 'right' | 'center' children: ReactNode[] | ReactNode } @@ -47,13 +49,20 @@ const DropdownMenu = ({ children, testId = 'fs-dropdown-menu', size = 'regular', + align = 'left', style, ...otherProps }: PropsWithChildren) => { - const { isOpen, close, dropdownItemsRef, selectedDropdownItemIndexRef, dropdownButtonRef, id } = - useDropdown() + const { + isOpen, + close, + dropdownItemsRef, + selectedDropdownItemIndexRef, + dropdownTriggerRef, + id, + } = useDropdown() - const dropdownPosition = useDropdownPosition() + const { loading: loadingPosition, ...dropdownPosition } = useDropdownPosition(align) const childrenLength = React.Children.toArray(children).length @@ -89,25 +98,56 @@ const DropdownMenu = ({ const handleEscapePress = () => { close?.() - dropdownButtonRef?.current?.focus() + dropdownTriggerRef?.current?.focus() } + const handleKeyNavigatePress = (key: string) => { + const dropdownItems = dropdownItemsRef?.current ?? []; + const selectedIndex = selectedDropdownItemIndexRef!.current; + + const rearrangedDropdownItems = [ + ...dropdownItems.slice(selectedIndex + 1), + ...dropdownItems.slice(0, selectedIndex + 1), + ]; + + const matchItem = rearrangedDropdownItems.find( + (item) => item.textContent?.[0]?.toLowerCase() === key.toLowerCase() + ); + + if (matchItem) { + selectedDropdownItemIndexRef!.current = dropdownItems.indexOf(matchItem); + matchItem.focus(); + } + }; + + const handleBackdropKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.key === 'Enter') { + if (event.defaultPrevented || event.key === 'Enter' || event.key === ' ') { return } event.preventDefault() - event.key === 'Escape' && handleEscapePress() - - event.key === 'ArrowDown' && handleDownPress() - - event.key === 'ArrowUp' && handleUpPress() - - event.key === 'Home' && handleHomePress() - - event.key === 'End' && handleEndPress() + switch (event.key) { + case 'Escape': + handleEscapePress() + break + case 'ArrowDown': + handleDownPress() + break + case 'ArrowUp': + handleUpPress() + break + case 'Home': + handleHomePress() + break + case 'End': + handleEndPress() + break + default: + handleKeyNavigatePress(event.key) + break + } event.stopPropagation() } @@ -118,7 +158,7 @@ const DropdownMenu = ({ return null } - return isOpen + return (isOpen && !loadingPosition) ? createPortal(
= { /** * Control de Dropdown state as Opened (true) or Closed (false). */ @@ -8,7 +11,7 @@ export type DropdownContextState = { /** * Reference to DropdownButton, used to calculate a position for the DropdownMenu. */ - dropdownButtonRef: React.RefObject | null + dropdownTriggerRef: React.MutableRefObject | null /** * Reference to a selected DropdownItem, used to manipulate focus. */ @@ -16,11 +19,7 @@ export type DropdownContextState = { /** * Array of References to dropdownItems in a DropdownMenu. */ - dropdownItemsRef: React.MutableRefObject | null - /** - * Close DropdownMenu event inherited from Modal. - */ - onDismiss?(): void + dropdownItemsRef: React.MutableRefObject | null /** * Function responsible for close the DropdownMenu in this context. */ @@ -33,16 +32,19 @@ export type DropdownContextState = { * Function responsible for switch the the DropdownMenu state in this context. */ toggle?(): void - /** * Identifier to be used in aria-controls */ id: string + /** + * Associates the dropdown trigger element's ref for managing its position and interaction events. + */ + addDropdownTriggerRef?(ref: T | null): void } const defaultState: DropdownContextState = { isOpen: false, - dropdownButtonRef: null, + dropdownTriggerRef: null, selectedDropdownItemIndexRef: null, dropdownItemsRef: null, id: 'fs-dropdown', diff --git a/packages/components/src/molecules/Dropdown/hooks/useDropdown.ts b/packages/components/src/molecules/Dropdown/hooks/useDropdown.ts index 06aa17b3f3..22d24d6e86 100644 --- a/packages/components/src/molecules/Dropdown/hooks/useDropdown.ts +++ b/packages/components/src/molecules/Dropdown/hooks/useDropdown.ts @@ -7,12 +7,12 @@ import DropdownContext from '../contexts/DropdownContext' * Hook to use the Dropdown context. * @returns Dropdown context. */ -export const useDropdown = () => { - const context = useContext(DropdownContext) +export const useDropdown = () => { + const context = useContext>(DropdownContext) if (context === undefined) { throw new Error('Do not use useDropdown hook outside the Dropdown context.') } - return context + return context as DropdownContextState } diff --git a/packages/components/src/molecules/Dropdown/hooks/useDropdownItem.ts b/packages/components/src/molecules/Dropdown/hooks/useDropdownItem.ts new file mode 100644 index 0000000000..112f46bba8 --- /dev/null +++ b/packages/components/src/molecules/Dropdown/hooks/useDropdownItem.ts @@ -0,0 +1,56 @@ +import React, { useImperativeHandle, useRef, useState } from 'react' + +import { useDropdown } from './useDropdown' + +export type UseDropdownItemProps = { + ref: React.ForwardedRef + onClick?: React.MouseEventHandler + dismissOnClick?: boolean +} + +export const useDropdownItem = ({ + ref, + onClick, + dismissOnClick = true, +}: UseDropdownItemProps) => { + const { dropdownItemsRef, selectedDropdownItemIndexRef, close } = useDropdown< + never, + E + >() + + const [dropdownItemIndex, setDropdownItemIndex] = useState(0) + const dropdownItemRef = useRef() + + const addToRefs = (el: E) => { + if (el && !dropdownItemsRef?.current.includes(el)) { + dropdownItemsRef?.current.push(el) + setDropdownItemIndex( + dropdownItemsRef?.current.findIndex((element) => element === el) ?? 0 + ) + } + + dropdownItemRef.current = el + } + + const onFocusItem = () => { + selectedDropdownItemIndexRef!.current = dropdownItemIndex + dropdownItemsRef?.current[selectedDropdownItemIndexRef!.current]?.focus() + } + + const handleOnClickItem = (event: React.MouseEvent) => { + onClick?.(event) + dismissOnClick && close?.() + } + + useImperativeHandle(ref, () => dropdownItemRef.current!, []) + + return { + ref: addToRefs, + onFocus: onFocusItem, + onMouseEnter: onFocusItem, + onClick: handleOnClickItem, + role: 'menuitem', + tabIndex: -1, + 'data-index': dropdownItemIndex, + } +} diff --git a/packages/components/src/molecules/Dropdown/hooks/useDropdownPosition.ts b/packages/components/src/molecules/Dropdown/hooks/useDropdownPosition.ts index c4abd9edd5..73fbe1f95b 100644 --- a/packages/components/src/molecules/Dropdown/hooks/useDropdownPosition.ts +++ b/packages/components/src/molecules/Dropdown/hooks/useDropdownPosition.ts @@ -1,33 +1,76 @@ +import { useEffect, useState } from 'react' import { useDropdown } from './useDropdown' -type DropdownPosition = Pick +type DropdownPosition = { + loading: boolean +} & Pick /** * Hook used to find the DropdownMenu position in relation to DropdownButton * @returns Style with positions. */ -export const useDropdownPosition = (): DropdownPosition => { - const { dropdownButtonRef } = useDropdown() +export const useDropdownPosition = (align: 'left' | 'center' | 'right' = 'left'): DropdownPosition => { + const { dropdownTriggerRef, isOpen } = useDropdown() - // Necessary to use this component in SSR - const isBrowser = typeof window !== 'undefined' + const [positionProps, setPositionProps] = useState({ + top: 0, + left: 0 as React.CSSProperties['left'], + right: 'auto', + transform: 'none', + loading: true + }) - const buttonRect = dropdownButtonRef?.current?.getBoundingClientRect() - const topLevel = buttonRect?.top ?? 0 - const topOffset = buttonRect?.height ?? 0 - const leftLevel = buttonRect?.left ?? 0 + useEffect(() => { + const updateMenuPosition = () => { + // Necessary to use this component in SSR + const isBrowser = typeof window !== 'undefined' - // The scroll properties fix the position of DropdownMenu when the scroll is activated. - const scrollTop = isBrowser ? document?.documentElement?.scrollTop : 0 - const scrollLeft = isBrowser ? document?.documentElement?.scrollLeft : 0 + if (!dropdownTriggerRef?.current) return - const topPosition = topLevel + topOffset + scrollTop + const buttonRect = dropdownTriggerRef.current.getBoundingClientRect() + const topLevel = buttonRect?.top ?? 0 + const topOffset = buttonRect?.height ?? 0 + const leftLevel = buttonRect?.left ?? 0 + const buttonWidth = buttonRect?.width ?? 0 - const leftPosition = leftLevel + scrollLeft + // The scroll properties fix the position of DropdownMenu when the scroll is activated. + const scrollTop = isBrowser ? document?.documentElement?.scrollTop : 0 + const scrollLeft = isBrowser ? document?.documentElement?.scrollLeft : 0 - return { - position: 'absolute', - top: topPosition, - left: leftPosition, - } + const topPosition = topLevel + topOffset + scrollTop + + let leftPosition: React.CSSProperties['left'] = leftLevel + scrollLeft + let rightPosition = 'auto' + let transform = 'none' + + if (align === 'right') { + rightPosition = `${document.documentElement.clientWidth - leftLevel - buttonWidth}px` + leftPosition = 'auto' + } else if (align === 'center') { + leftPosition = leftLevel + (buttonWidth / 2) + scrollLeft + transform = 'translateX(-50%)' + } + + setPositionProps({ + top: topPosition, + left: leftPosition, + right: rightPosition, + transform, + loading: false + }) + } + + if (isOpen) { + // Update the position of the menu + updateMenuPosition() + window.addEventListener('resize', updateMenuPosition) + } + + // Cleanup listener on unmount or close + return () => { + window.removeEventListener('resize', updateMenuPosition) + } + }, [dropdownTriggerRef, isOpen, align]) + + return { ...positionProps, position: 'absolute' as const } } diff --git a/packages/components/src/molecules/Dropdown/hooks/useDropdownTrigger.ts b/packages/components/src/molecules/Dropdown/hooks/useDropdownTrigger.ts new file mode 100644 index 0000000000..d85962f313 --- /dev/null +++ b/packages/components/src/molecules/Dropdown/hooks/useDropdownTrigger.ts @@ -0,0 +1,26 @@ +import { useDropdown } from './useDropdown' +import React, { useImperativeHandle } from 'react' + +type UseDropdownTriggerProps = { + triggerRef: React.ForwardedRef + label?: string +} + +export const useDropdownTrigger = ({ + triggerRef, +}: UseDropdownTriggerProps) => { + const { toggle, dropdownTriggerRef, addDropdownTriggerRef, isOpen, id } = + useDropdown() + + useImperativeHandle(triggerRef, () => dropdownTriggerRef!.current!, [ + dropdownTriggerRef, + ]) + + return { + onClick: toggle, + ref: addDropdownTriggerRef, + 'aria-expanded': isOpen, + 'aria-controls': id, + 'aria-haspopup': 'menu' as const, + } +}