diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index e0ca04d9d69ae..f94053214051c 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Internal + +- `NavigableContainer`: Convert to TypeScript ([#49377](https://github.com/WordPress/gutenberg/pull/49377)). + ## 23.9.0 (2023-04-26) ### Internal diff --git a/packages/components/src/navigable-container/README.md b/packages/components/src/navigable-container/README.md index de6c1103ae6de..a982219248f50 100644 --- a/packages/components/src/navigable-container/README.md +++ b/packages/components/src/navigable-container/README.md @@ -2,38 +2,49 @@ `NavigableContainer` is a React component to render a container navigable using the keyboard. Only things that are focusable can be navigated to. It will currently always be a `div`. -`NavigableContainer` is exported as two classes: `NavigableMenu` and `TabbableContainer`. `NavigableContainer` itself is **not** exported. `NavigableMenu` and `TabbableContainer` have the props listed below. Any other props will be passed through to the `div`. +`NavigableContainer` is exported as two components: `NavigableMenu` and `TabbableContainer`. `NavigableContainer` itself is **not** exported. `NavigableMenu` and `TabbableContainer` have the props listed below. Any other props will be passed through to the `div`. --- ## Props -These are the props that `NavigableMenu` and `TabbableContainer`. Any props which are specific to one class are labelled appropriately. +These are the props that `NavigableMenu` and `TabbableContainer`. Any props which are specific to one component are labelled appropriately. -### onNavigate +### `cycle`: `boolean` -A callback invoked when the menu navigates to one of its children passing the index and child as an argument +A boolean which tells the component whether or not to cycle from the end back to the beginning and vice versa. -- Type: `Function` - Required: No +- default: `true` -### cycle +### `eventToOffset`: `( event: KeyboardEvent ) => -1 | 0 | 1 | undefined` -A boolean which tells the component whether or not to cycle from the end back to the beginning and vice versa. +(TabbableContainer only) +Gets an offset, given an event. + +- Required: No + +### `onKeydown`: `( event: KeyboardEvent ) => void` + +A callback invoked on the keydown event. + +- Required: No + +### `onNavigate`: `( index: number, focusable: HTMLElement ) => void` + +A callback invoked when the menu navigates to one of its children passing the index and child as an argument -- Type: `Boolean` - Required: No -- default: true -### orientation (NavigableMenu only) +### `orientation`: `'vertical' | 'horizontal' | 'both'` -The orientation of the menu. It could be "vertical", "horizontal" or "both" +(NavigableMenu only) +The orientation of the menu. It could be "vertical", "horizontal", or "both". -- Type: `String` - Required: No - Default: `"vertical"` -## Classes +## Components ### NavigableMenu diff --git a/packages/components/src/navigable-container/container.js b/packages/components/src/navigable-container/container.tsx similarity index 73% rename from packages/components/src/navigable-container/container.js rename to packages/components/src/navigable-container/container.tsx index 6c9de468f0c59..f52754e06d61a 100644 --- a/packages/components/src/navigable-container/container.js +++ b/packages/components/src/navigable-container/container.tsx @@ -1,14 +1,23 @@ -// @ts-nocheck +/** + * External dependencies + */ +import type { ForwardedRef } from 'react'; + /** * WordPress dependencies */ import { Component, forwardRef } from '@wordpress/element'; import { focus } from '@wordpress/dom'; +/** + * Internal dependencies + */ +import type { NavigableContainerProps } from './types'; + const noop = () => {}; const MENU_ITEM_ROLES = [ 'menuitem', 'menuitemradio', 'menuitemcheckbox' ]; -function cycleValue( value, total, offset ) { +function cycleValue( value: number, total: number, offset: number ) { const nextValue = value + offset; if ( nextValue < 0 ) { return total + nextValue; @@ -19,9 +28,11 @@ function cycleValue( value, total, offset ) { return nextValue; } -class NavigableContainer extends Component { - constructor() { - super( ...arguments ); +class NavigableContainer extends Component< NavigableContainerProps > { + container?: HTMLDivElement; + + constructor( args: NavigableContainerProps ) { + super( args ); this.onKeyDown = this.onKeyDown.bind( this ); this.bindContainer = this.bindContainer.bind( this ); @@ -30,21 +41,27 @@ class NavigableContainer extends Component { } componentDidMount() { + if ( ! this.container ) { + return; + } + // We use DOM event listeners instead of React event listeners // because we want to catch events from the underlying DOM tree // The React Tree can be different from the DOM tree when using // portals. Block Toolbars for instance are rendered in a separate // React Trees. this.container.addEventListener( 'keydown', this.onKeyDown ); - this.container.addEventListener( 'focus', this.onFocus ); } componentWillUnmount() { + if ( ! this.container ) { + return; + } + this.container.removeEventListener( 'keydown', this.onKeyDown ); - this.container.removeEventListener( 'focus', this.onFocus ); } - bindContainer( ref ) { + bindContainer( ref: HTMLDivElement ) { const { forwardedRef } = this.props; this.container = ref; @@ -55,10 +72,14 @@ class NavigableContainer extends Component { } } - getFocusableContext( target ) { + getFocusableContext( target: Element ) { + if ( ! this.container ) { + return null; + } + const { onlyBrowserTabstops } = this.props; const finder = onlyBrowserTabstops ? focus.tabbable : focus.focusable; - const focusables = finder.find( this.container ); + const focusables = finder.find( this.container ) as HTMLElement[]; const index = this.getFocusableIndex( focusables, target ); if ( index > -1 && target ) { @@ -67,14 +88,11 @@ class NavigableContainer extends Component { return null; } - getFocusableIndex( focusables, target ) { - const directIndex = focusables.indexOf( target ); - if ( directIndex !== -1 ) { - return directIndex; - } + getFocusableIndex( focusables: Element[], target: Element ) { + return focusables.indexOf( target ); } - onKeyDown( event ) { + onKeyDown( event: KeyboardEvent ) { if ( this.props.onKeyDown ) { this.props.onKeyDown( event ); } @@ -98,9 +116,11 @@ class NavigableContainer extends Component { // from scrolling. The preventDefault also prevents Voiceover from // 'handling' the event, as voiceover will try to use arrow keys // for highlighting text. - const targetRole = event.target.getAttribute( 'role' ); + const targetRole = ( + event.target as HTMLDivElement | null + )?.getAttribute( 'role' ); const targetHasMenuItemRole = - MENU_ITEM_ROLES.includes( targetRole ); + !! targetRole && MENU_ITEM_ROLES.includes( targetRole ); // `preventDefault()` on tab to avoid having the browser move the focus // after this component has already moved it. @@ -115,9 +135,13 @@ class NavigableContainer extends Component { return; } - const context = getFocusableContext( - event.target.ownerDocument.activeElement - ); + const activeElement = ( event.target as HTMLElement | null ) + ?.ownerDocument?.activeElement; + if ( ! activeElement ) { + return; + } + + const context = getFocusableContext( activeElement ); if ( ! context ) { return; } @@ -152,7 +176,10 @@ class NavigableContainer extends Component { } } -const forwardedNavigableContainer = ( props, ref ) => { +const forwardedNavigableContainer = ( + props: NavigableContainerProps, + ref: ForwardedRef< HTMLDivElement > +) => { return ; }; forwardedNavigableContainer.displayName = 'NavigableContainer'; diff --git a/packages/components/src/navigable-container/index.js b/packages/components/src/navigable-container/index.tsx similarity index 90% rename from packages/components/src/navigable-container/index.js rename to packages/components/src/navigable-container/index.tsx index b47667fd39cd7..e98f1b1236d7b 100644 --- a/packages/components/src/navigable-container/index.js +++ b/packages/components/src/navigable-container/index.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Internal Dependencies */ diff --git a/packages/components/src/navigable-container/menu.js b/packages/components/src/navigable-container/menu.js deleted file mode 100644 index cf19c23adcb9d..0000000000000 --- a/packages/components/src/navigable-container/menu.js +++ /dev/null @@ -1,62 +0,0 @@ -// @ts-nocheck - -/** - * WordPress dependencies - */ -import { forwardRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import NavigableContainer from './container'; - -export function NavigableMenu( - { role = 'menu', orientation = 'vertical', ...rest }, - ref -) { - const eventToOffset = ( evt ) => { - const { code } = evt; - - let next = [ 'ArrowDown' ]; - let previous = [ 'ArrowUp' ]; - - if ( orientation === 'horizontal' ) { - next = [ 'ArrowRight' ]; - previous = [ 'ArrowLeft' ]; - } - - if ( orientation === 'both' ) { - next = [ 'ArrowRight', 'ArrowDown' ]; - previous = [ 'ArrowLeft', 'ArrowUp' ]; - } - - if ( next.includes( code ) ) { - return 1; - } else if ( previous.includes( code ) ) { - return -1; - } else if ( - [ 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight' ].includes( - code - ) - ) { - // Key press should be handled, e.g. have event propagation and - // default behavior handled by NavigableContainer but not result - // in an offset. - return 0; - } - }; - - return ( - - ); -} - -export default forwardRef( NavigableMenu ); diff --git a/packages/components/src/navigable-container/menu.tsx b/packages/components/src/navigable-container/menu.tsx new file mode 100644 index 0000000000000..ae865fe443aae --- /dev/null +++ b/packages/components/src/navigable-container/menu.tsx @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import type { ForwardedRef } from 'react'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import NavigableContainer from './container'; +import type { NavigableMenuProps } from './types'; + +export function UnforwardedNavigableMenu( + { role = 'menu', orientation = 'vertical', ...rest }: NavigableMenuProps, + ref: ForwardedRef< any > +) { + const eventToOffset = ( evt: KeyboardEvent ) => { + const { code } = evt; + + let next = [ 'ArrowDown' ]; + let previous = [ 'ArrowUp' ]; + + if ( orientation === 'horizontal' ) { + next = [ 'ArrowRight' ]; + previous = [ 'ArrowLeft' ]; + } + + if ( orientation === 'both' ) { + next = [ 'ArrowRight', 'ArrowDown' ]; + previous = [ 'ArrowLeft', 'ArrowUp' ]; + } + + if ( next.includes( code ) ) { + return 1; + } else if ( previous.includes( code ) ) { + return -1; + } else if ( + [ 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight' ].includes( + code + ) + ) { + // Key press should be handled, e.g. have event propagation and + // default behavior handled by NavigableContainer but not result + // in an offset. + return 0; + } + + return undefined; + }; + + return ( + + ); +} + +/** + * A container for a navigable menu. + * + * ```jsx + * import { + * NavigableMenu, + * Button, + * } from '@wordpress/components'; + * + * function onNavigate( index, target ) { + * console.log( `Navigates to ${ index }`, target ); + * } + * + * const MyNavigableContainer = () => ( + *
+ * Navigable Menu: + * + * + * + * + * + *
+ * ); + * ``` + */ +export const NavigableMenu = forwardRef( UnforwardedNavigableMenu ); + +export default NavigableMenu; diff --git a/packages/components/src/navigable-container/stories/navigable-menu.js b/packages/components/src/navigable-container/stories/navigable-menu.tsx similarity index 66% rename from packages/components/src/navigable-container/stories/navigable-menu.js rename to packages/components/src/navigable-container/stories/navigable-menu.tsx index af9b08fd9b032..5f8ee2e4e5d85 100644 --- a/packages/components/src/navigable-container/stories/navigable-menu.js +++ b/packages/components/src/navigable-container/stories/navigable-menu.tsx @@ -1,25 +1,30 @@ +/** + * External dependencies + */ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; + /** * Internal dependencies */ import { NavigableMenu } from '..'; -export default { +const meta: ComponentMeta< typeof NavigableMenu > = { title: 'Components/NavigableMenu', component: NavigableMenu, argTypes: { - children: { type: null }, - cycle: { - type: 'boolean', - }, - onNavigate: { action: 'onNavigate' }, - orientation: { - options: [ 'horizontal', 'vertical' ], - control: { type: 'radio' }, + children: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, }, + docs: { source: { state: 'open' } }, }, }; +export default meta; -export const Default = ( args ) => { +export const Default: ComponentStory< typeof NavigableMenu > = ( args ) => { return ( <> diff --git a/packages/components/src/navigable-container/stories/tabbable-container.js b/packages/components/src/navigable-container/stories/tabbable-container.tsx similarity index 61% rename from packages/components/src/navigable-container/stories/tabbable-container.js rename to packages/components/src/navigable-container/stories/tabbable-container.tsx index 3e12825189f1a..b517019e29571 100644 --- a/packages/components/src/navigable-container/stories/tabbable-container.js +++ b/packages/components/src/navigable-container/stories/tabbable-container.tsx @@ -1,21 +1,30 @@ +/** + * External dependencies + */ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; + /** * Internal dependencies */ import { TabbableContainer } from '..'; -export default { +const meta: ComponentMeta< typeof TabbableContainer > = { title: 'Components/TabbableContainer', component: TabbableContainer, argTypes: { - children: { type: null }, - cycle: { - type: 'boolean', + children: { control: { type: null } }, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, }, - onNavigate: { action: 'onNavigate' }, + docs: { source: { state: 'open' } }, }, }; +export default meta; -export const Default = ( args ) => { +export const Default: ComponentStory< typeof TabbableContainer > = ( args ) => { return ( <> diff --git a/packages/components/src/navigable-container/tabbable.js b/packages/components/src/navigable-container/tabbable.js deleted file mode 100644 index 8f38970bf0670..0000000000000 --- a/packages/components/src/navigable-container/tabbable.js +++ /dev/null @@ -1,46 +0,0 @@ -// @ts-nocheck -/** - * WordPress dependencies - */ -import { forwardRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import NavigableContainer from './container'; - -export function TabbableContainer( { eventToOffset, ...props }, ref ) { - const innerEventToOffset = ( evt ) => { - const { code, shiftKey } = evt; - if ( 'Tab' === code ) { - return shiftKey ? -1 : 1; - } - - // Allow custom handling of keys besides Tab. - // - // By default, TabbableContainer will move focus forward on Tab and - // backward on Shift+Tab. The handler below will be used for all other - // events. The semantics for `eventToOffset`'s return - // values are the following: - // - // - +1: move focus forward - // - -1: move focus backward - // - 0: don't move focus, but acknowledge event and thus stop it - // - undefined: do nothing, let the event propagate. - if ( eventToOffset ) { - return eventToOffset( evt ); - } - }; - - return ( - - ); -} - -export default forwardRef( TabbableContainer ); diff --git a/packages/components/src/navigable-container/tabbable.tsx b/packages/components/src/navigable-container/tabbable.tsx new file mode 100644 index 0000000000000..565007bf62d83 --- /dev/null +++ b/packages/components/src/navigable-container/tabbable.tsx @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import type { ForwardedRef } from 'react'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import NavigableContainer from './container'; +import type { TabbableContainerProps } from './types'; + +export function UnforwardedTabbableContainer( + { eventToOffset, ...props }: TabbableContainerProps, + ref: ForwardedRef< any > +) { + const innerEventToOffset = ( evt: KeyboardEvent ) => { + const { code, shiftKey } = evt; + if ( 'Tab' === code ) { + return shiftKey ? -1 : 1; + } + + // Allow custom handling of keys besides Tab. + // + // By default, TabbableContainer will move focus forward on Tab and + // backward on Shift+Tab. The handler below will be used for all other + // events. The semantics for `eventToOffset`'s return + // values are the following: + // + // - +1: move focus forward + // - -1: move focus backward + // - 0: don't move focus, but acknowledge event and thus stop it + // - undefined: do nothing, let the event propagate. + if ( eventToOffset ) { + return eventToOffset( evt ); + } + + return undefined; + }; + + return ( + + ); +} + +/** + * A container for tabbable elements. + * + * ```jsx + * import { + * TabbableContainer, + * Button, + * } from '@wordpress/components'; + * + * function onNavigate( index, target ) { + * console.log( `Navigates to ${ index }`, target ); + * } + * + * const MyTabbableContainer = () => ( + *
+ * Tabbable Container: + * + * + * + * + * + * + *
+ * ); + * ``` + */ +export const TabbableContainer = forwardRef( UnforwardedTabbableContainer ); + +export default TabbableContainer; diff --git a/packages/components/src/navigable-container/test/navigable-menu.js b/packages/components/src/navigable-container/test/navigable-menu.tsx similarity index 97% rename from packages/components/src/navigable-container/test/navigable-menu.js rename to packages/components/src/navigable-container/test/navigable-menu.tsx index 8152ca61ed73c..a5ab228b0c2b2 100644 --- a/packages/components/src/navigable-container/test/navigable-menu.js +++ b/packages/components/src/navigable-container/test/navigable-menu.tsx @@ -8,8 +8,9 @@ import userEvent from '@testing-library/user-event'; * Internal dependencies */ import { NavigableMenu } from '../menu'; +import type { NavigableMenuProps } from '../types'; -const NavigableMenuTestCase = ( props ) => ( +const NavigableMenuTestCase = ( props: NavigableMenuProps ) => ( @@ -34,6 +35,7 @@ describe( 'NavigableMenu', () => { // Mocking `getClientRects()` is necessary to pass a check performed by // the `focus.tabbable.find()` and by the `focus.focusable.find()` functions // from the `@wordpress/dom` package. + // @ts-expect-error We're not trying to comply to the DOM spec, only mocking window.HTMLElement.prototype.getClientRects = function () { return [ 'trick-jsdom-into-having-size-for-element-rect' ]; }; diff --git a/packages/components/src/navigable-container/test/tababble-container.js b/packages/components/src/navigable-container/test/tababble-container.tsx similarity index 96% rename from packages/components/src/navigable-container/test/tababble-container.js rename to packages/components/src/navigable-container/test/tababble-container.tsx index f514df39a50c8..88fc10bd98560 100644 --- a/packages/components/src/navigable-container/test/tababble-container.js +++ b/packages/components/src/navigable-container/test/tababble-container.tsx @@ -8,8 +8,9 @@ import userEvent from '@testing-library/user-event'; * Internal dependencies */ import { TabbableContainer } from '../tabbable'; +import type { TabbableContainerProps } from '../types'; -const TabbableContainerTestCase = ( props ) => ( +const TabbableContainerTestCase = ( props: TabbableContainerProps ) => ( @@ -37,6 +38,7 @@ describe( 'TabbableContainer', () => { // Mocking `getClientRects()` is necessary to pass a check performed by // the `focus.tabbable.find()` and by the `focus.focusable.find()` functions // from the `@wordpress/dom` package. + // @ts-expect-error We're not trying to comply to the DOM spec, only mocking window.HTMLElement.prototype.getClientRects = function () { return [ 'trick-jsdom-into-having-size-for-element-rect' ]; }; diff --git a/packages/components/src/navigable-container/types.ts b/packages/components/src/navigable-container/types.ts new file mode 100644 index 0000000000000..e64ff575069ac --- /dev/null +++ b/packages/components/src/navigable-container/types.ts @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import type { ForwardedRef, ReactNode } from 'react'; + +/** + * Internal dependencies + */ +import type { WordPressComponentProps } from '../ui/context'; + +type BaseProps = { + /** + * The component children. + */ + children?: ReactNode; + /** + * A boolean which tells the component whether or not to cycle from the end back to the beginning and vice versa. + * + * @default true + */ + cycle?: boolean; + /** + * A callback invoked on the keydown event. + */ + onKeyDown?: ( event: KeyboardEvent ) => void; + /** + * A callback invoked when the menu navigates to one of its children passing the index and child as an argument + */ + onNavigate?: ( index: number, focusable: HTMLElement ) => void; +}; + +export type NavigableContainerProps = WordPressComponentProps< + BaseProps & { + /** + * Gets an offset, given an event. + */ + eventToOffset: ( event: KeyboardEvent ) => -1 | 0 | 1 | undefined; + /** + * The forwarded ref. + */ + forwardedRef?: ForwardedRef< any >; + /** + * Whether to only consider browser tab stops. + * + * @default false + */ + onlyBrowserTabstops: boolean; + /** + * Whether to stop navigation events. + * + * @default false + */ + stopNavigationEvents: boolean; + }, + 'div', + false +>; + +export type NavigableMenuProps = WordPressComponentProps< + BaseProps & { + /** + * The orientation of the menu. + * + * @default 'vertical' + */ + orientation?: 'vertical' | 'horizontal' | 'both'; + }, + 'div', + false +>; + +export type TabbableContainerProps = WordPressComponentProps< + BaseProps & Partial< Pick< NavigableContainerProps, 'eventToOffset' > >, + 'div', + false +>; diff --git a/packages/components/src/tab-panel/index.tsx b/packages/components/src/tab-panel/index.tsx index b2af12b90c85f..4ed4dc76da22d 100644 --- a/packages/components/src/tab-panel/index.tsx +++ b/packages/components/src/tab-panel/index.tsx @@ -101,7 +101,7 @@ export function TabPanel( { // to show the `tab-panel` associated with the clicked tab. const activateTabAutomatically = ( _childIndex: number, - child: HTMLButtonElement + child: HTMLElement ) => { child.click(); };