Skip to content
Closed
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
Expand Up @@ -11,7 +11,7 @@ export const toggleButtonBehavior: Accessibility<ToggleButtonBehaviorProps> = pr
return behaviorData;
};

type ToggleButtonBehaviorProps = ButtonBehaviorProps & {
export type ToggleButtonBehaviorProps = ButtonBehaviorProps & {
/** Indicates if a button is in pressed state. */
active: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export const menuItemBehavior: Accessibility<MenuItemBehaviorProps> = 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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = <Props extends Record<string, any>>(
displayName: string,
behavior: Accessibility<Props>,
behaviorProps: Props,
isRtlEnabled: boolean,
Expand All @@ -38,28 +33,10 @@ export const getAccessibility = <Props extends Record<string, any>>(
};
}

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,
};
};
5 changes: 3 additions & 2 deletions packages/fluentui/react-bindings/src/accessibility/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import * as React from 'react';
* Accessibility types for React implementation.
*/

export interface ReactAccessibilityBehavior extends AccessibilityDefinition {
export interface ReactAccessibilityBehavior extends Pick<AccessibilityDefinition, 'focusZone' | 'childBehaviors'> {
attributes: AccessibilityAttributesBySlot;
keyHandlers: AccessibilityKeyHandlers;
rtl: boolean;
}

export type AccessibilityKeyHandlers = {
Expand All @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,8 @@ export const useAccessibility = <Props extends {}>(
behavior: Accessibility<Props>,
options: UseAccessibilityOptions<Props> = {},
) => {
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<ReactAccessibilityBehavior>();
const slotHandlers = React.useRef<Record<string, KeyboardEventHandler>>({});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Accessibility } from '@fluentui/accessibility';

import { getAccessibility } from '../accessibility/getAccessibility';
import type { AccessibilityActionHandlers, ReactAccessibilityBehavior } from '../accessibility/types';

type UseAccessibilityOptions<Props> = {
actionHandlers?: AccessibilityActionHandlers;
behaviorProps?: Props;
rtl: boolean;
};

export const useAccessibilityBehavior = <Props extends {}>(
behavior: Accessibility<Props>,
options: UseAccessibilityOptions<Props>,
): 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);
};
Original file line number Diff line number Diff line change
@@ -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 extends Record<string, unknown>> = SlotProps & Partial<AccessibilityAttributes> & UserProps;

export const useAccessibilitySlotProps = <SlotProps extends Record<string, unknown> & UserProps>(
definition: ReactAccessibilityBehavior,
slotName: string,
slotProps: SlotProps,
): MergedProps<SlotProps> => {
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 }),
};
};
3 changes: 3 additions & 0 deletions packages/fluentui/react-bindings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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<any>,
): React.ReactElement {
if (definition.focusZone) {
let child: React.ReactElement & React.RefAttributes<any> = element;

if (process.env.NODE_ENV !== 'production') {
child = React.Children.only(element);
}

return (
<FocusZone
{...definition.focusZone.props}
{...child.props}
as={child.type}
innerRef={child.ref}
isRtl={definition.rtl}
/>
);
}

return element;
}
Original file line number Diff line number Diff line change
@@ -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<TestBehaviorProps> = 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);
});
});
Loading