From eac75641c15f760208c0b01b379ac22a6ef4df2a Mon Sep 17 00:00:00 2001 From: ling1726 Date: Wed, 24 Feb 2021 17:34:30 +0000 Subject: [PATCH] Implement react-focus-management and Menu usage (#17137) * react-focus-management * Add first char navigation * add default value for setFocusByFirstCharacter * remove spec for now * narrow props * comment * Integrate to fluent provider * update tsconfig.base.json * fix deps * fix deps * memoize context value * update naming * change naming * fix tsdoc * add README * remove spec for now * revert menu spec deletion * fix tests, update version * update version * update version * rename and cleanup * Change files * update beachball for new package * remove test setup * Update packages/react-focus-management/README.md Co-authored-by: Elizabeth Craig * remove import * update deps * fixes * Update packages/react-focus-management/README.md Co-authored-by: Oleksandr Fediashov * shared character search hook as util * remove document check * update API * update API Co-authored-by: Elizabeth Craig Co-authored-by: Oleksandr Fediashov --- ...-ee21e96b-7e8d-4b84-a400-e3b1301ff920.json | 7 ++ ...-e28da5de-1239-4efa-8891-07530af5ec1f.json | 7 ++ ...-e29e9fce-6918-4d31-b87f-370107e2331c.json | 7 ++ .../react-focus-management/.eslintrc.json | 4 + packages/react-focus-management/.npmignore | 34 ++++++++ packages/react-focus-management/LICENSE | 15 ++++ packages/react-focus-management/README.md | 42 ++++++++++ .../config/api-extractor.json | 3 + .../etc/react-focus-management.api.md | 58 +++++++++++++ .../react-focus-management/jest.config.js | 5 ++ .../react-focus-management/just.config.ts | 3 + packages/react-focus-management/package.json | 61 ++++++++++++++ .../src/FocusManagementProvider.tsx | 83 +++++++++++++++++++ .../src/focusManagementContext.tsx | 33 ++++++++ .../react-focus-management/src/hooks/index.ts | 2 + .../src/hooks/useArrowNavigationGroup.ts | 25 ++++++ .../src/hooks/useFocusFinders.ts | 20 +++++ packages/react-focus-management/src/index.ts | 5 ++ .../react-focus-management/src/version.ts | 5 ++ packages/react-focus-management/tsconfig.json | 22 +++++ packages/react-menu/etc/react-menu.api.md | 3 + packages/react-menu/package.json | 1 + .../__snapshots__/MenuItem.test.tsx.snap | 1 + .../src/components/MenuItem/useMenuItem.ts | 2 + .../MenuItemCheckbox/useMenuItemCheckbox.ts | 3 + .../MenuItemRadio/useMenuItemRadio.ts | 4 +- .../src/components/MenuList/MenuList.types.ts | 6 ++ .../__snapshots__/MenuList.test.tsx.snap | 1 + .../components/MenuList/renderMenuList.tsx | 6 +- .../src/components/MenuList/useMenuList.ts | 47 +++++++++++ packages/react-menu/src/menuListContext.tsx | 2 + .../src/utils/useCharacterSearch.ts | 22 +++++ packages/react-provider/package.json | 3 +- .../react-provider/src/FluentProvider.tsx | 5 +- tsconfig.base.json | 3 +- yarn.lock | 5 ++ 36 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 change/@fluentui-react-focus-management-ee21e96b-7e8d-4b84-a400-e3b1301ff920.json create mode 100644 change/@fluentui-react-menu-e28da5de-1239-4efa-8891-07530af5ec1f.json create mode 100644 change/@fluentui-react-provider-e29e9fce-6918-4d31-b87f-370107e2331c.json create mode 100644 packages/react-focus-management/.eslintrc.json create mode 100644 packages/react-focus-management/.npmignore create mode 100644 packages/react-focus-management/LICENSE create mode 100644 packages/react-focus-management/README.md create mode 100644 packages/react-focus-management/config/api-extractor.json create mode 100644 packages/react-focus-management/etc/react-focus-management.api.md create mode 100644 packages/react-focus-management/jest.config.js create mode 100644 packages/react-focus-management/just.config.ts create mode 100644 packages/react-focus-management/package.json create mode 100644 packages/react-focus-management/src/FocusManagementProvider.tsx create mode 100644 packages/react-focus-management/src/focusManagementContext.tsx create mode 100644 packages/react-focus-management/src/hooks/index.ts create mode 100644 packages/react-focus-management/src/hooks/useArrowNavigationGroup.ts create mode 100644 packages/react-focus-management/src/hooks/useFocusFinders.ts create mode 100644 packages/react-focus-management/src/index.ts create mode 100644 packages/react-focus-management/src/version.ts create mode 100644 packages/react-focus-management/tsconfig.json create mode 100644 packages/react-menu/src/utils/useCharacterSearch.ts diff --git a/change/@fluentui-react-focus-management-ee21e96b-7e8d-4b84-a400-e3b1301ff920.json b/change/@fluentui-react-focus-management-ee21e96b-7e8d-4b84-a400-e3b1301ff920.json new file mode 100644 index 0000000000000..6156602715b85 --- /dev/null +++ b/change/@fluentui-react-focus-management-ee21e96b-7e8d-4b84-a400-e3b1301ff920.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Bootstrap react-focus-management", + "packageName": "@fluentui/react-focus-management", + "email": "lingfan.gao@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-menu-e28da5de-1239-4efa-8891-07530af5ec1f.json b/change/@fluentui-react-menu-e28da5de-1239-4efa-8891-07530af5ec1f.json new file mode 100644 index 0000000000000..8cf623633f1ce --- /dev/null +++ b/change/@fluentui-react-menu-e28da5de-1239-4efa-8891-07530af5ec1f.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Support keyboard navigation", + "packageName": "@fluentui/react-menu", + "email": "lingfan.gao@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-provider-e29e9fce-6918-4d31-b87f-370107e2331c.json b/change/@fluentui-react-provider-e29e9fce-6918-4d31-b87f-370107e2331c.json new file mode 100644 index 0000000000000..a981bdd4a54f6 --- /dev/null +++ b/change/@fluentui-react-provider-e29e9fce-6918-4d31-b87f-370107e2331c.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Use react-focus-management", + "packageName": "@fluentui/react-provider", + "email": "lingfan.gao@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-focus-management/.eslintrc.json b/packages/react-focus-management/.eslintrc.json new file mode 100644 index 0000000000000..ceea884c70dcc --- /dev/null +++ b/packages/react-focus-management/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["plugin:@fluentui/eslint-plugin/react"], + "root": true +} diff --git a/packages/react-focus-management/.npmignore b/packages/react-focus-management/.npmignore new file mode 100644 index 0000000000000..24337b6c973e8 --- /dev/null +++ b/packages/react-focus-management/.npmignore @@ -0,0 +1,34 @@ +*.api.json +*.config.js +*.log +*.nuspec +*.test.* +*.yml +.editorconfig +.eslintrc* +.eslintcache +.gitattributes +.gitignore +.vscode +coverage +dist/storybook +dist/*.stats.html +dist/*.stats.json +dist/demo +fabric-test* +gulpfile.js +images +index.html +jsconfig.json +node_modules +results +src/**/* +!src/**/examples/*.tsx +!src/**/docs/**/*.md +!src/**/*.types.ts +temp +tsconfig.json +tsd.json +tslint.json +typings +visualtests diff --git a/packages/react-focus-management/LICENSE b/packages/react-focus-management/LICENSE new file mode 100644 index 0000000000000..40cbf5ea4ed96 --- /dev/null +++ b/packages/react-focus-management/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-focus-management + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/react-focus-management/README.md b/packages/react-focus-management/README.md new file mode 100644 index 0000000000000..81a7bf8d265ed --- /dev/null +++ b/packages/react-focus-management/README.md @@ -0,0 +1,42 @@ +# @fluentui/react-focus-management + +**Focus management components for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)** + +Experimental library for focus management that leverages [ability-helpers](https://github.com/microsoft/ability-helpers). + +These library is not production-ready and **should never be used in product**. This space is useful for testing new features whose APIs might change before final release. + +The provider needs to be wrapped around your application: + +```tsx +{children} +``` + +The API currently only supports declarative data-\* attributes that are returned using the exported react hooks: + +```tsx +const Item: React.FC = ({ children }) =>
Item
; + +const ArrowNavigationExample: React.FC = ({ children }) => { + const attrs = useArrowNavigationGroup({ circular: true }); + + return ( +
+ + + + + + +
+ ); +}; + +const App: React.FC = () => { + return ( + + + + ); +}; +``` diff --git a/packages/react-focus-management/config/api-extractor.json b/packages/react-focus-management/config/api-extractor.json new file mode 100644 index 0000000000000..c8406ab42ca3c --- /dev/null +++ b/packages/react-focus-management/config/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "@fluentui/scripts/api-extractor/api-extractor.common.json" +} diff --git a/packages/react-focus-management/etc/react-focus-management.api.md b/packages/react-focus-management/etc/react-focus-management.api.md new file mode 100644 index 0000000000000..45f1eb639eeea --- /dev/null +++ b/packages/react-focus-management/etc/react-focus-management.api.md @@ -0,0 +1,58 @@ +## API Report File for "@fluentui/react-focus-management" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { getAbilityHelpersAttribute } from 'ability-helpers'; +import * as React from 'react'; +import { Types } from 'ability-helpers'; + +// @public (undocumented) +export interface FocusManagementProvideProps extends React.HTMLAttributes { + customRoot?: boolean; + // (undocumented) + dir?: 'ltr' | 'rtl'; + document?: Document; +} + +// @public +export const FocusManagementProvider: React.FunctionComponent; + +// @public (undocumented) +export interface FocusManagementProviderState extends FocusManagementProvideProps { + // Warning: (ae-forgotten-export) The symbol "FocusManagementContextValue" needs to be exported by the entry point index.d.ts + // + // (undocumented) + contextValue: FocusManagementContextValue; + // (undocumented) + dir: FocusManagementProvideProps['dir']; +} + +export { getAbilityHelpersAttribute } + +// @public (undocumented) +export const renderFocusManagementProvider: (state: FocusManagementProviderState) => JSX.Element; + +// @public +export const useArrowNavigationGroup: (options?: UseArrowNavigationGroupOptions) => Types.AbilityHelpersDOMAttribute; + +// @public (undocumented) +export interface UseArrowNavigationGroupOptions { + circular?: boolean; +} + +// @public +export const useFocusFinders: () => { + findAllFocusable: (root: HTMLElement, matcher: (el: HTMLElement) => boolean) => HTMLElement[]; + findFirstFocusable: (root: HTMLElement) => HTMLElement | null | undefined; + findLastFocusable: (root: HTMLElement) => HTMLElement | null | undefined; +}; + +// @public (undocumented) +export const useFocusManagementProvider: (props: FocusManagementProvideProps, ref: React.Ref) => FocusManagementProviderState; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-focus-management/jest.config.js b/packages/react-focus-management/jest.config.js new file mode 100644 index 0000000000000..ddecc5348fd56 --- /dev/null +++ b/packages/react-focus-management/jest.config.js @@ -0,0 +1,5 @@ +const { createConfig } = require('@fluentui/scripts/jest/jest-resources'); + +const config = createConfig({}); + +module.exports = config; diff --git a/packages/react-focus-management/just.config.ts b/packages/react-focus-management/just.config.ts new file mode 100644 index 0000000000000..bcc7d9d264037 --- /dev/null +++ b/packages/react-focus-management/just.config.ts @@ -0,0 +1,3 @@ +import { preset } from '@fluentui/scripts'; + +preset(); diff --git a/packages/react-focus-management/package.json b/packages/react-focus-management/package.json new file mode 100644 index 0000000000000..43b622d43a6c5 --- /dev/null +++ b/packages/react-focus-management/package.json @@ -0,0 +1,61 @@ +{ + "name": "@fluentui/react-focus-management", + "version": "9.0.0-alpha.0", + "description": "Utilities for focus management and facade for ability-helpers", + "main": "lib-commonjs/index.js", + "module": "lib/index.js", + "typings": "lib/index.d.ts", + "sideEffects": [ + "lib/version.js" + ], + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "scripts": { + "build": "just-scripts build", + "bundle": "just-scripts bundle", + "clean": "just-scripts clean", + "code-style": "just-scripts code-style", + "just": "just-scripts", + "lint": "just-scripts lint", + "start": "just-scripts dev:storybook", + "start-test": "just-scripts jest-watch", + "test": "just-scripts test", + "update-snapshots": "just-scripts jest -u" + }, + "devDependencies": { + "@fluentui/eslint-plugin": "^1.0.0-beta.2", + "@fluentui/scripts": "^1.0.0", + "@testing-library/react": "^10.4.9", + "@testing-library/react-hooks": "^5.0.3", + "@types/react": "16.9.42", + "@types/react-dom": "16.9.10", + "@types/react-test-renderer": "^16.0.0", + "react": "16.8.6", + "react-app-polyfill": "~1.0.1", + "react-dom": "16.8.6", + "react-test-renderer": "^16.3.0" + }, + "dependencies": { + "@fluentui/react-utilities": "^9.0.0-alpha.1", + "@fluentui/set-version": "^8.0.0-beta.2", + "ability-helpers": "^0.4.3", + "tslib": "^1.10.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <18.0.0", + "@types/react-dom": ">=16.8.0 <18.0.0", + "react": ">=16.8.0 <18.0.0", + "react-dom": ">=16.8.0 <18.0.0" + }, + "beachball": { + "tag": "alpha", + "disallowedChangeTypes": [ + "major", + "minor", + "patch" + ] + } +} diff --git a/packages/react-focus-management/src/FocusManagementProvider.tsx b/packages/react-focus-management/src/FocusManagementProvider.tsx new file mode 100644 index 0000000000000..5b939af964091 --- /dev/null +++ b/packages/react-focus-management/src/FocusManagementProvider.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { makeMergeProps, useMergedRefs } from '@fluentui/react-utilities'; +import { getCurrentAbilityHelpers, createAbilityHelpers, Types as AHTypes } from 'ability-helpers'; +import { internal__FocusManagementContext, FocusManagementContextValue } from './focusManagementContext'; + +export interface FocusManagementProvideProps extends React.HTMLAttributes { + dir?: 'ltr' | 'rtl'; + + /** + * The document, which can be null during server render in SSR + */ + document?: Document; + + /** + * The root is automatically set as the `body` element of the ownerDocument. + * This prop needs to be set if a custom root is used + */ + customRoot?: boolean; +} + +export interface FocusManagementProviderState extends FocusManagementProvideProps { + dir: FocusManagementProvideProps['dir']; + + contextValue: FocusManagementContextValue; +} + +const mergeProps = makeMergeProps(); + +export const useFocusManagementProvider = ( + props: FocusManagementProvideProps, + ref: React.Ref, +): FocusManagementProviderState => { + const rootRef = useMergedRefs(ref, React.useRef(null)); + const state = mergeProps( + { + ref: rootRef, + as: 'div', + }, + {}, + props, + ); + + state.dir = state.dir || 'ltr'; + + const ahOptions = { autoRoot: {} }; + if (state.customRoot) { + delete ahOptions.autoRoot; + } + + // only one instance per window of ability helpers should exist + let ahInstance: AHTypes.AbilityHelpersCore | undefined = undefined; + if (state.document?.defaultView) { + ahInstance = + getCurrentAbilityHelpers(state.document.defaultView) || + createAbilityHelpers(state.document.defaultView, ahOptions); + } + // memoize context value so that it's stable + state.contextValue = React.useMemo(() => ({ focusable: ahInstance?.focusable, ahInstance }), [ahInstance]); + + return state; +}; + +export const renderFocusManagementProvider = (state: FocusManagementProviderState) => { + return ( + + {state.children} + + ); +}; + +/** + * A React provider that manages and exposes an ability-helpers instance for focus management + */ +export const FocusManagementProvider: React.FunctionComponent = React.forwardRef< + HTMLDivElement, + FocusManagementProvideProps +>((props: FocusManagementProvideProps, ref: React.Ref) => { + const state = useFocusManagementProvider(props, ref); + + return renderFocusManagementProvider(state); +}); + +FocusManagementProvider.displayName = 'FocusManagementProvider'; diff --git a/packages/react-focus-management/src/focusManagementContext.tsx b/packages/react-focus-management/src/focusManagementContext.tsx new file mode 100644 index 0000000000000..92f333aee5a07 --- /dev/null +++ b/packages/react-focus-management/src/focusManagementContext.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Types as AHTypes } from 'ability-helpers'; + +// NOTE: very likely contents will change as usage is specced out properly +// creating for now to unblock initial experimentation and usage + +/** + * Context value, all members here can be undefined since the API relies heavily on window/document which are not + * present during server render in SSR + */ +export interface FocusManagementContextValue { + /** + * Ability helpers focusable API + */ + focusable?: AHTypes.FocusableAPI; + + /** + * Raw Ability helpers instance + */ + ahInstance?: AHTypes.AbilityHelpersCore; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const internal__FocusManagementContext = React.createContext( + ({} as unknown) as FocusManagementContextValue, +); + +/** + * Exposes the entire focus management context + * Should be used in the package but not exported in the public API + */ +export const useFocusManagementContext = (): FocusManagementContextValue => + React.useContext(internal__FocusManagementContext); diff --git a/packages/react-focus-management/src/hooks/index.ts b/packages/react-focus-management/src/hooks/index.ts new file mode 100644 index 0000000000000..d76a9fd41c9cf --- /dev/null +++ b/packages/react-focus-management/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useArrowNavigationGroup'; +export * from './useFocusFinders'; diff --git a/packages/react-focus-management/src/hooks/useArrowNavigationGroup.ts b/packages/react-focus-management/src/hooks/useArrowNavigationGroup.ts new file mode 100644 index 0000000000000..811fe5152e18b --- /dev/null +++ b/packages/react-focus-management/src/hooks/useArrowNavigationGroup.ts @@ -0,0 +1,25 @@ +import { getAbilityHelpersAttribute, Types as AHTypes } from 'ability-helpers'; + +export interface UseArrowNavigationGroupOptions { + /** + * Focus will cycle to the first/last elements of the group without stopping + */ + circular?: boolean; +} + +/** + * A hook that returns the necessary ability-helpers attributes to support arrow key navigation + * @param options - Options to configure keyboard navigation + */ +export const useArrowNavigationGroup = (options: UseArrowNavigationGroupOptions = {}) => { + const ahOptions = { + focusable: { + mover: { + navigationType: AHTypes.MoverKeys.Arrows, + cyclic: !!options.circular, + }, + }, + }; + + return getAbilityHelpersAttribute(ahOptions); +}; diff --git a/packages/react-focus-management/src/hooks/useFocusFinders.ts b/packages/react-focus-management/src/hooks/useFocusFinders.ts new file mode 100644 index 0000000000000..6881815c1165a --- /dev/null +++ b/packages/react-focus-management/src/hooks/useFocusFinders.ts @@ -0,0 +1,20 @@ +import { useFocusManagementContext } from '../focusManagementContext'; + +/** + * Returns a set of helper functions that will traverse focusable elements in the context of a root DOM element + */ +export const useFocusFinders = () => { + const { focusable } = useFocusManagementContext(); + + // Narrow props for now and let need dictate additional props in the future + const findAllFocusable = (root: HTMLElement, matcher: (el: HTMLElement) => boolean) => + focusable?.findAll(root, matcher) || []; + const findFirstFocusable = (root: HTMLElement) => focusable?.findFirst(root); + const findLastFocusable = (root: HTMLElement) => focusable?.findLast(root); + + return { + findAllFocusable, + findFirstFocusable, + findLastFocusable, + }; +}; diff --git a/packages/react-focus-management/src/index.ts b/packages/react-focus-management/src/index.ts new file mode 100644 index 0000000000000..1b8f3a91870d0 --- /dev/null +++ b/packages/react-focus-management/src/index.ts @@ -0,0 +1,5 @@ +import './version'; +export { getAbilityHelpersAttribute } from 'ability-helpers'; + +export * from './FocusManagementProvider'; +export * from './hooks/index'; diff --git a/packages/react-focus-management/src/version.ts b/packages/react-focus-management/src/version.ts new file mode 100644 index 0000000000000..4ab188e747a53 --- /dev/null +++ b/packages/react-focus-management/src/version.ts @@ -0,0 +1,5 @@ +// Do not modify this file; it is generated as part of publish. +// The checked in version is a placeholder only and will not be updated. +import { setVersion } from '@fluentui/set-version'; +setVersion('@fluentui/react-ability-helpers', '0.0.0'); + diff --git a/packages/react-focus-management/tsconfig.json b/packages/react-focus-management/tsconfig.json new file mode 100644 index 0000000000000..0a88d158924ad --- /dev/null +++ b/packages/react-focus-management/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "target": "es5", + "module": "commonjs", + "jsx": "react", + "declaration": true, + "sourceMap": true, + "importHelpers": true, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "moduleResolution": "node", + "preserveConstEnums": true, + "lib": ["es5", "dom"], + "skipLibCheck": true, + "typeRoots": ["../../node_modules/@types", "../../typings"], + "types": ["jest", "custom-global"] + }, + "include": ["src"] +} diff --git a/packages/react-menu/etc/react-menu.api.md b/packages/react-menu/etc/react-menu.api.md index c97f242bcca67..8e97e8dca697e 100644 --- a/packages/react-menu/etc/react-menu.api.md +++ b/packages/react-menu/etc/react-menu.api.md @@ -134,6 +134,9 @@ export interface MenuListProps extends ComponentProps, React.HTMLAttributes; selectRadio: SelectableHandler; + // Warning: (ae-forgotten-export) The symbol "MenuListContextValue" needs to be exported by the entry point index.d.ts + setFocusByFirstCharacter: MenuListContextValue['setFocusByFirstCharacter']; + // (undocumented) toggleCheckbox: SelectableHandler; } diff --git a/packages/react-menu/package.json b/packages/react-menu/package.json index 89ec25146179f..7b44f4ac8705c 100644 --- a/packages/react-menu/package.json +++ b/packages/react-menu/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@fluentui/keyboard-key": "^0.2.13", + "@fluentui/react-focus-management": "^9.0.0-alpha.0", "@fluentui/react-context-selector": "^0.53.1", "@fluentui/react-make-styles": "^9.0.0-alpha.1", "@fluentui/react-utilities": "^9.0.0-alpha.1", diff --git a/packages/react-menu/src/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap b/packages/react-menu/src/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap index 7b751bd7f176f..ac01b22c9c554 100644 --- a/packages/react-menu/src/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap +++ b/packages/react-menu/src/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap @@ -3,6 +3,7 @@ exports[`MenuItem renders a default state 1`] = `
diff --git a/packages/react-menu/src/components/MenuItem/useMenuItem.ts b/packages/react-menu/src/components/MenuItem/useMenuItem.ts index 7d6af21c3da1c..10ab4f8375089 100644 --- a/packages/react-menu/src/components/MenuItem/useMenuItem.ts +++ b/packages/react-menu/src/components/MenuItem/useMenuItem.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import { makeMergeProps, resolveShorthandProps, useMergedRefs } from '@fluentui/react-utilities'; import { MenuItemProps, MenuItemState } from './MenuItem.types'; +import { useCharacterSearch } from '../../utils/useCharacterSearch'; /** * Consts listing which props are shorthand props. @@ -26,5 +27,6 @@ export const useMenuItem = ( resolveShorthandProps(props, menuItemShorthandProps), ); + useCharacterSearch(state); return state; }; diff --git a/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckbox.ts b/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckbox.ts index 4dc2b7cd8ced6..bba0b9b21deb3 100644 --- a/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckbox.ts +++ b/packages/react-menu/src/components/MenuItemCheckbox/useMenuItemCheckbox.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import { makeMergeProps, resolveShorthandProps, useMergedRefs } from '@fluentui/react-utilities'; import { MenuItemCheckboxProps, MenuItemCheckboxState } from './MenuItemCheckbox.types'; import { useMenuItemSelectable } from '../../selectable/index'; +import { useCharacterSearch } from '../../utils/useCharacterSearch'; import { useMenuListContext } from '../../menuListContext'; /** @@ -29,6 +30,8 @@ export const useMenuItemCheckbox = ( resolveShorthandProps(props, menuItemCheckboxShorthandProps), ); + useCharacterSearch(state); + const toggleCheckbox = useMenuListContext(context => context.toggleCheckbox); useMenuItemSelectable(state, toggleCheckbox); diff --git a/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadio.ts b/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadio.ts index 91d3e60fc8da6..2db38307da8c0 100644 --- a/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadio.ts +++ b/packages/react-menu/src/components/MenuItemRadio/useMenuItemRadio.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import { makeMergeProps, resolveShorthandProps, useMergedRefs } from '@fluentui/react-utilities'; import { MenuItemRadioProps, MenuItemRadioState } from './MenuItemRadio.types'; import { useMenuItemSelectable } from '../../selectable/index'; +import { useCharacterSearch } from '../../utils/useCharacterSearch'; import { useMenuListContext } from '../../menuListContext'; /** @@ -31,8 +32,9 @@ export const useMenuItemRadio = ( resolveShorthandProps(props, menuItemRadioShorthandProps), ); - const selectRadio = useMenuListContext(context => context.selectRadio); + useCharacterSearch(state); + const selectRadio = useMenuListContext(context => context.selectRadio); useMenuItemSelectable(state, selectRadio); return state; }; diff --git a/packages/react-menu/src/components/MenuList/MenuList.types.ts b/packages/react-menu/src/components/MenuList/MenuList.types.ts index 370be3acf0c5d..ab7febf3d2a92 100644 --- a/packages/react-menu/src/components/MenuList/MenuList.types.ts +++ b/packages/react-menu/src/components/MenuList/MenuList.types.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { ComponentProps } from '@fluentui/react-utilities'; +import { MenuListContextValue } from '../../menuListContext'; import { SelectableHandler } from '../../selectable/index'; export interface MenuListProps extends ComponentProps, React.HTMLAttributes { @@ -24,6 +25,11 @@ export interface MenuListState extends MenuListProps { ref: React.MutableRefObject; /** + * Callback to set focus on the next menu item by first character + */ + setFocusByFirstCharacter: MenuListContextValue['setFocusByFirstCharacter']; + + /* * Toggles the state of a checkbox item */ toggleCheckbox: SelectableHandler; diff --git a/packages/react-menu/src/components/MenuList/__snapshots__/MenuList.test.tsx.snap b/packages/react-menu/src/components/MenuList/__snapshots__/MenuList.test.tsx.snap index d4f387c997350..e308222d5fb09 100644 --- a/packages/react-menu/src/components/MenuList/__snapshots__/MenuList.test.tsx.snap +++ b/packages/react-menu/src/components/MenuList/__snapshots__/MenuList.test.tsx.snap @@ -2,6 +2,7 @@ exports[`MenuList renders a default state 1`] = `
Default MenuList diff --git a/packages/react-menu/src/components/MenuList/renderMenuList.tsx b/packages/react-menu/src/components/MenuList/renderMenuList.tsx index 48289a71ab465..b22f2996a9a6b 100644 --- a/packages/react-menu/src/components/MenuList/renderMenuList.tsx +++ b/packages/react-menu/src/components/MenuList/renderMenuList.tsx @@ -8,10 +8,12 @@ import { MenuListProvider } from '../../menuListContext'; */ export const renderMenuList = (state: MenuListState) => { const { slots, slotProps } = getSlots(state); - const { onCheckedValueChange, checkedValues, toggleCheckbox, selectRadio } = state; + const { onCheckedValueChange, checkedValues, toggleCheckbox, selectRadio, setFocusByFirstCharacter } = state; return ( - + {state.children} ); diff --git a/packages/react-menu/src/components/MenuList/useMenuList.ts b/packages/react-menu/src/components/MenuList/useMenuList.ts index b020bddd3dbf0..65893664a4580 100644 --- a/packages/react-menu/src/components/MenuList/useMenuList.ts +++ b/packages/react-menu/src/components/MenuList/useMenuList.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { makeMergeProps, resolveShorthandProps, useMergedRefs, useEventCallback } from '@fluentui/react-utilities'; +import { useArrowNavigationGroup, useFocusFinders } from '@fluentui/react-focus-management'; import { MenuListProps, MenuListState } from './MenuList.types'; const mergeProps = makeMergeProps(); @@ -12,15 +13,61 @@ export const useMenuList = ( ref: React.Ref, defaultProps?: MenuListProps, ): MenuListState => { + const focusAttributes = useArrowNavigationGroup({ circular: true }); + const { findAllFocusable } = useFocusFinders(); + const state = mergeProps( { ref: useMergedRefs(ref, React.useRef(null)), role: 'menu', + ...focusAttributes, }, defaultProps, resolveShorthandProps(props, []), ); + state.setFocusByFirstCharacter = React.useCallback( + (e: React.KeyboardEvent, itemEl: HTMLElement) => { + // TODO use some kind of children registration to reduce dependency on DOM roles + const acceptedRoles = ['menuitem', 'menuitemcheckbox', 'menuitemradio']; + const menuItems = findAllFocusable( + state.ref.current, + (el: HTMLElement) => el.hasAttribute('role') && acceptedRoles.indexOf(el.getAttribute('role')!) !== -1, + ); + + let startIndex = menuItems.indexOf(itemEl) + 1; + if (startIndex === menuItems.length) { + startIndex = 0; + } + + const firstChars = menuItems.map(menuItem => menuItem.textContent?.charAt(0).toLowerCase()); + const char = e.key.toLowerCase(); + + const getIndexFirstChars = (start: number, firstChar: string) => { + for (let i = start; i < firstChars.length; i++) { + if (char === firstChars[i]) { + return i; + } + } + return -1; + }; + + // Check remaining slots in the menu + let index = getIndexFirstChars(startIndex, char); + + // If not found in remaining slots, check from beginning + if (index === -1) { + index = getIndexFirstChars(0, char); + } + + // If match was found... + if (index > -1) { + menuItems[index].focus(); + } + }, + [findAllFocusable, state.ref], + ); + const { checkedValues, onCheckedValueChange } = state; state.toggleCheckbox = useEventCallback( (e: React.MouseEvent | React.KeyboardEvent, name: string, value: string, checked: boolean) => { diff --git a/packages/react-menu/src/menuListContext.tsx b/packages/react-menu/src/menuListContext.tsx index faee4a04b6a8c..31c3f06fa0543 100644 --- a/packages/react-menu/src/menuListContext.tsx +++ b/packages/react-menu/src/menuListContext.tsx @@ -5,6 +5,7 @@ import { SelectableHandler } from './selectable/index'; const MenuListContext = createContext({ checkedValues: {}, onCheckedValueChange: () => null, + setFocusByFirstCharacter: () => null, toggleCheckbox: () => null, selectRadio: () => null, }); @@ -15,6 +16,7 @@ const MenuListContext = createContext({ export interface MenuListContextValue { checkedValues?: Record; onCheckedValueChange?: (e: React.MouseEvent | React.KeyboardEvent, name: string, items: string[]) => void; + setFocusByFirstCharacter?: (e: React.KeyboardEvent, itemEl: HTMLElement) => void; toggleCheckbox?: SelectableHandler; selectRadio?: SelectableHandler; } diff --git a/packages/react-menu/src/utils/useCharacterSearch.ts b/packages/react-menu/src/utils/useCharacterSearch.ts new file mode 100644 index 0000000000000..d03edfb75638e --- /dev/null +++ b/packages/react-menu/src/utils/useCharacterSearch.ts @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { MenuItemState } from '../components/index'; +import { useMenuListContext } from '../menuListContext'; + +export const useCharacterSearch = (state: MenuItemState) => { + const setFocusByFirstCharacter = useMenuListContext(context => context.setFocusByFirstCharacter); + + const { onKeyDown: onKeyDownBase } = state; + state.onKeyDown = (e: React.KeyboardEvent) => { + if (onKeyDownBase) { + onKeyDownBase(e); + } + + if (e.key?.length > 1) { + return; + } + + setFocusByFirstCharacter?.(e, state.ref.current); + }; + + return state; +}; diff --git a/packages/react-provider/package.json b/packages/react-provider/package.json index 0a5c4926a0389..8df9329105dc9 100644 --- a/packages/react-provider/package.json +++ b/packages/react-provider/package.json @@ -25,15 +25,16 @@ }, "devDependencies": { "@fluentui/eslint-plugin": "^1.0.0-beta.2", + "@fluentui/scripts": "^1.0.0", "@types/react": "16.9.42", "@types/react-dom": "16.9.10", "@types/webpack-env": "1.16.0", - "@fluentui/scripts": "^1.0.0", "react": "16.8.6", "react-app-polyfill": "~1.0.1", "react-dom": "16.8.6" }, "dependencies": { + "@fluentui/react-focus-management": "^9.0.0-alpha.0", "@fluentui/react-theme": "^9.0.0-alpha.1", "@fluentui/react-theme-provider": "^9.0.0-alpha.1", "@fluentui/react-utilities": "^9.0.0-alpha.1", diff --git a/packages/react-provider/src/FluentProvider.tsx b/packages/react-provider/src/FluentProvider.tsx index 89b97d0db7bd6..ceaff035a77fc 100644 --- a/packages/react-provider/src/FluentProvider.tsx +++ b/packages/react-provider/src/FluentProvider.tsx @@ -1,5 +1,6 @@ import { PartialTheme, Theme } from '@fluentui/react-theme'; import { internal__ThemeContext, ThemeProviderState, useThemeProviderState } from '@fluentui/react-theme-provider'; +import { FocusManagementProvider } from '@fluentui/react-focus-management'; import { getSlots, makeMergeProps, useMergedRefs } from '@fluentui/react-utilities'; import * as React from 'react'; @@ -42,7 +43,9 @@ export function renderFluentProvider(state: ProviderState) { return ( - + + + ); diff --git a/tsconfig.base.json b/tsconfig.base.json index 63146b98b580d..11a526c35b966 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,7 +17,8 @@ "@fluentui/react-utilities": ["packages/react-utilities/src/index.ts"], "@fluentui/react-make-styles": ["packages/react-make-styles/src/index.ts"], "@fluentui/keyboard-key": ["packages/keyboard-key/src/index.ts"], - "@fluentui/react-menu": ["packages/react-menu/src/index.ts"] + "@fluentui/react-menu": ["packages/react-menu/src/index.ts"], + "@fluentui/react-focus-management": ["packages/react-focus-management/src/index.ts"] } }, "exclude": ["node_modules"] diff --git a/yarn.lock b/yarn.lock index 97484b307c445..7063c6e6940d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5598,6 +5598,11 @@ ability-attributes@^0.0.8: dependencies: ability-attributes-js-constraints "^0.0.8" +ability-helpers@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/ability-helpers/-/ability-helpers-0.4.3.tgz#3b8df86d819303910dcdd0cd841123aa070fab9d" + integrity sha512-9D3ky8YclOD6g4T8cmsZpu3NrR+BfrJH6OKFYbM96a2NEBfHpRbNZ7aciLuBnLtHVkkFj9Jj2Yaa97pzBk0OVw== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"