Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Menu Converged - submenuIndicator slotted content 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png 413 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default - RTL.submenus open.chromium.png 404 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 728 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 130 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 91 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 51 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 26 Changed
vr-tests-react-components/TagPicker 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled - RTL.disabled input hover.chromium.png 635 Changed

There were 2 duplicate changes discarded. Check the build logs for more information.

"type": "patch",
"comment": "feat: extract shared useComboboxExpandIconSlot hook for the Combobox expand icon slot",
"packageName": "@fluentui/react-combobox",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ export const useComboboxBaseState: (props: ComboboxBaseProps & {
// @public (undocumented)
export function useComboboxContextValues(state: Omit<ComboboxBaseState, 'freeform'> & Pick<ComboboxState, 'activeDescendantController'>): ComboboxBaseContextValues;

// @public
export function useComboboxExpandIconSlot(expandIconFromProps: Slot<'span'> | undefined | null, options: UseComboboxExpandIconSlotOptions): SlotComponentType<ExtractSlotProps<Slot<'span'>>> | undefined;

// @public (undocumented)
export type UseComboboxExpandIconSlotOptions = {
disabled?: boolean;
open: boolean;
'aria-label'?: string;
'aria-labelledby'?: string;
triggerLabelledBy?: string;
};

// @internal (undocumented)
export function useComboboxFilter<T extends {
children: React_2.ReactNode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ exports[`Combobox renders an open listbox 1`] = `
>
<input
aria-activedescendant="fluent-option_r_k_"
aria-controls="fluent-listbox_r_j_"
aria-controls="fluent-listbox_r_i_"
aria-expanded="true"
class="fui-Combobox__input"
data-tabster="{\\"focusable\\":{\\"ignoreKeydown\\":{\\"Escape\\":true}}}"
Expand Down Expand Up @@ -114,7 +114,7 @@ exports[`Combobox renders an open listbox 1`] = `
</span>
<div
class="fui-Listbox fui-Combobox__listbox"
id="fluent-listbox_r_j_"
id="fluent-listbox_r_i_"
role="listbox"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
getPartitionedNativeProps,
mergeCallbacks,
useEventCallback,
useId,
useMergedRefs,
slot,
useOnClickOutside,
Expand All @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ExtractSlotProps<Slot<'span'>>> | 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ComboboxProps } from '.';
// The Combobox `id` prop maps to the trigger <input> 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"]';
Expand Down Expand Up @@ -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(
<BasicCombobox
expandIcon={{ 'data-testid': 'expand-icon', children: 'v' } as React.HTMLAttributes<HTMLSpanElement>}
/>,
);
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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -29,7 +29,11 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref<HTMLInputElemen
});

const { appearance: _appearance, size: _size, ...baseState } = internalState;
const { clearable, clearSelection, disabled, hasFocus, multiselect, open, selectedOptions } = baseState;
const { clearable, clearSelection, disabled, hasFocus, multiselect, open, selectedOptions, setOpen } = baseState;
// The listbox is a native popover: light-dismiss closes it on pointerup, before `click` runs.
// Toggling has to be based on the state captured when the interaction started, otherwise clicking
// the icon to close would immediately reopen it.
const openOnPointerDownRef = React.useRef(false);

const triggerSlot = useInputTriggerSlot(mergedProps.input ?? {}, useMergedRefs(triggerRef, activeParentRef, ref), {
state: internalState,
Expand All @@ -56,19 +60,45 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref<HTMLInputElemen
},
listbox: open || hasFocus ? listbox : undefined,
clearIcon: slot.optional(mergedProps.clearIcon, {
defaultProps: { 'aria-hidden': 'true' },
defaultProps: { 'aria-hidden': 'true', role: 'button' },
elementType: 'span',
renderByDefault: true,
}),
expandIcon: slot.optional(mergedProps.expandIcon, {
renderByDefault: true,
elementType: 'span',
expandIcon: useComboboxExpandIconSlot(mergedProps.expandIcon, {
disabled,
open,
'aria-label': mergedProps['aria-label'],
'aria-labelledby': mergedProps['aria-labelledby'],
triggerLabelledBy: triggerSlot['aria-labelledby'],
}),
showClearIcon,
activeDescendantController,
...baseState,
};

const onExpandIconMouseDown = useEventCallback(
// eslint-disable-next-line react-hooks/refs
mergeCallbacks(state.expandIcon?.onMouseDown, (event: React.MouseEvent<HTMLSpanElement>) => {
// 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<HTMLSpanElement>) => {
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<HTMLSpanElement>) => {
ev.preventDefault();
Expand Down
Loading