diff --git a/packages/react/src/ActionList/TrailingAction.tsx b/packages/react/src/ActionList/TrailingAction.tsx index 933c3a6f584..d42dd202082 100644 --- a/packages/react/src/ActionList/TrailingAction.tsx +++ b/packages/react/src/ActionList/TrailingAction.tsx @@ -1,9 +1,10 @@ import type React from 'react' -import {forwardRef} from 'react' +import {forwardRef, useContext} from 'react' import {Button, IconButton} from '../Button' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {clsx} from 'clsx' import classes from './ActionList.module.css' +import {ActionListContainerContext} from './ActionListContainerContext' type ElementProps = | { @@ -30,6 +31,17 @@ export type ActionListTrailingActionProps = ElementProps & { export const TrailingAction = forwardRef( ({as = 'button', icon, label, href = null, className, style, loading, ...props}, forwardedRef) => { + // Use context from ActionList + const {selectionVariant} = useContext(ActionListContainerContext) + // TODO: Rename + const withinMenu = selectionVariant === 'single' || selectionVariant === 'multiple' + + if (withinMenu) { + console.log( + 'Warning: ActionList.TrailingAction should not be used within selectable ActionLists. Please remove it to avoid unexpected behavior.', + ) + } + return ( {icon ? ( @@ -38,13 +50,15 @@ export const TrailingAction = forwardRef( aria-label={label} icon={icon} variant="invisible" - tooltipDirection="w" + tooltipDirection="e" href={href} loading={loading} data-loading={Boolean(loading)} // @ts-expect-error StyledButton wants both Anchor and Button refs ref={forwardedRef} className={classes.TrailingActionButton} + aria-hidden={!withinMenu ? 'true' : undefined} + tabIndex={!withinMenu ? -1 : undefined} {...props} /> ) : ( @@ -57,6 +71,7 @@ export const TrailingAction = forwardRef( data-loading={Boolean(loading)} ref={forwardedRef} className={classes.TrailingActionButton} + aria-hidden={withinMenu ? 'true' : undefined} {...props} > {label} diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index c0cd18f64ef..9a5918738a6 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -225,7 +225,9 @@ export const AnchoredOverlay: React.FC> + +const options = [ + {label: 'Apple', id: '1'}, + {label: 'Banana', id: '2'}, + {label: 'Cherry', id: '3'}, + {label: 'Date', id: '4'}, + {label: 'Elderberry', id: '5'}, + {label: 'Fig', id: '6'}, + {label: 'Grape', id: '7'}, +] + +export const Playground: StoryFn> = args => + +Playground.argTypes = { + options: { + control: 'object', + }, +} + +export const Default = () => { + return +} + +const groupedOptions = [ + {label: 'Apple', id: '1', group: 'Fruits'}, + {label: 'Banana', id: '2', group: 'Fruits'}, + {label: 'Carrot', id: '3', group: 'Vegetables'}, + {label: 'Broccoli', id: '4', group: 'Vegetables'}, +] + +export const WithGroups = () => { + return +} + +const optionsWithSelected = [ + {label: 'Apple', id: '1'}, + {label: 'Banana', id: '2', selected: true}, + {label: 'Cherry', id: '3'}, + {label: 'Date', id: '4'}, +] + +export const WithSelection = () => { + return +} diff --git a/packages/react/src/Combobox/Combobox.test.tsx b/packages/react/src/Combobox/Combobox.test.tsx new file mode 100644 index 00000000000..db720b929dc --- /dev/null +++ b/packages/react/src/Combobox/Combobox.test.tsx @@ -0,0 +1,68 @@ +import {describe, expect, it} from 'vitest' +import {render} from '@testing-library/react' +import {Combobox} from '../Combobox' + +describe('Combobox', () => { + it('should support `className` on the root element', () => { + const Element = () => + expect(render().container.firstChild).toHaveClass('test-class-name') + }) + + it('renders with label', () => { + const {getByText} = render() + expect(getByText('Test Label')).toBeInTheDocument() + }) + + it('renders Combobox.Input with correct role', () => { + const {container} = render() + const input = container.querySelector('input') + expect(input).toHaveAttribute('role', 'combobox') + expect(input).toHaveAttribute('aria-autocomplete', 'list') + }) + + it('renders Combobox.List with correct role', () => { + const {container} = render( + + Option 1 + , + ) + const list = container.querySelector('ul') + expect(list).toHaveAttribute('role', 'listbox') + }) + + it('renders Combobox.Option with correct role', () => { + const {getByRole} = render(Test Option) + expect(getByRole('option')).toBeInTheDocument() + expect(getByRole('option')).toHaveTextContent('Test Option') + }) + + it('renders selected option with aria-selected', () => { + const {getByRole} = render(Selected Option) + expect(getByRole('option')).toHaveAttribute('aria-selected', 'true') + }) + + it('renders complete combobox structure', () => { + const {container, getByPlaceholderText} = render( + + + + Option 1 + Option 2 + + , + ) + expect(getByPlaceholderText('Search...')).toBeInTheDocument() + expect(container.querySelectorAll('[role="option"]')).toHaveLength(2) + }) + + it('renders grouped options', () => { + const {getByText} = render( + + + Option 1 + + , + ) + expect(getByText('Group 1')).toBeInTheDocument() + }) +}) diff --git a/packages/react/src/Combobox/Combobox.tsx b/packages/react/src/Combobox/Combobox.tsx new file mode 100644 index 00000000000..65a663e9974 --- /dev/null +++ b/packages/react/src/Combobox/Combobox.tsx @@ -0,0 +1,333 @@ +import {clsx} from 'clsx' +import classes from '../ActionList/ActionList.module.css' +import styles from '../FilteredActionList/FilteredActionList.module.css' +import selectPanelStyles from '../SelectPanel/SelectPanel.module.css' +import React, {useRef} from 'react' +import {MappedActionListItem} from '../FilteredActionList/components/MappedActionListItem' +import {AnchoredOverlay, type AnchoredOverlayProps} from '../AnchoredOverlay' +import TextInput from '../TextInput' +import styles2 from './Combobox.module.css' +import Heading from '../Heading' +import {FocusKeys, useFocusZone} from '../hooks/useFocusZone' +import {scrollIntoView} from '@primer/behaviors' +import {useProvidedRefOrCreate} from '../hooks' + +type renderAnchor = , 'aria-label' | 'aria-labelledby'>>( + props: T, +) => JSX.Element | null + +export type ComboboxRootProps = { + children?: React.ReactNode + /** Defines the keyboard navigation mode for the Combobox */ + focusMode?: 'roving' | 'active-descendant' + /** A function that renders the anchor element for the Combobox */ + renderAnchor?: renderAnchor | null + + anchoredOverlayProps?: Omit + + open?: boolean // TODO: DRY fix + anchorId?: string // TODO: DRY fix + side?: AnchoredOverlayProps['side'] // TODO: DRY fix + anchorRef?: React.RefObject // TODO: DRY fix + width?: AnchoredOverlayProps['width'] // TODO: DRY fix + anchorOffset?: number // TODO: DRY fix +} + +export type ComboboxOverlayProps = { + /** The content of the Combobox */ + children: React.ReactNode + /** A function that renders the anchor element for the Combobox */ + renderAnchor?: renderAnchor | null + /** Whether the Combobox is open */ + open?: boolean + + anchoredOverlayProps?: Omit + anchorRef?: React.RefObject // todo: adjust + listboxRef?: React.RefObject // todo: adjust +} + +export const ComboboxContext = React.createContext<{ + isSubmenu?: boolean + focusMode?: 'roving' | 'active-descendant' +}>({isSubmenu: false, focusMode: 'active-descendant'}) + +export const ComboboxRoot = ({children, focusMode: _focusMode, ...rest}: ComboboxRootProps) => { + return ( + + {children} + + ) +} + +type ComboboxOptions = { + label: string + id: string + group?: string + selected?: boolean +} + +type ComboboxProps = { + children?: React.ReactNode + options?: ComboboxOptions[] + label?: string +} + +export const Combobox = ({children, options, ...rest}: ComboboxProps) => { + const listboxRef = useRef(null) + + // TODO: Add support for groups - let's use Listbox + // TODO: Add label prop + + return ( + + {children ? ( + children + ) : ( + + {options?.map(option => {option.label})} + + )} + + ) +} + +const ComboboxOverlay = ({ + children, + open, + renderAnchor, + anchorRef, + listboxRef, + anchoredOverlayProps, + ...rest +}: ComboboxOverlayProps) => { + // TODO: Add conditional logic for focusMode prop + const {focusMode} = React.useContext(ComboboxContext) + const [comboboxOpen, setComboboxOpen] = React.useState(false) + const inputTrigger = React.useRef(null) + + const renderComboboxAnchor: AnchoredOverlayProps['renderAnchor'] = props => { + if (renderAnchor === null || !renderAnchor) { + return ( + setComboboxOpen(true)} + /> + ) + } + + const anchor = renderAnchor(props) + if (React.isValidElement(anchor)) { + return anchor + } + + // Fallback to default input + return ( + setComboboxOpen(true)} + /> + ) + } + + const activeDescendantRef = React.useRef(null) + const menuScrollMargins = {startMargin: 0, endMargin: 8} + + const focusZoneSettings = + focusMode === 'active-descendant' && listboxRef + ? { + containerRef: listboxRef, + bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, + focusOutBehavior: 'wrap' as const, + focusableElementFilter: (element: HTMLElement) => { + return !(element instanceof HTMLInputElement) && !element.hasAttribute('aria-hidden') + }, + activeDescendantFocus: inputTrigger, + onActiveDescendantChanged: ( + current: HTMLElement | undefined, + previous: HTMLElement | undefined, + directlyActivated: boolean, + ) => { + activeDescendantRef.current = current ?? null + + if (current && listboxRef.current && directlyActivated) { + scrollIntoView(current, listboxRef.current, menuScrollMargins) + } + }, + focusInStrategy: 'previous' as const, + } + : { + focusableElementFilter: (element: HTMLElement) => { + return ( + !element.hasAttribute('aria-hidden') && + !(element instanceof HTMLInputElement) && + !(element instanceof HTMLButtonElement) + ) + }, + focusOutBehavior: 'wrap' as const, + } + + return ( + setComboboxOpen(true)} + renderAnchor={renderAnchor === null ? null : renderComboboxAnchor} + anchorRef={renderAnchor === null ? anchorRef || inputTrigger : inputTrigger} + // focusTrapSettings={{disabled: focusMode === 'active-descendant' && !renderAnchor ? true : false}} + width="medium" + focusZoneSettings={focusZoneSettings} + onClose={() => { + if (renderAnchor) setComboboxOpen(false) + }} + displayCloseButton={false} + {...anchoredOverlayProps} + {...rest} + > + {children} + + ) +} + +type ComboboxInputProps = { + className?: string + placeholder?: string + onFocus?: React.FocusEventHandler + value?: string + onChange?: React.ChangeEventHandler + listboxRef?: React.RefObject +} + +export const ComboboxInput = React.forwardRef(function ComboboxInput( + {className, placeholder = 'Search...', onFocus, value, onChange, listboxRef, ...rest}, + ref, +) { + const inputRef = useProvidedRefOrCreate(ref as React.RefObject) + + const activeDescendantRef = React.useRef() + const scrollContainerRef = listboxRef + const menuScrollMargins = {startMargin: 0, endMargin: 8} + + const {focusMode} = React.useContext(ComboboxContext) + + // We only want to use the focus zone if a external listboxRef is provided and - + // the focusMode is set to "active-descendant" + useFocusZone( + listboxRef && focusMode === 'active-descendant' + ? { + containerRef: listboxRef, + bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, + focusOutBehavior: 'wrap', + focusableElementFilter: element => { + return !(element instanceof HTMLInputElement) && !element.hasAttribute('aria-hidden') + }, + activeDescendantFocus: inputRef, + onActiveDescendantChanged: (current, previous, directlyActivated) => { + activeDescendantRef.current = current + + if (current && scrollContainerRef?.current && directlyActivated) { + scrollIntoView(current, scrollContainerRef.current, menuScrollMargins) + } + }, + focusInStrategy: 'previous', + } + : undefined, + [], + ) + + return ( +