From f04f5c5ee7875bd858d35891565f1b7f736c772f Mon Sep 17 00:00:00 2001 From: Bart Kalisz Date: Fri, 6 Oct 2023 09:12:50 +0000 Subject: [PATCH 1/6] E2E: Add missing comment (#55093) --- .../e2e/specs/editor/various/autocomplete-and-mentions.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js b/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js index ec0ca999993c2..9d6fa02802efe 100644 --- a/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js +++ b/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js @@ -427,6 +427,9 @@ test.describe( 'Autocomplete (@firefox, @webkit)', () => { page, editor, } ) => { + // The autocomplete popup is flaky when typing too fast, so we need to + // slow it down until it's addressed in the component. + // See https://github.com/WordPress/gutenberg/pull/55081 const typingDelay = 100; await editor.canvas From 15ece32d14d69f17f442dc7456c1781fd3bd5efa Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Fri, 6 Oct 2023 14:10:04 +0200 Subject: [PATCH 2/6] Fix Image block lightbox missing alt attribute and improve accessibility (#55010) * Move lightbox open button after the image. * Fix getting the lightbox image alt attribute. * Improve docblocks. * Do not render empty role attribute. * Remove unnecessary aria-hidden attribute. * Set aria-modal attribute dynamically. * More meaningful and simpler modal dialog aria-label. * Increase Close button target size. * Add enlarged image base64 encoded placeholder. * Better check for alt attribute as a string. * Update changelog. * Move changelog entry to the block library changelog. * Set lightbox dialog aria-label dynamically. * Hide background scrim container from assistive technology. * Remove obsolete code --------- Co-authored-by: Ricardo Artemio Morales --- lib/block-supports/behaviors.php | 49 +++++------ lib/load.php | 1 + packages/block-library/CHANGELOG.md | 5 ++ packages/block-library/src/image/index.php | 94 +++++++++++---------- packages/block-library/src/image/style.scss | 11 ++- packages/block-library/src/image/view.js | 18 +++- 6 files changed, 104 insertions(+), 74 deletions(-) diff --git a/lib/block-supports/behaviors.php b/lib/block-supports/behaviors.php index 6f442d7b0d2d7..c1b3dceceee94 100644 --- a/lib/block-supports/behaviors.php +++ b/lib/block-supports/behaviors.php @@ -84,17 +84,19 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $aria_label = __( 'Enlarge image', 'gutenberg' ); + $processor->next_tag( 'img' ); $alt_attribute = $processor->get_attribute( 'alt' ); - if ( null !== $alt_attribute ) { + // An empty alt attribute `alt=""` is valid for decorative images. + if ( is_string( $alt_attribute ) ) { $alt_attribute = trim( $alt_attribute ); } + // It only makes sense to append the alt text to the button aria-label when the alt text is non-empty. if ( $alt_attribute ) { /* translators: %s: Image alt text. */ $aria_label = sprintf( __( 'Enlarge image: %s', 'gutenberg' ), $alt_attribute ); } - $content = $processor->get_updated_html(); // If we don't set a default, it won't work if Lightbox is set to enabled by default. $lightbox_animation = 'zoom'; @@ -102,17 +104,15 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $lightbox_animation = $lightbox_settings['animation']; } - // We want to store the src in the context so we can set it dynamically when the lightbox is opened. - $z = new WP_HTML_Tag_Processor( $content ); - $z->next_tag( 'img' ); - + // Note: We want to store the `src` in the context so we + // can set it dynamically when the lightbox is opened. if ( isset( $block['attrs']['id'] ) ) { $img_uploaded_src = wp_get_attachment_url( $block['attrs']['id'] ); $img_metadata = wp_get_attachment_metadata( $block['attrs']['id'] ); $img_width = $img_metadata['width']; $img_height = $img_metadata['height']; } else { - $img_uploaded_src = $z->get_attribute( 'src' ); + $img_uploaded_src = $processor->get_attribute( 'src' ); $img_width = 'none'; $img_height = 'none'; } @@ -123,7 +123,7 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $scale_attr = false; } - $w = new WP_HTML_Tag_Processor( $content ); + $w = new WP_HTML_Tag_Processor( $block_content ); $w->next_tag( 'figure' ); $w->add_class( 'wp-lightbox-container' ); $w->set_attribute( 'data-wp-interactive', true ); @@ -163,19 +163,20 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { // Wrap the image in the body content with a button. $img = null; preg_match( '/]+>/', $body_content, $img ); - $button = - '' - . $img[0]; + + $button = + $img[0] + . ''; + $body_content = preg_replace( '/]+>/', $button, $body_content ); // We need both a responsive image and an enlarged image to animate @@ -183,7 +184,7 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { // image is a copy of the one in the body, which animates immediately // as the lightbox is opened, while the enlarged one is a full-sized // version that will likely still be loading as the animation begins. - $m = new WP_HTML_Tag_Processor( $content ); + $m = new WP_HTML_Tag_Processor( $block_content ); $m->next_tag( 'figure' ); $m->add_class( 'responsive-image' ); $m->next_tag( 'img' ); @@ -199,7 +200,7 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $m->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); $initial_image_content = $m->get_updated_html(); - $q = new WP_HTML_Tag_Processor( $content ); + $q = new WP_HTML_Tag_Processor( $block_content ); $q->next_tag( 'figure' ); $q->add_class( 'enlarged-image' ); $q->next_tag( 'img' ); @@ -219,7 +220,7 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $close_button_icon = ''; $close_button_color = esc_attr( wp_get_global_styles( array( 'color', 'text' ) ) ); - $dialog_label = $alt_attribute ? esc_attr( $alt_attribute ) : esc_attr__( 'Image', 'gutenberg' ); + $dialog_label = esc_attr__( 'Enlarged image', 'gutenberg' ); $close_button_label = esc_attr__( 'Close', 'gutenberg' ); $lightbox_html = <<next_tag( 'img' ); $alt_attribute = $processor->get_attribute( 'alt' ); - if ( null !== $alt_attribute ) { + // An empty alt attribute `alt=""` is valid for decorative images. + if ( is_string( $alt_attribute ) ) { $alt_attribute = trim( $alt_attribute ); } + // It only makes sense to append the alt text to the button aria-label when the alt text is non-empty. if ( $alt_attribute ) { /* translators: %s: Image alt text. */ $aria_label = sprintf( __( 'Enlarge image: %s' ), $alt_attribute ); } - $content = $processor->get_updated_html(); // Currently, we are only enabling the zoom animation. $lightbox_animation = 'zoom'; - // We want to store the src in the context so we can set it dynamically when the lightbox is opened. - $z = new WP_HTML_Tag_Processor( $content ); - $z->next_tag( 'img' ); - + // Note: We want to store the `src` in the context so we + // can set it dynamically when the lightbox is opened. if ( isset( $block['attrs']['id'] ) ) { $img_uploaded_src = wp_get_attachment_url( $block['attrs']['id'] ); $img_metadata = wp_get_attachment_metadata( $block['attrs']['id'] ); $img_width = $img_metadata['width']; $img_height = $img_metadata['height']; } else { - $img_uploaded_src = $z->get_attribute( 'src' ); + $img_uploaded_src = $processor->get_attribute( 'src' ); $img_width = 'none'; $img_height = 'none'; } @@ -160,7 +163,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { $scale_attr = false; } - $w = new WP_HTML_Tag_Processor( $content ); + $w = new WP_HTML_Tag_Processor( $block_content ); $w->next_tag( 'figure' ); $w->add_class( 'wp-lightbox-container' ); $w->set_attribute( 'data-wp-interactive', true ); @@ -180,7 +183,8 @@ function block_core_image_render_lightbox( $block_content, $block ) { "imageCurrentSrc": "", "targetWidth": "%s", "targetHeight": "%s", - "scaleAttr": "%s" + "scaleAttr": "%s", + "dialogLabel": "%s" } } }', @@ -188,7 +192,8 @@ function block_core_image_render_lightbox( $block_content, $block ) { $img_uploaded_src, $img_width, $img_height, - $scale_attr + $scale_attr, + __( 'Enlarged image' ) ) ); $w->next_tag( 'img' ); @@ -200,19 +205,20 @@ function block_core_image_render_lightbox( $block_content, $block ) { // Wrap the image in the body content with a button. $img = null; preg_match( '/]+>/', $body_content, $img ); - $button = - '' - . $img[0]; + + $button = + $img[0] + . ''; + $body_content = preg_replace( '/]+>/', $button, $body_content ); // We need both a responsive image and an enlarged image to animate @@ -220,7 +226,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { // image is a copy of the one in the body, which animates immediately // as the lightbox is opened, while the enlarged one is a full-sized // version that will likely still be loading as the animation begins. - $m = new WP_HTML_Tag_Processor( $content ); + $m = new WP_HTML_Tag_Processor( $block_content ); $m->next_tag( 'figure' ); $m->add_class( 'responsive-image' ); $m->next_tag( 'img' ); @@ -236,7 +242,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { $m->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); $initial_image_content = $m->get_updated_html(); - $q = new WP_HTML_Tag_Processor( $content ); + $q = new WP_HTML_Tag_Processor( $block_content ); $q->next_tag( 'figure' ); $q->add_class( 'enlarged-image' ); $q->next_tag( 'img' ); @@ -268,20 +274,16 @@ function block_core_image_render_lightbox( $block_content, $block ) { } $close_button_icon = ''; - $dialog_label = $alt_attribute ? esc_attr( $alt_attribute ) : esc_attr__( 'Image' ); $close_button_label = esc_attr__( 'Close' ); $lightbox_html = << -
+ HTML; @@ -302,11 +304,13 @@ function block_core_image_render_lightbox( $block_content, $block ) { } /** - * Ensure that the view script has the `wp-interactivity` dependency. + * Ensures that the view script has the `wp-interactivity` dependency. * * @since 6.4.0 * * @global WP_Scripts $wp_scripts + * + * @return void */ function block_core_image_ensure_interactivity_dependency() { global $wp_scripts; @@ -322,6 +326,8 @@ function block_core_image_ensure_interactivity_dependency() { /** * Registers the `core/image` block on server. + * + * @return void */ function register_block_core_image() { register_block_type_from_metadata( diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index 752ff773394a4..2ef602982e57b 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -154,6 +154,8 @@ .wp-lightbox-container { position: relative; + display: flex; + flex-direction: column; button { border: none; @@ -193,11 +195,16 @@ .close-button { position: absolute; - top: calc(env(safe-area-inset-top) + 20px); - right: calc(env(safe-area-inset-right) + 20px); + top: calc(env(safe-area-inset-top) + 16px); // equivalent to $grid-unit-20 + right: calc(env(safe-area-inset-right) + 16px); // equivalent to $grid-unit-20 padding: 0; cursor: pointer; z-index: 5000000; + min-width: 40px; // equivalent to $button-size-next-default-40px + min-height: 40px; // equivalent to $button-size-next-default-40px + display: flex; + align-items: center; + justify-content: center; &:hover, &:focus, diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 13f20c9cd7cb6..3eb47dcc7cab4 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -227,7 +227,17 @@ store( roleAttribute: ( { context } ) => { return context.core.image.lightboxEnabled ? 'dialog' - : ''; + : null; + }, + ariaModal: ( { context } ) => { + return context.core.image.lightboxEnabled + ? 'true' + : null; + }, + dialogLabel: ( { context } ) => { + return context.core.image.lightboxEnabled + ? context.core.image.dialogLabel + : null; }, lightboxObjectFit: ( { context } ) => { if ( context.core.image.initialized ) { @@ -237,7 +247,7 @@ store( enlargedImgSrc: ( { context } ) => { return context.core.image.initialized ? context.core.image.imageUploadedSrc - : ''; + : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; }, }, }, @@ -360,9 +370,9 @@ function setStyles( context, event ) { naturalHeight, offsetWidth: originalWidth, offsetHeight: originalHeight, - } = event.target.nextElementSibling; + } = event.target.previousElementSibling; let { x: screenPosX, y: screenPosY } = - event.target.nextElementSibling.getBoundingClientRect(); + event.target.previousElementSibling.getBoundingClientRect(); // Natural ratio of the image clicked to open the lightbox. const naturalRatio = naturalWidth / naturalHeight; From 48890612047eba22cce4b576df15c5a027ac7423 Mon Sep 17 00:00:00 2001 From: Chad Chadbourne <13856531+chad1008@users.noreply.github.com> Date: Fri, 6 Oct 2023 13:46:46 -0400 Subject: [PATCH 3/6] Components: add `Tabs` (a composable `TabPanel` v2) (#53960) Co-authored-by: Marco Ciampini --- docs/manifest.json | 6 + packages/components/CHANGELOG.md | 4 + packages/components/src/tabs/README.md | 242 ++++ packages/components/src/tabs/context.ts | 13 + packages/components/src/tabs/index.tsx | 167 +++ .../src/tabs/stories/index.story.tsx | 352 ++++++ packages/components/src/tabs/styles.ts | 103 ++ packages/components/src/tabs/tab.tsx | 39 + packages/components/src/tabs/tablist.tsx | 40 + packages/components/src/tabs/tabpanel.tsx | 42 + packages/components/src/tabs/test/index.tsx | 1124 +++++++++++++++++ packages/components/src/tabs/types.ts | 142 +++ 12 files changed, 2274 insertions(+) create mode 100644 packages/components/src/tabs/README.md create mode 100644 packages/components/src/tabs/context.ts create mode 100644 packages/components/src/tabs/index.tsx create mode 100644 packages/components/src/tabs/stories/index.story.tsx create mode 100644 packages/components/src/tabs/styles.ts create mode 100644 packages/components/src/tabs/tab.tsx create mode 100644 packages/components/src/tabs/tablist.tsx create mode 100644 packages/components/src/tabs/tabpanel.tsx create mode 100644 packages/components/src/tabs/test/index.tsx create mode 100644 packages/components/src/tabs/types.ts diff --git a/docs/manifest.json b/docs/manifest.json index 4108da22296ef..447d5b0f4eeb8 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1229,6 +1229,12 @@ "markdown_source": "../packages/components/src/tab-panel/README.md", "parent": "components" }, + { + "title": "Tabs", + "slug": "tabs", + "markdown_source": "../packages/components/src/tabs/README.md", + "parent": "components" + }, { "title": "TextControl", "slug": "text-control", diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 14166f8827ec8..2df99e7413ffa 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -42,6 +42,10 @@ - Ensure `@types/` dependencies used by final type files are included in the main dependency field ([#50231](https://github.com/WordPress/gutenberg/pull/50231)). - `Text`: Migrate to TypeScript. ([#54953](https://github.com/WordPress/gutenberg/pull/54953)). +### Experimental + +- Introduce `Tabs`, an experimental v2 of `TabPanel`: ([#53960](https://github.com/WordPress/gutenberg/pull/53960)). + ## 25.8.0 (2023-09-20) ### Enhancements diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md new file mode 100644 index 0000000000000..6907f385fda37 --- /dev/null +++ b/packages/components/src/tabs/README.md @@ -0,0 +1,242 @@ +# Tabs + +
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+ +Tabs is a collection of React components that combine to render an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). + +Tabs organizes content across different screens, data sets, and interactions. It has two sections: a list of tabs, and the view to show when tabs are chosen. + +## Development guidelines + +### Usage + +#### Uncontrolled Mode + +Tabs can be used in an uncontrolled mode, where the component manages its own state. In this mode, the `initialTabId` prop can be used to set the initially selected tab. If this prop is not set, the first tab will be selected by default. In addition, in most cases where the currently active tab becomes disabled or otherwise unavailable, uncontrolled mode will automatically fall back to selecting the first available tab. + +```jsx +import { Tabs } from '@wordpress/components'; + +const onSelect = ( tabName ) => { + console.log( 'Selecting tab', tabName ); +}; + +const MyUncontrolledTabs = () => ( + + + + Tab 1 + + + Tab 2 + + + Tab 3 + + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +``` + +#### Controlled Mode + +Tabs can also be used in a controlled mode, where the parent component specifies the `selectedTabId` and the `onSelect` props to control tab selection. In this mode, the `initialTabId` prop will be ignored if it is provided. If the `selectedTabId` is `null`, no tab is selected. In this mode, if the currently selected tab becomes disabled or otherwise unavailable, the component will _not_ fall back to another available tab, leaving the controlling component in charge of implementing the desired logic. + +```jsx +import { Tabs } from '@wordpress/components'; + const [ selectedTabId, setSelectedTabId ] = useState< + string | undefined | null + >(); + +const onSelect = ( tabName ) => { + console.log( 'Selecting tab', tabName ); +}; + +const MyControlledTabs = () => ( + { + setSelectedTabId( selectedId ); + onSelect( selectedId ); + } } + > + + + Tab 1 + + + Tab 2 + + + Tab 3 + + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +``` + +### Components and Sub-components + +Tabs is comprised of four individual components: +- `Tabs`: a wrapper component and context provider. It is responsible for managing the state of the tabs and rendering the `TabList` and `TabPanels`. +- `TabList`: a wrapper component for the `Tab` components. It is responsible for rendering the list of tabs. +- `Tab`: renders a single tab. The currently active tab receives default styling that can be overridden with CSS targeting [aria-selected="true"]. +- `TabPanel`: renders the content to display for a single tab once that tab is selected. + +#### Tabs + +##### Props + +###### `children`: `React.ReactNode` + +The children elements, which should be at least a `Tabs.Tablist` component and a series of `Tabs.TabPanel` components. + +- Required: Yes + +###### `selectOnMove`: `boolean` + +When `true`, the tab will be selected when receiving focus (automatic tab activation). When `false`, the tab will be selected only when clicked (manual tab activation). See the [official W3C docs](https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/) for more info. + +- Required: No +- Default: `true` + +###### `initialTabId`: `string` + +The id of the tab to be selected upon mounting of component. If this prop is not set, the first tab will be selected by default. The id provided will be internally prefixed with a unique instance ID to avoid collisions. + +_Note: this prop will be overridden by the `selectedTabId` prop if it is provided. (Controlled Mode)_ + +- Required: No + +###### `onSelect`: `( ( selectedId: string | null | undefined ) => void )` + +The function called when a tab has been selected. It is passed the selected tab's ID as an argument. + +- Required: No +- Default: `noop` + +###### `orientation`: `horizontal | vertical` + +The orientation of the `tablist` (`vertical` or `horizontal`) + +- Required: No +- Default: `horizontal` + +###### `selectedTabId`: `string | null` + +The ID of the tab to display. This id is prepended with the `Tabs` instanceId internally. +If left `undefined`, the component assumes it is being used in uncontrolled mode. Consequently, any value different than `undefined` will set the component in `controlled` mode. When in controlled mode, the `null` value will result in no tab being selected. + +- Required: No + +#### TabList + +##### Props + +###### `children`: `React.ReactNode` + +The children elements, which should be a series of `Tabs.TabPanel` components. + +- Required: No + +###### `className`: `string` + +The class name to apply to the tablist. + +- Required: No +- Default: '' + +###### `style`: `React.CSSProperties` + +Custom CSS styles for the tablist. + +- Required: No + +#### Tab + +##### Props + +###### `id`: `string` + +The id of the tab, which is prepended with the `Tabs` instance ID. + +- Required: Yes + +###### `style`: `React.CSSProperties` + +Custom CSS styles for the tab. + +- Required: No + +###### `children`: `React.ReactNode` + +The children elements, generally the text to display on the tab. + +- Required: No + +###### `className`: `string` + +The class name to apply to the tab. + +- Required: No + +###### `disabled`: `boolean` + +Determines if the tab button should be disabled. + +- Required: No +- Default: `false` + +###### `render`: `React.ReactNode` + +The type of component to render the tab button as. If this prop is not provided, the tab button will be rendered as a `button` element. + +- Required: No + +#### TabPanel + +##### Props + +###### `children`: `React.ReactNode` + +The children elements, generally the content to display on the tabpanel. + +- Required: No + +###### `id`: `string` + +The id of the tabpanel, which is combined with the `Tabs` instance ID and the suffix `-view` + +- Required: Yes + +###### `className`: `string` + +The class name to apply to the tabpanel. + +- Required: No + +###### `style`: `React.CSSProperties` + +Custom CSS styles for the tab. + +- Required: No diff --git a/packages/components/src/tabs/context.ts b/packages/components/src/tabs/context.ts new file mode 100644 index 0000000000000..cc6184d827138 --- /dev/null +++ b/packages/components/src/tabs/context.ts @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabsContextProps } from './types'; + +export const TabsContext = createContext< TabsContextProps >( undefined ); + +export const useTabsContext = () => useContext( TabsContext ); diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx new file mode 100644 index 0000000000000..54f547ad2f52d --- /dev/null +++ b/packages/components/src/tabs/index.tsx @@ -0,0 +1,167 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; +import { useEffect, useLayoutEffect, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabsProps } from './types'; +import { TabsContext } from './context'; +import { Tab } from './tab'; +import { TabList } from './tablist'; +import { TabPanel } from './tabpanel'; + +function Tabs( { + selectOnMove = true, + initialTabId, + orientation = 'horizontal', + onSelect, + children, + selectedTabId, +}: TabsProps ) { + const instanceId = useInstanceId( Tabs, 'tabs' ); + const store = Ariakit.useTabStore( { + selectOnMove, + orientation, + defaultSelectedId: initialTabId && `${ instanceId }-${ initialTabId }`, + setSelectedId: ( selectedId ) => { + const strippedDownId = + typeof selectedId === 'string' + ? selectedId.replace( `${ instanceId }-`, '' ) + : selectedId; + onSelect?.( strippedDownId ); + }, + selectedId: selectedTabId && `${ instanceId }-${ selectedTabId }`, + } ); + + const isControlled = selectedTabId !== undefined; + + const { items, selectedId } = store.useState(); + const { setSelectedId } = store; + + // Keep track of whether tabs have been populated. This is used to prevent + // certain effects from firing too early while tab data and relevant + // variables are undefined during the initial render. + const tabsHavePopulated = useRef( false ); + if ( items.length > 0 ) { + tabsHavePopulated.current = true; + } + + const selectedTab = items.find( ( item ) => item.id === selectedId ); + const firstEnabledTab = items.find( ( item ) => { + // Ariakit internally refers to disabled tabs as `dimmed`. + return ! item.dimmed; + } ); + const initialTab = items.find( + ( item ) => item.id === `${ instanceId }-${ initialTabId }` + ); + + // Handle selecting the initial tab. + useLayoutEffect( () => { + if ( isControlled ) { + return; + } + + // Wait for the denoted initial tab to be declared before making a + // selection. This ensures that if a tab is declared lazily it can + // still receive initial selection, as well as ensuring no tab is + // selected if an invalid `initialTabId` is provided. + if ( initialTabId && ! initialTab ) { + return; + } + + // If the currently selected tab is missing (i.e. removed from the DOM), + // fall back to the initial tab or the first enabled tab if there is + // one. Otherwise, no tab should be selected. + if ( ! items.find( ( item ) => item.id === selectedId ) ) { + if ( initialTab && ! initialTab.dimmed ) { + setSelectedId( initialTab?.id ); + return; + } + + if ( firstEnabledTab ) { + setSelectedId( firstEnabledTab.id ); + } else if ( tabsHavePopulated.current ) { + setSelectedId( null ); + } + } + }, [ + firstEnabledTab, + initialTab, + initialTabId, + isControlled, + items, + selectedId, + setSelectedId, + ] ); + + // Handle the currently selected tab becoming disabled. + useEffect( () => { + if ( ! selectedTab?.dimmed ) { + return; + } + + // In controlled mode, we trust that disabling tabs is done + // intentionally, and don't select a new tab automatically. + if ( isControlled ) { + setSelectedId( null ); + return; + } + + // If the currently selected tab becomes disabled, fall back to the + // `initialTabId` if possible. Otherwise select the first + // enabled tab (if there is one). + if ( initialTab && ! initialTab.dimmed ) { + setSelectedId( initialTab.id ); + return; + } + + if ( firstEnabledTab ) { + setSelectedId( firstEnabledTab.id ); + } + }, [ + firstEnabledTab, + initialTab, + isControlled, + selectedTab?.dimmed, + setSelectedId, + ] ); + + // Clear `selectedId` if the active tab is removed from the DOM in controlled mode. + useEffect( () => { + if ( ! isControlled ) { + return; + } + + // Once the tabs have populated, if the `selectedTabId` still can't be + // found, clear the selection. + if ( tabsHavePopulated.current && !! selectedTabId && ! selectedTab ) { + setSelectedId( null ); + } + }, [ + isControlled, + selectedId, + selectedTab, + selectedTabId, + setSelectedId, + ] ); + + return ( + + { children } + + ); +} + +Tabs.TabList = TabList; +Tabs.Tab = Tab; +Tabs.TabPanel = TabPanel; +export default Tabs; diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx new file mode 100644 index 0000000000000..3b6ba022f6d91 --- /dev/null +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -0,0 +1,352 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { wordpress, more, link } from '@wordpress/icons'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Tabs from '..'; +import { Slot, Fill, Provider as SlotFillProvider } from '../../slot-fill'; +import DropdownMenu from '../../dropdown-menu'; +import Button from '../../button'; + +const meta: Meta< typeof Tabs > = { + title: 'Components (Experimental)/Tabs', + component: Tabs, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + Tab 1 + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +}; + +export const Default = Template.bind( {} ); + +const DisabledTabTemplate: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + + Tab 1 + + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +}; +export const DisabledTab = DisabledTabTemplate.bind( {} ); + +const WithTabIconsAndTooltipsTemplate: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + + } + /> + + } + /> + + } + /> + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +}; +export const WithTabIconsAndTooltips = WithTabIconsAndTooltipsTemplate.bind( + {} +); + +export const ManualActivation = Template.bind( {} ); +ManualActivation.args = { + selectOnMove: false, +}; + +const UsingSlotFillTemplate: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + + Tab 1 + Tab 2 + Tab 3 + + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+
+
+

other stuff

+

other stuff

+

this is fun!

+

other stuff

+ +
+
+ ); +}; +export const UsingSlotFill = UsingSlotFillTemplate.bind( {} ); +UsingSlotFill.storyName = 'Using SlotFill'; + +const CloseButtonTemplate: StoryFn< typeof Tabs > = ( props ) => { + const [ isOpen, setIsOpen ] = useState( true ); + + return ( + <> + { isOpen ? ( +
+ +
+ + Tab 1 + Tab 2 + Tab 3 + + +
+ +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+
+ ) : ( + + ) } + + ); +}; +export const InsertCustomElements = CloseButtonTemplate.bind( {} ); + +const ControlledModeTemplate: StoryFn< typeof Tabs > = ( props ) => { + const [ selectedTabId, setSelectedTabId ] = useState< + string | undefined | null + >( props.selectedTabId ); + + return ( + <> + { + setSelectedTabId( selectedId ); + props.onSelect?.( selectedId ); + } } + > + + Tab 1 + + Tab 2 + + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ { +
+

Select a tab:

+ setSelectedTabId( 'tab1' ), + title: 'Tab 1', + isActive: selectedTabId === 'tab1', + }, + { + onClick: () => setSelectedTabId( 'tab2' ), + title: 'Tab 2', + isActive: selectedTabId === 'tab2', + }, + { + onClick: () => setSelectedTabId( 'tab3' ), + title: 'Tab 3', + isActive: selectedTabId === 'tab3', + }, + ] } + label="Choose a tab. The power is yours." + /> +
+ } + + ); +}; + +export const ControlledMode = ControlledModeTemplate.bind( {} ); +ControlledMode.args = { + selectedTabId: 'tab3', +}; + +const TabBecomesDisabledTemplate: StoryFn< typeof Tabs > = ( props ) => { + const [ disableTab2, setDisableTab2 ] = useState( false ); + + return ( + <> + + + + Tab 1 + + Tab 2 + + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ + ); +}; +export const TabBecomesDisabled = TabBecomesDisabledTemplate.bind( {} ); + +const TabGetsRemovedTemplate: StoryFn< typeof Tabs > = ( props ) => { + const [ removeTab1, setRemoveTab1 ] = useState( false ); + + return ( + <> + + + + { ! removeTab1 && Tab 1 } + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ + ); +}; +export const TabGetsRemoved = TabGetsRemovedTemplate.bind( {} ); diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts new file mode 100644 index 0000000000000..091ba608fb6ec --- /dev/null +++ b/packages/components/src/tabs/styles.ts @@ -0,0 +1,103 @@ +/** + * External dependencies + */ +import styled from '@emotion/styled'; +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * Internal dependencies + */ +import { COLORS } from '../utils'; +import { space } from '../utils/space'; +import { reduceMotion } from '../utils/reduce-motion'; + +export const TabListWrapper = styled.div` + display: flex; + align-items: stretch; + flex-direction: row; + &[aria-orientation='vertical'] { + flex-direction: column; + } +`; + +export const Tab = styled( Ariakit.Tab )` + && { + position: relative; + border-radius: 0; + height: ${ space( 12 ) }; + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; + padding: 3px ${ space( 4 ) }; // Use padding to offset the [aria-selected="true"] border, this benefits Windows High Contrast mode + margin-left: 0; + font-weight: 500; + + &[aria-disabled='true'] { + cursor: default; + opacity: 0.3; + } + + &:focus:not( :disabled ) { + position: relative; + box-shadow: none; + outline: none; + } + + // Tab indicator + &::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + + // Draw the indicator. + background: ${ COLORS.theme.accent }; + height: calc( 0 * var( --wp-admin-border-width-focus ) ); + border-radius: 0; + + // Animation + transition: all 0.1s linear; + ${ reduceMotion( 'transition' ) }; + } + + // Active. + &[aria-selected='true']::after { + height: calc( 1 * var( --wp-admin-border-width-focus ) ); + + // Windows high contrast mode. + outline: 2px solid transparent; + outline-offset: -1px; + } + + // Focus. + &::before { + content: ''; + position: absolute; + top: ${ space( 3 ) }; + right: ${ space( 3 ) }; + bottom: ${ space( 3 ) }; + left: ${ space( 3 ) }; + pointer-events: none; + + // Draw the indicator. + box-shadow: 0 0 0 0 transparent; + border-radius: 2px; + + // Animation + transition: all 0.1s linear; + ${ reduceMotion( 'transition' ) }; + } + + &:focus-visible::before { + box-shadow: 0 0 0 var( --wp-admin-border-width-focus ) + ${ COLORS.theme.accent }; + + // Windows high contrast mode. + outline: 2px solid transparent; + } + } +`; diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx new file mode 100644 index 0000000000000..75b3df1c1ba01 --- /dev/null +++ b/packages/components/src/tabs/tab.tsx @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ + +import { useContext, forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabProps } from './types'; +import warning from '@wordpress/warning'; +import { TabsContext } from './context'; +import { Tab as StyledTab } from './styles'; + +export const Tab = forwardRef< HTMLButtonElement, TabProps >( function Tab( + { children, id, className, disabled, render, style }, + ref +) { + const context = useContext( TabsContext ); + if ( ! context ) { + warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); + return null; + } + const { store, instanceId } = context; + const instancedTabId = `${ instanceId }-${ id }`; + return ( + + { children } + + ); +} ); diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx new file mode 100644 index 0000000000000..02255fefd2082 --- /dev/null +++ b/packages/components/src/tabs/tablist.tsx @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * WordPress dependencies + */ +import warning from '@wordpress/warning'; +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabListProps } from './types'; +import { useTabsContext } from './context'; +import { TabListWrapper } from './styles'; + +export const TabList = forwardRef< HTMLDivElement, TabListProps >( + function TabList( { children, className, style }, ref ) { + const context = useTabsContext(); + if ( ! context ) { + warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); + return null; + } + const { store } = context; + return ( + } + > + { children } + + ); + } +); diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx new file mode 100644 index 0000000000000..fb62fc9191233 --- /dev/null +++ b/packages/components/src/tabs/tabpanel.tsx @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * WordPress dependencies + */ + +import { forwardRef, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabPanelProps } from './types'; + +import warning from '@wordpress/warning'; +import { TabsContext } from './context'; + +export const TabPanel = forwardRef< HTMLDivElement, TabPanelProps >( + function TabPanel( { children, id, className, style }, ref ) { + const context = useContext( TabsContext ); + if ( ! context ) { + warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' ); + return null; + } + const { store, instanceId } = context; + + return ( + + { children } + + ); + } +); diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx new file mode 100644 index 0000000000000..1b437966239a0 --- /dev/null +++ b/packages/components/src/tabs/test/index.tsx @@ -0,0 +1,1124 @@ +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import { wordpress, category, media } from '@wordpress/icons'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Tabs from '..'; +import type { TabsProps } from '../types'; +import type { IconType } from '../../icon'; + +type Tab = { + id: string; + title: string; + content: React.ReactNode; + tab: { + className?: string; + icon?: IconType; + disabled?: boolean; + }; +}; + +const TABS: Tab[] = [ + { + id: 'alpha', + title: 'Alpha', + content: 'Selected tab: Alpha', + tab: { className: 'alpha-class', icon: wordpress }, + }, + { + id: 'beta', + title: 'Beta', + content: 'Selected tab: Beta', + tab: { className: 'beta-class', icon: category }, + }, + { + id: 'gamma', + title: 'Gamma', + content: 'Selected tab: Gamma', + tab: { className: 'gamma-class', icon: media }, + }, +]; + +const TABS_WITH_DELTA: Tab[] = [ + ...TABS, + { + id: 'delta', + title: 'Delta', + content: 'Selected tab: Delta', + tab: { className: 'delta-class', icon: media }, + }, +]; + +const UncontrolledTabs = ( { + tabs, + showTabIcons = false, + ...props +}: Omit< TabsProps, 'children' > & { + tabs: Tab[]; + showTabIcons?: boolean; +} ) => { + return ( + + + { tabs.map( ( tabObj ) => ( + + { showTabIcons ? null : tabObj.title } + + ) ) } + + { tabs.map( ( tabObj ) => ( + + { tabObj.content } + + ) ) } + + ); +}; + +const ControlledTabs = ( { + tabs, + showTabIcons = false, + ...props +}: Omit< TabsProps, 'children' > & { + tabs: Tab[]; + showTabIcons?: boolean; +} ) => { + const [ selectedTabId, setSelectedTabId ] = useState< + string | undefined | null + >( props.selectedTabId ); + + return ( + { + setSelectedTabId( selectedId ); + props.onSelect?.( selectedId ); + } } + > + + { tabs.map( ( tabObj ) => ( + + { showTabIcons ? null : tabObj.title } + + ) ) } + + { tabs.map( ( tabObj ) => ( + + { tabObj.content } + + ) ) } + + ); +}; + +const getSelectedTab = async () => + await screen.findByRole( 'tab', { selected: true } ); + +let originalGetClientRects: () => DOMRectList; + +describe( 'Tabs', () => { + beforeAll( () => { + originalGetClientRects = window.HTMLElement.prototype.getClientRects; + // 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' ]; + }; + } ); + + afterAll( () => { + window.HTMLElement.prototype.getClientRects = originalGetClientRects; + } ); + + describe( 'Accessibility and semantics', () => { + it( 'should use the correct aria attributes', async () => { + render( ); + + const tabList = screen.getByRole( 'tablist' ); + const allTabs = screen.getAllByRole( 'tab' ); + const selectedTabPanel = await screen.findByRole( 'tabpanel' ); + + expect( tabList ).toBeVisible(); + expect( tabList ).toHaveAttribute( + 'aria-orientation', + 'horizontal' + ); + + expect( allTabs ).toHaveLength( TABS.length ); + + // The selected `tab` aria-controls the active `tabpanel`, + // which is `aria-labelledby` the selected `tab`. + expect( selectedTabPanel ).toBeVisible(); + expect( allTabs[ 0 ] ).toHaveAttribute( + 'aria-controls', + selectedTabPanel.getAttribute( 'id' ) + ); + expect( selectedTabPanel ).toHaveAttribute( + 'aria-labelledby', + allTabs[ 0 ].getAttribute( 'id' ) + ); + } ); + } ); + + describe( 'Tab Attributes', () => { + it( "should apply the tab's `className` to the tab button", async () => { + render( ); + + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveClass( 'alpha-class' ); + expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( + 'beta-class' + ); + expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveClass( + 'gamma-class' + ); + } ); + } ); + + describe( 'Tab Activation', () => { + it( 'defaults to automatic tab activation (pointer clicks)', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // Alpha is the initially selected tab + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Click on Beta, make sure beta is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( + screen.getByRole( 'tabpanel', { name: 'Beta' } ) + ).toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Click on Alpha, make sure beta is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'defaults to automatic tab activation (arrow keys)', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // onSelect gets called on the initial render. It should be called + // with the first enabled tab, which is alpha. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'wraps around the last/first tab when using arrow keys', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate backwards with arrow keys and make sure that the Gamma tab + // (the last tab) is selected automatically. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + // Navigate forward with arrow keys. Make sure alpha (the first tab) is + // selected automatically. + await user.keyboard( '[ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'should not move tab selection when pressing the up/down arrow keys, unless the orientation is changed to `vertical`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + // onSelect gets called on the initial render. It should be called + // with the first enabled tab, which is alpha. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Press the arrow up key, nothing happens. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Press the arrow down key, nothing happens + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Change orientation to `vertical`. When the orientation is vertical, + // left/right arrow keys are replaced by up/down arrow keys. + rerender( + + ); + + expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( + 'aria-orientation', + 'vertical' + ); + + // Make sure alpha is still focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'should move focus on a tab even if disabled with arrow key, but not with pointer clicks', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => + tabObj.id === 'delta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + render( + + ); + + // onSelect gets called on the initial render. It should be called + // with the first enabled tab, which is alpha. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + // Confirm onSelect has not been re-called + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Press the right arrow key three times. Since the delta tab is disabled: + // - it won't be selected. The gamma tab will be selected instead, since + // it was the tab that was last selected before delta. Therefore, the + // `mockOnSelect` function gets called only twice (and not three times) + // - it will receive focus, when using arrow keys + await user.keyboard( '[ArrowRight][ArrowRight][ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + // Navigate backwards with arrow keys. The gamma tab receives focus. + // The `mockOnSelect` callback doesn't fire, since the gamma tab was + // already selected. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + + // Click on the disabled tab. Compared to using arrow keys to move the + // focus, disabled tabs ignore pointer clicks — and therefore, they don't + // receive focus, nor they cause the `mockOnSelect` function to fire. + await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'should not focus the next tab when the Tab key is pressed', async () => { + const user = userEvent.setup(); + + render( ); + + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + + // Because all other tabs should have `tabindex=-1`, pressing Tab + // should NOT move the focus to the next tab, which is Beta. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).not.toHaveFocus(); + } ); + + it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // onSelect gets called on the initial render. It should be called + // with the first enabled tab, which is alpha. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Click on Alpha and make sure it is selected. + // onSelect shouldn't fire since the selected tab didn't change. + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Navigate forward with arrow keys. Make sure Beta is focused, but + // that the tab selection happens only when pressing the spacebar + // or enter key. onSelect shouldn't fire since the selected tab + // didn't change. + await user.keyboard( '[ArrowRight]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); + + await user.keyboard( '[Enter]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Navigate forward with arrow keys. Make sure Gamma (last tab) is + // focused, but that tab selection happens only when pressing the + // spacebar or enter key. onSelect shouldn't fire since the selected + // tab didn't change. + await user.keyboard( '[ArrowRight]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( + screen.getByRole( 'tab', { name: 'Gamma' } ) + ).toHaveFocus(); + + await user.keyboard( '[Space]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + } ); + } ); + describe( 'Uncontrolled mode', () => { + describe( 'Without `initialTabId` prop', () => { + it( 'should render first tab', async () => { + render( ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + } ); + it( 'should fall back to first enabled tab if the active tab is removed', async () => { + const { rerender } = render( + + ); + + // Remove first item from `TABS` array + rerender( ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + it( 'should not load any tab if the active tab is removed and there are no enabled tabs', async () => { + const TABS_WITH_BETA_GAMMA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id !== 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + const { rerender } = render( + + ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + + // Remove alpha + rerender( + + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'With `initialTabId`', () => { + it( 'should render the tab set by `initialTabId` prop', async () => { + render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should not select a tab when `initialTabId` does not match any known tab', () => { + render( + + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + it( 'should not change tabs when initialTabId is changed', async () => { + const { rerender } = render( + + ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should fall back to the tab associated to `initialTabId` if the currently active tab is removed', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + await user.click( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should fall back to the tab associated to `initialTabId` if the currently active tab becomes disabled', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + await user.click( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should have no active tabs when the tab associated to `initialTabId` is removed while being the active tab', async () => { + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Remove gamma + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + // No tab should be selected i.e. it doesn't fall back to first tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + + it( 'waits for the tab with the `initialTabId` to be present in the `tabs` array before selecting it', async () => { + const { rerender } = render( + + ); + + // There should be no selected tab yet. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); + } ); + } ); + + describe( 'Disabled tab', () => { + it( 'should disable the tab when `disabled` is `true`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( + ( tabObj ) => + tabObj.id === 'delta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + render( + + ); + + expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // onSelect should not be called since the disabled tab is + // highlighted, but not selected. + await user.keyboard( '[Tab]' ); + await user.keyboard( '[ArrowLeft]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Delta (which is disabled) has focus + expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveFocus(); + + // Alpha retains the selection, even if it's not focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + } ); + + it( 'should select first enabled tab when the initial tab is disabled', async () => { + const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + const { rerender } = render( + + ); + + // As alpha (first tab) is disabled, + // the first enabled tab should be beta. + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Re-enable all tabs + rerender( ); + + // Even if the initial tab becomes enabled again, the selected + // tab doesn't change. + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => { + const TABS_ONLY_GAMMA_ENABLED = TABS.map( ( tabObj ) => + tabObj.id !== 'gamma' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + const { rerender } = render( + + ); + + // As alpha (first tab), and beta (the initial tab), are both + // disabled the first enabled tab should be gamma. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Re-enable all tabs + rerender( + + ); + + // Even if the initial tab becomes enabled again, the selected tab doesn't + // change. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should select the first enabled tab when the selected tab becomes disabled', async () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + // Disable alpha + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Re-enable all tabs + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + } ); + + it( 'should select the first enabled tab when the tab associated to `initialTabId` becomes disabled while being the active tab', async () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + const TABS_WITH_GAMMA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'gamma' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + // Disable gamma + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Re-enable all tabs + rerender( + + ); + + // Confirm that alpha is still selected, and that onSelect has + // not been called again. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + } ); + + describe( 'Controlled mode', () => { + it( 'should render the tab specified by the `selectedTabId` prop', async () => { + render( ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( + await screen.findByRole( 'tabpanel', { name: 'Beta' } ) + ).toBeInTheDocument(); + } ); + it( 'should render the specified `selectedTabId`, and ignore the `initialTabId` prop', async () => { + render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + it( 'should not render any tab if `selectedTabId` does not match any known tab', async () => { + render( + + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + // `waitFor` is needed here to prevent testing library from + // throwing a 'not wrapped in `act()`' error. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + // No tabpanel should be rendered either + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); + } ); + it( 'should not render any tab if the active tab is removed', async () => { + const { rerender } = render( + + ); + + // Remove beta + rerender( + tab.id !== 'beta' ) } + selectedTabId="beta" + /> + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + // `waitFor` is needed here to prevent testing library from + // throwing a 'not wrapped in `act()`' error. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + // No tabpanel should be rendered either + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); + + // Restore beta + rerender( ); + + // No tab should be selected i.e. it doesn't reselect the previously + // removed tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); + } ); + + describe( 'Disabled tab', () => { + it( 'should not render any tab if `selectedTabId` refers to a disabled tab', async () => { + const TABS_WITH_DELTA_WITH_BETA_DISABLED = TABS_WITH_DELTA.map( + ( tabObj ) => + tabObj.id === 'beta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + render( + + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + await waitFor( () => { + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + } ); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + it( 'should not render any tab when the selected tab becomes disabled', async () => { + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'beta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + rerender( + + ); + // No tab should be selected i.e. it doesn't fall back to first tab. + // `waitFor` is needed here to prevent testing library from + // throwing a 'not wrapped in `act()`' error. + await waitFor( () => { + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + } ); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + + // re-enable all tabs + rerender( + + ); + + // If the previously selected tab is reenabled, it should not + // be reselected. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + } ); + } ); +} ); diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts new file mode 100644 index 0000000000000..88e25eb5a3863 --- /dev/null +++ b/packages/components/src/tabs/types.ts @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import type * as Ariakit from '@ariakit/react'; + +/** + * Internal dependencies + */ +import type { IconType } from '../icon'; + +export type TabsContextProps = + | { + /** + * The tabStore object returned by Ariakit's `useTabStore` hook. + */ + store: Ariakit.TabStore; + /** + * The unique id string for this instance of the Tabs component. + */ + instanceId: string; + } + | undefined; + +export type TabsProps = { + /** + * The children elements, which should be at least a + * `Tabs.Tablist` component and a series of `Tabs.TabPanel` + * components. + */ + children: React.ReactNode; + /** + * When `true`, the tab will be selected when receiving focus (automatic tab + * activation). When `false`, the tab will be selected only when clicked + * (manual tab activation). See the official W3C docs for more info. + * + * @default true + * + * @see https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/ + */ + selectOnMove?: boolean; + /** + * The id of the tab to be selected upon mounting of component. + * If this prop is not set, the first tab will be selected by default. + * The id provided will be internally prefixed with the + * `TabsContextProps.instanceId`. + * + * Note: this prop will be overridden by the `selectedTabId` prop if it is + * provided. (Controlled Mode) + */ + initialTabId?: string; + /** + * The function called when a tab has been selected. + * It is passed the id of the newly selected tab as an argument. + */ + onSelect?: ( selectedId: string | null | undefined ) => void; + + /** + * The orientation of the tablist. + * + * @default `horizontal` + */ + orientation?: 'horizontal' | 'vertical'; + /** + * The Id of the tab to display. This id is prepended with the `Tabs` + * instanceId internally. + * + * If left `undefined`, the component assumes it is being used in + * uncontrolled mode. Consequently, any value different than `undefined` + * will set the component in `controlled` mode. + * When in controlled mode, the `null` value will result in no tab being selected. + */ + selectedTabId?: string | null; +}; + +export type TabListProps = { + /** + * The children elements, which should be a series of `Tabs.TabPanel` components. + */ + children?: React.ReactNode; + /** + * The class name to apply to the tablist. + */ + className?: string; + /** + * Custom CSS styles for the rendered tablist. + */ + style?: React.CSSProperties; +}; + +export type TabProps = { + /** + * The id of the tab, which is prepended with the `Tabs` instanceId. + */ + id: string; + /** + * Custom CSS styles for the tab. + */ + style?: React.CSSProperties; + /** + * The children elements, generally the text to display on the tab. + */ + children?: React.ReactNode; + /** + * The class name to apply to the tab button. + */ + className?: string; + /** + * The icon used for the tab button. + */ + icon?: IconType; + /** + * Determines if the tab button should be disabled. + * + * @default false + */ + disabled?: boolean; + /** + * The type of component to render the tab button as. If this prop is not + * provided, the tab button will be rendered as a `button` element. + */ + render?: React.ReactElement; +}; + +export type TabPanelProps = { + /** + * The children elements, generally the content to display on the tabpanel. + */ + children?: React.ReactNode; + /** + * A unique identifier for the TabPanel, which is used to generate a unique `id` for the underlying element. + */ + id: string; + /** + * The class name to apply to the tabpanel. + */ + className?: string; + /** + * Custom CSS styles for the rendered `TabPanel` component. + */ + style?: React.CSSProperties; +}; From c6718435c545175675d883d1f5003b5f97a53b8e Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Fri, 6 Oct 2023 13:05:06 -0500 Subject: [PATCH 4/6] Remove wp-now doc and update main readme. (#55136) --- docs/getting-started/devenv/README.md | 11 ++- .../devenv/get-started-with-wp-now.md | 70 ------------------- docs/manifest.json | 6 -- docs/toc.json | 3 - 4 files changed, 5 insertions(+), 85 deletions(-) delete mode 100644 docs/getting-started/devenv/get-started-with-wp-now.md diff --git a/docs/getting-started/devenv/README.md b/docs/getting-started/devenv/README.md index d6e8d9e1ae24b..6a21eea879380 100644 --- a/docs/getting-started/devenv/README.md +++ b/docs/getting-started/devenv/README.md @@ -41,11 +41,10 @@ To be able to use the Node.js tools and [packages provided by WordPress](https:/ A local WordPress environment (site) provides a controlled, efficient, and secure space for development, allowing you to build and test your code before deploying it to a production site. The [same requirements](https://en-gb.wordpress.org/about/requirements/) for WordPress apply to local sites. -Many tools are available for setting up a local WordPress environment on your computer. The Block Editor Handbook covers `wp-env` and `wp-now`, both of which are open-source and maintained by the WordPress project itself. +In the boarder WordPress community, there are many available tools for setting up a local WordPress environment on your computer. The Block Editor Handbook covers `wp-env`, which is open-source and maintained by the WordPress project itself. It's also the recommended tool for Gutenberg development. -Refer to the individual guides below for setup instructions. +Refer to the [Get started with `wp-env`](/docs/getting-started/devenv/get-started-with-wp-env.md) guide for setup instructions. -- [Get started with `wp-env`](/docs/getting-started/devenv/get-started-with-wp-env.md) -- [Get started with `wp-now`](/docs/getting-started/devenv/get-started-with-wp-now.md) - -Of the two, `wp-env` is the more solid and complete solution. It's also the recommended tool for Gutenberg development. On the other hand, `wp-now` offers a simplified setup but is more limited than `wp-env`. Both are valid options, so the choice is yours. +
+ Throughout the Handbook, you may also see references to wp-now. This is a lightweight tool powered by WordPress Playground that streamlines setting up a simple local WordPress environment. While still experimental, this tool is great for quickly testing WordPress releases, plugins, and themes. +
diff --git a/docs/getting-started/devenv/get-started-with-wp-now.md b/docs/getting-started/devenv/get-started-with-wp-now.md deleted file mode 100644 index ddcf4c300b4a2..0000000000000 --- a/docs/getting-started/devenv/get-started-with-wp-now.md +++ /dev/null @@ -1,70 +0,0 @@ -
-wp-now is still experimental. For a more comprehensive overview of their current status and roadmap, please check WordPress Playground's repository and wp-now's. -
- -# Get started with wp-now - -The [@wp-now/wp-now](https://www.npmjs.com/package/@wordpress/env) package (`wp-now`) is a lightweight tool powered by [WordPress Playground](https://developer.wordpress.org/playground/) that streamlines setting up a local WordPress environment. - -Before following this guide, install [Node.js development tools](/docs/getting-started/devenv#node-js-development-tools) if you have not already done so. It's recommended that you use the latest version of `node`. `wp-now` requires at least `node` v18 and v20 if you intend to use its [Blueprints](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now#using-blueprints) feature. - -## Quick start - -1. Run `npm -g install @wp-now/wp-now` in the terminal to install `wp-now` globally. -2. In the terminal, navigate to an existing plugin directory, theme directory, or a new working directory. -3. Run `wp-now start` in the terminal to start the local WordPress environment. -4. After the script runs, your default web browser will automatically open the new local site, and you'll be logged in with the username `admin` and the password `password`. - -## Install and run `wp-now` - -Under the hood, `wp-now` is powered by WordPress Playground and only requires Node.js, unlike `wp-env`, which also requires Docker. To install `wp-now`, open the terminal and run the command: - -```sh -npm -g install @wp-now/wp-now -``` - -This will install the `wp-now` globally, allowing the tool to be run from any directory. To confirm it's installed and available, run `wp-now --version`, and the version number should appear. - -Next, navigate to an existing plugin directory, theme directory, or a new working directory in the terminal and run: - -```sh -wp-now start -``` - -After the script runs, your default web browser will automatically open the new local site, and you'll be logged in with the username `admin` and the password `password`. - -
- If you encounter any errors when running wp-now start, make sure that you are using at least node v18, or v20 if you are using the Blueprint feature. -
- -When running `wp-now` you can also pass arguments that modify the behavior of the WordPress environment. For example, `wp-now start --wp=6.3 --php=8` will start a site running WordPress 6.3 and PHP 8, which can be useful for testing purposes. - -Refer to the [@wp-now/wp-now](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now) documentation for all available arguments. - -### Where to run `wp-now` - -The `wp-now` tool can be used practically anywhere and has different modes depending on how the directory is set up when you run `wp-now start`. Despite the many options, when developing for the Block Editor, you will likely use: - -- `plugin`, `theme`, or `wp-content`: Loads the project files into a virtual filesystem with WordPress and a SQLite-based database. Everything (including WordPress core files, the database, wp-config.php, etc.) is stored in the user's home directory and loaded into the virtual filesystem. The mode will be determined by: - - `plugin`: Presence of a PHP file with 'Plugin Name:' in its contents. - - `theme`: Presence of a `style.css` file with 'Theme Name:' in its contents. - - `wp-content`: Presence of `/plugins` and `/themes` subdirectories. -- `playground`: If no other mode conditions are matched, `wp-now` launches a completely virtualized WordPress site. - -Refer to the [@wp-now/wp-now](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now) documentation for a more detailed explanation of all modes. - -### Known issues - -Since `wp-now` is a relatively new tool, there are a few [known issues](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now#known-issues) to be aware of. However, these issues are unlikely to impact most block, theme, or plugin development. - -### Uninstall or reset `wp-now` - -Here are a few instructions if you need to start over or want to remove what was installed. - -- If you just want to reset and clean the WordPress database, run `wp-now --reset` -- To globally uninstall the `wp-now` tool, run `npm -g uninstall @wp-now/wp-now` - -## Additional resources - -- [@wp-now/wp-now](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now) (Official documentation) -- [WordPress Playground](https://developer.wordpress.org/playground/) (Developer overview) diff --git a/docs/manifest.json b/docs/manifest.json index 447d5b0f4eeb8..297f30fda0b6d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -29,12 +29,6 @@ "markdown_source": "../docs/getting-started/devenv/get-started-with-wp-env.md", "parent": "devenv" }, - { - "title": "Get started with wp-now", - "slug": "get-started-with-wp-now", - "markdown_source": "../docs/getting-started/devenv/get-started-with-wp-now.md", - "parent": "devenv" - }, { "title": "Create a Block Tutorial", "slug": "create-block", diff --git a/docs/toc.json b/docs/toc.json index 621fd7c48c7c9..1629a22b6753f 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -11,9 +11,6 @@ }, { "docs/getting-started/devenv/get-started-with-wp-env.md": [] - }, - { - "docs/getting-started/devenv/get-started-with-wp-now.md": [] } ] }, From 72c7d06179da806e2a9b3255694792d81b1e90d6 Mon Sep 17 00:00:00 2001 From: Marcelo Serpa <81248+fullofcaffeine@users.noreply.github.com> Date: Fri, 6 Oct 2023 16:41:46 -0600 Subject: [PATCH 5/6] (WP 6.4-compat) Add missing `type` schema attribute in WP 6.4 compat's `block-hooks.php` (#55138) * Add missing `type` attribute in WP 6.4 compat's `block-hooks.php` * Type is required for API schemas --- lib/compat/wordpress-6.4/block-hooks.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/compat/wordpress-6.4/block-hooks.php b/lib/compat/wordpress-6.4/block-hooks.php index 07f5aaf218d67..9cf6d2414926a 100644 --- a/lib/compat/wordpress-6.4/block-hooks.php +++ b/lib/compat/wordpress-6.4/block-hooks.php @@ -290,6 +290,7 @@ function gutenberg_register_block_hooks_rest_field() { array( 'schema' => array( 'description' => __( 'This block is automatically inserted near any occurence of the block types used as keys of this map, into a relative position given by the corresponding value.', 'gutenberg' ), + 'type' => 'object', 'patternProperties' => array( '^[a-zA-Z0-9-]+/[a-zA-Z0-9-]+$' => array( 'type' => 'string', From d16cf6b88e3bb2bdcfe723ccdf5f8cbe1c7904a1 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Fri, 6 Oct 2023 23:06:58 +0000 Subject: [PATCH 6/6] Update Changelog for 16.7.1 --- changelog.txt | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/changelog.txt b/changelog.txt index 25b25bfa296f3..07dd475745056 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,25 @@ == Changelog == += 16.7.1 = + +## Changelog + +### Tools + +#### Build Tooling +- Fix incorrect resource URL in source map for sources coming from @wordpress packages. ([51401](https://github.com/WordPress/gutenberg/pull/51401)) + +### Various + +- Add missing schema `type` attribute for in WP 6.4 compat's `block-hooks.php`. ([55138](https://github.com/WordPress/gutenberg/pull/55138)) + +## Contributors + +The following contributors merged PRs in this release: + +@fullofcaffeine @torounit + + = 16.8.0-rc.1 = ## Changelog