diff --git a/package-lock.json b/package-lock.json index b04638c0d..61f731c71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2384,6 +2384,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/packages/components/dropdown/src/Dropdown.doc.mdx b/packages/components/dropdown/src/Dropdown.doc.mdx index 6049f7499..14e27f431 100644 --- a/packages/components/dropdown/src/Dropdown.doc.mdx +++ b/packages/components/dropdown/src/Dropdown.doc.mdx @@ -1,5 +1,6 @@ import { Meta, Canvas } from '@storybook/addon-docs' import { ArgTypes as ExtendedArgTypes } from '@docs/helpers/ArgTypes' +import { Callout } from '@docs/helpers/Callout' import { Dropdown } from '.' @@ -9,7 +10,44 @@ import * as stories from './Dropdown.stories' # Dropdown -Displays a list of options for the user to pick from—triggered by a button. +Dropdown is an interactive element +that allows users to select an option from a list of choices presented in a collapsible menu. +It saves space on the interface by concealing the options until the user interacts with the component. + +Displays a **closed** list of options for the user to pick triggered by a button. + +#### Dropdown is not a ComboBox + + + Dropdowns and Comboboxes are similar in that every both provide visual selectable options for + users. However, there are important distinctions to be aware of. + + +#### Dropdown, Select and Combobox + +Dropdown, Select and Combobox are visually similar when its options are not displayed +(unfocused closed trigger element). +All of them also share the same behavior on its options/items, toggling its own item's +value to its parent selected state. + +However, they have different behaviors and purposes: + +- Dropdown: + - **Closed** list of options for the user to pick triggered by a button. + - The trigger element is rolled as a **button**. + - The options are displayed fully customizable inside a **popover** (normally). +- Select: + - **Closed** list of options for the user to pick triggered by a button. + - The structural select role, an abstract role, is superclass role for four form widgets, `listbox`, `menu`, + `radiogroup`, and `tree`, which allow users to make selections from a set of choices. + - The options can only be text and are its look and feel is managed by the browser natively. + - Cannot be used for multiple selections. +- Combobox + - **Open** list of options for the user to pick triggered by a button. + - The trigger element is rolled as an **input**. + - The options can be text or any other JSX markup. + - Can be used for multiple selections. + - Can be used inside a form. ## Install diff --git a/packages/components/dropdown/src/DropdownDivider.tsx b/packages/components/dropdown/src/DropdownDivider.tsx index dbe7df964..3653cdbf9 100644 --- a/packages/components/dropdown/src/DropdownDivider.tsx +++ b/packages/components/dropdown/src/DropdownDivider.tsx @@ -1,12 +1,14 @@ import { cx } from 'class-variance-authority' +import { forwardRef, type Ref } from 'react' interface DividerProps { className?: string } -export const Divider = ({ className }: DividerProps) => { - return
-} +export const Divider = forwardRef( + ({ className }: DividerProps, forwardedRef: Ref) => { + return
+ } +) -Divider.id = 'Divider' Divider.displayName = 'Dropdown.Divider' diff --git a/packages/components/dropdown/src/DropdownGroup.tsx b/packages/components/dropdown/src/DropdownGroup.tsx index c5f58c9f7..1cc1aee6c 100644 --- a/packages/components/dropdown/src/DropdownGroup.tsx +++ b/packages/components/dropdown/src/DropdownGroup.tsx @@ -1,5 +1,5 @@ import { cx } from 'class-variance-authority' -import { ReactNode } from 'react' +import { forwardRef, ReactNode, type Ref } from 'react' import { DropdownGroupProvider, useDropdownGroupContext } from './DropdownItemsGroupContext' @@ -8,23 +8,28 @@ interface GroupProps { className?: string } -export const Group = ({ children, ...props }: GroupProps) => { - return ( - - {children} - - ) -} +export const Group = forwardRef( + ({ children, ...props }: GroupProps, forwardedRef: Ref) => { + return ( + + + {children} + + + ) + } +) -const GroupContent = ({ children, className }: GroupProps) => { - const { labelId } = useDropdownGroupContext() +const GroupContent = forwardRef( + ({ children, className }: GroupProps, forwardedRef: Ref) => { + const { labelId } = useDropdownGroupContext() - return ( -
- {children} -
- ) -} + return ( +
+ {children} +
+ ) + } +) -Group.id = 'Group' Group.displayName = 'Dropdown.Group' diff --git a/packages/components/dropdown/src/DropdownItem.tsx b/packages/components/dropdown/src/DropdownItem.tsx index 256589257..a2823939c 100644 --- a/packages/components/dropdown/src/DropdownItem.tsx +++ b/packages/components/dropdown/src/DropdownItem.tsx @@ -1,5 +1,5 @@ import { cx } from 'class-variance-authority' -import { ReactNode } from 'react' +import { forwardRef, ReactNode, type Ref } from 'react' import { useDropdownContext } from './DropdownContext' import { DropdownItemProvider, useDropdownItemContext } from './DropdownItemContext' @@ -11,41 +11,50 @@ export interface ItemProps { className?: string } -export const Item = ({ children, ...props }: ItemProps) => { - const { value, disabled } = props +export const Item = forwardRef( + ({ children, ...props }: ItemProps, forwardedRef: Ref) => { + const { value, disabled } = props + + return ( + + + {children} + + + ) + } +) + +const ItemContent = forwardRef( + ( + { className, disabled = false, value, children }: ItemProps, + forwardedRef: Ref + ) => { + const { getItemProps, highlightedItem, lastInteractionType } = useDropdownContext() + + const { textId, index, itemData, isSelected } = useDropdownItemContext() + + const isHighlighted = highlightedItem?.value === value + + return ( +
  • + {children} +
  • + ) + } +) - return ( - - {children} - - ) -} - -const ItemContent = ({ className, disabled = false, value, children }: ItemProps) => { - const { getItemProps, highlightedItem, lastInteractionType } = useDropdownContext() - - const { textId, index, itemData, isSelected } = useDropdownItemContext() - - const isHighlighted = highlightedItem?.value === value - - return ( -
  • - {children} -
  • - ) -} - -Item.id = 'Item' Item.displayName = 'Dropdown.Item' diff --git a/packages/components/dropdown/src/DropdownItemIndicator.tsx b/packages/components/dropdown/src/DropdownItemIndicator.tsx index 44d0cb507..2668403f8 100644 --- a/packages/components/dropdown/src/DropdownItemIndicator.tsx +++ b/packages/components/dropdown/src/DropdownItemIndicator.tsx @@ -1,6 +1,6 @@ import { Check } from '@spark-ui/icons/dist/icons/Check' import { cx } from 'class-variance-authority' -import { ReactNode } from 'react' +import { forwardRef, ReactNode, type Ref } from 'react' import { useDropdownContext } from './DropdownContext' import { useDropdownItemContext } from './DropdownItemContext' @@ -11,18 +11,22 @@ export interface ItemIndicatorProps { label?: string } -export const ItemIndicator = ({ className, children, label }: ItemIndicatorProps) => { - const { disabled, isSelected } = useDropdownItemContext() - const { multiple } = useDropdownContext() - const childElement = - children || (multiple ? : ) +export const ItemIndicator = forwardRef( + ({ className, children, label }: ItemIndicatorProps, forwardedRef: Ref) => { + const { disabled, isSelected } = useDropdownItemContext() + const { multiple } = useDropdownContext() + const childElement = + children || (multiple ? : ) - return ( - - {isSelected && childElement} - - ) -} + return ( + + {isSelected && childElement} + + ) + } +) -ItemIndicator.id = 'ItemIndicator' ItemIndicator.displayName = 'Dropdown.ItemIndicator' diff --git a/packages/components/dropdown/src/DropdownItemText.tsx b/packages/components/dropdown/src/DropdownItemText.tsx index 94324394f..8a308c543 100644 --- a/packages/components/dropdown/src/DropdownItemText.tsx +++ b/packages/components/dropdown/src/DropdownItemText.tsx @@ -1,6 +1,6 @@ import { useId } from '@radix-ui/react-id' import { cx } from 'class-variance-authority' -import { useEffect } from 'react' +import { forwardRef, type Ref, useEffect } from 'react' import { useDropdownItemContext } from './DropdownItemContext' @@ -8,23 +8,24 @@ export interface ItemTextProps { children: string } -export const ItemText = ({ children }: ItemTextProps) => { - const id = useId() +export const ItemText = forwardRef( + ({ children }: ItemTextProps, forwardedRef: Ref) => { + const id = useId() - const { setTextId } = useDropdownItemContext() + const { setTextId } = useDropdownItemContext() - useEffect(() => { - setTextId(id) + useEffect(() => { + setTextId(id) - return () => setTextId(undefined) - }) + return () => setTextId(undefined) + }) - return ( - - {children} - - ) -} + return ( + + {children} + + ) + } +) -ItemText.id = 'ItemText' ItemText.displayName = 'Dropdown.ItemText' diff --git a/packages/components/dropdown/src/DropdownItems.tsx b/packages/components/dropdown/src/DropdownItems.tsx index e768b5650..7a32ae94d 100644 --- a/packages/components/dropdown/src/DropdownItems.tsx +++ b/packages/components/dropdown/src/DropdownItems.tsx @@ -1,5 +1,5 @@ import { cx } from 'class-variance-authority' -import React, { ReactNode } from 'react' +import { forwardRef, ReactNode, type Ref } from 'react' import { useDropdownContext } from './DropdownContext' @@ -8,30 +8,33 @@ interface ItemsProps { className?: string } -export const Items = React.forwardRef(({ children, className, ...props }: ItemsProps, ref) => { - const { isOpen, getMenuProps, hasPopover, setLastInteractionType } = useDropdownContext() +export const Items = forwardRef( + ({ children, className, ...props }: ItemsProps, forwardedRef: Ref) => { + const { isOpen, getMenuProps, hasPopover, setLastInteractionType } = useDropdownContext() - const downshiftProps = getMenuProps({ - onMouseMove: () => { - setLastInteractionType('mouse') - }, - }) + const downshiftProps = getMenuProps({ + onMouseMove: () => { + setLastInteractionType('mouse') + }, + }) - return ( -
      - {children} -
    - ) -}) + return ( +
      + {children} +
    + ) + } +) Items.displayName = 'Dropdown.Items' diff --git a/packages/components/dropdown/src/DropdownLabel.tsx b/packages/components/dropdown/src/DropdownLabel.tsx index 3ce853466..87052bbc4 100644 --- a/packages/components/dropdown/src/DropdownLabel.tsx +++ b/packages/components/dropdown/src/DropdownLabel.tsx @@ -1,4 +1,5 @@ import { cx } from 'class-variance-authority' +import { forwardRef, type Ref } from 'react' import { useDropdownGroupContext } from './DropdownItemsGroupContext' @@ -7,15 +8,20 @@ interface LabelProps { className?: string } -export const Label = ({ children, className }: LabelProps) => { - const { labelId } = useDropdownGroupContext() +export const Label = forwardRef( + ({ children, className }: LabelProps, forwardedRef: Ref) => { + const { labelId } = useDropdownGroupContext() - return ( -
    - {children} -
    - ) -} + return ( +
    + {children} +
    + ) + } +) -Label.id = 'Label' Label.displayName = 'Dropdown.Label' diff --git a/packages/components/dropdown/src/DropdownLeadingIcon.tsx b/packages/components/dropdown/src/DropdownLeadingIcon.tsx index 19fbf636b..1966ef549 100644 --- a/packages/components/dropdown/src/DropdownLeadingIcon.tsx +++ b/packages/components/dropdown/src/DropdownLeadingIcon.tsx @@ -9,5 +9,4 @@ export const LeadingIcon = ({ children }: { children: ReactElement }) => { ) } -LeadingIcon.id = 'LeadingIcon' LeadingIcon.displayName = 'Dropdown.LeadingIcon' diff --git a/packages/components/dropdown/src/DropdownPopover.tsx b/packages/components/dropdown/src/DropdownPopover.tsx index 03a93e12d..14582586e 100644 --- a/packages/components/dropdown/src/DropdownPopover.tsx +++ b/packages/components/dropdown/src/DropdownPopover.tsx @@ -1,38 +1,44 @@ import { Popover as SparkPopover } from '@spark-ui/popover' import { cx } from 'class-variance-authority' -import { ComponentProps, useEffect } from 'react' +import { ComponentProps, forwardRef, Ref, useEffect } from 'react' import { useDropdownContext } from './DropdownContext' -export const Popover = ({ - children, - matchTriggerWidth = true, - sideOffset = 4, - ...props -}: ComponentProps) => { - const { isOpen, hasPopover, setHasPopover } = useDropdownContext() +export const Popover = forwardRef( + ( + { + children, + matchTriggerWidth = true, + sideOffset = 4, + ...props + }: ComponentProps, + forwardedRef: Ref + ) => { + const { isOpen, hasPopover, setHasPopover } = useDropdownContext() - useEffect(() => { - setHasPopover(true) + useEffect(() => { + setHasPopover(true) - return () => setHasPopover(false) - }, [setHasPopover]) + return () => setHasPopover(false) + }, [setHasPopover]) - if (!hasPopover) return children + if (!hasPopover) return children - return ( - - {children} - - ) -} + return ( + + {children} + + ) + } +) -Popover.id = 'Popover' Popover.displayName = 'Dropdown.Popover' diff --git a/packages/components/dropdown/src/DropdownTrigger.tsx b/packages/components/dropdown/src/DropdownTrigger.tsx index 73f476239..962665518 100644 --- a/packages/components/dropdown/src/DropdownTrigger.tsx +++ b/packages/components/dropdown/src/DropdownTrigger.tsx @@ -3,7 +3,7 @@ import { ArrowHorizontalDown } from '@spark-ui/icons/dist/icons/ArrowHorizontalD import { Popover } from '@spark-ui/popover' import { VisuallyHidden } from '@spark-ui/visually-hidden' import { cva } from 'class-variance-authority' -import { Fragment, ReactNode } from 'react' +import { forwardRef, Fragment, ReactNode, type Ref } from 'react' import { useDropdownContext } from './DropdownContext' import { DropdownStateIndicator } from './DropdownStateIndicator' @@ -33,51 +33,57 @@ const styles = cva( } ) -export const Trigger = ({ 'aria-label': ariaLabel, children, className }: TriggerProps) => { - const { - getToggleButtonProps, - getDropdownProps, - getLabelProps, - hasPopover, - state, - setLastInteractionType, - } = useDropdownContext() +export const Trigger = forwardRef( + ( + { 'aria-label': ariaLabel, children, className }: TriggerProps, + forwardedRef: Ref + ) => { + const { + getToggleButtonProps, + getDropdownProps, + getLabelProps, + hasPopover, + state, + setLastInteractionType, + } = useDropdownContext() - const [WrapperComponent, wrapperProps] = hasPopover - ? [Popover.Trigger, { asChild: true }] - : [Fragment, {}] + const [WrapperComponent, wrapperProps] = hasPopover + ? [Popover.Trigger, { asChild: true }] + : [Fragment, {}] - return ( - <> - {ariaLabel && ( - - - - )} - - - - - ) -} +
    + + + + +
    + + + + ) + } +) -Trigger.id = 'Trigger' Trigger.displayName = 'Dropdown.Trigger' diff --git a/packages/components/dropdown/src/DropdownValue.tsx b/packages/components/dropdown/src/DropdownValue.tsx index 43f97629f..0f76f4f98 100644 --- a/packages/components/dropdown/src/DropdownValue.tsx +++ b/packages/components/dropdown/src/DropdownValue.tsx @@ -1,5 +1,5 @@ import { cx } from 'class-variance-authority' -import { ReactNode } from 'react' +import { forwardRef, ReactNode, type Ref } from 'react' import { useDropdownContext } from './DropdownContext' @@ -9,22 +9,23 @@ export interface ValueProps { placeholder: string } -export const Value = ({ children, className, placeholder }: ValueProps) => { - const { selectedItem, multiple, selectedItems } = useDropdownContext() +export const Value = forwardRef( + ({ children, className, placeholder }: ValueProps, forwardedRef: Ref) => { + const { selectedItem, multiple, selectedItems } = useDropdownContext() - const hasSelectedItems = !!(multiple ? selectedItems.length : selectedItem) - const text = multiple ? selectedItems[0]?.text : selectedItem?.text - const suffix = selectedItems.length > 1 ? `, +${selectedItems.length - 1}` : '' + const hasSelectedItems = !!(multiple ? selectedItems.length : selectedItem) + const text = multiple ? selectedItems[0]?.text : selectedItem?.text + const suffix = selectedItems.length > 1 ? `, +${selectedItems.length - 1}` : '' - return ( - - - {!hasSelectedItems ? placeholder : children || text} + return ( + + + {!hasSelectedItems ? placeholder : children || text} + + {suffix && {suffix}} - {suffix && {suffix}} - - ) -} + ) + } +) -Value.id = 'Value' Value.displayName = 'Dropdown.Value' diff --git a/packages/components/dropdown/src/utils.ts b/packages/components/dropdown/src/utils.ts index f0e4f0729..c3e2d562e 100644 --- a/packages/components/dropdown/src/utils.ts +++ b/packages/components/dropdown/src/utils.ts @@ -31,8 +31,8 @@ export const getElementByIndex = (map: ItemsMap, index: number) => { return key !== undefined ? map.get(key) : undefined } -const getElementId = (element?: ReactElement) => { - return element ? (element.type as FC & { id?: string }).id : '' +const getElementDisplayName = (element?: ReactElement) => { + return element ? (element.type as FC & { displayName?: string }).displayName : '' } export const getOrderedItems = ( @@ -42,7 +42,7 @@ export const getOrderedItems = ( React.Children.forEach(children, child => { if (!isValidElement(child)) return - if (getElementId(child) === 'Item') { + if (getElementDisplayName(child) === 'Dropdown.Item') { const childProps = child.props as ItemProps result.push({ value: childProps.value, @@ -72,7 +72,7 @@ export const getItemText = (children: ReactNode, itemText = ''): string => { React.Children.forEach(children, child => { if (!isValidElement(child)) return - if (getElementId(child) === 'ItemText') { + if (getElementDisplayName(child) === 'Dropdown.ItemText') { itemText = child.props.children }