diff --git a/packages/fluentui/accessibility/src/behaviors/Button/toggleButtonBehavior.ts b/packages/fluentui/accessibility/src/behaviors/Button/toggleButtonBehavior.ts index 695833003a9863..819c697ab33b99 100644 --- a/packages/fluentui/accessibility/src/behaviors/Button/toggleButtonBehavior.ts +++ b/packages/fluentui/accessibility/src/behaviors/Button/toggleButtonBehavior.ts @@ -11,7 +11,7 @@ export const toggleButtonBehavior: Accessibility = pr return behaviorData; }; -type ToggleButtonBehaviorProps = ButtonBehaviorProps & { +export type ToggleButtonBehaviorProps = ButtonBehaviorProps & { /** Indicates if a button is in pressed state. */ active: boolean; }; diff --git a/packages/fluentui/accessibility/src/behaviors/Menu/menuItemBehavior.ts b/packages/fluentui/accessibility/src/behaviors/Menu/menuItemBehavior.ts index bf9d7f3c2b320e..8e8ec639488b10 100644 --- a/packages/fluentui/accessibility/src/behaviors/Menu/menuItemBehavior.ts +++ b/packages/fluentui/accessibility/src/behaviors/Menu/menuItemBehavior.ts @@ -70,6 +70,8 @@ export const menuItemBehavior: Accessibility = props => ( }); export type MenuItemBehaviorProps = { + /** Indicates if tab is selected. */ + active?: boolean; /** Indicated if menu item has submenu. */ hasMenu?: boolean | object; /** Defines if submenu is opened. */ diff --git a/packages/fluentui/react-bindings/src/accessibility/getAccessibility.ts b/packages/fluentui/react-bindings/src/accessibility/getAccessibility.ts index 6d9154617fad3c..bc1fee19ca8f82 100644 --- a/packages/fluentui/react-bindings/src/accessibility/getAccessibility.ts +++ b/packages/fluentui/react-bindings/src/accessibility/getAccessibility.ts @@ -1,20 +1,15 @@ -import { - Accessibility, - AccessibilityAttributes, - AccessibilityAttributesBySlot, - AccessibilityDefinition, -} from '@fluentui/accessibility'; +import type { Accessibility, AccessibilityDefinition } from '@fluentui/accessibility'; import { getKeyDownHandlers } from './getKeyDownHandlers'; -import { AccessibilityActionHandlers, ReactAccessibilityBehavior } from './types'; +import type { AccessibilityActionHandlers, ReactAccessibilityBehavior } from './types'; -const emptyBehavior: ReactAccessibilityBehavior = { +export const emptyBehavior: ReactAccessibilityBehavior = { attributes: {}, keyHandlers: {}, + rtl: false, }; export const getAccessibility = >( - displayName: string, behavior: Accessibility, behaviorProps: Props, isRtlEnabled: boolean, @@ -38,28 +33,10 @@ export const getAccessibility = >( }; } - if (process.env.NODE_ENV !== 'production') { - // For the non-production builds we enable the runtime accessibility attributes validator. - // We're adding the data-aa-class attribute which is being consumed by the validator, the - // schema is located in @fluentui/ability-attributes package. - if (definition.attributes) { - Object.keys(definition.attributes).forEach(slotName => { - const validatorName = - (definition.attributes as AccessibilityAttributesBySlot)[slotName]['data-aa-class'] || - `${displayName}${slotName === 'root' ? '' : `__${slotName}`}`; - - if (!(definition.attributes as AccessibilityAttributesBySlot)[slotName]) { - (definition.attributes as AccessibilityAttributesBySlot)[slotName] = {} as AccessibilityAttributes; - } - - (definition.attributes as AccessibilityAttributesBySlot)[slotName]['data-aa-class'] = validatorName; - }); - } - } - return { ...emptyBehavior, ...definition, keyHandlers, + rtl: isRtlEnabled, }; }; diff --git a/packages/fluentui/react-bindings/src/accessibility/types.ts b/packages/fluentui/react-bindings/src/accessibility/types.ts index fe009083986f8b..1ee78d74034a2e 100644 --- a/packages/fluentui/react-bindings/src/accessibility/types.ts +++ b/packages/fluentui/react-bindings/src/accessibility/types.ts @@ -5,9 +5,10 @@ import * as React from 'react'; * Accessibility types for React implementation. */ -export interface ReactAccessibilityBehavior extends AccessibilityDefinition { +export interface ReactAccessibilityBehavior extends Pick { attributes: AccessibilityAttributesBySlot; keyHandlers: AccessibilityKeyHandlers; + rtl: boolean; } export type AccessibilityKeyHandlers = { @@ -22,4 +23,4 @@ export type AccessibilityActionHandlers = { [actionName: string]: KeyboardEventHandler; }; -export type KeyboardEventHandler = (event: React.KeyboardEvent) => void; +export type KeyboardEventHandler = (event: React.KeyboardEvent, ...args: unknown[]) => void; diff --git a/packages/fluentui/react-bindings/src/hooks/useAccessibility.ts b/packages/fluentui/react-bindings/src/hooks/useAccessibility.ts index ed1ffa4c0719ac..5402bf322b5bb5 100644 --- a/packages/fluentui/react-bindings/src/hooks/useAccessibility.ts +++ b/packages/fluentui/react-bindings/src/hooks/useAccessibility.ts @@ -33,9 +33,8 @@ export const useAccessibility = ( behavior: Accessibility, options: UseAccessibilityOptions = {}, ) => { - const { actionHandlers, debugName = 'Undefined', mapPropsToBehavior = () => ({}), rtl = false } = options; - - const definition = getAccessibility(debugName, behavior, mapPropsToBehavior(), rtl, actionHandlers); + const { actionHandlers, mapPropsToBehavior = () => ({}), rtl = false } = options; + const definition = getAccessibility(behavior, mapPropsToBehavior(), rtl, actionHandlers); const latestDefinition = React.useRef(); const slotHandlers = React.useRef>({}); diff --git a/packages/fluentui/react-bindings/src/hooks/useAccessibilityBehavior.ts b/packages/fluentui/react-bindings/src/hooks/useAccessibilityBehavior.ts new file mode 100644 index 00000000000000..8038ac15595171 --- /dev/null +++ b/packages/fluentui/react-bindings/src/hooks/useAccessibilityBehavior.ts @@ -0,0 +1,22 @@ +import type { Accessibility } from '@fluentui/accessibility'; + +import { getAccessibility } from '../accessibility/getAccessibility'; +import type { AccessibilityActionHandlers, ReactAccessibilityBehavior } from '../accessibility/types'; + +type UseAccessibilityOptions = { + actionHandlers?: AccessibilityActionHandlers; + behaviorProps?: Props; + rtl: boolean; +}; + +export const useAccessibilityBehavior = ( + behavior: Accessibility, + options: UseAccessibilityOptions, +): ReactAccessibilityBehavior => { + const { actionHandlers, behaviorProps, rtl = false } = options; + + // No need to memoize this as behaviors return: + // - flat props per slots - references don't matter there + // - action handlers - useAccessibilitySlotProps() uses useEventCallback() that does not "care" about callback references + return getAccessibility(behavior, behaviorProps ?? {}, rtl, actionHandlers); +}; diff --git a/packages/fluentui/react-bindings/src/hooks/useAccessibilitySlotProps.ts b/packages/fluentui/react-bindings/src/hooks/useAccessibilitySlotProps.ts new file mode 100644 index 00000000000000..d14b285f592995 --- /dev/null +++ b/packages/fluentui/react-bindings/src/hooks/useAccessibilitySlotProps.ts @@ -0,0 +1,34 @@ +import type { AccessibilityAttributes } from '@fluentui/accessibility'; +import * as React from 'react'; + +import type { KeyboardEventHandler, ReactAccessibilityBehavior } from '../accessibility/types'; +import { useEventCallback } from './useEventCallback'; + +type UserProps = { + onKeyDown?: KeyboardEventHandler; +}; + +type MergedProps> = SlotProps & Partial & UserProps; + +export const useAccessibilitySlotProps = & UserProps>( + definition: ReactAccessibilityBehavior, + slotName: string, + slotProps: SlotProps, +): MergedProps => { + const accessibilityHandler = definition.keyHandlers[slotName]?.onKeyDown; + const childBehavior = definition.childBehaviors ? definition.childBehaviors[slotName] : undefined; + + const handleKeyDown = useEventCallback((e: React.KeyboardEvent, ...args: unknown[]) => { + const userHandler = slotProps.onKeyDown; + + if (accessibilityHandler) accessibilityHandler(e); + if (userHandler) userHandler(e, ...args); + }); + + return { + ...(childBehavior && { accessibility: childBehavior }), + ...definition.attributes[slotName], + ...slotProps, + ...(!!accessibilityHandler && { onKeyDown: handleKeyDown }), + }; +}; diff --git a/packages/fluentui/react-bindings/src/index.ts b/packages/fluentui/react-bindings/src/index.ts index 2774a198d16dc0..003cdb10514726 100644 --- a/packages/fluentui/react-bindings/src/index.ts +++ b/packages/fluentui/react-bindings/src/index.ts @@ -12,6 +12,8 @@ export * from './FocusZone/FocusZone.types'; export * from './FocusZone/focusUtilities'; export { useAccessibility } from './hooks/useAccessibility'; +export { useAccessibilityBehavior } from './hooks/useAccessibilityBehavior'; +export { useAccessibilitySlotProps } from './hooks/useAccessibilitySlotProps'; export { useCallbackRef } from './hooks/useCallbackRef'; export { useControllableState } from './hooks/useControllableState'; export { useDeepMemo } from './hooks/useDeepMemo'; @@ -36,6 +38,7 @@ export { childrenExist } from './utils/childrenExist'; export { getElementType } from './utils/getElementType'; export { getUnhandledProps } from './utils/getUnhandledProps'; export { mergeVariablesOverrides } from './utils/mergeVariablesOverrides'; +export { wrapWithFocusZone } from './utils/wrapWithFocusZone'; export * from './context'; diff --git a/packages/fluentui/react-bindings/src/utils/wrapWithFocusZone.tsx b/packages/fluentui/react-bindings/src/utils/wrapWithFocusZone.tsx new file mode 100644 index 00000000000000..d84210787f23c7 --- /dev/null +++ b/packages/fluentui/react-bindings/src/utils/wrapWithFocusZone.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import type { ReactAccessibilityBehavior } from '../accessibility/types'; +import { FocusZone } from '../FocusZone/FocusZone'; + +export function wrapWithFocusZone( + definition: ReactAccessibilityBehavior, + element: React.ReactElement & React.RefAttributes, +): React.ReactElement { + if (definition.focusZone) { + let child: React.ReactElement & React.RefAttributes = element; + + if (process.env.NODE_ENV !== 'production') { + child = React.Children.only(element); + } + + return ( + + ); + } + + return element; +} diff --git a/packages/fluentui/react-bindings/test/hooks/useAccessibilityBehavior-test.tsx b/packages/fluentui/react-bindings/test/hooks/useAccessibilityBehavior-test.tsx new file mode 100644 index 00000000000000..7aeff00cc57c5b --- /dev/null +++ b/packages/fluentui/react-bindings/test/hooks/useAccessibilityBehavior-test.tsx @@ -0,0 +1,65 @@ +import { Accessibility, keyboardKey } from '@fluentui/accessibility'; +import { renderHook } from '@testing-library/react'; +import * as React from 'react'; + +import { useAccessibilityBehavior } from '../../src/hooks/useAccessibilityBehavior'; + +type TestBehaviorProps = { + disabled: boolean; +}; + +const testBehavior: Accessibility = props => ({ + attributes: { + root: { + 'aria-disabled': props.disabled, + tabIndex: 1, + }, + }, + keyActions: { + root: { + click: { + keyCombinations: [{ keyCode: keyboardKey.ArrowDown }], + }, + }, + }, +}); + +describe('useAccessibilityBehavior', () => { + it('sets attributes', () => { + const onClick = jest.fn(); + + const { result } = renderHook(() => + useAccessibilityBehavior(testBehavior, { + behaviorProps: { disabled: true }, + actionHandlers: { + click: ev => { + onClick(ev); + }, + }, + rtl: false, + }), + ); + + expect(result.current.attributes).toEqual({ + root: { + 'aria-disabled': true, + tabIndex: 1, + }, + }); + expect(result.current.childBehaviors).toBeUndefined(); + expect(result.current.focusZone).toBeUndefined(); + expect(result.current.keyHandlers).toEqual({ + root: { + onKeyDown: expect.any(Function), + }, + }); + + // Calls on `onClick` + result.current.keyHandlers.root?.onKeyDown?.({ keyCode: keyboardKey.ArrowDown } as unknown as React.KeyboardEvent); + // Does nothing + result.current.keyHandlers.root?.onKeyDown?.({ keyCode: keyboardKey.ArrowUp } as unknown as React.KeyboardEvent); + + expect(onClick).toHaveBeenCalledWith({ keyCode: keyboardKey.ArrowDown }); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/fluentui/react-bindings/test/hooks/useAccessibilitySlotProps-test.tsx b/packages/fluentui/react-bindings/test/hooks/useAccessibilitySlotProps-test.tsx new file mode 100644 index 00000000000000..c356aaff265dd1 --- /dev/null +++ b/packages/fluentui/react-bindings/test/hooks/useAccessibilitySlotProps-test.tsx @@ -0,0 +1,273 @@ +import { Accessibility, keyboardKey } from '@fluentui/accessibility'; +import { render, renderHook, fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { useAccessibilityBehavior } from '../../src/hooks/useAccessibilityBehavior'; +import { useAccessibilitySlotProps } from '../../src/hooks/useAccessibilitySlotProps'; +import { wrapWithFocusZone } from '../../src/utils/wrapWithFocusZone'; + +type TestBehaviorProps = { + disabled: boolean; +}; + +type ChildBehaviorProps = { + pressed: boolean; +}; + +const testBehavior: Accessibility = props => ({ + attributes: { + root: { + 'aria-disabled': props.disabled, + tabIndex: 1, + }, + img: { + 'aria-label': 'Pixel', + role: 'presentation', + }, + }, + keyActions: { + root: { + click: { + keyCombinations: [{ keyCode: keyboardKey.ArrowDown }], + }, + }, + }, +}); + +const conditionalBehavior: Accessibility<{ disabled: boolean }> = props => ({ + attributes: { + root: { + 'aria-label': 'Noop behavior', + }, + }, + keyActions: { + root: { + ...((!props.disabled && { + click: { + keyCombinations: [{ keyCode: keyboardKey.ArrowDown }], + }, + }) as any), + }, + img: { + click: { + keyCombinations: [props.disabled ? { keyCode: keyboardKey.ArrowDown } : { keyCode: keyboardKey.ArrowUp }], + }, + }, + }, +}); + +const childOverriddenBehavior: Accessibility = props => ({ + attributes: { + root: { + 'data-behavior': 'overridden', + 'aria-pressed': props.pressed, + 'aria-label': 'overridden', + }, + }, +}); + +const childBehavior: Accessibility = props => ({ + attributes: { + root: { + 'data-behavior': 'original', + 'aria-pressed': props.pressed, + }, + }, +}); + +const overriddenChildBehavior: Accessibility = props => ({ + attributes: { + root: { + 'aria-disabled': props.disabled, + tabIndex: 1, + }, + img: { + 'aria-label': 'Pixel', + role: 'presentation', + }, + }, + keyActions: { + root: { + click: { + keyCombinations: [{ keyCode: keyboardKey.ArrowDown }], + }, + }, + }, + childBehaviors: { + child: childOverriddenBehavior, + }, +}); + +type TestComponentProps = { + accessibility?: Accessibility; + disabled?: boolean; + onClick?: (e: React.KeyboardEvent, slotName: string) => void; + onKeyDown?: React.KeyboardEventHandler; +} & React.HTMLAttributes; + +type ChildComponentProps = { + accessibility?: Accessibility; + pressed?: boolean; + onKeyDown?: React.KeyboardEventHandler; +}; + +const TestComponent: React.FunctionComponent = props => { + const { accessibility = testBehavior, disabled, onClick, onKeyDown, ...rest } = props; + + const a11yBehavior = useAccessibilityBehavior(accessibility, { + behaviorProps: { disabled }, + actionHandlers: { + click: (e: React.KeyboardEvent) => { + if (onClick) onClick(e, 'root'); + }, + }, + rtl: false, + }); + + return wrapWithFocusZone( + a11yBehavior, +
+ + +
, + ); +}; + +const ChildComponent: React.FunctionComponent = props => { + const { accessibility = childBehavior, pressed, onKeyDown, ...rest } = props; + const a11yBehavior = useAccessibilityBehavior(accessibility, { + behaviorProps: { pressed }, + rtl: false, + }); + + return