diff --git a/changelog.txt b/changelog.txt
index 25b25bfa296f36..07dd4757450562 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
diff --git a/docs/getting-started/devenv/README.md b/docs/getting-started/devenv/README.md
index d6e8d9e1ae24b2..6a21eea8793805 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.
+
-
-# 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 4108da22296efe..297f30fda0b6d6 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",
@@ -1229,6 +1223,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/docs/toc.json b/docs/toc.json
index 621fd7c48c7c9f..1629a22b6753f5 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": []
}
]
},
diff --git a/lib/block-supports/behaviors.php b/lib/block-supports/behaviors.php
index 6f442d7b0d2d7c..c1b3dceceee94d 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 = << 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',
diff --git a/lib/load.php b/lib/load.php
index a3a61407764b5c..33176c422b2210 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -247,3 +247,4 @@ function () {
require __DIR__ . '/block-supports/duotone.php';
require __DIR__ . '/block-supports/shadow.php';
require __DIR__ . '/block-supports/background.php';
+require __DIR__ . '/block-supports/behaviors.php';
diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md
index 075a5d472b3fbd..5598b49bcad76c 100644
--- a/packages/block-library/CHANGELOG.md
+++ b/packages/block-library/CHANGELOG.md
@@ -2,6 +2,11 @@
## Unreleased
+### Bug Fix
+
+- Fix Image block lightbox missing alt attribute and improve accessibility. ([#54608](https://github.com/WordPress/gutenberg/pull/55010))
+
+
## 8.20.0 (2023-10-05)
## 8.19.0 (2023-09-20)
diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php
index cba0203b477a45..bfc3af8754bc1d 100644
--- a/packages/block-library/src/image/index.php
+++ b/packages/block-library/src/image/index.php
@@ -9,10 +9,11 @@
* Renders the `core/image` block on the server,
* adding a data-id attribute to the element if core/gallery has added on pre-render.
*
- * @param array $attributes The block attributes.
- * @param string $content The block content.
- * @param WP_Block $block The block object.
- * @return string Returns the block content with the data-id attribute added.
+ * @param array $attributes The block attributes.
+ * @param string $content The block content.
+ * @param WP_Block $block The block object.
+ *
+ * @return string The block content with the data-id attribute added.
*/
function render_block_core_image( $attributes, $content, $block ) {
@@ -76,12 +77,13 @@ function render_block_core_image( $attributes, $content, $block ) {
}
/**
- * Add the lightboxEnabled flag to the block data.
+ * Adds the lightboxEnabled flag to the block data.
*
* This is used to determine whether the lightbox should be rendered or not.
*
- * @param array $block Block data.
- * @return array Filtered block data.
+ * @param array $block Block data.
+ *
+ * @return array Filtered block data.
*/
function block_core_image_get_lightbox_settings( $block ) {
// Get the lightbox setting from the block attributes.
@@ -113,43 +115,44 @@ function block_core_image_get_lightbox_settings( $block ) {
}
/**
- * Add the directives and layout needed for the lightbox behavior.
+ * Adds the directives and layout needed for the lightbox behavior.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $block Block object.
*
- * @param string $block_content Rendered block content.
- * @param array $block Block object.
- * @return string Filtered block content.
+ * @return string Filtered block content.
*/
function block_core_image_render_lightbox( $block_content, $block ) {
$processor = new WP_HTML_Tag_Processor( $block_content );
$aria_label = __( 'Enlarge image' );
+ $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' ), $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 = <<
$initial_image_content
$enlarged_image_content
-
+
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 752ff773394a44..2ef602982e57b5 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 13f20c9cd7cb68..3eb47dcc7cab4b 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;
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 14166f8827ec80..2df99e7413ffa6 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 00000000000000..6907f385fda371
--- /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 00000000000000..cc6184d827138f
--- /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 00000000000000..54f547ad2f52d7
--- /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 00000000000000..3b6ba022f6d91b
--- /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
+
+
+