From 34df2032837a8fde94749b905874050e9974aae3 Mon Sep 17 00:00:00 2001 From: Juraj Kapsiar Date: Sun, 16 Mar 2025 21:51:24 +0000 Subject: [PATCH 1/7] chore: single state for menu and combobox --- .../Combobox.stories.tsx | 59 +++++++++++++++++++ .../ListOfScenarios.stories.mdx | 4 ++ .../AccessibilityScenarios/Menu.stories.tsx | 2 +- .../src/components/Option/useOption.tsx | 12 +++- .../Option/useOptionStyles.styles.ts | 13 ++-- .../library/src/components/Menu/useMenu.tsx | 13 +++- .../src/components/MenuItem/useMenuItem.tsx | 5 ++ .../MenuItem/useMenuItemStyles.styles.ts | 15 ++++- .../src/components/MenuList/useMenuList.ts | 33 +++++++++++ .../library/src/contexts/menuListContext.tsx | 11 ++++ 10 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx 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..b978ffc0318a37 --- /dev/null +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { Combobox, ComboboxProps, makeStyles, useComboboxFilter, 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 = [ + { children: 'Alligator', value: 'Alligator' }, + { children: 'Bee', value: 'Bee' }, + { children: 'Bird', value: 'Bird' }, + { children: 'Cheetah', disabled: true, value: 'Cheetah' }, + { children: 'Dog', value: 'Dog' }, + { children: 'Dolphin', value: 'Dolphin' }, + { children: 'Ferret', value: 'Ferret' }, + { children: 'Firefly', value: 'Firefly' }, + { children: 'Fish', value: 'Fish' }, + { children: 'Goat', value: 'Goat' }, + { children: 'Horse', value: 'Horse' }, + { children: 'Lion', value: 'Lion' }, +]; + +export const FilteringCombobox: React.FunctionComponent = () => { + const comboId = useId(); + const styles = useStyles(); + + const [query, setQuery] = React.useState(''); + const children = useComboboxFilter(query, options, { + noOptionsMessage: 'No animals match your search.', + }); + const onOptionSelect: ComboboxProps['onOptionSelect'] = (e, data) => { + setQuery(data.optionText ?? ''); + }; + + return ( + +
+ + setQuery(ev.target.value)} + value={query} + > + {children} + +
+
+ ); +}; 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..116483db08b68c 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx @@ -71,7 +71,7 @@ export const ProfileMenu: React.FunctionComponent = () => { - Profile + Profile - single state 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/useMenuItem.tsx b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx index 750a3deeda900c..d757bed7f42b42 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,6 +37,7 @@ 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); @@ -75,6 +76,10 @@ export const useMenuItem_unstable = (props: MenuItemProps, ref: React.Ref { + if (!mouseInputState?.isMouseInput()) { + mouseInputState?.setMouseInput(true); + } + if (event.currentTarget.ownerDocument.activeElement !== event.currentTarget) { 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..2b4e292a770811 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,8 @@ const useRootBaseStyles = makeResetStyles({ cursor: 'pointer', gap: '4px', - ':hover': { + ':focus': { + outline: 'none', backgroundColor: tokens.colorNeutralBackground1Hover, color: tokens.colorNeutralForeground2Hover, @@ -55,6 +56,10 @@ const useRootBaseStyles = makeResetStyles({ }, }, + ':focus-visible': { + outlineStyle: 'none', + }, + ':hover:active': { backgroundColor: tokens.colorNeutralBackground1Pressed, color: tokens.colorNeutralForeground2Pressed, @@ -64,6 +69,14 @@ const useRootBaseStyles = makeResetStyles({ }, }, + ':hover:active:focus': { + outlineStyle: 'none', + }, + + ':hover:active:focus-visible': { + outlineStyle: 'none', + }, + // High contrast styles '@media (forced-colors: active)': { ':hover': { 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..5381c5388e2d24 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(!isMouseInput); + 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; From c64c084013fe48173f6e3f5cfed4d1ca317b6411 Mon Sep 17 00:00:00 2001 From: Juraj Kapsiar Date: Sun, 16 Mar 2025 22:19:21 +0000 Subject: [PATCH 2/7] fix --- .../src/AccessibilityScenarios/Combobox.stories.tsx | 2 +- .../react-menu/library/src/components/MenuList/useMenuList.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx index b978ffc0318a37..c250894a0df3e1 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx @@ -41,7 +41,7 @@ export const FilteringCombobox: React.FunctionComponent = () => { }; return ( - +
{ const setMouseInput = React.useCallback( (isMouseInput: boolean) => { - setKeyboardNavigationState(!isMouseInput); + setKeyboardNavigationState(true); parentContext?.setMouseInput(isMouseInput); isMouseInputRef.current = isMouseInput; }, From c1bff20f80f35e915d2de46ab9fe6845cce455e4 Mon Sep 17 00:00:00 2001 From: Juraj Kapsiar Date: Sun, 16 Mar 2025 22:33:10 +0000 Subject: [PATCH 3/7] fix --- .../src/AccessibilityScenarios/index.stories.tsx | 1 + 1 file changed, 1 insertion(+) 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'; From 9e624a123ee26f176c1a1c47d7ac488ca98fe7b3 Mon Sep 17 00:00:00 2001 From: Juraj Kapsiar Date: Mon, 17 Mar 2025 08:24:03 +0100 Subject: [PATCH 4/7] better --- .../library/src/components/MenuItem/MenuItem.types.ts | 4 +++- .../library/src/components/MenuItem/useMenuItem.tsx | 4 +++- .../src/components/MenuItem/useMenuItemStyles.styles.ts | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) 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 d757bed7f42b42..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 @@ -42,6 +42,7 @@ export const useMenuItem_unstable = (props: MenuItemProps, ref: React.Ref context.setOpen); useNotifySplitItemMultiline({ multiline: !!props.subText, hasSubmenu }); + const isSubmenuOpen = useMenuContext_unstable(context => context.open); const { dir } = useFluent(); const innerRef = React.useRef>(null); @@ -51,6 +52,7 @@ export const useMenuItem_unstable = (props: MenuItemProps, ref: React.Ref Date: Wed, 19 Mar 2025 12:45:09 +0000 Subject: [PATCH 5/7] better --- .../Combobox.stories.tsx | 78 ++++++---- .../AccessibilityScenarios/Menu.stories.tsx | 136 +++++++----------- 2 files changed, 102 insertions(+), 112 deletions(-) diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx index c250894a0df3e1..20877a4d7a2e27 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Combobox, ComboboxProps, makeStyles, useComboboxFilter, useId } from '@fluentui/react-components'; +import { Combobox, Option, makeStyles, useId } from '@fluentui/react-components'; import { Scenario } from './utils'; const useStyles = makeStyles({ @@ -14,44 +14,62 @@ const useStyles = makeStyles({ }); const options = [ - { children: 'Alligator', value: 'Alligator' }, - { children: 'Bee', value: 'Bee' }, - { children: 'Bird', value: 'Bird' }, - { children: 'Cheetah', disabled: true, value: 'Cheetah' }, - { children: 'Dog', value: 'Dog' }, - { children: 'Dolphin', value: 'Dolphin' }, - { children: 'Ferret', value: 'Ferret' }, - { children: 'Firefly', value: 'Firefly' }, - { children: 'Fish', value: 'Fish' }, - { children: 'Goat', value: 'Goat' }, - { children: 'Horse', value: 'Horse' }, - { children: 'Lion', value: 'Lion' }, + '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(); - const [query, setQuery] = React.useState(''); - const children = useComboboxFilter(query, options, { - noOptionsMessage: 'No animals match your search.', - }); - const onOptionSelect: ComboboxProps['onOptionSelect'] = (e, data) => { - setQuery(data.optionText ?? ''); - }; - return ( +
Single state
- - setQuery(ev.target.value)} - value={query} - > - {children} + + + {options.map(option => ( + + ))}
diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx b/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx index 116483db08b68c..16df6289b14f6c 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/Menu.stories.tsx @@ -1,99 +1,71 @@ 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 ( +
Single state
- Profile - single state + 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 From 1af4ac281efdb80ff5e520a1bc4e1f5bb99d91d5 Mon Sep 17 00:00:00 2001 From: Juraj Kapsiar Date: Mon, 24 Mar 2025 20:28:11 +0000 Subject: [PATCH 6/7] autosize --- .../src/AccessibilityScenarios/Combobox.stories.tsx | 2 +- .../src/AccessibilityScenarios/Menu.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx index 20877a4d7a2e27..e3aac196b6b69d 100644 --- a/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx +++ b/apps/public-docsite-v9/src/AccessibilityScenarios/Combobox.stories.tsx @@ -64,7 +64,7 @@ export const FilteringCombobox: React.FunctionComponent = () => {
Single state
- + {options.map(option => (