diff --git a/change/@fluentui-react-combobox-3f1c7a2e-9b41-4d5e-8c6a-1e2b7d4f0a93.json b/change/@fluentui-react-combobox-3f1c7a2e-9b41-4d5e-8c6a-1e2b7d4f0a93.json new file mode 100644 index 00000000000000..920df423e67b94 --- /dev/null +++ b/change/@fluentui-react-combobox-3f1c7a2e-9b41-4d5e-8c6a-1e2b7d4f0a93.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: extract shared useComboboxExpandIconSlot hook for the Combobox expand icon slot", + "packageName": "@fluentui/react-combobox", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-headless-components-preview-combobox-expand-icon.json b/change/@fluentui-react-headless-components-preview-combobox-expand-icon.json new file mode 100644 index 00000000000000..be1fbac75564ba --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-combobox-expand-icon.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: wire up Combobox expandIcon so it toggles the listbox and exposes button semantics", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-combobox/library/etc/react-combobox.api.md b/packages/react-components/react-combobox/library/etc/react-combobox.api.md index fa49c97753a481..6aee9ea8b150c8 100644 --- a/packages/react-components/react-combobox/library/etc/react-combobox.api.md +++ b/packages/react-components/react-combobox/library/etc/react-combobox.api.md @@ -292,6 +292,18 @@ export const useComboboxBaseState: (props: ComboboxBaseProps & { // @public (undocumented) export function useComboboxContextValues(state: Omit & Pick): ComboboxBaseContextValues; +// @public +export function useComboboxExpandIconSlot(expandIconFromProps: Slot<'span'> | undefined | null, options: UseComboboxExpandIconSlotOptions): SlotComponentType>> | undefined; + +// @public (undocumented) +export type UseComboboxExpandIconSlotOptions = { + disabled?: boolean; + open: boolean; + 'aria-label'?: string; + 'aria-labelledby'?: string; + triggerLabelledBy?: string; +}; + // @internal (undocumented) export function useComboboxFilter
diff --git a/packages/react-components/react-combobox/library/src/components/Combobox/useCombobox.tsx b/packages/react-components/react-combobox/library/src/components/Combobox/useCombobox.tsx index 1dba0dfeffc572..50ab7b7161b7f7 100644 --- a/packages/react-components/react-combobox/library/src/components/Combobox/useCombobox.tsx +++ b/packages/react-components/react-combobox/library/src/components/Combobox/useCombobox.tsx @@ -8,7 +8,6 @@ import { getPartitionedNativeProps, mergeCallbacks, useEventCallback, - useId, useMergedRefs, slot, useOnClickOutside, @@ -26,6 +25,7 @@ import type { } from './Combobox.types'; import { useListboxSlot } from '../../utils/useListboxSlot'; import { useInputTriggerSlot } from './useInputTriggerSlot'; +import { useComboboxExpandIconSlot } from './useComboboxExpandIconSlot'; import { isComboboxOptionElement } from '../../utils/isComboboxOptionElement'; import { useTabsterEscapeIgnore } from '../../hooks/useTabsterEscapeIgnore'; @@ -55,7 +55,6 @@ export const useComboboxBase_unstable = ( baseState; const [comboboxPopupRef, comboboxTargetRef] = useComboboxPositioning(props); const { disableAutoFocus = false, freeform, inlinePopup } = props; - const comboId = useId('combobox-'); const { primary: triggerNativeProps, root: rootNativeProps } = getPartitionedNativeProps({ props, @@ -108,14 +107,12 @@ export const useComboboxBase_unstable = ( elementType: 'span', renderByDefault: true, }), - expandIcon: slot.optional(props.expandIcon, { - renderByDefault: true, - defaultProps: { - 'aria-disabled': disabled ? 'true' : undefined, - 'aria-expanded': open, - role: 'button', - }, - elementType: 'span', + expandIcon: useComboboxExpandIconSlot(props.expandIcon, { + disabled, + open, + 'aria-label': props['aria-label'], + 'aria-labelledby': props['aria-labelledby'], + triggerLabelledBy: triggerSlot['aria-labelledby'], }), showClearIcon, activeDescendantController, @@ -145,29 +142,6 @@ export const useComboboxBase_unstable = ( if (state.expandIcon) { state.expandIcon.onMouseDown = onExpandIconMouseDown; - - // If there is no explicit aria-label, calculate default accName attribute for expandIcon button, - // using the following steps: - // 1. If there is an aria-label, it is "Open [aria-label]" - // 2. If there is an aria-labelledby, it is "Open [aria-labelledby target]" (using aria-labelledby + ids) - // 3. If there is no aria-label/ledby attr, it falls back to "Open" - // We can't fall back to a label/htmlFor name because of https://github.com/w3c/accname/issues/179 - const hasExpandLabel = state.expandIcon['aria-label'] || state.expandIcon['aria-labelledby']; - const defaultOpenString = 'Open'; // this is english-only since it is the fallback - if (!hasExpandLabel) { - if (props['aria-labelledby']) { - const chevronId = state.expandIcon.id ?? `${comboId}-chevron`; - const chevronLabelledBy = `${chevronId} ${state.input['aria-labelledby']}`; - - state.expandIcon['aria-label'] = defaultOpenString; - state.expandIcon.id = chevronId; - state.expandIcon['aria-labelledby'] = chevronLabelledBy; - } else if (props['aria-label']) { - state.expandIcon['aria-label'] = `${defaultOpenString} ${props['aria-label']}`; - } else { - state.expandIcon['aria-label'] = defaultOpenString; - } - } } const onClearIconMouseDown = useEventCallback( diff --git a/packages/react-components/react-combobox/library/src/components/Combobox/useComboboxExpandIconSlot.ts b/packages/react-components/react-combobox/library/src/components/Combobox/useComboboxExpandIconSlot.ts new file mode 100644 index 00000000000000..177e6a70f38964 --- /dev/null +++ b/packages/react-components/react-combobox/library/src/components/Combobox/useComboboxExpandIconSlot.ts @@ -0,0 +1,67 @@ +import { slot, useId } from '@fluentui/react-utilities'; +import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react-utilities'; + +export type UseComboboxExpandIconSlotOptions = { + /** Whether the combobox trigger is disabled. */ + disabled?: boolean; + /** Whether the listbox is currently open. */ + open: boolean; + /** `aria-label` passed to the combobox. */ + 'aria-label'?: string; + /** `aria-labelledby` passed to the combobox. */ + 'aria-labelledby'?: string; + /** `aria-labelledby` of the resolved trigger slot, used to build the labelling chain. */ + triggerLabelledBy?: string; +}; + +/** + * Creates the `expandIcon` slot of a combobox: button semantics plus the default accessible name. + * Event handlers are layered on by the caller, since the toggle mechanics differ between the + * positioning-based and popover-based implementations. + */ +export function useComboboxExpandIconSlot( + expandIconFromProps: Slot<'span'> | undefined | null, + options: UseComboboxExpandIconSlotOptions, +): SlotComponentType>> | undefined { + const { disabled, open, triggerLabelledBy } = options; + const fallbackId = useId('combobox-chevron-'); + + const expandIcon = slot.optional(expandIconFromProps, { + renderByDefault: true, + defaultProps: { + 'aria-disabled': disabled ? 'true' : undefined, + 'aria-expanded': open, + role: 'button', + }, + elementType: 'span', + }); + + if (!expandIcon) { + return expandIcon; + } + + // If there is no explicit aria-label, calculate default accName attribute for expandIcon button, + // using the following steps: + // 1. If there is an aria-label, it is "Open [aria-label]" + // 2. If there is an aria-labelledby, it is "Open [aria-labelledby target]" (using aria-labelledby + ids) + // 3. If there is no aria-label/ledby attr, it falls back to "Open" + // We can't fall back to a label/htmlFor name because of https://github.com/w3c/accname/issues/179 + const hasExpandLabel = expandIcon['aria-label'] || expandIcon['aria-labelledby']; + const defaultOpenString = 'Open'; // this is english-only since it is the fallback + + if (!hasExpandLabel) { + if (options['aria-labelledby']) { + const chevronId = expandIcon.id ?? fallbackId; + + expandIcon['aria-label'] = defaultOpenString; + expandIcon.id = chevronId; + expandIcon['aria-labelledby'] = `${chevronId} ${triggerLabelledBy}`; + } else if (options['aria-label']) { + expandIcon['aria-label'] = `${defaultOpenString} ${options['aria-label']}`; + } else { + expandIcon['aria-label'] = defaultOpenString; + } + } + + return expandIcon; +} diff --git a/packages/react-components/react-combobox/library/src/index.ts b/packages/react-components/react-combobox/library/src/index.ts index 89fca52157cfe8..7eab2ca3b6c47a 100644 --- a/packages/react-components/react-combobox/library/src/index.ts +++ b/packages/react-components/react-combobox/library/src/index.ts @@ -74,6 +74,8 @@ export { useComboboxFilter } from './hooks/useComboboxFilter'; export { useComboboxBaseState } from './utils/useComboboxBaseState'; export { useButtonTriggerSlot } from './components/Dropdown/useButtonTriggerSlot'; export { useInputTriggerSlot } from './components/Combobox/useInputTriggerSlot'; +export { useComboboxExpandIconSlot } from './components/Combobox/useComboboxExpandIconSlot'; +export type { UseComboboxExpandIconSlotOptions } from './components/Combobox/useComboboxExpandIconSlot'; export { useListboxSlot } from './utils/useListboxSlot'; export type { ComboboxBaseState, ComboboxBaseProps } from './utils/ComboboxBase.types'; export { isComboboxOptionElement } from './utils/isComboboxOptionElement'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md index 18ccb1784a0c8a..b8348b47c2c4c4 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md @@ -20,7 +20,7 @@ import type { OptionGroupState as OptionGroupState_2 } from '@fluentui/react-com import type { OptionProps as OptionProps_2 } from '@fluentui/react-combobox'; import type { OptionSlots as OptionSlots_2 } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; -import type * as React_2 from 'react'; +import * as React_2 from 'react'; import { renderListbox_unstable as renderListbox } from '@fluentui/react-combobox'; import { renderOption_unstable as renderOption } from '@fluentui/react-combobox'; import { renderOptionGroup_unstable as renderOptionGroup } from '@fluentui/react-combobox'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.cy.tsx index eeedfe742a1d7a..ef5db110802c46 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.cy.tsx @@ -8,6 +8,7 @@ import type { ComboboxProps } from '.'; // The Combobox `id` prop maps to the trigger element. const trigger = '#combobox'; const listbox = '[role="listbox"]'; +const expandIcon = '[data-testid="expand-icon"]'; const option = '[role="option"]'; const multiselectPopup = '[role="menu"]'; const multiselectOption = '[role="menuitemcheckbox"]'; @@ -62,6 +63,19 @@ describe('Combobox', () => { cy.get('body').realClick({ position: 'bottomRight' }); cy.get(listbox).should('not.exist'); }); + + it('toggles on expand icon click', () => { + mount( + } + />, + ); + cy.get(expandIcon).realClick(); + cy.get(listbox).should('exist'); + cy.get(trigger).should('be.focused'); + cy.get(expandIcon).realClick(); + cy.get(listbox).should('not.exist'); + }); }); describe('option selection', () => { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts index 88cfb273951b94..d8b44a09b7655c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts @@ -1,9 +1,9 @@ 'use client'; -import type * as React from 'react'; +import * as React from 'react'; import { mergeCallbacks, useEventCallback, useMergedRefs, slot } from '@fluentui/react-utilities'; import type { ComboboxProps, ComboboxState } from './Combobox.types'; -import { useInputTriggerSlot } from '@fluentui/react-combobox'; +import { useComboboxExpandIconSlot, useInputTriggerSlot } from '@fluentui/react-combobox'; import { Listbox } from '../Dropdown/Listbox'; import { stringifyDataAttribute } from '../../utils'; import { useListboxPopupState } from '../Dropdown/useListboxPopupState'; @@ -29,7 +29,11 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref) => { + // Keep focus on the input instead of moving it to the icon + event.preventDefault(); + openOnPointerDownRef.current = open; + }), + ); + + const onExpandIconClick = useEventCallback( + // eslint-disable-next-line react-hooks/refs + mergeCallbacks(state.expandIcon?.onClick, (event: React.MouseEvent) => { + event.preventDefault(); + setOpen(event, !openOnPointerDownRef.current); + triggerRef.current?.focus(); + }), + ); + + if (state.expandIcon) { + state.expandIcon.onMouseDown = onExpandIconMouseDown; + state.expandIcon.onClick = onExpandIconClick; + } + const onClearIconMouseDown = useEventCallback( mergeCallbacks(state.clearIcon?.onMouseDown, (ev: React.MouseEvent) => { ev.preventDefault();