diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx new file mode 100644 index 00000000000000..04f30838b1e00a --- /dev/null +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { Combobox, Option, makeStyles, useId } from '@fluentui/react-components'; +import { Scenario } from './utils'; + +const useStyles = makeStyles({ + root: { + // Stack the label above the field with a gap + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + gap: '2px', + maxWidth: '400px', + }, +}); + +const options = [ + 'Apple', + 'Apricot', + 'Avocado', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Boysenberry', + 'Cantaloupe', + 'Coconut', + 'Currant', + 'Date', + 'Dragon fruit', + 'Fig', + 'Grape', + 'Grapefruit', + 'Honeydew', + 'Kiwi', + 'Lemon', + 'Lime', + 'Lychee', + 'Mango', + 'Mandarin orange', + 'Melon', + 'Nectarine', + 'Olive', + 'Orange', + 'Papaya', + 'Passion Fruit', + 'Peach', + 'Pear', + 'Persimmon', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberry', + 'Ramnutan', + 'Strawberry', + 'Tangerine', + 'Watermelon', +]; + +export const FilteringCombobox: React.FunctionComponent = () => { + const comboId = useId(); + const styles = useStyles(); + + return ( + +

Test 3

+
Single State Interaction (Unified Hover/Focus)
+
+ + + {options.map(option => ( + + ))} + +
+
+ ); +}; diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/ListOfScenarios.stories.mdx b/apps/public-docsite-v9/src/AccessibilityScenarios/ListOfScenarios.stories.mdx index e71f4feed04269..fdf73a6186f5b8 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/ListOfScenarios.stories.mdx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/ListOfScenarios.stories.mdx @@ -38,6 +38,10 @@ Accessibility scenarios are used to validate accessibility of components and dem - - +## Component: Combobox (single state) + +- + ## Component: Popover - diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx b/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx index c7b74a0d8ab6a2..c263deb488639c 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx @@ -1,99 +1,72 @@ import * as React from 'react'; -import { - Menu, - MenuButton, - MenuGroup, - MenuGroupHeader, - MenuItem, - MenuItemCheckbox, - MenuItemRadio, - MenuList, - MenuPopover, - MenuTrigger, -} from '@fluentui/react-components'; +import { Menu, MenuButton, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-components'; import { Scenario } from './utils'; -interface StatusSubmenuProps { - checkedValues: Record; - onChange: OnCheckedValueChangeCallback; -} - -const StatusSubmenu: React.FunctionComponent = props => { - const { checkedValues, onChange } = props; - - return ( - - - Status - - - - - - Online - - - Away - - - Offline - - - - - ); -}; - -type OnCheckedValueChangeDataType = { - name: string; - checkedItems: string[]; -}; - -type OnCheckedValueChangeCallback = ( - event: React.MouseEvent | React.KeyboardEvent, - data: OnCheckedValueChangeDataType, -) => void; - -type ProfileMenuStatus = { status: Array<'online' | 'away' | 'offline'> }; - export const ProfileMenu: React.FunctionComponent = () => { - const [statusCheckedValues, setStatusCheckedValues] = React.useState({ status: ['online'] }); - const onStatusChange = ( - event: React.MouseEvent | React.KeyboardEvent, - { name, checkedItems }: OnCheckedValueChangeDataType, - ) => { - setStatusCheckedValues(state => ({ ...state, [name]: checkedItems })); - }; - return ( - +

Test 1

+
Single State Interaction (Unified Hover/Focus)
+ - Profile + Pick a fruit - - Information - Help - - - Settings - - Run at startup - - - Show notifications - - - - Account - - Logout - + Apple + Apricot + Avocado + Banana + + + + Berries + + + + + Blackberry + Boysenberry + Blueberry + Strawberry + Rapsberry + Barry White + + + + + Cantaloupe + Coconut + Currant + Dragon Fruit + Grape + Grapefruit + Honeydew + Kiwi + Lemon + Lime + Lychee + Mango + Mandarin Orange + Melon + Nectarine + Olive + Orange + Papaya + Passion Fruit + Peach + Pear + Persimmon + Pineapple + Plum + Pomegranate + Raspberry + Rambutan + Strawberry + Watermelon diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/index.stories.tsx b/apps/public-docsite-v9/src/AccessibilityScenarios/index.stories.tsx index 8f50cd7590c4b2..618bbf09d9176f 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/index.stories.tsx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/index.stories.tsx @@ -5,6 +5,7 @@ export { QuestionnaireAboutFoodCheckboxes } from './Checkbox.stories'; export { TicketOrderFormInputs } from './Input.stories'; export { SiteNavigationLinks } from './Link.stories'; export { ProfileMenu } from './Menu.stories'; +export { FilteringCombobox } from './Combobox.stories'; export { MenuWithSplitItem } from './MenuSplitGroup.stories'; export { AddPeoplePopover } from './Popover.stories'; export { QuestionnaireAboutTransportationRadios } from './RadioGroup.stories'; diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/utils.tsx b/apps/public-docsite-v9/src/AccessibilityScenarios/utils.tsx index 12c494c2dc788d..a3b0f69f538ec2 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/utils.tsx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/utils.tsx @@ -34,11 +34,5 @@ export const Scenario: React.FunctionComponent<{ pageTitle: string }> = ({ pageT document.title = pageTitle + APP_TITLE_SEPARATOR + APP_TITLE; }, [pageTitle]); - return ( -
- -
- {children} -
- ); + return
{children}
; }; diff --git a/packages/react-components/react-combobox/library/src/components/Option/useOption.tsx b/packages/react-components/react-combobox/library/src/components/Option/useOption.tsx index 608262d15b63a1..a4b85b4e06a161 100644 --- a/packages/react-components/react-combobox/library/src/components/Option/useOption.tsx +++ b/packages/react-components/react-combobox/library/src/components/Option/useOption.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { getIntrinsicElementProps, useId, useMergedRefs, slot } from '@fluentui/react-utilities'; +import { getIntrinsicElementProps, useId, useMergedRefs, slot, useEventCallback } from '@fluentui/react-utilities'; import { useActiveDescendantContext } from '@fluentui/react-aria'; import { CheckmarkFilled, Checkmark12Filled } from '@fluentui/react-icons'; import { useListboxContext_unstable } from '../../contexts/ListboxContext'; +import { useSetKeyboardNavigation } from '@fluentui/react-tabster'; import type { OptionValue } from '../../utils/OptionCollection.types'; import type { OptionProps, OptionState } from './Option.types'; @@ -87,6 +88,14 @@ export const useOption_unstable = (props: OptionProps, ref: React.Ref { + if (activeDescendantController.active() !== id) { + activeDescendantController.focus(id); + } + setKeyboardNavigationState(false); + }); + // register option data with context React.useEffect(() => { if (id && optionRef.current) { @@ -114,6 +123,7 @@ export const useOption_unstable = (props: OptionProps, ref: React.Ref context.mouseInputState); + React.useEffect(() => { if (open) { - focusFirst(); + // TODO this should not be called when user is moving the mouse around + // This only applies to submenus + // Submenu should only read this state if it's wrapped by a menulist + // MenuList should set this state + if (!mouseInputState?.isMouseInput()) { + focusFirst(); + } } else { if (!firstMount) { if (targetDocument?.activeElement === targetDocument?.body) { @@ -287,7 +296,7 @@ const useMenuOpenState = ( } // firstMount change should not re-run this effect // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.triggerRef, state.isSubmenu, open, focusFirst, targetDocument, state.menuPopoverRef]); + }, [state.triggerRef, state.isSubmenu, open, focusFirst, targetDocument, state.menuPopoverRef, mouseInputState]); return [open, setOpen] as const; }; diff --git a/packages/react-components/react-menu/library/src/components/MenuItem/MenuItem.types.ts b/packages/react-components/react-menu/library/src/components/MenuItem/MenuItem.types.ts index f8e0576690f440..5632086bbf28bc 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItem/MenuItem.types.ts +++ b/packages/react-components/react-menu/library/src/components/MenuItem/MenuItem.types.ts @@ -61,4 +61,6 @@ export type MenuItemProps = Omit>, 'conten }; export type MenuItemState = ComponentState & - Required>; + Required> & { + isSubmenuOpen: boolean; + }; diff --git a/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx index 750a3deeda900c..6faa4de845b104 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx @@ -37,10 +37,12 @@ const ChevronLeftIcon = bundleIcon(ChevronLeftFilled, ChevronLeftRegular); export const useMenuItem_unstable = (props: MenuItemProps, ref: React.Ref>): MenuItemState => { const isSubmenuTrigger = useMenuTriggerContext_unstable(); const persistOnClickContext = useMenuContext_unstable(context => context.persistOnItemClick); + const mouseInputState = useMenuListContext_unstable(ctx => ctx.mouseInputState); const { as = 'div', disabled = false, hasSubmenu = isSubmenuTrigger, persistOnClick = persistOnClickContext } = props; const { hasIcons, hasCheckmarks } = useIconAndCheckmarkAlignment({ hasSubmenu }); const setOpen = useMenuContext_unstable(context => context.setOpen); useNotifySplitItemMultiline({ multiline: !!props.subText, hasSubmenu }); + const isSubmenuOpen = useMenuContext_unstable(context => context.open); const { dir } = useFluent(); const innerRef = React.useRef>(null); @@ -50,6 +52,7 @@ export const useMenuItem_unstable = (props: MenuItemProps, ref: React.Ref { - if (event.currentTarget.ownerDocument.activeElement !== event.currentTarget) { + if (!mouseInputState?.isMouseInput()) { + mouseInputState?.setMouseInput(true); + } + + if (event.currentTarget.ownerDocument.activeElement !== event.currentTarget && !(hasSubmenu && isSubmenuOpen)) { innerRef.current?.focus(); } diff --git a/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItemStyles.styles.ts b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItemStyles.styles.ts index fca9ed9dd16d43..525091995d716f 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItemStyles.styles.ts +++ b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItemStyles.styles.ts @@ -36,7 +36,11 @@ const useRootBaseStyles = makeResetStyles({ cursor: 'pointer', gap: '4px', - ':hover': { + ':hover:focus': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + ':focus': { + outline: 'none', backgroundColor: tokens.colorNeutralBackground1Hover, color: tokens.colorNeutralForeground2Hover, @@ -55,6 +59,10 @@ const useRootBaseStyles = makeResetStyles({ }, }, + ':focus-visible': { + outlineStyle: 'none', + }, + ':hover:active': { backgroundColor: tokens.colorNeutralBackground1Pressed, color: tokens.colorNeutralForeground2Pressed, @@ -64,6 +72,14 @@ const useRootBaseStyles = makeResetStyles({ }, }, + ':hover:active:focus': { + outlineStyle: 'none', + }, + + ':hover:active:focus-visible': { + outlineStyle: 'none', + }, + // High contrast styles '@media (forced-colors: active)': { ':hover': { @@ -126,6 +142,9 @@ const useSubtextBaseStyles = makeResetStyles({ }); const useStyles = makeStyles({ + submenuOpen: { + backgroundColor: tokens.colorNeutralBackground2, + }, checkmark: { marginTop: '2px', }, @@ -221,6 +240,7 @@ export const useMenuItemStyles_unstable = (state: MenuItemState): MenuItemState menuItemClassNames.root, rootBaseStyles, state.disabled && styles.disabled, + state.hasSubmenu && state.isSubmenuOpen && styles.submenuOpen, state.root.className, ); diff --git a/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts index 9846dcff0a377e..66e07bc4f00bd2 100644 --- a/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts +++ b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts @@ -11,11 +11,14 @@ import { useFocusFinders, TabsterMoveFocusEventName, type TabsterMoveFocusEvent, + useSetKeyboardNavigation, + useOnKeyboardNavigationChange, } from '@fluentui/react-tabster'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { useHasParentContext } from '@fluentui/react-context-selector'; import { useMenuContext_unstable } from '../../contexts/menuContext'; import { MenuContext } from '../../contexts/menuContext'; +import { useMenuListContext_unstable } from '../../contexts/menuListContext'; import type { MenuListProps, MenuListState } from './MenuList.types'; /** @@ -133,6 +136,8 @@ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref { + const parentContext = useMenuListContext_unstable(ctx => ctx.mouseInputState); + const setKeyboardNavigationState = useSetKeyboardNavigation(); + const isMouseInputRef = React.useRef(!!parentContext?.isMouseInput()); + + const setMouseInput = React.useCallback( + (isMouseInput: boolean) => { + setKeyboardNavigationState(true); + parentContext?.setMouseInput(isMouseInput); + isMouseInputRef.current = isMouseInput; + }, + [parentContext, setKeyboardNavigationState], + ); + + useOnKeyboardNavigationChange(isNavigatingWithKeyboard => { + setMouseInput(!isNavigatingWithKeyboard); + }); + + return { + isMouseInput: () => isMouseInputRef.current, + setMouseInput, + }; +}; diff --git a/packages/react-components/react-menu/library/src/contexts/menuListContext.tsx b/packages/react-components/react-menu/library/src/contexts/menuListContext.tsx index d4ffe7d62429da..b0e04c11e00e22 100644 --- a/packages/react-components/react-menu/library/src/contexts/menuListContext.tsx +++ b/packages/react-components/react-menu/library/src/contexts/menuListContext.tsx @@ -15,6 +15,10 @@ const menuListContextDefaultValue: MenuListContextValue = { selectRadio: () => null, hasIcons: false, hasCheckmarks: false, + mouseInputState: { + isMouseInput: () => false, + setMouseInput: () => null, + }, }; /** @@ -34,6 +38,13 @@ export type MenuListContextValue = Pick void; + /** + * State for tracking whether interaction is via mouse or keyboard + */ + mouseInputState?: { + isMouseInput: () => boolean; + setMouseInput: (isMouseInput: boolean) => void; + }; }; export const MenuListProvider = MenuListContext.Provider;