- /* eslint-enable jsx-a11y/no-redundant-roles */
- );
-}
-
-BlockNavigationList.defaultProps = {
- selectBlock: () => {},
-};
-
-export default function BlockNavigationListWrapper( {
- __experimentalWithBlockNavigationSlots,
- ...props
-} ) {
- const blockNavigationContext = useMemo(
- () => ( { __experimentalWithBlockNavigationSlots } ),
- [ __experimentalWithBlockNavigationSlots ]
- );
- return (
-
-
-
- );
-}
diff --git a/packages/block-editor/src/components/block-navigation/style.scss b/packages/block-editor/src/components/block-navigation/style.scss
index c9576d7024a35..82d766c6a27f6 100644
--- a/packages/block-editor/src/components/block-navigation/style.scss
+++ b/packages/block-editor/src/components/block-navigation/style.scss
@@ -10,48 +10,76 @@ $tree-item-height: 36px;
color: $dark-gray-300;
}
-.block-editor-block-navigation__list,
-.block-editor-block-navigation__paragraph {
+.block-editor-block-navigation__container {
+ padding: $grid-unit - $border-width;
+}
+
+.block-editor-block-navigation-tree {
+ width: 100%;
+ border-collapse: collapse;
padding: 0;
margin: 0;
}
-.block-editor-block-navigation__list .block-editor-button-block-appender {
- outline: none;
- background: none;
- padding: $grid-unit-10;
- padding: 6px;
- margin-left: 0.8em;
- width: calc(100% - 0.8em);
-}
+.block-editor-block-navigation-leaf {
+ // Use position relative for row animation.
+ position: relative;
-.block-editor-block-navigation__list .block-editor-block-navigation__list {
- margin-top: 2px;
- border-left: $tree-border-width solid $light-gray-900;
- margin-left: 1em;
+ .block-editor-block-navigation-block-content-wrapper {
+ display: flex;
+ align-items: center;
+ width: calc(100% - 0.8em);
+ height: auto;
+ padding: 6px;
+ text-align: left;
+ color: $dark-gray-600;
+ border-radius: 2px;
+ }
- .block-editor-block-navigation__list {
- margin-left: 1.5em;
+ &.is-visible .block-editor-block-navigation-block-content-wrapper {
+ opacity: 1;
+ @include edit-post__fade-in-animation;
}
- .block-editor-block-navigation__list-item {
- position: relative;
+ // .block-editor-block-navigation__list-item {
+ // position: relative;
+ // }
- &::before {
- position: absolute;
- left: 0;
- background: $light-gray-900;
- width: 0.5em;
- height: $tree-border-width;
- content: "";
- top: calc(50% - #{ $tree-border-width / 2 });
+ .block-editor-block-icon {
+ margin-right: 6px;
+ }
+
+ &.is-selected .block-editor-block-icon svg,
+ &.is-selected:focus .block-editor-block-icon svg {
+ color: $white;
+ background: $dark-gray-primary;
+ box-shadow: 0 0 0 $border-width $dark-gray-primary;
+ border-radius: $border-width;
+ }
+
+ .block-editor-block-navigation-block__mover-cell {
+ width: $button-size;
+ opacity: 0;
+ @include reduce-motion("transition");
+
+ &.is-visible {
+ opacity: 1;
+ @include edit-post__fade-in-animation;
}
}
- .block-editor-block-navigation__list-item-button {
+ .block-editor-block-mover-button {
+ width: $button-size;
+ height: $button-size;
+ }
+
+ .block-editor-button-block-appender {
+ outline: none;
+ background: none;
+ padding: $grid-unit-10;
+ padding: 6px;
margin-left: 0.8em;
width: calc(100% - 0.8em);
- height: auto;
}
& > li:last-child {
@@ -68,28 +96,61 @@ $tree-item-height: 36px;
}
}
-.block-editor-block-navigation__list-item-button {
- display: flex;
- align-items: center;
- width: 100%;
- padding: 6px;
- text-align: left;
- color: $dark-gray-600;
- border-radius: 2px;
+.block-editor-block-navigation-block-content-wrapper__description,
+.block-editor-block-navigation-appender__description {
+ display: none;
+}
- .block-editor-block-icon {
- margin-right: 6px;
+.block-editor-block-navigation-block__contents-cell,
+.block-editor-block-navigation-appender__cell {
+ .block-editor-block-navigation-block__contents-container,
+ .block-editor-block-navigation-appender__container {
+ display: flex;
}
- &.is-selected svg,
- &.is-selected:focus svg {
- color: $white;
- background: $dark-gray-primary;
- box-shadow: 0 0 0 $border-width $dark-gray-primary;
- border-radius: $border-width;
- }
-}
+ .block-editor-block-navigator-descender-line {
+ position: relative;
+ flex-shrink: 0;
+ width: ( $button-size / 2 ) + 6px;
+
+ &:first-child {
+ width: ( $button-size / 2 );
+ }
+
+ &.has-item {
+ margin-right: 6px;
+ }
-.components-popover.block-editor-block-navigation__popover {
- z-index: z-index(".components-popover.block-editor-block-navigation__popover");
+ // Draw a vertical line using border-right.
+ &::before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: -1px;
+ bottom: -2px;
+ right: 0;
+ border-right: 2px solid $light-gray-900;
+ }
+
+ // If a vertical line has terminated, don't draw it.
+ &.is-terminated::before {
+ border-color: transparent;
+ }
+
+ // Make the last vertical line half-height.
+ &.has-item.is-last-row {
+ height: $grid-unit-20;
+ }
+
+ // Draw a horizontal line using border-bottom.
+ &.has-item::after {
+ content: "";
+ display: block;
+ position: absolute;
+ top: $grid-unit-20;
+ left: 100%;
+ width: 5px;
+ border-bottom: 2px solid $light-gray-900;
+ }
+ }
}
diff --git a/packages/block-editor/src/components/block-navigation/tree.js b/packages/block-editor/src/components/block-navigation/tree.js
new file mode 100644
index 0000000000000..ee7d688565c01
--- /dev/null
+++ b/packages/block-editor/src/components/block-navigation/tree.js
@@ -0,0 +1,41 @@
+/**
+ * WordPress dependencies
+ */
+
+import { __experimentalTreeGrid as TreeGrid } from '@wordpress/components';
+import { useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import BlockNavigationBranch from './branch';
+import { BlockNavigationContext } from './context';
+
+/**
+ * Wrap `BlockNavigationRows` with `TreeGrid`. BlockNavigationRows is a
+ * recursive component (it renders itself), so this ensures TreeGrid is only
+ * present at the very top of the navigation grid.
+ *
+ * @param {Object} props
+ */
+export default function BlockNavigationTree( {
+ __experimentalWithBlockNavigationSlots,
+ ...props
+} ) {
+ const contextValue = useMemo(
+ () => ( { __experimentalWithBlockNavigationSlots } ),
+ [ __experimentalWithBlockNavigationSlots ]
+ );
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/block-editor/src/components/button-block-appender/index.js b/packages/block-editor/src/components/button-block-appender/index.js
index 385666e257e2e..6bdeb479dd163 100644
--- a/packages/block-editor/src/components/button-block-appender/index.js
+++ b/packages/block-editor/src/components/button-block-appender/index.js
@@ -7,6 +7,7 @@ import classnames from 'classnames';
* WordPress dependencies
*/
import { Button, Tooltip, VisuallyHidden } from '@wordpress/components';
+import { forwardRef } from '@wordpress/element';
import { _x, sprintf } from '@wordpress/i18n';
import { Icon, create } from '@wordpress/icons';
@@ -15,11 +16,16 @@ import { Icon, create } from '@wordpress/icons';
*/
import Inserter from '../inserter';
-function ButtonBlockAppender( {
- rootClientId,
- className,
- __experimentalSelectBlockOnInsert: selectBlockOnInsert,
-} ) {
+function ButtonBlockAppender(
+ {
+ rootClientId,
+ className,
+ __experimentalSelectBlockOnInsert: selectBlockOnInsert,
+ onFocus,
+ tabIndex,
+ },
+ ref
+) {
return (
);
}
diff --git a/packages/components/src/index.js b/packages/components/src/index.js
index ca4df08a9e4a3..22010927940e1 100644
--- a/packages/components/src/index.js
+++ b/packages/components/src/index.js
@@ -77,6 +77,10 @@ export { default as RadioControl } from './radio-control';
export { default as RangeControl } from './range-control';
export { default as ResizableBox } from './resizable-box';
export { default as ResponsiveWrapper } from './responsive-wrapper';
+export {
+ default as __experimentalRovingTabIndex,
+ RovingTabIndexItem as __experimentalRovingTabIndexItem,
+} from './roving-tab-index';
export { default as SandBox } from './sandbox';
export { default as SelectControl } from './select-control';
export { default as Snackbar } from './snackbar';
@@ -95,6 +99,11 @@ export { default as __experimentalToolbarContext } from './toolbar-context';
export { default as ToolbarGroup } from './toolbar-group';
export { default as __experimentalToolbarItem } from './toolbar-item';
export { default as Tooltip } from './tooltip';
+export {
+ default as __experimentalTreeGrid,
+ TreeGridRow as __experimentalTreeGridRow,
+ TreeGridCell as __experimentalTreeGridCell,
+} from './tree-grid';
export { default as TreeSelect } from './tree-select';
export { default as __experimentalUnitControl } from './unit-control';
export { default as VisuallyHidden } from './visually-hidden';
diff --git a/packages/components/src/roving-tab-index/README.md b/packages/components/src/roving-tab-index/README.md
new file mode 100644
index 0000000000000..66cf6bdc00eae
--- /dev/null
+++ b/packages/components/src/roving-tab-index/README.md
@@ -0,0 +1,97 @@
+# RovingTabIndex
+
+## Table of contents
+
+1. [Development guidelines](#development-guidelines)
+2. [Related components](#related-components)
+
+## Development guidelines
+
+`RovingTabIndex` and `RovingTabIndexItem` are components that help to manage focus in the style of a roving tab index.
+
+A roving tab index is helpful for a UI component that contains multiple focusable elements, it reduces the number of tab stops in such a component to a single tab stop. Some more information is available here:
+
+- [WAI Aria Authoring Practices - Roving Tab Index](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_roving_tabindex)
+
+Some patterns that implement a roving tab index are:
+
+- [Layout Grid](https://www.w3.org/TR/wai-aria-practices/examples/grid/LayoutGrids.html)
+- [Editor Menu Bar](https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html)
+- [Navigation Menu Bar](https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-1/menubar-1.html)
+- [Radio Group](https://www.w3.org/TR/wai-aria-practices/examples/radio/radio-1/radio-1.html)
+- [Toolbar](https://www.w3.org/TR/wai-aria-practices/examples/toolbar/toolbar.html)
+- [Tree Grid](https://www.w3.org/TR/wai-aria-practices/examples/treegrid/treegrid-1.html)
+- [Tree View](https://www.w3.org/TR/wai-aria-practices/examples/treeview/treeview-2/treeview-2a.html)
+
+This component doesn't implement any keyboard navigation, instead it handles setting the correct `tabIndex` value on focusable elements. It should be composed with another component that implements keyboard navigation (e.g. arrow key navigation).
+
+### Usage
+
+Wrap the component with `RovingTabIndex` and declare individual focusable elements within the component with `RovingTabIndexItem`.
+
+```jsx
+function TreeMenu() {
+ return (
+
+
+
+ First item
+
+ Select
+
+
+
+ Second item
+
+ Select
+
+
+
+
+ );
+}
+```
+
+### Sub-Components
+
+#### RovingTabIndex
+
+##### Props
+
+This component takes no props, but should always have `children`.
+
+#### `RovingTabIndexItem`
+
+##### Props
+
+###### as
+
+Specify the component that the item should render as. For example the following renders a button with the text 'Close':
+
+```jsx
+Close
+```
+
+All props other than `as` are forwarded to the component, for example, the following code passes the `onClick` handler to the rendered `Button` component:
+
+```jsx
+Close
+```
+
+Components used with the `as` prop must be able to receive the `onFocus`, `tabIndex`, and `ref` props and pass those props to the element it renders.
+
+As an alternative to `as`, `RovingTabIndexItem` also supports a render prop function:
+
+```jsx
+
+ { ( props ) => }
+
+```
+
+The `props` passed to `Button` in this example contain the aforementioned `onFocus`, `tabIndex`, and `ref` props. For the roving tab index functionality to work, they should always be passed onto the `RovingTabIndexItem`'s child and handled correctly by that child.
+
+- Type: `React Component`
+- Required: No
+
+## Related components
+- `TreeGrid` implements a `RovingTabIndex`.
diff --git a/packages/components/src/roving-tab-index/context.js b/packages/components/src/roving-tab-index/context.js
new file mode 100644
index 0000000000000..90c3ca16704d7
--- /dev/null
+++ b/packages/components/src/roving-tab-index/context.js
@@ -0,0 +1,9 @@
+/**
+ * WordPress dependencies
+ */
+import { createContext, useContext } from '@wordpress/element';
+
+const RovingTabIndexContext = createContext();
+export const useRovingTabIndexContext = () =>
+ useContext( RovingTabIndexContext );
+export const RovingTabIndexProvider = RovingTabIndexContext.Provider;
diff --git a/packages/components/src/roving-tab-index/index.js b/packages/components/src/roving-tab-index/index.js
new file mode 100644
index 0000000000000..f39d8bde78f95
--- /dev/null
+++ b/packages/components/src/roving-tab-index/index.js
@@ -0,0 +1,32 @@
+/**
+ * WordPress dependencies
+ */
+import { useState, useMemo } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { RovingTabIndexProvider } from './context';
+
+/**
+ * @see https://github.com/WordPress/gutenberg/blob/master/packages/components/src/roving-tab-index/README.md
+ */
+export default function RovingTabIndex( { children } ) {
+ const [ lastFocusedElement, setLastFocusedElement ] = useState();
+
+ // Use `useMemo` to avoid creation of a new object for the providerValue
+ // on every render. Only create a new object when the `lastFocusedElement`
+ // value changes.
+ const providerValue = useMemo(
+ () => ( { lastFocusedElement, setLastFocusedElement } ),
+ [ lastFocusedElement ]
+ );
+
+ return (
+
+ { children }
+
+ );
+}
+
+export { default as RovingTabIndexItem } from './item';
diff --git a/packages/components/src/roving-tab-index/item.js b/packages/components/src/roving-tab-index/item.js
new file mode 100644
index 0000000000000..f1f951f2b1eca
--- /dev/null
+++ b/packages/components/src/roving-tab-index/item.js
@@ -0,0 +1,35 @@
+/**
+ * WordPress dependencies
+ */
+import { useRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { useRovingTabIndexContext } from './context';
+
+export default function RovingTabIndexItem( {
+ children,
+ as: Component,
+ ...props
+} ) {
+ const ref = useRef();
+ const {
+ lastFocusedElement,
+ setLastFocusedElement,
+ } = useRovingTabIndexContext();
+ let tabIndex;
+
+ if ( lastFocusedElement ) {
+ tabIndex = lastFocusedElement === ref.current ? 0 : -1;
+ }
+
+ const onFocus = ( event ) => setLastFocusedElement( event.target );
+ const allProps = { ref, tabIndex, onFocus, ...props };
+
+ if ( typeof children === 'function' ) {
+ return children( allProps );
+ }
+
+ return { children };
+}
diff --git a/packages/components/src/roving-tab-index/stories/index.js b/packages/components/src/roving-tab-index/stories/index.js
new file mode 100644
index 0000000000000..36eaf245cd512
--- /dev/null
+++ b/packages/components/src/roving-tab-index/stories/index.js
@@ -0,0 +1,64 @@
+/**
+ * Internal dependencies
+ */
+import RovingTabIndex, { RovingTabIndexItem } from '../';
+import { Button } from '../../';
+
+export default {
+ title: 'Components/RovingTabIndex',
+ component: RovingTabIndex,
+};
+
+export const _default = () => {
+ const onKeyDown = ( event ) => {
+ // Left key pressed.
+ if ( event.keyCode === 37 ) {
+ const previous = event.target.previousElementSibling;
+ if ( previous ) {
+ previous.focus();
+ }
+ }
+ // Right key pressed.
+ if ( event.keyCode === 39 ) {
+ const next = event.target.nextElementSibling;
+ if ( next ) {
+ next.focus();
+ }
+ }
+ // Home key pressed.
+ if ( event.keyCode === 36 ) {
+ const first = event.target.parentElement.firstElementChild;
+ if ( first ) {
+ first.focus();
+ }
+ }
+ // End key pressed.
+ if ( event.keyCode === 35 ) {
+ const last = event.target.parentElement.lastElementChild;
+ if ( last ) {
+ last.focus();
+ }
+ }
+ };
+
+ return (
+
+
+
+ Item 1
+
+
+ Item 2
+
+
+ Item 3
+
+
+
+ );
+};
diff --git a/packages/components/src/roving-tab-index/test/__snapshots__/index.js.snap b/packages/components/src/roving-tab-index/test/__snapshots__/index.js.snap
new file mode 100644
index 0000000000000..aa77ed429bd2e
--- /dev/null
+++ b/packages/components/src/roving-tab-index/test/__snapshots__/index.js.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RovingTabIndex does not render any elements other than its children 1`] = `
+
+ child element
+
+`;
diff --git a/packages/components/src/roving-tab-index/test/__snapshots__/item.js.snap b/packages/components/src/roving-tab-index/test/__snapshots__/item.js.snap
new file mode 100644
index 0000000000000..73aa1411cffc1
--- /dev/null
+++ b/packages/components/src/roving-tab-index/test/__snapshots__/item.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RovingTabIndexItem allows another component to be specified as the rendered component using the \`as\` prop 1`] = `
+
+`;
+
+exports[`RovingTabIndexItem allows children to be declared using a child render function as an alternative to \`as\` 1`] = `
+
+`;
+
+exports[`RovingTabIndexItem forwards props to the \`as\` component 1`] = `
+
+`;
diff --git a/packages/components/src/roving-tab-index/test/index.js b/packages/components/src/roving-tab-index/test/index.js
new file mode 100644
index 0000000000000..942064581a351
--- /dev/null
+++ b/packages/components/src/roving-tab-index/test/index.js
@@ -0,0 +1,21 @@
+/**
+ * External dependencies
+ */
+import TestRenderer from 'react-test-renderer';
+
+/**
+ * Internal dependencies
+ */
+import RovingTabIndex from '../';
+
+describe( 'RovingTabIndex', () => {
+ it( 'does not render any elements other than its children', () => {
+ const renderer = TestRenderer.create(
+
+
child element
+
+ );
+
+ expect( renderer.toJSON() ).toMatchSnapshot();
+ } );
+} );
diff --git a/packages/components/src/roving-tab-index/test/item.js b/packages/components/src/roving-tab-index/test/item.js
new file mode 100644
index 0000000000000..80b97377da497
--- /dev/null
+++ b/packages/components/src/roving-tab-index/test/item.js
@@ -0,0 +1,66 @@
+/**
+ * External dependencies
+ */
+import TestRenderer from 'react-test-renderer';
+
+/**
+ * WordPress dependencies
+ */
+import { forwardRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import RovingTabIndex from '../';
+import RovingTabIndexItem from '../item';
+
+const TestButton = forwardRef( ( { ...props }, ref ) => (
+
+) );
+
+describe( 'RovingTabIndexItem', () => {
+ it( 'requires RovingTabIndex to be declared as a parent component somewhere in the component hierarchy', () => {
+ expect( () =>
+ TestRenderer.create( )
+ ).toThrow();
+ expect( console ).toHaveErrored();
+ } );
+
+ it( 'allows another component to be specified as the rendered component using the `as` prop', () => {
+ const renderer = TestRenderer.create(
+
+
+
+ );
+
+ expect( renderer.toJSON() ).toMatchSnapshot();
+ } );
+
+ it( 'forwards props to the `as` component', () => {
+ const renderer = TestRenderer.create(
+
+
+ Click Me!
+
+
+ );
+
+ expect( renderer.toJSON() ).toMatchSnapshot();
+ } );
+
+ it( 'allows children to be declared using a child render function as an alternative to `as`', () => {
+ const renderer = TestRenderer.create(
+
+
+ { ( props ) => (
+
+ Click Me!
+
+ ) }
+
+
+ );
+
+ expect( renderer.toJSON() ).toMatchSnapshot();
+ } );
+} );
diff --git a/packages/components/src/tree-grid/README.md b/packages/components/src/tree-grid/README.md
new file mode 100644
index 0000000000000..47820f253c0b6
--- /dev/null
+++ b/packages/components/src/tree-grid/README.md
@@ -0,0 +1,129 @@
+# TreeGrid
+
+## Table of contents
+
+1. [Development guidelines](#development-guidelines)
+2. [Related components](#related-components)
+
+## Development guidelines
+
+`TreeGrid`, `TreeGridRow`, and `TreeGridCell` are components used to create a tree hierarchy. They're not visually styled components, but instead help with adding keyboard navigation and roving tab index behaviors to tree grid structures.
+
+A tree grid is a hierarchical 2 dimensional UI component, for example it could be used to implement a file system browser.
+
+A tree grid allows the user to navigate using arrow keys. Up/down to navigate vertically across rows, and left/right to navigate horizontally between focusables in a row.
+
+For more information on a tree grid, see the following links:
+
+- https://www.w3.org/TR/wai-aria-practices/examples/treegrid/treegrid-1.html
+
+### Usage
+
+The `TreeGrid` renders both a `table` and `tbody` element, and is intended to be used with `TreeGridRow` (`tr`) and `TreeGridCell` (`td`) to build out a grid.
+
+```jsx
+function TreeMenu() {
+ return (
+
+
+
+ { ( props ) => }
+
+
+ { ( props ) => }
+
+
+ { ( props ) => }
+
+
+
+
+ { ( props ) => }
+
+
+ { ( props ) => }
+
+
+ { ( props ) => }
+
+
+
+
+ { ( props ) => }
+
+
+ { ( props ) => }
+
+
+ { ( props ) => }
+
+
+
+ );
+}
+```
+
+### Sub-Components
+
+#### TreeGrid
+
+##### Props
+
+`TreeGrid` accepts no specific props. Any props specified will be passed to the `table` element rendered by `TreeGrid`.
+
+`TreeGrid` should always have children.
+
+#### TreeGridRow
+
+##### Props
+
+Additional props other than those specified below will be passed to the `tr` element rendered by `TreeGridRow`, so for example, it is possible to also set a `className` on a row.
+
+###### level
+
+An integer value designating the level in the hierarchical tree structure. Counting starts at 1. A value of `1` indicates the root level of the structure.
+
+- Type: `Number`
+- Required: Yes
+
+###### positionInSet
+
+An integer value that represents the position in the set. A set is the count of elements at a specific level. Counting starts at 1.
+
+- Type: `Number`
+- Required: Yes
+
+###### setSize
+
+An integer value that represents the total number of items in the set ... that is the total number of items at this specific level of the hierarchy.
+
+- Type: `Number`
+- Required: Yes
+
+###### isExpanded
+
+An optional value that designates whether a row is expanded or collapsed. Currently this value only sets the correct aria-expanded property on a row, it has no other built-in behavior.
+
+- Type: `Boolean`
+- Required: No
+
+### TreeGridCell
+
+#### Props
+
+`TreeGridCell` accepts no specific props. Any props specified will be passed to the `td` element rendered by `TreeGridCell`.
+
+#### `children` as a function
+
+`TreeGridCell` renders children using a function:
+
+```jsx
+
+ { ( props ) => }
+
+```
+
+Props passed as an argument to the render prop must be passed to the child focusable component/element within the cell. If a component is used, it must correctly handle the `onFocus`, `tabIndex`, and `ref` props, passing these to the element it renders. These props are used to handle the roving tab index functionality of the tree grid.
+
+## Related components
+- This component implements `RovingTabIndex`.
diff --git a/packages/components/src/tree-grid/cell.js b/packages/components/src/tree-grid/cell.js
new file mode 100644
index 0000000000000..a8e200ddce564
--- /dev/null
+++ b/packages/components/src/tree-grid/cell.js
@@ -0,0 +1,12 @@
+/**
+ * Internal dependencies
+ */
+import { RovingTabIndexItem } from '../roving-tab-index';
+
+export default function TreeGridCell( { children, ...props } ) {
+ return (
+
+ { children }
+
+ );
+}
diff --git a/packages/components/src/tree-grid/index.js b/packages/components/src/tree-grid/index.js
new file mode 100644
index 0000000000000..e9d3c9d507b13
--- /dev/null
+++ b/packages/components/src/tree-grid/index.js
@@ -0,0 +1,157 @@
+/**
+ * External dependencies
+ */
+import { includes } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { focus } from '@wordpress/dom';
+import { useCallback } from '@wordpress/element';
+import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes';
+
+/**
+ * Internal dependencies
+ */
+import RovingTabIndexContainer from '../roving-tab-index';
+
+/**
+ * Return focusables in a row element, excluding those from other branches
+ * nested within the row.
+ *
+ * @param {Element} rowElement The DOM element representing the row.
+ *
+ * @return {?Array} The array of focusables in the row.
+ */
+function getRowFocusables( rowElement ) {
+ const focusablesInRow = focus.focusable.find( rowElement );
+
+ if ( ! focusablesInRow || ! focusablesInRow.length ) {
+ return;
+ }
+
+ return focusablesInRow.filter( ( focusable ) => {
+ return focusable.closest( '[role="row"]' ) === rowElement;
+ } );
+}
+
+/**
+ * @see https://github.com/WordPress/gutenberg/blob/master/packages/components/src/tree-grid/README.md
+ */
+export default function TreeGrid( { children, ...props } ) {
+ const onKeyDown = useCallback( ( event ) => {
+ const { keyCode, metaKey, ctrlKey, altKey, shiftKey } = event;
+
+ const hasModifierKeyPressed = metaKey || ctrlKey || altKey || shiftKey;
+
+ if (
+ hasModifierKeyPressed ||
+ ! includes( [ UP, DOWN, LEFT, RIGHT ], keyCode )
+ ) {
+ return;
+ }
+
+ // The event will be handled, stop propagation.
+ event.stopPropagation();
+
+ const { activeElement } = document;
+ const { currentTarget: treeGridElement } = event;
+ if ( ! treeGridElement.contains( activeElement ) ) {
+ return;
+ }
+
+ // Calculate the columnIndex of the active element.
+ const activeRow = activeElement.closest( '[role="row"]' );
+ const focusablesInRow = getRowFocusables( activeRow );
+ const currentColumnIndex = focusablesInRow.indexOf( activeElement );
+
+ if ( includes( [ LEFT, RIGHT ], keyCode ) ) {
+ // Calculate to the next element.
+ let nextIndex;
+ if ( keyCode === LEFT ) {
+ nextIndex = Math.max( 0, currentColumnIndex - 1 );
+ } else {
+ nextIndex = Math.min(
+ currentColumnIndex + 1,
+ focusablesInRow.length - 1
+ );
+ }
+
+ // Focus is either at the left or right edge of the grid. Do nothing.
+ if ( nextIndex === currentColumnIndex ) {
+ // Prevent key use for anything else. For example, Voiceover
+ // will start reading text on continued use of left/right arrow
+ // keys.
+ event.preventDefault();
+ return;
+ }
+
+ // Focus the next element.
+ focusablesInRow[ nextIndex ].focus();
+
+ // Prevent key use for anything else. This ensures Voiceover
+ // doesn't try to handle key navigation.
+ event.preventDefault();
+ } else if ( includes( [ UP, DOWN ], keyCode ) ) {
+ // Calculate the rowIndex of the next row.
+ const rows = Array.from(
+ treeGridElement.querySelectorAll( '[role="row"]' )
+ );
+ const currentRowIndex = rows.indexOf( activeRow );
+ let nextRowIndex;
+
+ if ( keyCode === UP ) {
+ nextRowIndex = Math.max( 0, currentRowIndex - 1 );
+ } else {
+ nextRowIndex = Math.min( currentRowIndex + 1, rows.length - 1 );
+ }
+
+ // Focus is either at the top or bottom edge of the grid. Do nothing.
+ if ( nextRowIndex === currentRowIndex ) {
+ // Prevent key use for anything else. For example, Voiceover
+ // will start navigating horizontally when reaching the vertical
+ // bounds of a table.
+ event.preventDefault();
+ return;
+ }
+
+ // Get the focusables in the next row.
+ const focusablesInNextRow = getRowFocusables(
+ rows[ nextRowIndex ]
+ );
+
+ // If for some reason there are no focusables in the next row, do nothing.
+ if ( ! focusablesInNextRow || ! focusablesInNextRow.length ) {
+ // Prevent key use for anything else. For example, Voiceover
+ // will still focus text when using arrow keys, while this
+ // component should limit navigation to focusables.
+ event.preventDefault();
+ return;
+ }
+
+ // Try to focus the element in the next row that's at a similar column to the activeElement.
+ const nextIndex = Math.min(
+ currentColumnIndex,
+ focusablesInNextRow.length - 1
+ );
+ focusablesInNextRow[ nextIndex ].focus();
+
+ // Prevent key use for anything else. This ensures Voiceover
+ // doesn't try to handle key navigation.
+ event.preventDefault();
+ }
+ }, [] );
+
+ return (
+
+ { /* Disable reason: A treegrid is implemented using a table element. */ }
+ { /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role */ }
+
+ { children }
+
+
+ );
+}
+
+export { default as TreeGridRow } from './row';
+export { default as TreeGridCell } from './cell';
diff --git a/packages/components/src/tree-grid/row.js b/packages/components/src/tree-grid/row.js
new file mode 100644
index 0000000000000..744055194ad22
--- /dev/null
+++ b/packages/components/src/tree-grid/row.js
@@ -0,0 +1,31 @@
+/**
+ * WordPress dependencies
+ */
+import { forwardRef } from '@wordpress/element';
+
+function TreeGridRow(
+ { children, level, positionInSet, setSize, isExpanded, ...props },
+ ref
+) {
+ return (
+ // Disable reason: Due to an error in the ARIA 1.1 specification, the
+ // aria-posinset and aria-setsize properties are not supported on row
+ // elements. This is being corrected in ARIA 1.2. Consequently, the
+ // linting rule fails when validating this markup.
+ //
+ // eslint-disable-next-line jsx-a11y/role-supports-aria-props
+