From daaa785b27c4124c7d39a7ee12bc2b4ce0359aa4 Mon Sep 17 00:00:00 2001 From: Marin Atanasov <8436925+tyxla@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:05:14 +0200 Subject: [PATCH 01/20] Edit Site: Fix sidebar template author navigation (#67382) Co-authored-by: tyxla Co-authored-by: youknowriad --- .../sidebar-navigation-screen-templates-browse/content.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js index 5d3819eac0ee3c..aad38959c73dcd 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js @@ -6,6 +6,7 @@ import { useMemo } from '@wordpress/element'; import { __experimentalItemGroup as ItemGroup } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -25,7 +26,7 @@ function TemplateDataviewItem( { template, isActive } ) { return ( From 007daf07967134b15a09eb7b9f0e59f7ffa0584e Mon Sep 17 00:00:00 2001 From: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com> Date: Thu, 28 Nov 2024 15:29:40 +0100 Subject: [PATCH 02/20] Only pass `aria-label` when it is not empty (#67381) Co-authored-by: SantosGuillamot Co-authored-by: afercia --- packages/block-library/src/navigation/index.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 68b23aceeced65..9a56e399fcfecb 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -567,13 +567,14 @@ private static function get_nav_wrapper_attributes( $attributes, $inner_blocks ) $is_responsive_menu = static::is_responsive( $attributes ); $style = static::get_styles( $attributes ); $class = static::get_classes( $attributes ); - $wrapper_attributes = get_block_wrapper_attributes( - array( - 'class' => $class, - 'style' => $style, - 'aria-label' => $nav_menu_name, - ) + $extra_attributes = array( + 'class' => $class, + 'style' => $style, ); + if ( ! empty( $nav_menu_name ) ) { + $extra_attributes['aria-label'] = $nav_menu_name; + } + $wrapper_attributes = get_block_wrapper_attributes( $extra_attributes ); if ( $is_responsive_menu ) { $nav_element_directives = static::get_nav_element_directives( $is_interactive ); From 81327d1e18a724758eedbb461bde29dfd8b41b7f Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 28 Nov 2024 15:54:46 +0100 Subject: [PATCH 03/20] Update @ariakit/react to 0.4.13 (#65907) * Remove all ariakit dependencies * Re-add ariakit dependencies targeting latest version * Remove focus-visible DropdownMenuV2 workaround * Remove composite tabbable workaround * CHANGELOG * Remove Tabs workaround --- Co-authored-by: ciampo Co-authored-by: oandregal Co-authored-by: tyxla Co-authored-by: diegohaz Co-authored-by: jsnajdr Co-authored-by: t-hamano --- package-lock.json | 82 ++++++++++++------- package.json | 2 +- packages/components/CHANGELOG.md | 6 +- packages/components/package.json | 2 +- packages/components/src/composite/item.tsx | 20 +---- .../components/src/menu/checkbox-item.tsx | 6 +- packages/components/src/menu/item.tsx | 6 +- packages/components/src/menu/radio-item.tsx | 6 +- .../menu/use-temporary-focus-visible-fix.ts | 22 ----- packages/components/src/tabs/tab.tsx | 18 ---- packages/dataviews/package.json | 2 +- 11 files changed, 63 insertions(+), 109 deletions(-) delete mode 100644 packages/components/src/menu/use-temporary-focus-visible-fix.ts diff --git a/package-lock.json b/package-lock.json index 80a64c6f7a04ba..58479ecfa2ed99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@actions/core": "1.9.1", "@actions/github": "5.0.0", "@apidevtools/json-schema-ref-parser": "11.6.4", - "@ariakit/test": "^0.4.2", + "@ariakit/test": "^0.4.5", "@babel/core": "7.25.7", "@babel/plugin-syntax-jsx": "7.25.7", "@babel/runtime-corejs3": "7.25.7", @@ -1432,18 +1432,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ariakit/core": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.9.tgz", - "integrity": "sha512-nV0B/OTK/0iB+P9RC7fudznYZ8eR6rR1F912Zc54e3+wSW5RrRvNOiRxyMrgENidd4R7cCMDw77XJLSBLKgEPQ==" - }, "node_modules/@ariakit/test": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@ariakit/test/-/test-0.4.2.tgz", - "integrity": "sha512-WXAAiAyTaHV9klntOB81Y+YHyA5iGxy9wXCmjQOfYK5InsuIour+7TVXICUxn2NF0XD6j6OoEJbCVDJ2Y46xEA==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@ariakit/test/-/test-0.4.5.tgz", + "integrity": "sha512-dK9OtI8MeKfdtOiW1auDITnyaelq0O0aUTnolIqJj+RJd8LFai0gi7fQUgrun9CZHJ2wWsEad4vlviGfhfIIhQ==", "dev": true, + "license": "MIT", "dependencies": { - "@ariakit/core": "0.4.9", + "@ariakit/core": "0.4.12", "@testing-library/dom": "^8.0.0 || ^9.0.0 || ^10.0.0" }, "peerDependencies": { @@ -1463,6 +1459,13 @@ } } }, + "node_modules/@ariakit/test/node_modules/@ariakit/core": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.12.tgz", + "integrity": "sha512-+NNpy88tdP/w9mOBPuDrMTbtapPbo/8yVIzpQB7TAmN0sPh/Cq3nU1f2KCTCIujPmwRvAcMSW9UHOlFmbKEPOA==", + "dev": true, + "license": "MIT" + }, "node_modules/@aw-web-design/x-default-browser": { "version": "1.4.126", "resolved": "https://registry.npmjs.org/@aw-web-design/x-default-browser/-/x-default-browser-1.4.126.tgz", @@ -49340,9 +49343,10 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } @@ -53677,7 +53681,7 @@ "version": "28.13.0", "license": "GPL-2.0-or-later", "dependencies": { - "@ariakit/react": "^0.4.10", + "@ariakit/react": "^0.4.13", "@babel/runtime": "7.25.7", "@emotion/cache": "^11.7.1", "@emotion/css": "^11.7.1", @@ -53732,12 +53736,19 @@ "react-dom": "^18.0.0" } }, + "packages/components/node_modules/@ariakit/core": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.12.tgz", + "integrity": "sha512-+NNpy88tdP/w9mOBPuDrMTbtapPbo/8yVIzpQB7TAmN0sPh/Cq3nU1f2KCTCIujPmwRvAcMSW9UHOlFmbKEPOA==", + "license": "MIT" + }, "packages/components/node_modules/@ariakit/react": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.10.tgz", - "integrity": "sha512-c1+6sNLj57aAXrBZMCVGG+OXeFrPAG0TV1jT7oPJcN/KLRs3aCuO3CCJVep/eKepFzzK01kNRGYX3wPT1TXPNw==", + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.13.tgz", + "integrity": "sha512-pTGYgoqCojfyt2xNJ5VQhejxXwwtcP7VDDqcnnVChv7TA2TWWyYerJ5m4oxViI1pgeNqnHZwKlQ79ZipF7W2kQ==", + "license": "MIT", "dependencies": { - "@ariakit/react-core": "0.4.10" + "@ariakit/react-core": "0.4.13" }, "funding": { "type": "opencollective", @@ -53749,11 +53760,12 @@ } }, "packages/components/node_modules/@ariakit/react-core": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.10.tgz", - "integrity": "sha512-r6DZmtHBmSoOj848+RpBwdZy/55YxPhMhfH14JIO2OLn1F6iSFkQwR7AAGpIrlYycWJFSF7KrQu50O+SSfFJdQ==", + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.13.tgz", + "integrity": "sha512-iIjQeupP9d0pOubOzX4a0UPXbhXbp0ZCduDpkv7+u/pYP/utk/YRECD0M/QpZr6YSeltmDiNxKjdyK8r9Yhv4Q==", + "license": "MIT", "dependencies": { - "@ariakit/core": "0.4.9", + "@ariakit/core": "0.4.12", "@floating-ui/dom": "^1.0.0", "use-sync-external-store": "^1.2.0" }, @@ -54039,7 +54051,7 @@ "version": "4.9.0", "license": "GPL-2.0-or-later", "dependencies": { - "@ariakit/react": "^0.4.10", + "@ariakit/react": "^0.4.13", "@babel/runtime": "7.25.7", "@wordpress/components": "*", "@wordpress/compose": "*", @@ -54061,12 +54073,19 @@ "react": "^18.0.0" } }, + "packages/dataviews/node_modules/@ariakit/core": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.12.tgz", + "integrity": "sha512-+NNpy88tdP/w9mOBPuDrMTbtapPbo/8yVIzpQB7TAmN0sPh/Cq3nU1f2KCTCIujPmwRvAcMSW9UHOlFmbKEPOA==", + "license": "MIT" + }, "packages/dataviews/node_modules/@ariakit/react": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.10.tgz", - "integrity": "sha512-c1+6sNLj57aAXrBZMCVGG+OXeFrPAG0TV1jT7oPJcN/KLRs3aCuO3CCJVep/eKepFzzK01kNRGYX3wPT1TXPNw==", + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.13.tgz", + "integrity": "sha512-pTGYgoqCojfyt2xNJ5VQhejxXwwtcP7VDDqcnnVChv7TA2TWWyYerJ5m4oxViI1pgeNqnHZwKlQ79ZipF7W2kQ==", + "license": "MIT", "dependencies": { - "@ariakit/react-core": "0.4.10" + "@ariakit/react-core": "0.4.13" }, "funding": { "type": "opencollective", @@ -54078,11 +54097,12 @@ } }, "packages/dataviews/node_modules/@ariakit/react-core": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.10.tgz", - "integrity": "sha512-r6DZmtHBmSoOj848+RpBwdZy/55YxPhMhfH14JIO2OLn1F6iSFkQwR7AAGpIrlYycWJFSF7KrQu50O+SSfFJdQ==", + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.13.tgz", + "integrity": "sha512-iIjQeupP9d0pOubOzX4a0UPXbhXbp0ZCduDpkv7+u/pYP/utk/YRECD0M/QpZr6YSeltmDiNxKjdyK8r9Yhv4Q==", + "license": "MIT", "dependencies": { - "@ariakit/core": "0.4.9", + "@ariakit/core": "0.4.12", "@floating-ui/dom": "^1.0.0", "use-sync-external-store": "^1.2.0" }, diff --git a/package.json b/package.json index 3ddcb981f6f6f7..84425dbd1cff21 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@actions/core": "1.9.1", "@actions/github": "5.0.0", "@apidevtools/json-schema-ref-parser": "11.6.4", - "@ariakit/test": "^0.4.2", + "@ariakit/test": "^0.4.5", "@babel/core": "7.25.7", "@babel/plugin-syntax-jsx": "7.25.7", "@babel/runtime-corejs3": "7.25.7", diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index be930515f16659..937027ecdd1ea3 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Internal + +- Upgraded `@ariakit/react` (v0.4.13) and `@ariakit/test` (v0.4.5) ([#65907](https://github.com/WordPress/gutenberg/pull/65907)). + ## 28.13.0 (2024-11-27) ### Deprecations @@ -11,7 +15,7 @@ - `FontSizePicker`: Deprecate 36px default size ([#66920](https://github.com/WordPress/gutenberg/pull/66920)). - `ComboboxControl`: Deprecate 36px default size ([#66900](https://github.com/WordPress/gutenberg/pull/66900)). - `ToggleGroupControl`: Deprecate 36px default size ([#66747](https://github.com/WordPress/gutenberg/pull/66747)). -- `RangeControl`: Deprecate 36px default size ([#66721](https://github.com/WordPress/gutenberg/pull/66721)). +- `RangeControl`: Deprecate 36px default size ([#66721](https://github.com/WordPress/gutenberg/pull/66721)). ### Bug Fixes diff --git a/packages/components/package.json b/packages/components/package.json index dc62f992c3bb29..75f0d1eb1f2331 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -32,7 +32,7 @@ "src/**/*.scss" ], "dependencies": { - "@ariakit/react": "^0.4.10", + "@ariakit/react": "^0.4.13", "@babel/runtime": "7.25.7", "@emotion/cache": "^11.7.1", "@emotion/css": "^11.7.1", diff --git a/packages/components/src/composite/item.tsx b/packages/components/src/composite/item.tsx index edbf0b92e039af..4a02f76039a5cf 100644 --- a/packages/components/src/composite/item.tsx +++ b/packages/components/src/composite/item.tsx @@ -26,23 +26,5 @@ export const CompositeItem = forwardRef< // obfuscated to discourage its use outside of the component's internals. const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; - // If the active item is not connected, Composite may end up in a state - // where none of the items are tabbable. In this case, we force all items to - // be tabbable, so that as soon as an item received focus, it becomes active - // and Composite goes back to working as expected. - const tabbable = Ariakit.useStoreState( store, ( state ) => { - return ( - state?.activeId !== null && - ! store?.item( state?.activeId )?.element?.isConnected - ); - } ); - - return ( - - ); + return ; } ); diff --git a/packages/components/src/menu/checkbox-item.tsx b/packages/components/src/menu/checkbox-item.tsx index b9a9b8105e517e..182c27dfdee305 100644 --- a/packages/components/src/menu/checkbox-item.tsx +++ b/packages/components/src/menu/checkbox-item.tsx @@ -16,24 +16,20 @@ import type { WordPressComponentProps } from '../context'; import { MenuContext } from './context'; import type { MenuCheckboxItemProps } from './types'; import * as Styled from './styles'; -import { useTemporaryFocusVisibleFix } from './use-temporary-focus-visible-fix'; export const MenuCheckboxItem = forwardRef< HTMLDivElement, WordPressComponentProps< MenuCheckboxItemProps, 'div', false > >( function MenuCheckboxItem( - { suffix, children, onBlur, hideOnClick = false, ...props }, + { suffix, children, hideOnClick = false, ...props }, ref ) { - // TODO: Remove when https://github.com/ariakit/ariakit/issues/4083 is fixed - const focusVisibleFixProps = useTemporaryFocusVisibleFix( { onBlur } ); const menuContext = useContext( MenuContext ); return ( >( function MenuItem( - { prefix, suffix, children, onBlur, hideOnClick = true, ...props }, + { prefix, suffix, children, hideOnClick = true, ...props }, ref ) { - // TODO: Remove when https://github.com/ariakit/ariakit/issues/4083 is fixed - const focusVisibleFixProps = useTemporaryFocusVisibleFix( { onBlur } ); const menuContext = useContext( MenuContext ); return ( @@ -29,18 +28,15 @@ export const MenuRadioItem = forwardRef< HTMLDivElement, WordPressComponentProps< MenuRadioItemProps, 'div', false > >( function MenuRadioItem( - { suffix, children, onBlur, hideOnClick = false, ...props }, + { suffix, children, hideOnClick = false, ...props }, ref ) { - // TODO: Remove when https://github.com/ariakit/ariakit/issues/4083 is fixed - const focusVisibleFixProps = useTemporaryFocusVisibleFix( { onBlur } ); const menuContext = useContext( MenuContext ); return ( ; -} ) { - const [ focusVisible, setFocusVisible ] = useState( false ); - return { - 'data-focus-visible': focusVisible || undefined, - onFocusVisible: () => { - flushSync( () => setFocusVisible( true ) ); - }, - onBlur: ( ( event ) => { - onBlurProp?.( event ); - setFocusVisible( false ); - } ) as React.FocusEventHandler< HTMLDivElement >, - }; -} diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx index 70f56e52ad2627..8226d0589f08c8 100644 --- a/packages/components/src/tabs/tab.tsx +++ b/packages/components/src/tabs/tab.tsx @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import * as Ariakit from '@ariakit/react'; - /** * WordPress dependencies */ @@ -29,18 +24,6 @@ export const Tab = forwardRef< >( function Tab( { children, tabId, disabled, render, ...otherProps }, ref ) { const { store, instanceId } = useTabsContext() ?? {}; - // If the active item is not connected, the tablist may end up in a state - // where none of the tabs are tabbable. In this case, we force all tabs to - // be tabbable, so that as soon as an item received focus, it becomes active - // and Tablist goes back to working as expected. - // eslint-disable-next-line @wordpress/no-unused-vars-before-return - const tabbable = Ariakit.useStoreState( store, ( state ) => { - return ( - state?.activeId !== null && - ! store?.item( state?.activeId )?.element?.isConnected - ); - } ); - if ( ! store ) { warning( '`Tabs.Tab` must be wrapped in a `Tabs` component.' ); return null; @@ -55,7 +38,6 @@ export const Tab = forwardRef< id={ instancedTabId } disabled={ disabled } render={ render } - tabbable={ tabbable } { ...otherProps } > { children } diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index f4d42102731eb2..8fe2e04236725c 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -43,7 +43,7 @@ "types": "build-types", "sideEffects": false, "dependencies": { - "@ariakit/react": "^0.4.10", + "@ariakit/react": "^0.4.13", "@babel/runtime": "7.25.7", "@wordpress/components": "*", "@wordpress/compose": "*", From a387fbbdf9fa468217944aa5b78fb6113c2ed63b Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 28 Nov 2024 19:17:37 +0400 Subject: [PATCH 04/20] Block Editor: Fix JS error in the 'useTabNav' hook (#67102) * Block Editor: Fix JS error in the 'useTabNav' hook * Focus on canvas when there's no section root Co-authored-by: Mamaduka Co-authored-by: jeryj Co-authored-by: getdave --- .../src/components/writing-flow/use-tab-nav.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js index 16a18358fb2ede..46c40d56fe96d9 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js @@ -35,6 +35,11 @@ export default function useTabNav() { const noCaptureRef = useRef(); function onFocusCapture( event ) { + const canvasElement = + container.current.ownerDocument === event.target.ownerDocument + ? container.current + : container.current.ownerDocument.defaultView.frameElement; + // Do not capture incoming focus if set by us in WritingFlow. if ( noCaptureRef.current ) { noCaptureRef.current = null; @@ -64,17 +69,15 @@ export default function useTabNav() { .focus(); } // If we don't have any section blocks, focus the section root. - else { + else if ( sectionRootClientId ) { container.current .querySelector( `[data-block="${ sectionRootClientId }"]` ) .focus(); + } else { + // If we don't have any section root, focus the canvas. + canvasElement.focus(); } } else { - const canvasElement = - container.current.ownerDocument === event.target.ownerDocument - ? container.current - : container.current.ownerDocument.defaultView.frameElement; - const isBefore = // eslint-disable-next-line no-bitwise event.target.compareDocumentPosition( canvasElement ) & From 5efeef9ae740d2aa51fa2493782773083b5363ee Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 28 Nov 2024 07:45:13 -0800 Subject: [PATCH 05/20] Convert lock unlock to generics (#66682) * Convert lock unlock to generics * Set object type from generic for unlock * Fix types affected by updated signature of lock/unlock * Improve signature * Remove expected errors no longer needed * Restore the type for component private APIs Co-authored-by: manzoorwanijk Co-authored-by: youknowriad Co-authored-by: jsnajdr --- packages/private-apis/src/implementation.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/private-apis/src/implementation.ts b/packages/private-apis/src/implementation.ts index bae53bae8d158a..5a5fb3f39fa183 100644 --- a/packages/private-apis/src/implementation.ts +++ b/packages/private-apis/src/implementation.ts @@ -137,14 +137,16 @@ export const __dangerousOptInToUnstableAPIsOnlyForCoreModules = ( * @param object The object to bind the private data to. * @param privateData The private data to bind to the object. */ -function lock( object: Record< symbol, WeakKey >, privateData: unknown ) { +function lock( object: unknown, privateData: unknown ) { if ( ! object ) { throw new Error( 'Cannot lock an undefined object.' ); } - if ( ! ( __private in object ) ) { - object[ __private ] = {}; + const _object = object as Record< symbol, WeakKey >; + + if ( ! ( __private in _object ) ) { + _object[ __private ] = {}; } - lockedData.set( object[ __private ], privateData ); + lockedData.set( _object[ __private ], privateData ); } /** @@ -170,17 +172,19 @@ function lock( object: Record< symbol, WeakKey >, privateData: unknown ) { * @param object The object to unlock the private data from. * @return The private data bound to the object. */ -function unlock( object: Record< symbol, WeakKey > ) { +function unlock< T = any >( object: unknown ): T { if ( ! object ) { throw new Error( 'Cannot unlock an undefined object.' ); } - if ( ! ( __private in object ) ) { + const _object = object as Record< symbol, WeakKey >; + + if ( ! ( __private in _object ) ) { throw new Error( 'Cannot unlock an object that was not locked before. ' ); } - return lockedData.get( object[ __private ] ); + return lockedData.get( _object[ __private ] ); } const lockedData = new WeakMap(); From db263fb38a7e6bd8bbf7d27cb189c13063979c90 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 28 Nov 2024 20:24:44 +0400 Subject: [PATCH 06/20] Components: Fix the 'ClipboardButton' effect cleanup (#67399) Co-authored-by: Mamaduka Co-authored-by: tyxla --- packages/components/src/clipboard-button/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/components/src/clipboard-button/index.tsx b/packages/components/src/clipboard-button/index.tsx index 0bf7d177e251ef..492ab64b7290e2 100644 --- a/packages/components/src/clipboard-button/index.tsx +++ b/packages/components/src/clipboard-button/index.tsx @@ -45,9 +45,11 @@ export default function ClipboardButton( { } ); useEffect( () => { - if ( timeoutIdRef.current ) { - clearTimeout( timeoutIdRef.current ); - } + return () => { + if ( timeoutIdRef.current ) { + clearTimeout( timeoutIdRef.current ); + } + }; }, [] ); const classes = clsx( 'components-clipboard-button', className ); From 6a989b420b2dc809076f79985b976b0b400749e3 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 28 Nov 2024 18:08:39 +0100 Subject: [PATCH 07/20] DataViews: Avoid double click handler on primary fields (#67393) Co-authored-by: youknowriad Co-authored-by: gigitux --- .../dataviews-layouts/utils/get-clickable-item-props.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/dataviews/src/dataviews-layouts/utils/get-clickable-item-props.ts b/packages/dataviews/src/dataviews-layouts/utils/get-clickable-item-props.ts index e2a6081a68fa3e..efb4a8f598c7b8 100644 --- a/packages/dataviews/src/dataviews-layouts/utils/get-clickable-item-props.ts +++ b/packages/dataviews/src/dataviews-layouts/utils/get-clickable-item-props.ts @@ -12,9 +12,15 @@ export default function getClickableItemProps< Item >( className: `${ className } ${ className }--clickable`, role: 'button', tabIndex: 0, - onClick: () => onClickItem( item ), + onClick: ( event: React.MouseEvent ) => { + // Prevents onChangeSelection from triggering. + event.stopPropagation(); + onClickItem( item ); + }, onKeyDown: ( event: React.KeyboardEvent ) => { if ( event.key === 'Enter' || event.key === '' ) { + // Prevents onChangeSelection from triggering. + event.stopPropagation(); onClickItem( item ); } }, From 9c1446de351e94f3b186050e9c4396c2747d38e5 Mon Sep 17 00:00:00 2001 From: Yogesh Bhutkar Date: Fri, 29 Nov 2024 00:05:45 +0530 Subject: [PATCH 08/20] FontCollection: Update pagination controls (#67143) Co-authored-by: yogeshbhutkar Co-authored-by: matiasbenedetto Co-authored-by: juanfra --- .../font-library-modal/font-collection.js | 67 +++++++++++-------- .../font-library-modal/style.scss | 17 +++-- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js index caf339091de752..3aef0171ec358f 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js @@ -27,7 +27,13 @@ import { } from '@wordpress/components'; import { debounce } from '@wordpress/compose'; import { sprintf, __, _x, isRTL } from '@wordpress/i18n'; -import { moreVertical, chevronLeft, chevronRight } from '@wordpress/icons'; +import { + moreVertical, + next, + previous, + chevronLeft, + chevronRight, +} from '@wordpress/icons'; /** * Internal dependencies @@ -486,37 +492,30 @@ function FontCollection( { slug } ) { { ! selectedFont && ( - ); } -class PostPublishPanelPostpublish extends Component { - constructor() { - super( ...arguments ); - this.state = { - showCopyConfirmation: false, +export default function PostPublishPanelPostpublish( { + focusOnMount, + children, +} ) { + const { post, postType, isScheduled } = useSelect( ( select ) => { + const { + getEditedPostAttribute, + getCurrentPost, + isCurrentPostScheduled, + } = select( editorStore ); + const { getPostType } = select( coreStore ); + + return { + post: getCurrentPost(), + postType: getPostType( getEditedPostAttribute( 'type' ) ), + isScheduled: isCurrentPostScheduled(), }; - this.onCopy = this.onCopy.bind( this ); - this.onSelectInput = this.onSelectInput.bind( this ); - this.postLink = createRef(); - } - - componentDidMount() { - if ( this.props.focusOnMount ) { - this.postLink.current.focus(); - } - } - - componentWillUnmount() { - clearTimeout( this.dismissCopyConfirmation ); - } - - onCopy() { - this.setState( { - showCopyConfirmation: true, - } ); + }, [] ); + + const postLabel = postType?.labels?.singular_name; + const viewPostLabel = postType?.labels?.view_item; + const addNewPostLabel = postType?.labels?.add_new_item; + const link = + post.status === 'future' ? getFuturePostUrl( post ) : post.link; + const addLink = addQueryArgs( 'post-new.php', { + post_type: post.type, + } ); + + const postLinkRef = useCallback( + ( node ) => { + if ( focusOnMount && node ) { + node.focus(); + } + }, + [ focusOnMount ] + ); - clearTimeout( this.dismissCopyConfirmation ); - this.dismissCopyConfirmation = setTimeout( () => { - this.setState( { - showCopyConfirmation: false, - } ); - }, 4000 ); - } + const postPublishNonLinkHeader = isScheduled ? ( + <> + { __( 'is now scheduled. It will go live on' ) }{ ' ' } + . + + ) : ( + __( 'is now live.' ) + ); - onSelectInput( event ) { - event.target.select(); - } + return ( +
+ + + { decodeEntities( post.title ) || __( '(no title)' ) } + { ' ' } + { postPublishNonLinkHeader } + + +

+ { __( 'What’s next?' ) } +

+
+ event.target.select() } + /> - render() { - const { children, isScheduled, post, postType } = this.props; - const postLabel = postType?.labels?.singular_name; - const viewPostLabel = postType?.labels?.view_item; - const addNewPostLabel = postType?.labels?.add_new_item; - const link = - post.status === 'future' ? getFuturePostUrl( post ) : post.link; - const addLink = addQueryArgs( 'post-new.php', { - post_type: post.type, - } ); - - const postPublishNonLinkHeader = isScheduled ? ( - <> - { __( 'is now scheduled. It will go live on' ) }{ ' ' } - . - - ) : ( - __( 'is now live.' ) - ); - - return ( -
- - - { decodeEntities( post.title ) || __( '(no title)' ) } - { ' ' } - { postPublishNonLinkHeader } - - -

- { __( 'What’s next?' ) } -

-
- - -
- - { this.state.showCopyConfirmation - ? __( 'Copied!' ) - : __( 'Copy' ) } - -
+
+
+
-
- { ! isScheduled && ( - - ) } +
+ { ! isScheduled && ( -
- - { children } -
- ); - } + ) } + +
+ + { children } +
+ ); } - -export default withSelect( ( select ) => { - const { getEditedPostAttribute, getCurrentPost, isCurrentPostScheduled } = - select( editorStore ); - const { getPostType } = select( coreStore ); - - return { - post: getCurrentPost(), - postType: getPostType( getEditedPostAttribute( 'type' ) ), - isScheduled: isCurrentPostScheduled(), - }; -} )( PostPublishPanelPostpublish ); From a572238c5d90b83e3d8d55c738a0cc5f474c2c7b Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Fri, 29 Nov 2024 08:39:23 +0100 Subject: [PATCH 13/20] [mini] drag and drop: fix scroll disorientation after drop (#67405) Co-authored-by: ellatrix Co-authored-by: talldan --- .../src/components/use-moving-animation/index.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/block-editor/src/components/use-moving-animation/index.js b/packages/block-editor/src/components/use-moving-animation/index.js index 602b683150d0cc..ef367c0f332101 100644 --- a/packages/block-editor/src/components/use-moving-animation/index.js +++ b/packages/block-editor/src/components/use-moving-animation/index.js @@ -52,6 +52,7 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { isFirstMultiSelectedBlock, isBlockMultiSelected, isAncestorMultiSelected, + isDraggingBlocks, } = useSelect( blockEditorStore ); // Whenever the trigger changes, we need to take a snapshot of the current @@ -85,6 +86,11 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { } } + // Neither animate nor scroll. + if ( isDraggingBlocks() ) { + return; + } + // We disable the animation if the user has a preference for reduced // motion, if the user is typing (insertion by Enter), or if the block // count exceeds the threshold (insertion caused all the blocks that @@ -153,6 +159,7 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { isFirstMultiSelectedBlock, isBlockMultiSelected, isAncestorMultiSelected, + isDraggingBlocks, ] ); return ref; From 8acc11dec8cbb3e335002c1119da1d5b6b90eabc Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 29 Nov 2024 10:19:48 +0100 Subject: [PATCH 14/20] Menu: throw when subcomponents are not rendered inside top level Menu (#67411) * Menu: throw when subcomponents are not rendered inside top level Menu * CHANGELOG * Remove unnecessary optional chaining * Rename changelog section --- Co-authored-by: ciampo Co-authored-by: tyxla --- packages/components/CHANGELOG.md | 6 +++++- packages/components/src/menu/checkbox-item.tsx | 10 ++++++++-- packages/components/src/menu/group-label.tsx | 9 ++++++++- packages/components/src/menu/group.tsx | 9 ++++++++- packages/components/src/menu/item-help-text.tsx | 11 ++++++++++- packages/components/src/menu/item-label.tsx | 11 ++++++++++- packages/components/src/menu/item.tsx | 8 +++++++- packages/components/src/menu/radio-item.tsx | 10 ++++++++-- packages/components/src/menu/separator.tsx | 11 +++++++++-- 9 files changed, 73 insertions(+), 12 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index fe326e847970b4..37da311b0547a3 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -7,6 +7,10 @@ - `BoxControl`: Passive deprecate `onMouseOver`/`onMouseOut`. Pass to the `inputProps` prop instead ([#67332](https://github.com/WordPress/gutenberg/pull/67332)). - `BoxControl`: Deprecate 36px default size ([#66704](https://github.com/WordPress/gutenberg/pull/66704)). +### Experimental + +- `Menu`: throw when subcomponents are not rendered inside top level `Menu` ([#67411](https://github.com/WordPress/gutenberg/pull/67411)). + ### Internal - Upgraded `@ariakit/react` (v0.4.13) and `@ariakit/test` (v0.4.5) ([#65907](https://github.com/WordPress/gutenberg/pull/65907)). @@ -20,7 +24,7 @@ - `FontSizePicker`: Deprecate 36px default size ([#66920](https://github.com/WordPress/gutenberg/pull/66920)). - `ComboboxControl`: Deprecate 36px default size ([#66900](https://github.com/WordPress/gutenberg/pull/66900)). - `ToggleGroupControl`: Deprecate 36px default size ([#66747](https://github.com/WordPress/gutenberg/pull/66747)). -- `RangeControl`: Deprecate 36px default size ([#66721](https://github.com/WordPress/gutenberg/pull/66721)). +- `RangeControl`: Deprecate 36px default size ([#66721](https://github.com/WordPress/gutenberg/pull/66721)). ### Bug Fixes diff --git a/packages/components/src/menu/checkbox-item.tsx b/packages/components/src/menu/checkbox-item.tsx index 182c27dfdee305..ddb700b43324a6 100644 --- a/packages/components/src/menu/checkbox-item.tsx +++ b/packages/components/src/menu/checkbox-item.tsx @@ -26,16 +26,22 @@ export const MenuCheckboxItem = forwardRef< ) { const menuContext = useContext( MenuContext ); + if ( ! menuContext?.store ) { + throw new Error( + 'Menu.CheckboxItem can only be rendered inside a Menu component' + ); + } + return ( } // Override some ariakit inline styles style={ { width: 'auto', height: 'auto' } } diff --git a/packages/components/src/menu/group-label.tsx b/packages/components/src/menu/group-label.tsx index 71c5c7de69941e..5bf081880cb1d7 100644 --- a/packages/components/src/menu/group-label.tsx +++ b/packages/components/src/menu/group-label.tsx @@ -17,6 +17,13 @@ export const MenuGroupLabel = forwardRef< WordPressComponentProps< MenuGroupLabelProps, 'div', false > >( function MenuGroup( props, ref ) { const menuContext = useContext( MenuContext ); + + if ( ! menuContext?.store ) { + throw new Error( + 'Menu.GroupLabel can only be rendered inside a Menu component' + ); + } + return ( } { ...props } - store={ menuContext?.store } + store={ menuContext.store } /> ); } ); diff --git a/packages/components/src/menu/group.tsx b/packages/components/src/menu/group.tsx index f9a4138fe43580..834350955f3c5d 100644 --- a/packages/components/src/menu/group.tsx +++ b/packages/components/src/menu/group.tsx @@ -16,11 +16,18 @@ export const MenuGroup = forwardRef< WordPressComponentProps< MenuGroupProps, 'div', false > >( function MenuGroup( props, ref ) { const menuContext = useContext( MenuContext ); + + if ( ! menuContext?.store ) { + throw new Error( + 'Menu.Group can only be rendered inside a Menu component' + ); + } + return ( ); } ); diff --git a/packages/components/src/menu/item-help-text.tsx b/packages/components/src/menu/item-help-text.tsx index 0ccc8f7461a8ff..13d14c294125bd 100644 --- a/packages/components/src/menu/item-help-text.tsx +++ b/packages/components/src/menu/item-help-text.tsx @@ -1,18 +1,27 @@ /** * WordPress dependencies */ -import { forwardRef } from '@wordpress/element'; +import { forwardRef, useContext } from '@wordpress/element'; /** * Internal dependencies */ import type { WordPressComponentProps } from '../context'; +import { MenuContext } from './context'; import * as Styled from './styles'; export const MenuItemHelpText = forwardRef< HTMLSpanElement, WordPressComponentProps< { children: React.ReactNode }, 'span', true > >( function MenuItemHelpText( props, ref ) { + const menuContext = useContext( MenuContext ); + + if ( ! menuContext?.store ) { + throw new Error( + 'Menu.ItemHelpText can only be rendered inside a Menu component' + ); + } + return ( ); diff --git a/packages/components/src/menu/item-label.tsx b/packages/components/src/menu/item-label.tsx index 458f69558eafbc..4f5f80e547861f 100644 --- a/packages/components/src/menu/item-label.tsx +++ b/packages/components/src/menu/item-label.tsx @@ -1,18 +1,27 @@ /** * WordPress dependencies */ -import { forwardRef } from '@wordpress/element'; +import { forwardRef, useContext } from '@wordpress/element'; /** * Internal dependencies */ import type { WordPressComponentProps } from '../context'; +import { MenuContext } from './context'; import * as Styled from './styles'; export const MenuItemLabel = forwardRef< HTMLSpanElement, WordPressComponentProps< { children: React.ReactNode }, 'span', true > >( function MenuItemLabel( props, ref ) { + const menuContext = useContext( MenuContext ); + + if ( ! menuContext?.store ) { + throw new Error( + 'Menu.ItemLabel can only be rendered inside a Menu component' + ); + } + return ( ); diff --git a/packages/components/src/menu/item.tsx b/packages/components/src/menu/item.tsx index 6e8c510e4c9c2a..6d09bdf3d0f591 100644 --- a/packages/components/src/menu/item.tsx +++ b/packages/components/src/menu/item.tsx @@ -20,13 +20,19 @@ export const MenuItem = forwardRef< ) { const menuContext = useContext( MenuContext ); + if ( ! menuContext?.store ) { + throw new Error( + 'Menu.Item can only be rendered inside a Menu component' + ); + } + return ( { prefix } diff --git a/packages/components/src/menu/radio-item.tsx b/packages/components/src/menu/radio-item.tsx index 45b0039f900e20..5534a6b7f3e10c 100644 --- a/packages/components/src/menu/radio-item.tsx +++ b/packages/components/src/menu/radio-item.tsx @@ -33,16 +33,22 @@ export const MenuRadioItem = forwardRef< ) { const menuContext = useContext( MenuContext ); + if ( ! menuContext?.store ) { + throw new Error( + 'Menu.RadioItem can only be rendered inside a Menu component' + ); + } + return ( } // Override some ariakit inline styles style={ { width: 'auto', height: 'auto' } } diff --git a/packages/components/src/menu/separator.tsx b/packages/components/src/menu/separator.tsx index 5d0110016d9c4a..57cff572c287a0 100644 --- a/packages/components/src/menu/separator.tsx +++ b/packages/components/src/menu/separator.tsx @@ -16,12 +16,19 @@ export const MenuSeparator = forwardRef< WordPressComponentProps< MenuSeparatorProps, 'hr', false > >( function MenuSeparator( props, ref ) { const menuContext = useContext( MenuContext ); + + if ( ! menuContext?.store ) { + throw new Error( + 'Menu.Separator can only be rendered inside a Menu component' + ); + } + return ( ); } ); From 68c7aba5277eb1fb2d956f88ba9729325eec2e12 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 29 Nov 2024 10:42:52 +0100 Subject: [PATCH 15/20] Tabs: overhaul unit tests (#66140) * Rewrite unit tests * Extract waitForComponentToBeInitializedWithSelectedTab utility function * Use describe.each to run same test against controlled / uncontrolled components * Re-enable tabs when testing disabled tabs * Mock isRTL and test RTL keyboard navigation * CHANGELOG * Remove CHANGELOG entry * Fix typo --- Co-authored-by: ciampo Co-authored-by: tyxla --- packages/components/src/tabs/test/index.tsx | 2439 ++++++++++++------- 1 file changed, 1492 insertions(+), 947 deletions(-) diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx index dcf64102c9fa67..fd9ceb38190a79 100644 --- a/packages/components/src/tabs/test/index.tsx +++ b/packages/components/src/tabs/test/index.tsx @@ -9,6 +9,7 @@ import { render } from '@ariakit/test/react'; * WordPress dependencies */ import { useEffect, useState } from '@wordpress/element'; +import { isRTL } from '@wordpress/i18n'; /** * Internal dependencies @@ -16,6 +17,16 @@ import { useEffect, useState } from '@wordpress/element'; import { Tabs } from '..'; import type { TabsProps } from '../types'; +// Setup mocking the `isRTL` function to test arrow key navigation behavior. +jest.mock( '@wordpress/i18n', () => { + const original = jest.requireActual( '@wordpress/i18n' ); + return { + ...original, + isRTL: jest.fn( () => false ), + }; +} ); +const mockedIsRTL = isRTL as jest.Mock; + type Tab = { tabId: string; title: string; @@ -50,6 +61,30 @@ const TABS: Tab[] = [ }, ]; +const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.tabId === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj +); + +const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => + tabObj.tabId === 'beta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj +); + const TABS_WITH_DELTA: Tab[] = [ ...TABS, { @@ -141,11 +176,47 @@ const ControlledTabs = ( { ); }; -const getSelectedTab = async () => - await screen.findByRole( 'tab', { selected: true } ); - let originalGetClientRects: () => DOMRectList; +async function waitForComponentToBeInitializedWithSelectedTab( + selectedTabName: string | undefined +) { + if ( ! selectedTabName ) { + // Wait for the tablist to be tabbable as a mean to know + // that ariakit has finished initializing. + await waitFor( () => + expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( + 'tabindex', + expect.stringMatching( /^(0|-1)$/ ) + ) + ); + // No initially selected tabs or tabpanels. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + await waitFor( () => + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument() + ); + } else { + // Waiting for a tab to be selected is a sign that the component + // has fully initialized. + expect( + await screen.findByRole( 'tab', { + selected: true, + name: selectedTabName, + } ) + ).toBeVisible(); + // The corresponding tabpanel is also shown. + expect( + screen.getByRole( 'tabpanel', { + name: selectedTabName, + } ) + ).toBeVisible(); + } +} + describe( 'Tabs', () => { beforeAll( () => { originalGetClientRects = window.HTMLElement.prototype.getClientRects; @@ -162,13 +233,16 @@ describe( 'Tabs', () => { window.HTMLElement.prototype.getClientRects = originalGetClientRects; } ); - describe( 'Accessibility and semantics', () => { - it( 'should use the correct aria attributes', async () => { + describe( 'Adherence to spec and basic behavior', () => { + it( 'should apply the correct roles, semantics and attributes', async () => { await render( ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + const tabList = screen.getByRole( 'tablist' ); const allTabs = screen.getAllByRole( 'tab' ); - const selectedTabPanel = await screen.findByRole( 'tabpanel' ); + const allTabpanels = screen.getAllByRole( 'tabpanel' ); expect( tabList ).toBeVisible(); expect( tabList ).toHaveAttribute( @@ -178,133 +252,103 @@ describe( 'Tabs', () => { expect( allTabs ).toHaveLength( TABS.length ); - // The selected `tab` aria-controls the active `tabpanel`, - // which is `aria-labelledby` the selected `tab`. - expect( selectedTabPanel ).toBeVisible(); + // Only 1 tab panel is accessible — the one associated with the + // selected tab. The selected `tab` aria-controls the active + /// `tabpanel`, which is `aria-labelledby` the selected `tab`. + expect( allTabpanels ).toHaveLength( 1 ); + + expect( allTabpanels[ 0 ] ).toBeVisible(); expect( allTabs[ 0 ] ).toHaveAttribute( 'aria-controls', - selectedTabPanel.getAttribute( 'id' ) + allTabpanels[ 0 ].getAttribute( 'id' ) ); - expect( selectedTabPanel ).toHaveAttribute( + expect( allTabpanels[ 0 ] ).toHaveAttribute( 'aria-labelledby', allTabs[ 0 ].getAttribute( 'id' ) ); } ); - } ); - describe( 'Focus Behavior', () => { - it( 'should focus on the related TabPanel when pressing the Tab key', async () => { - await render( ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - - const selectedTabPanel = await screen.findByRole( 'tabpanel' ); - - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await press.Tab(); - expect( - await screen.findByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus(); - - // By default the tabpanel should receive focus - await press.Tab(); - expect( selectedTabPanel ).toHaveFocus(); - } ); - it( 'should not focus on the related TabPanel when pressing the Tab key if `focusable: false` is set', async () => { - const TABS_WITH_ALPHA_FOCUSABLE_FALSE = TABS.map( ( tabObj ) => - tabObj.tabId === 'alpha' - ? { - ...tabObj, - content: ( - <> - Selected Tab: Alpha - - - ), - tabpanel: { focusable: false }, - } - : tabObj - ); + it( 'should associate each `tab` with the correct `tabpanel`, even if they are not rendered in the same order', async () => { + const TABS_WITH_DELTA_REVERSED = [ ...TABS_WITH_DELTA ].reverse(); await render( - + + + { TABS_WITH_DELTA.map( ( tabObj ) => ( + + { tabObj.title } + + ) ) } + + { TABS_WITH_DELTA_REVERSED.map( ( tabObj ) => ( + + { tabObj.content } + + ) ) } + ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - - const alphaButton = await screen.findByRole( 'button', { - name: /alpha button/i, - } ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await press.Tab(); + // Select Beta, make sure the correct tabpanel is rendered + await click( screen.getByRole( 'tab', { name: 'Beta' } ) ); expect( - await screen.findByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus(); - // Because the alpha tabpanel is set to `focusable: false`, pressing - // the Tab key should focus the button, not the tabpanel - await press.Tab(); - expect( alphaButton ).toHaveFocus(); - } ); - - it( "should focus the first tab, even if disabled, when the current selected tab id doesn't match an existing one", async () => { - const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.tabId === 'alpha' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); - - await 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(); - - await press.Tab(); + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); expect( - await screen.findByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus(); + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); - await press.ArrowRight(); + // Select Gamma, make sure the correct tabpanel is rendered + await click( screen.getByRole( 'tab', { name: 'Gamma' } ) ); expect( - await screen.findByRole( 'tab', { name: 'Beta' } ) - ).toHaveFocus(); - - await press.ArrowRight(); + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); expect( - await screen.findByRole( 'tab', { name: 'Gamma' } ) - ).toHaveFocus(); + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); - await press.Tab(); - await press.ShiftTab(); + // Select Delta, make sure the correct tabpanel is rendered + await click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Delta', + } ) + ).toBeVisible(); expect( - await screen.findByRole( 'tab', { name: 'Gamma' } ) - ).toHaveFocus(); + screen.getByRole( 'tabpanel', { + name: 'Delta', + } ) + ).toBeVisible(); } ); - } ); - describe( 'Tab Attributes', () => { it( "should apply the tab's `className` to the tab button", async () => { await render( ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveClass( 'alpha-class' ); @@ -317,908 +361,1076 @@ describe( 'Tabs', () => { } ); } ); - describe( 'Tab Activation', () => { - it( 'defaults to automatic tab activation (pointer clicks)', async () => { + describe( 'pointer interactions', () => { + it( 'should select a tab when clicked', async () => { const mockOnSelect = jest.fn(); await render( ); - // Alpha is the initially selected tab - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( - await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) - ).toBeInTheDocument(); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Click on Beta, make sure beta is the selected tab await click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( - screen.getByRole( 'tabpanel', { name: 'Beta' } ) - ).toBeInTheDocument(); + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - // Click on Alpha, make sure beta is the selected tab + // Click on Alpha, make sure alpha is the selected tab await 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 mockOnSelect = jest.fn(); - - await render( - - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - - // 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 press.Tab(); - expect( await getSelectedTab() ).toHaveFocus(); - - // Navigate forward with arrow keys and make sure the Beta tab is - // selected automatically. - await press.ArrowRight(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); - // Navigate backwards with arrow keys. Make sure alpha is - // selected automatically. - await press.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 () => { + it( 'should not select a disabled tab when clicked', async () => { const mockOnSelect = jest.fn(); await render( - + ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).not.toHaveFocus(); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - // onSelect gets called on the initial render. expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - // Tab to focus the tablist. Make sure Alpha is focused. - await press.Tab(); - expect( await getSelectedTab() ).toHaveFocus(); + // Clicking on Beta does not result in beta being selected + // because the tab is disabled. + await click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - // Navigate backwards with arrow keys and make sure that the Gamma tab - // (the last tab) is selected automatically. - await press.ArrowLeft(); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); - // Navigate forward with arrow keys. Make sure alpha (the first tab) is - // selected automatically. - await press.ArrowRight(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); } ); + } ); - it( 'should not move tab selection when pressing the up/down arrow keys, unless the orientation is changed to `vertical`', async () => { - const mockOnSelect = jest.fn(); + describe( 'initial tab selection', () => { + describe( 'when a selected tab id is not specified', () => { + describe( 'when left `undefined` [Uncontrolled]', () => { + it( 'should choose the first tab as selected', async () => { + await render( ); - const { rerender } = await render( - - ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).not.toHaveFocus(); + // Press tab. The selected tab (alpha) received focus. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + } ); + + it( 'should choose the first non-disabled tab if the first tab is disabled', async () => { + await render( + + ); + + // Beta is automatically selected as the selected tab, since alpha is + // disabled. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus. The corresponding + // tabpanel is shown. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + } ); + describe( 'when `null` [Controlled]', () => { + it( 'should not have a selected tab nor show any tabpanels, make the tablist tabbable and still allow selecting tabs', async () => { + await render( + + ); + + // No initially selected tabs or tabpanels. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab. The tablist receives focus + await press.Tab(); + expect( + await screen.findByRole( 'tablist' ) + ).toHaveFocus(); - // onSelect gets called on the initial render. - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + // Press right arrow to select the first tab (alpha) and + // show the related tabpanel. + await press.ArrowRight(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } ); + } ); + } ); - // Tab to focus the tablist. Make sure Alpha is focused. - await press.Tab(); - expect( await getSelectedTab() ).toHaveFocus(); + describe( 'when a selected tab id is specified', () => { + describe( 'through the `defaultTabId` prop [Uncontrolled]', () => { + it( 'should select the initial tab matching the `defaultTabId` prop', async () => { + await render( + + ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus. The corresponding + // tabpanel is shown. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + + it( 'should select the initial tab matching the `defaultTabId` prop even if the tab is disabled', async () => { + await render( + + ); + + // Beta is automatically selected as the selected tab despite being + // disabled, respecting the `defaultTabId` prop. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + + it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab when `defaultTabId` prop does not match any known tab', async () => { + await render( + + ); + + // No initially selected tabs or tabpanels, since the `defaultTabId` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab. The first tab receives focus, but it's + // not selected. + await press.Tab(); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + await waitFor( () => + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument() + ); - // Press the arrow up key, nothing happens. - await press.ArrowUp(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + // Press right arrow to select the next tab (beta) and + // show the related tabpanel. + await press.ArrowRight(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } ); + + it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab, even when disabled, when `defaultTabId` prop does not match any known tab', async () => { + await render( + + ); + + // No initially selected tabs or tabpanels, since the `defaultTabId` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab. The first tab receives focus, but it's + // not selected. + await press.Tab(); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + await waitFor( () => + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument() + ); - // Press the arrow down key, nothing happens - await press.ArrowDown(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + // Press right arrow to select the next tab (beta) and + // show the related tabpanel. + await press.ArrowRight(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } ); + + it( 'should ignore any changes to the `defaultTabId` prop after the first render', async () => { + const mockOnSelect = jest.fn(); + + const { rerender } = await render( + + ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Changing the defaultTabId prop to gamma should not have any effect. + await rerender( + + ); - // Change orientation to `vertical`. When the orientation is vertical, - // left/right arrow keys are replaced by up/down arrow keys. - await rerender( - - ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); - expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( - 'aria-orientation', - 'vertical' - ); + expect( mockOnSelect ).not.toHaveBeenCalled(); + } ); + } ); - // Make sure alpha is still focused. - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).toHaveFocus(); + describe( 'through the `selectedTabId` prop [Controlled]', () => { + describe( 'when the `selectedTabId` matches an existing tab', () => { + it( 'should choose the initial tab matching the `selectedTabId`', async () => { + await render( + + ); - // Navigate forward with arrow keys and make sure the Beta tab is - // selected automatically. - await press.ArrowDown(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); - // Navigate backwards with arrow keys. Make sure alpha is - // selected automatically. - await press.ArrowUp(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); - // Navigate backwards with arrow keys. Make sure alpha is - // selected automatically. - await press.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 press.ArrowDown(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - } ); + it( 'should choose the initial tab matching the `selectedTabId` even if a `defaultTabId` is passed', async () => { + await render( + + ); - it( 'should move focus on a tab even if disabled with arrow key, but not with pointer clicks', async () => { - const mockOnSelect = jest.fn(); + // Gamma is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); - const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => - tabObj.tabId === 'delta' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); + // Press tab. The selected tab (gamma) received focus, since it is + // accessible despite being disabled. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + } ); - await render( - - ); + it( 'should choose the initial tab matching the `selectedTabId` even if the tab is disabled', async () => { + await render( + + ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).not.toHaveFocus(); + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); - // onSelect gets called on the initial render. - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + } ); - // Tab to focus the tablist. Make sure Alpha is focused. - await press.Tab(); - expect( await getSelectedTab() ).toHaveFocus(); + describe( "when the `selectedTabId` doesn't match an existing tab", () => { + it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab', async () => { + await render( + + ); - // Confirm onSelect has not been re-called - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + // No initially selected tabs or tabpanels, since the `selectedTabId` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); - // 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 press.ArrowRight(); - await press.ArrowRight(); - await press.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 press.ArrowLeft(); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + // Press tab. The first tab receives focus, but it's + // not selected. + await press.Tab(); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + await waitFor( () => + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument() + ); - // 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 click( screen.getByRole( 'tab', { name: 'Delta' } ) ); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); - } ); + // Press right arrow to select the next tab (beta) and + // show the related tabpanel. + await press.ArrowRight(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } ); - it( 'should not focus the next tab when the Tab key is pressed', async () => { - await render( ); + it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab even when disabled', async () => { + await render( + + ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).not.toHaveFocus(); + // No initially selected tabs or tabpanels, since the `selectedTabId` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await press.Tab(); - expect( - await screen.findByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus(); + // Press tab. The first tab receives focus, but it's + // not selected. + await press.Tab(); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + await waitFor( () => + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument() + ); - // Because all other tabs should have `tabindex=-1`, pressing Tab - // should NOT move the focus to the next tab, which is Beta. - // Instead, focus should go to the currently selected tabpanel (alpha). - await press.Tab(); - expect( - await screen.findByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toHaveFocus(); + // Press right arrow to select the next tab (beta) and + // show the related tabpanel. + await press.ArrowRight(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } ); + } ); + } ); } ); + } ); - it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { - const mockOnSelect = jest.fn(); + describe( 'keyboard interactions', () => { + describe.each( [ + [ 'Uncontrolled', UncontrolledTabs ], + [ 'Controlled', ControlledTabs ], + ] )( '[`%s`]', ( _mode, Component ) => { + it( 'should handle the tablist as one tab stop', async () => { + await render( ); - await render( - - ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).not.toHaveFocus(); + // Press tab. The selected tab (alpha) received focus. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); - // onSelect gets called on the initial render. - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + // By default the tabpanel should receive focus + await press.Tab(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toHaveFocus(); + } ); - // Click on Alpha and make sure it is selected. - // onSelect shouldn't fire since the selected tab didn't change. - await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( - await screen.findByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus(); - 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 press.ArrowRight(); - expect( - await screen.findByRole( 'tab', { name: 'Beta' } ) - ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - - await press.Enter(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + it( 'should not focus the tabpanel container when its `focusable` property is set to `false`', async () => { + await render( + + tabObj.tabId === 'alpha' + ? { + ...tabObj, + content: ( + <> + Selected Tab: Alpha + + + ), + tabpanel: { focusable: false }, + } + : tabObj + ) } + /> + ); - // 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 press.ArrowRight(); - expect( - await screen.findByRole( 'tab', { name: 'Gamma' } ) - ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( - screen.getByRole( 'tab', { name: 'Gamma' } ) - ).toHaveFocus(); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - await press.Space(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - } ); - } ); - describe( 'Uncontrolled mode', () => { - describe( 'Without `defaultTabId` prop', () => { - it( 'should render first tab', async () => { - await render( ); + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + // In this case, the tabpanel container is skipped and focus is + // moved directly to its contents + await press.Tab(); expect( - await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) - ).toBeInTheDocument(); + await screen.findByRole( 'button', { + name: 'Alpha Button', + } ) + ).toHaveFocus(); } ); - it( 'should not have a selected tab if the currently selected tab is removed', async () => { - const { rerender } = await render( - - ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( await getSelectedTab() ).not.toHaveFocus(); + it( 'should select tabs in the tablist when using the left and right arrow keys by default (automatic tab activation)', async () => { + const mockOnSelect = jest.fn(); - // Tab to focus the tablist. Make sure Alpha is focused. - await press.Tab(); - expect( await getSelectedTab() ).toHaveFocus(); + await render( + + ); - // Remove first item from `TABS` array - await rerender( ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - // 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() - ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - // No tabpanel should be rendered either + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await press.Tab(); expect( - screen.queryByRole( 'tabpanel' ) - ).not.toBeInTheDocument(); - } ); - } ); + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); - describe( 'With `defaultTabId`', () => { - it( 'should render the tab set by `defaultTabId` prop', async () => { - await render( - - ); + // Press the right arrow key to select the beta tab + await press.ArrowRight(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - } ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); - it( 'should not select a tab when `defaultTabId` does not match any known tab', async () => { - await render( - - ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - // No tab should be selected i.e. it doesn't fall back to first tab. - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument(); + // Press the right arrow key to select the gamma tab + await press.ArrowRight(); - // No tabpanel should be rendered either expect( - screen.queryByRole( 'tabpanel' ) - ).not.toBeInTheDocument(); - } ); - it( 'should not change tabs when defaultTabId is changed', async () => { - const { rerender } = await render( - - ); + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - await rerender( - - ); + // Press the left arrow key to select the beta tab + await press.ArrowLeft(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); } ); - it( 'should not have any selected tabs if the currently selected tab is removed, even if a tab is matching the defaultTabId', async () => { + it( 'should not automatically select tabs in the tablist when pressing the left and right arrow keys if the `selectOnMove` prop is set to `false` (manual tab activation)', async () => { const mockOnSelect = jest.fn(); - const { rerender } = await render( - ); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - await rerender( - - ); + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); - // 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() - ); + // Press the right arrow key to move focus to the beta tab, + // but without selecting it + await press.ArrowRight(); + + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - // No tabpanel should be rendered either + // Press the space key to click the beta tab, and select it. + // The same should be true with any other mean of clicking the tab button + // (ie. mouse click, enter key). + await press.Space(); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); expect( - screen.queryByRole( 'tabpanel' ) - ).not.toBeInTheDocument(); + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); } ); - it( 'should keep the currently selected tab even if it becomes disabled', async () => { + it( 'should not select tabs in the tablist when using the up and down arrow keys, unless the `orientation` prop is set to `vertical`', async () => { const mockOnSelect = jest.fn(); const { rerender } = await render( - + ); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - - await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.tabId === 'alpha' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); - await rerender( - - ); + // Press the up arrow key, but the focused/selected tab does not change. + await press.ArrowUp(); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - } ); - it( 'should have no active tabs when the tab associated to `defaultTabId` is removed while being the active tab', async () => { - const { rerender } = await render( - - ); + // Press the down arrow key, but the focused/selected tab does not change. + await press.ArrowDown(); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - // Remove gamma + // Change the orientation to "vertical" and rerender the component. await rerender( - ); - expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); - // No tab should be selected i.e. it doesn't fall back to first tab. + // Pressing the down arrow key now selects the next tab (beta). + await press.ArrowDown(); + expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument(); - // No tabpanel should be rendered either + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); expect( - screen.queryByRole( 'tabpanel' ) - ).not.toBeInTheDocument(); - } ); + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); - it( 'waits for the tab with the `defaultTabId` to be present in the `tabs` array before selecting it', async () => { - const { rerender } = await render( - - ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - // 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() - ); + // Pressing the up arrow key now selects the previous tab (alpha). + await press.ArrowUp(); - // No tabpanel should be rendered either expect( - screen.queryByRole( 'tabpanel' ) - ).not.toBeInTheDocument(); - - await rerender( - - ); + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); - expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); - } ); - describe( 'Disabled tab', () => { - it( 'should disable the tab when `disabled` is `true`', async () => { + it( 'should loop tab focus at the end of the tablist when using arrow keys', async () => { const mockOnSelect = jest.fn(); - const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( - ( tabObj ) => - tabObj.tabId === 'delta' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); - await render( - + ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - 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' ); - // Move focus to the tablist, make sure alpha is focused. + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. await press.Tab(); expect( - screen.getByRole( 'tab', { name: 'Alpha' } ) + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) ).toHaveFocus(); - // onSelect should not be called since the disabled tab is - // highlighted, but not selected. + // Press the left arrow key to loop around and select the gamma tab await press.ArrowLeft(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - // Delta (which is disabled) has focus expect( - screen.getByRole( 'tab', { name: 'Delta' } ) + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); - // 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.tabId === 'alpha' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); - - const { rerender } = await render( - - ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - // As alpha (first tab) is disabled, - // the first enabled tab should be beta. - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + // Press the right arrow key to loop around and select the alpha tab + await press.ArrowRight(); - // Re-enable all tabs - await rerender( ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); - // Even if the initial tab becomes enabled again, the selected - // tab doesn't change. - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); - it( 'should select the tab associated to `defaultTabId` even if the tab is disabled', async () => { - const TABS_ONLY_GAMMA_ENABLED = TABS.map( ( tabObj ) => - tabObj.tabId !== 'gamma' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); - const { rerender } = await render( - - ); + // TODO: mock writing direction to RTL + it( 'should swap the left and right arrow keys when selecting tabs if the writing direction is set to RTL', async () => { + // For this test only, mock the writing direction to RTL. + mockedIsRTL.mockImplementation( () => true ); - // As alpha (first tab), and beta (the initial tab), are both - // disabled the first enabled tab should be gamma. - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + const mockOnSelect = jest.fn(); - // Re-enable all tabs - await rerender( - + await render( + ); - // Even if the initial tab becomes enabled again, the selected tab doesn't - // change. - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - } ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - it( 'should keep the currently tab as selected even when it becomes disabled', async () => { - const mockOnSelect = jest.fn(); - const { rerender } = await render( - - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.tabId === 'alpha' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); - - // Disable alpha - await rerender( - - ); + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + // Press the left arrow key to select the beta tab + await press.ArrowLeft(); - // Re-enable all tabs - await rerender( - - ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - } ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - it( 'should select the tab associated to `defaultTabId` even when disabled', async () => { - const mockOnSelect = jest.fn(); + // Press the left arrow key to select the gamma tab + await press.ArrowLeft(); - const { rerender } = await render( - - ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - - const TABS_WITH_GAMMA_DISABLED = TABS.map( ( tabObj ) => - tabObj.tabId === 'gamma' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - // Disable gamma - await rerender( - - ); + // Press the right arrow key to select the beta tab + await press.ArrowRight(); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); - // Re-enable all tabs - await rerender( - - ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - // Confirm that alpha is still selected, and that onSelect has - // not been called again. - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).not.toHaveBeenCalled(); + // Restore the original implementation of the isRTL function. + mockedIsRTL.mockRestore(); } ); - } ); - } ); - - describe( 'Controlled mode', () => { - it( 'should render the tab specified by the `selectedTabId` prop', async () => { - await render( - - ); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( - await screen.findByRole( 'tabpanel', { name: 'Beta' } ) - ).toBeInTheDocument(); - } ); - it( 'should render the specified `selectedTabId`, and ignore the `defaultTabId` prop', async () => { - await render( - - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - } ); - it( 'should not have a selected tab if `selectedTabId` does not match any known tab', async () => { - await render( - - ); - - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument(); + it( 'should focus tabs in the tablist even if disabled', async () => { + const mockOnSelect = jest.fn(); - // No tabpanel should be rendered either - expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); - } ); - it( 'should not have a selected tab if the active tab is removed, but should select a tab that gets added if it matches the selectedTabId', async () => { - const { rerender } = await render( - - ); + await render( + + ); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - // Remove beta - await rerender( - tab.tabId !== 'beta' ) } - selectedTabId="beta" - /> - ); - - expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - // 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( () => + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await press.Tab(); expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument() - ); - - // No tabpanel should be rendered either - expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); - - // Restore beta - await rerender( - - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - } ); - - describe( 'Disabled tab', () => { - it( 'should `selectedTabId` refers to a disabled tab', async () => { - const TABS_WITH_DELTA_WITH_BETA_DISABLED = TABS_WITH_DELTA.map( - ( tabObj ) => - tabObj.tabId === 'beta' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); - - await render( - - ); + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - } ); - it( 'should keep the currently selected tab as selected even when it becomes disabled', async () => { - const { rerender } = await render( - - ); + // Pressing the right arrow key moves focus to the beta tab, but alpha + // remains the selected tab because beta is disabled. + await press.ArrowRight(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - - const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => - tabObj.tabId === 'beta' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj - ); + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); - await rerender( - - ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + // Press the right arrow key to select the gamma tab + await press.ArrowRight(); - // re-enable all tabs - await rerender( - - ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); } ); - describe( 'When `selectedId` is changed by the controlling component', () => { + + describe( 'When `selectedId` is changed by the controlling component [Controlled]', () => { describe.each( [ true, false ] )( 'and `selectOnMove` is %s', ( selectOnMove ) => { @@ -1231,17 +1443,18 @@ describe( 'Tabs', () => { /> ); - expect( await getSelectedTab() ).toHaveTextContent( + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Beta' ); // Tab key should focus the currently selected tab, which is Beta. await press.Tab(); - expect( await getSelectedTab() ).toHaveTextContent( - 'Beta' - ); expect( - screen.getByRole( 'tab', { name: 'Beta' } ) + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) ).toHaveFocus(); await rerender( @@ -1253,17 +1466,28 @@ describe( 'Tabs', () => { ); // When the selected tab is changed, focus should not be changed. - expect( await getSelectedTab() ).toHaveTextContent( - 'Gamma' - ); expect( - screen.getByRole( 'tab', { name: 'Beta' } ) + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) ).toHaveFocus(); - // Arrow keys should move focus to the next tab, which is Gamma - await press.ArrowRight(); + // Arrow left should move focus to the previous tab (alpha). + // The alpha tab should be always focused, and should be selected + // when the `selectOnMove` prop is set to `true`. + await press.ArrowLeft(); expect( - screen.getByRole( 'tab', { name: 'Gamma' } ) + screen.getByRole( 'tab', { + selected: selectOnMove, + name: 'Alpha', + } ) ).toHaveFocus(); } ); @@ -1279,20 +1503,22 @@ describe( 'Tabs', () => { ); - expect( await getSelectedTab() ).toHaveTextContent( + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Beta' ); // Tab key should focus the currently selected tab, which is Beta. await press.Tab(); await press.Tab(); - expect( await getSelectedTab() ).toHaveTextContent( - 'Beta' - ); expect( - screen.getByRole( 'tab', { name: 'Beta' } ) + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) ).toHaveFocus(); + // Change the selected tab to gamma via a controlled update. await rerender( <> @@ -1305,12 +1531,17 @@ describe( 'Tabs', () => { ); // When the selected tab is changed, it should not automatically receive focus. - expect( await getSelectedTab() ).toHaveTextContent( - 'Gamma' - ); - expect( - screen.getByRole( 'tab', { name: 'Beta' } ) + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) ).toHaveFocus(); // Press shift+tab, move focus to the button before Tabs @@ -1336,125 +1567,439 @@ describe( 'Tabs', () => { } ); } ); + } ); - describe( 'When `selectOnMove` is `true`', () => { - it( 'should automatically select a newly focused tab', async () => { - await render( - - ); + describe( 'miscellaneous runtime changes', () => { + describe( 'removing a tab', () => { + describe( 'with no explicitly set initial tab', () => { + it( 'should not select a new tab when the selected tab is removed', async () => { + const mockOnSelect = jest.fn(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + const { rerender } = await render( + + ); - await press.Tab(); + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); - // Tab key should focus the currently selected tab, which is Beta. - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - // Arrow keys should select and move focus to the next tab. - await press.ArrowRight(); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( await getSelectedTab() ).toHaveFocus(); + // Select gamma + await click( screen.getByRole( 'tab', { name: 'Gamma' } ) ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + // Remove gamma + await rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + + // No tab should be selected i.e. it doesn't fall back to gamma, + // even if it matches the `defaultTabId` prop. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + } ); } ); + + describe.each( [ + [ 'defaultTabId', 'Uncontrolled', UncontrolledTabs ], + [ 'selectedTabId', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, _mode, Component ) => { + it( 'should not select a new tab when the selected tab is removed', async () => { + const mockOnSelect = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'gamma', + onSelect: mockOnSelect, + }; + + const { rerender } = await render( + + ); + + // Gamma is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); + + // Remove gamma + await 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(); + + // Re-add gamma. Gamma becomes selected again. + await rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + TABS.length + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).not.toHaveBeenCalled(); + } ); + + it( `should not select the tab matching the \`${ propName }\` prop as a fallback when the selected tab is removed`, async () => { + const mockOnSelect = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'gamma', + onSelect: mockOnSelect, + }; + + const { rerender } = await render( + + ); + + // Gamma is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); + + // Select alpha + await click( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + 'alpha' + ); + + // Remove alpha + await rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + 2 + ); + + // No tab should be selected i.e. it doesn't fall back to gamma, + // even if it matches the `defaultTabId` prop. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + + // Re-add alpha. Alpha becomes selected again. + await rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + TABS.length + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + } ); + } + ); } ); - describe( 'When `selectOnMove` is `false`', () => { - it( 'should apply focus without automatically changing the selected tab', async () => { - await render( - - ); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + describe( 'adding a tab', () => { + describe.each( [ + [ 'defaultTabId', 'Uncontrolled', UncontrolledTabs ], + [ 'selectedTabId', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, _mode, Component ) => { + it( `should select a newly added tab if it matches the \`${ propName }\` prop`, async () => { + const mockOnSelect = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'delta', + onSelect: mockOnSelect, + }; - // Tab key should focus the currently selected tab, which is Beta. - await press.Tab(); - await waitFor( async () => - expect( - await screen.findByRole( 'tab', { name: 'Beta' } ) - ).toHaveFocus() - ); + const { rerender } = await render( + + ); - // Arrow key should move focus but not automatically change the selected tab. - await press.ArrowRight(); - expect( - screen.getByRole( 'tab', { name: 'Gamma' } ) - ).toHaveFocus(); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + // No initially selected tabs or tabpanels, since the `defaultTabId` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); - // Pressing the spacebar should select the focused tab. - await press.Space(); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).not.toHaveBeenCalled(); - // Arrow key should move focus but not automatically change the selected tab. - await press.ArrowRight(); - expect( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus(); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + // Re-render with beta disabled. + await rerender( + + ); - // Pressing the enter/return should select the focused tab. - await press.Enter(); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - } ); + // Delta becomes selected + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Delta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Delta', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).not.toHaveBeenCalled(); + } ); + } + ); } ); - } ); - it( 'should associate each `Tab` with the correct `TabPanel`, even if they are not rendered in the same order', async () => { - const TABS_WITH_DELTA_REVERSED = [ ...TABS_WITH_DELTA ].reverse(); - - await render( - - - { TABS_WITH_DELTA.map( ( tabObj ) => ( - - { tabObj.title } - - ) ) } - - { TABS_WITH_DELTA_REVERSED.map( ( tabObj ) => ( - - { tabObj.content } - - ) ) } - - ); + describe( 'a tab becomes disabled', () => { + describe.each( [ + [ 'defaultTabId', 'Uncontrolled', UncontrolledTabs ], + [ 'selectedTabId', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, _mode, Component ) => { + it( `should keep the initial tab matching the \`${ propName }\` prop as selected even if it becomes disabled`, async () => { + const mockOnSelect = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'beta', + onSelect: mockOnSelect, + }; - // Alpha is the initially selected tab,and should render the correct tabpanel - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( screen.getByRole( 'tabpanel' ) ).toHaveTextContent( - 'Selected tab: Alpha' - ); + const { rerender } = await render( + + ); - // Select Beta, make sure the correct tabpanel is rendered - await click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( screen.getByRole( 'tabpanel' ) ).toHaveTextContent( - 'Selected tab: Beta' - ); + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); - // Select Gamma, make sure the correct tabpanel is rendered - await click( screen.getByRole( 'tab', { name: 'Gamma' } ) ); - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( screen.getByRole( 'tabpanel' ) ).toHaveTextContent( - 'Selected tab: Gamma' - ); + expect( mockOnSelect ).not.toHaveBeenCalled(); - // Select Delta, make sure the correct tabpanel is rendered - await click( screen.getByRole( 'tab', { name: 'Delta' } ) ); - expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); - expect( screen.getByRole( 'tabpanel' ) ).toHaveTextContent( - 'Selected tab: Delta' - ); + // Re-render with beta disabled. + await rerender( + + ); + + // Beta continues to be selected and focused, even if it is disabled. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + // Re-enable beta. + await rerender( + + ); + + // Beta continues to be selected and focused. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).not.toHaveBeenCalled(); + } ); + + it( 'should keep the current tab selected by the user as selected even if it becomes disabled', async () => { + const mockOnSelect = jest.fn(); + + const { rerender } = await render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + 'alpha' + ); + + // Click on beta tab, beta becomes selected. + await click( + screen.getByRole( 'tab', { name: 'Beta' } ) + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + 'beta' + ); + + // Re-render with beta disabled. + await rerender( + + ); + + // Beta continues to be selected, even if it is disabled. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + // Re-enable beta. + await rerender( + + ); + + // Beta continues to be selected and focused. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + } ); + } + ); + } ); } ); } ); From 6f541e75ddc8c4601171f220ee17458249aa8408 Mon Sep 17 00:00:00 2001 From: James Koster Date: Fri, 29 Nov 2024 09:45:31 +0000 Subject: [PATCH 16/20] Sidebar: Update appearance of active items (#67318) Unlinked contributors: danielvann777. Co-authored-by: jameskoster Co-authored-by: jasmussen Co-authored-by: fcoveram Co-authored-by: richtabor Co-authored-by: ptesei Co-authored-by: joedolson --- .../edit-site/src/components/sidebar-dataviews/style.scss | 4 ++-- .../src/components/sidebar-navigation-item/style.scss | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/edit-site/src/components/sidebar-dataviews/style.scss b/packages/edit-site/src/components/sidebar-dataviews/style.scss index 14e6bf1d03fca8..a36d693c4f80ea 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/style.scss +++ b/packages/edit-site/src/components/sidebar-dataviews/style.scss @@ -19,11 +19,11 @@ &:focus, &[aria-current] { color: $gray-200; - background: $gray-800; } &.is-selected { - background: var(--wp-admin-theme-color); + background: $gray-800; + font-weight: $font-weight-medium; color: $white; } } diff --git a/packages/edit-site/src/components/sidebar-navigation-item/style.scss b/packages/edit-site/src/components/sidebar-navigation-item/style.scss index 202de5300076c1..ac1cf8b730861d 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss @@ -9,7 +9,6 @@ &:focus, &[aria-current="true"] { color: $gray-200; - background: $gray-800; .edit-site-sidebar-navigation-item__drilldown-indicator { fill: $gray-200; @@ -17,7 +16,7 @@ } &[aria-current="true"] { - background: var(--wp-admin-theme-color); + background: $gray-800; color: $white; } From aee29cb7d56c7aea6c03c4bd2a9847c87a8c5eaf Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 29 Nov 2024 11:13:25 +0100 Subject: [PATCH 17/20] Update @ariakit/react to 0.4.15 and @ariakit/test to 0.4.7 (#67404) * Remove ariakit dependencies * Update to latest version of ariakit * CHANGELOG * Dataviews CHANGELOG --- Co-authored-by: ciampo Co-authored-by: tyxla --- package-lock.json | 135 +++++++++++-------------------- package.json | 2 +- packages/components/CHANGELOG.md | 1 + packages/components/package.json | 2 +- packages/dataviews/CHANGELOG.md | 5 ++ packages/dataviews/package.json | 2 +- 6 files changed, 54 insertions(+), 93 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58479ecfa2ed99..98865c9d041a58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@actions/core": "1.9.1", "@actions/github": "5.0.0", "@apidevtools/json-schema-ref-parser": "11.6.4", - "@ariakit/test": "^0.4.5", + "@ariakit/test": "^0.4.7", "@babel/core": "7.25.7", "@babel/plugin-syntax-jsx": "7.25.7", "@babel/runtime-corejs3": "7.25.7", @@ -1432,14 +1432,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ariakit/core": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.14.tgz", + "integrity": "sha512-hpzZvyYzGhP09S9jW1XGsU/FD5K3BKsH1eG/QJ8rfgEeUdPS7BvHPt5lHbOeJ2cMrRzBEvsEzLi1ivfDifHsVA==", + "license": "MIT" + }, + "node_modules/@ariakit/react": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.15.tgz", + "integrity": "sha512-0V2LkNPFrGRT+SEIiObx/LQjR6v3rR+mKEDUu/3tq7jfCZ+7+6Q6EMR1rFaK+XMkaRY1RWUcj/rRDWAUWnsDww==", + "license": "MIT", + "dependencies": { + "@ariakit/react-core": "0.4.15" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ariakit" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@ariakit/react-core": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.15.tgz", + "integrity": "sha512-Up8+U97nAPJdyUh9E8BCEhJYTA+eVztWpHoo1R9zZfHd4cnBWAg5RHxEmMH+MamlvuRxBQA71hFKY/735fDg+A==", + "license": "MIT", + "dependencies": { + "@ariakit/core": "0.4.14", + "@floating-ui/dom": "^1.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@ariakit/test": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@ariakit/test/-/test-0.4.5.tgz", - "integrity": "sha512-dK9OtI8MeKfdtOiW1auDITnyaelq0O0aUTnolIqJj+RJd8LFai0gi7fQUgrun9CZHJ2wWsEad4vlviGfhfIIhQ==", + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@ariakit/test/-/test-0.4.7.tgz", + "integrity": "sha512-Zb5bnulzYGjr6sDubxOeOhk5Es6BYQq5lbcIe8xNrWUlpRiHsje/FlXNFpHnI92/7ESxH6X4pHhbb+qFAho1lw==", "dev": true, "license": "MIT", "dependencies": { - "@ariakit/core": "0.4.12", + "@ariakit/core": "0.4.14", "@testing-library/dom": "^8.0.0 || ^9.0.0 || ^10.0.0" }, "peerDependencies": { @@ -1459,13 +1497,6 @@ } } }, - "node_modules/@ariakit/test/node_modules/@ariakit/core": { - "version": "0.4.12", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.12.tgz", - "integrity": "sha512-+NNpy88tdP/w9mOBPuDrMTbtapPbo/8yVIzpQB7TAmN0sPh/Cq3nU1f2KCTCIujPmwRvAcMSW9UHOlFmbKEPOA==", - "dev": true, - "license": "MIT" - }, "node_modules/@aw-web-design/x-default-browser": { "version": "1.4.126", "resolved": "https://registry.npmjs.org/@aw-web-design/x-default-browser/-/x-default-browser-1.4.126.tgz", @@ -53681,7 +53712,7 @@ "version": "28.13.0", "license": "GPL-2.0-or-later", "dependencies": { - "@ariakit/react": "^0.4.13", + "@ariakit/react": "^0.4.15", "@babel/runtime": "7.25.7", "@emotion/cache": "^11.7.1", "@emotion/css": "^11.7.1", @@ -53736,44 +53767,6 @@ "react-dom": "^18.0.0" } }, - "packages/components/node_modules/@ariakit/core": { - "version": "0.4.12", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.12.tgz", - "integrity": "sha512-+NNpy88tdP/w9mOBPuDrMTbtapPbo/8yVIzpQB7TAmN0sPh/Cq3nU1f2KCTCIujPmwRvAcMSW9UHOlFmbKEPOA==", - "license": "MIT" - }, - "packages/components/node_modules/@ariakit/react": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.13.tgz", - "integrity": "sha512-pTGYgoqCojfyt2xNJ5VQhejxXwwtcP7VDDqcnnVChv7TA2TWWyYerJ5m4oxViI1pgeNqnHZwKlQ79ZipF7W2kQ==", - "license": "MIT", - "dependencies": { - "@ariakit/react-core": "0.4.13" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ariakit" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "packages/components/node_modules/@ariakit/react-core": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.13.tgz", - "integrity": "sha512-iIjQeupP9d0pOubOzX4a0UPXbhXbp0ZCduDpkv7+u/pYP/utk/YRECD0M/QpZr6YSeltmDiNxKjdyK8r9Yhv4Q==", - "license": "MIT", - "dependencies": { - "@ariakit/core": "0.4.12", - "@floating-ui/dom": "^1.0.0", - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "packages/components/node_modules/@floating-ui/react-dom": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.1.tgz", @@ -54051,7 +54044,7 @@ "version": "4.9.0", "license": "GPL-2.0-or-later", "dependencies": { - "@ariakit/react": "^0.4.13", + "@ariakit/react": "^0.4.15", "@babel/runtime": "7.25.7", "@wordpress/components": "*", "@wordpress/compose": "*", @@ -54073,44 +54066,6 @@ "react": "^18.0.0" } }, - "packages/dataviews/node_modules/@ariakit/core": { - "version": "0.4.12", - "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.12.tgz", - "integrity": "sha512-+NNpy88tdP/w9mOBPuDrMTbtapPbo/8yVIzpQB7TAmN0sPh/Cq3nU1f2KCTCIujPmwRvAcMSW9UHOlFmbKEPOA==", - "license": "MIT" - }, - "packages/dataviews/node_modules/@ariakit/react": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.13.tgz", - "integrity": "sha512-pTGYgoqCojfyt2xNJ5VQhejxXwwtcP7VDDqcnnVChv7TA2TWWyYerJ5m4oxViI1pgeNqnHZwKlQ79ZipF7W2kQ==", - "license": "MIT", - "dependencies": { - "@ariakit/react-core": "0.4.13" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ariakit" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "packages/dataviews/node_modules/@ariakit/react-core": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.13.tgz", - "integrity": "sha512-iIjQeupP9d0pOubOzX4a0UPXbhXbp0ZCduDpkv7+u/pYP/utk/YRECD0M/QpZr6YSeltmDiNxKjdyK8r9Yhv4Q==", - "license": "MIT", - "dependencies": { - "@ariakit/core": "0.4.12", - "@floating-ui/dom": "^1.0.0", - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "packages/date": { "name": "@wordpress/date", "version": "5.13.0", diff --git a/package.json b/package.json index 84425dbd1cff21..46a04a52aa6077 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@actions/core": "1.9.1", "@actions/github": "5.0.0", "@apidevtools/json-schema-ref-parser": "11.6.4", - "@ariakit/test": "^0.4.5", + "@ariakit/test": "^0.4.7", "@babel/core": "7.25.7", "@babel/plugin-syntax-jsx": "7.25.7", "@babel/runtime-corejs3": "7.25.7", diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 37da311b0547a3..8fc7aff329b031 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -14,6 +14,7 @@ ### Internal - Upgraded `@ariakit/react` (v0.4.13) and `@ariakit/test` (v0.4.5) ([#65907](https://github.com/WordPress/gutenberg/pull/65907)). +- Upgraded `@ariakit/react` (v0.4.15) and `@ariakit/test` (v0.4.7) ([#67404](https://github.com/WordPress/gutenberg/pull/67404)). ## 28.13.0 (2024-11-27) diff --git a/packages/components/package.json b/packages/components/package.json index 75f0d1eb1f2331..a2acf8e2c203d4 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -32,7 +32,7 @@ "src/**/*.scss" ], "dependencies": { - "@ariakit/react": "^0.4.13", + "@ariakit/react": "^0.4.15", "@babel/runtime": "7.25.7", "@emotion/cache": "^11.7.1", "@emotion/css": "^11.7.1", diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 72f08b987a3868..7ec1b24f8745c0 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +## Internal + +- Upgraded `@ariakit/react` (v0.4.13) and `@ariakit/test` (v0.4.5) ([#65907](https://github.com/WordPress/gutenberg/pull/65907)). +- Upgraded `@ariakit/react` (v0.4.15) and `@ariakit/test` (v0.4.7) ([#67404](https://github.com/WordPress/gutenberg/pull/67404)). + ## 4.9.0 (2024-11-27) ### Bug Fixes diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index 8fe2e04236725c..c2d16b664c9040 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -43,7 +43,7 @@ "types": "build-types", "sideEffects": false, "dependencies": { - "@ariakit/react": "^0.4.13", + "@ariakit/react": "^0.4.15", "@babel/runtime": "7.25.7", "@wordpress/components": "*", "@wordpress/compose": "*", From e19a6f0175805720c611c0c74347652ee3ce546c Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Fri, 29 Nov 2024 12:21:12 +0200 Subject: [PATCH 18/20] Remove PostSlugCheck and PostSlug unused components (#67414) Co-authored-by: ntsekouras Co-authored-by: Mamaduka Co-authored-by: youknowriad --- packages/editor/README.md | 21 ------ packages/editor/src/components/index.js | 2 - .../editor/src/components/post-slug/check.js | 20 ----- .../editor/src/components/post-slug/index.js | 73 ------------------- .../editor/src/components/post-slug/panel.js | 22 ------ .../src/components/post-slug/style.scss | 5 -- .../src/components/post-slug/test/index.js | 53 -------------- packages/editor/src/style.scss | 1 - 8 files changed, 197 deletions(-) delete mode 100644 packages/editor/src/components/post-slug/check.js delete mode 100644 packages/editor/src/components/post-slug/index.js delete mode 100644 packages/editor/src/components/post-slug/panel.js delete mode 100644 packages/editor/src/components/post-slug/style.scss delete mode 100644 packages/editor/src/components/post-slug/test/index.js diff --git a/packages/editor/README.md b/packages/editor/README.md index ac655bd1c99d8c..36126cb8eaee3f 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -1312,27 +1312,6 @@ _Returns_ - `React.ReactNode`: The rendered component. -### PostSlug - -Renders the PostSlug component. It provide a control for editing the post slug. - -_Returns_ - -- `React.ReactNode`: The rendered component. - -### PostSlugCheck - -Wrapper component that renders its children only if the post type supports the slug. - -_Parameters_ - -- _props_ `Object`: Props. -- _props.children_ `React.ReactNode`: Children to be rendered. - -_Returns_ - -- `React.ReactNode`: The rendered component. - ### PostSticky Renders the PostSticky component. It provides a checkbox control for the sticky post feature. diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index b42566aac653be..d940532be75a3d 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -68,8 +68,6 @@ export { usePostScheduleLabel, } from './post-schedule/label'; export { default as PostSchedulePanel } from './post-schedule/panel'; -export { default as PostSlug } from './post-slug'; -export { default as PostSlugCheck } from './post-slug/check'; export { default as PostSticky } from './post-sticky'; export { default as PostStickyCheck } from './post-sticky/check'; export { default as PostSwitchToDraftButton } from './post-switch-to-draft-button'; diff --git a/packages/editor/src/components/post-slug/check.js b/packages/editor/src/components/post-slug/check.js deleted file mode 100644 index 8ca7078a1a9e24..00000000000000 --- a/packages/editor/src/components/post-slug/check.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Internal dependencies - */ -import PostTypeSupportCheck from '../post-type-support-check'; - -/** - * Wrapper component that renders its children only if the post type supports the slug. - * - * @param {Object} props Props. - * @param {React.ReactNode} props.children Children to be rendered. - * - * @return {React.ReactNode} The rendered component. - */ -export default function PostSlugCheck( { children } ) { - return ( - - { children } - - ); -} diff --git a/packages/editor/src/components/post-slug/index.js b/packages/editor/src/components/post-slug/index.js deleted file mode 100644 index afff7f361ea428..00000000000000 --- a/packages/editor/src/components/post-slug/index.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * WordPress dependencies - */ -import { useDispatch, useSelect } from '@wordpress/data'; -import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { safeDecodeURIComponent, cleanForSlug } from '@wordpress/url'; -import { TextControl } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import PostSlugCheck from './check'; -import { store as editorStore } from '../../store'; - -function PostSlugControl() { - const postSlug = useSelect( ( select ) => { - return safeDecodeURIComponent( - select( editorStore ).getEditedPostSlug() - ); - }, [] ); - const { editPost } = useDispatch( editorStore ); - const [ forceEmptyField, setForceEmptyField ] = useState( false ); - - return ( - { - editPost( { slug: newValue } ); - // When we delete the field the permalink gets - // reverted to the original value. - // The forceEmptyField logic allows the user to have - // the field temporarily empty while typing. - if ( ! newValue ) { - if ( ! forceEmptyField ) { - setForceEmptyField( true ); - } - return; - } - if ( forceEmptyField ) { - setForceEmptyField( false ); - } - } } - onBlur={ ( event ) => { - editPost( { - slug: cleanForSlug( event.target.value ), - } ); - if ( forceEmptyField ) { - setForceEmptyField( false ); - } - } } - className="editor-post-slug" - /> - ); -} - -/** - * Renders the PostSlug component. It provide a control for editing the post slug. - * - * @return {React.ReactNode} The rendered component. - */ -export default function PostSlug() { - return ( - - - - ); -} diff --git a/packages/editor/src/components/post-slug/panel.js b/packages/editor/src/components/post-slug/panel.js deleted file mode 100644 index 6ab97a28b251c3..00000000000000 --- a/packages/editor/src/components/post-slug/panel.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * WordPress dependencies - */ -import { PanelRow } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import PostSlugForm from './'; -import PostSlugCheck from './check'; - -export function PostSlug() { - return ( - - - - - - ); -} - -export default PostSlug; diff --git a/packages/editor/src/components/post-slug/style.scss b/packages/editor/src/components/post-slug/style.scss deleted file mode 100644 index 551450582128e0..00000000000000 --- a/packages/editor/src/components/post-slug/style.scss +++ /dev/null @@ -1,5 +0,0 @@ -.editor-post-slug { - display: flex; - flex-direction: column; - align-items: stretch; -} diff --git a/packages/editor/src/components/post-slug/test/index.js b/packages/editor/src/components/post-slug/test/index.js deleted file mode 100644 index fb40055111b77a..00000000000000 --- a/packages/editor/src/components/post-slug/test/index.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * External dependencies - */ -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -/** - * WordPress dependencies - */ -import { useSelect, useDispatch } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import PostSlug from '../'; - -jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); -jest.mock( '@wordpress/data/src/components/use-dispatch/use-dispatch', () => - jest.fn() -); - -describe( 'PostSlug', () => { - it( 'should update slug with sanitized input', async () => { - const user = userEvent.setup(); - const editPost = jest.fn(); - - useSelect.mockImplementation( ( mapSelect ) => - mapSelect( () => ( { - getPostType: () => ( { - supports: { - slug: true, - }, - } ), - getEditedPostAttribute: () => 'post', - getEditedPostSlug: () => '1', - } ) ) - ); - useDispatch.mockImplementation( () => ( { - editPost, - } ) ); - - render( ); - - const input = screen.getByRole( 'textbox', { name: 'Slug' } ); - await user.type( input, '2', { - initialSelectionStart: 0, - initialSelectionEnd: 1, - } ); - act( () => input.blur() ); - - expect( editPost ).toHaveBeenCalledWith( { slug: '2' } ); - } ); -} ); diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index 88d722867d009b..1504211a51e899 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -34,7 +34,6 @@ @import "./components/post-publish-panel/style.scss"; @import "./components/post-saved-state/style.scss"; @import "./components/post-schedule/style.scss"; -@import "./components/post-slug/style.scss"; @import "./components/post-status/style.scss"; @import "./components/post-sticky/style.scss"; @import "./components/post-sync-status/style.scss"; From 96647ef634baa224ebed57415b1cc3f4a0b0956e Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Fri, 29 Nov 2024 14:58:55 +0400 Subject: [PATCH 19/20] Editor: Use hooks instead of HOC in 'PostPublishButtonOrToggle' (#67413) Co-authored-by: Mamaduka Co-authored-by: ntsekouras --- .../post-publish-button-or-toggle.js | 78 +++++++++---------- .../test/post-publish-button-or-toggle.js | 33 +++++--- 2 files changed, 62 insertions(+), 49 deletions(-) diff --git a/packages/editor/src/components/post-publish-button/post-publish-button-or-toggle.js b/packages/editor/src/components/post-publish-button/post-publish-button-or-toggle.js index bf742bef1429bb..c3a355d243f345 100644 --- a/packages/editor/src/components/post-publish-button/post-publish-button-or-toggle.js +++ b/packages/editor/src/components/post-publish-button/post-publish-button-or-toggle.js @@ -1,8 +1,8 @@ /** * WordPress dependencies */ -import { useViewportMatch, compose } from '@wordpress/compose'; -import { withDispatch, withSelect } from '@wordpress/data'; +import { useViewportMatch } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -10,24 +10,46 @@ import { withDispatch, withSelect } from '@wordpress/data'; import PostPublishButton from './index'; import { store as editorStore } from '../../store'; -export function PostPublishButtonOrToggle( { +const IS_TOGGLE = 'toggle'; +const IS_BUTTON = 'button'; + +export default function PostPublishButtonOrToggle( { forceIsDirty, - hasPublishAction, - isBeingScheduled, - isPending, - isPublished, - isPublishSidebarEnabled, - isPublishSidebarOpened, - isScheduled, - togglePublishSidebar, setEntitiesSavedStatesCallback, - postStatusHasChanged, - postStatus, } ) { - const IS_TOGGLE = 'toggle'; - const IS_BUTTON = 'button'; - const isSmallerThanMediumViewport = useViewportMatch( 'medium', '<' ); let component; + const isSmallerThanMediumViewport = useViewportMatch( 'medium', '<' ); + const { togglePublishSidebar } = useDispatch( editorStore ); + const { + hasPublishAction, + isBeingScheduled, + isPending, + isPublished, + isPublishSidebarEnabled, + isPublishSidebarOpened, + isScheduled, + postStatus, + postStatusHasChanged, + } = useSelect( ( select ) => { + return { + hasPublishAction: + !! select( editorStore ).getCurrentPost()?._links?.[ + 'wp:action-publish' + ] ?? false, + isBeingScheduled: + select( editorStore ).isEditedPostBeingScheduled(), + isPending: select( editorStore ).isCurrentPostPending(), + isPublished: select( editorStore ).isCurrentPostPublished(), + isPublishSidebarEnabled: + select( editorStore ).isPublishSidebarEnabled(), + isPublishSidebarOpened: + select( editorStore ).isPublishSidebarOpened(), + isScheduled: select( editorStore ).isCurrentPostScheduled(), + postStatus: + select( editorStore ).getEditedPostAttribute( 'status' ), + postStatusHasChanged: select( editorStore ).getPostEdits()?.status, + }; + }, [] ); /** * Conditions to show a BUTTON (publish directly) or a TOGGLE (open publish sidebar): @@ -76,27 +98,3 @@ export function PostPublishButtonOrToggle( { /> ); } - -export default compose( - withSelect( ( select ) => ( { - hasPublishAction: - select( editorStore ).getCurrentPost()?._links?.[ - 'wp:action-publish' - ] ?? false, - isBeingScheduled: select( editorStore ).isEditedPostBeingScheduled(), - isPending: select( editorStore ).isCurrentPostPending(), - isPublished: select( editorStore ).isCurrentPostPublished(), - isPublishSidebarEnabled: - select( editorStore ).isPublishSidebarEnabled(), - isPublishSidebarOpened: select( editorStore ).isPublishSidebarOpened(), - isScheduled: select( editorStore ).isCurrentPostScheduled(), - postStatus: select( editorStore ).getEditedPostAttribute( 'status' ), - postStatusHasChanged: select( editorStore ).getPostEdits()?.status, - } ) ), - withDispatch( ( dispatch ) => { - const { togglePublishSidebar } = dispatch( editorStore ); - return { - togglePublishSidebar, - }; - } ) -)( PostPublishButtonOrToggle ); diff --git a/packages/editor/src/components/post-publish-button/test/post-publish-button-or-toggle.js b/packages/editor/src/components/post-publish-button/test/post-publish-button-or-toggle.js index 0794c3c8995a1f..a8fa8b72db9c7b 100644 --- a/packages/editor/src/components/post-publish-button/test/post-publish-button-or-toggle.js +++ b/packages/editor/src/components/post-publish-button/test/post-publish-button-or-toggle.js @@ -7,13 +7,15 @@ import { render, screen } from '@testing-library/react'; * WordPress dependencies */ import { useViewportMatch } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ -import { PostPublishButtonOrToggle } from '../post-publish-button-or-toggle'; +import PostPublishButtonOrToggle from '../post-publish-button-or-toggle'; jest.mock( '@wordpress/compose/src/hooks/use-viewport-match' ); +jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); describe( 'PostPublishButtonOrToggle should render a', () => { afterEach( () => { @@ -21,23 +23,32 @@ describe( 'PostPublishButtonOrToggle should render a', () => { } ); it( 'button when the post is published (1)', () => { - render( ); + useSelect.mockImplementation( () => ( { + isPublished: true, + } ) ); + render( ); expect( screen.getByRole( 'button', { name: 'Submit for Review' } ) ).toBeVisible(); } ); it( 'button when the post is scheduled (2)', () => { - render( ); + useSelect.mockImplementation( () => ( { + isScheduled: true, + isBeingScheduled: true, + } ) ); + render( ); expect( screen.getByRole( 'button', { name: 'Submit for Review' } ) ).toBeVisible(); } ); it( 'button when the post is pending and cannot be published but the viewport is >= medium (3)', () => { - render( - - ); + useSelect.mockImplementation( () => ( { + isPending: true, + hasPublishAction: false, + } ) ); + render( ); expect( screen.getByRole( 'button', { name: 'Submit for Review' } ) @@ -46,6 +57,9 @@ describe( 'PostPublishButtonOrToggle should render a', () => { it( 'toggle when post is not (1), (2), (3), the viewport is <= medium, and the publish sidebar is enabled', () => { useViewportMatch.mockReturnValue( true ); + useSelect.mockImplementation( () => ( { + isPublishSidebarEnabled: true, + } ) ); render( ); expect( screen.getByRole( 'button', { name: 'Publish' } ) @@ -53,9 +67,10 @@ describe( 'PostPublishButtonOrToggle should render a', () => { } ); it( 'button when post is not (1), (2), (3), the viewport is >= medium, and the publish sidebar is disabled', () => { - render( - - ); + useSelect.mockImplementation( () => ( { + isPublishSidebarEnabled: false, + } ) ); + render( ); expect( screen.getByRole( 'button', { name: 'Submit for Review' } ) ).toBeVisible(); From 52b5429f52096c5e080b3cc5a2ff696e61cb7616 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:03:02 +0100 Subject: [PATCH 20/20] [mini] drag and drop: restore moving animation (#67417) Co-authored-by: ellatrix Co-authored-by: youknowriad --- .../components/use-moving-animation/index.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/block-editor/src/components/use-moving-animation/index.js b/packages/block-editor/src/components/use-moving-animation/index.js index ef367c0f332101..b11710acd24334 100644 --- a/packages/block-editor/src/components/use-moving-animation/index.js +++ b/packages/block-editor/src/components/use-moving-animation/index.js @@ -74,8 +74,14 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { const isSelected = isBlockSelected( clientId ); const adjustScrolling = isSelected || isFirstMultiSelectedBlock( clientId ); + const isDragging = isDraggingBlocks(); function preserveScrollPosition() { + // The user already scrolled when dragging blocks. + if ( isDragging ) { + return; + } + if ( adjustScrolling && prevRect ) { const blockRect = ref.current.getBoundingClientRect(); const diff = blockRect.top - prevRect.top; @@ -86,11 +92,6 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { } } - // Neither animate nor scroll. - if ( isDraggingBlocks() ) { - return; - } - // We disable the animation if the user has a preference for reduced // motion, if the user is typing (insertion by Enter), or if the block // count exceeds the threshold (insertion caused all the blocks that @@ -113,6 +114,13 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { isSelected || isBlockMultiSelected( clientId ) || isAncestorMultiSelected( clientId ); + + // The user already dragged the blocks to the new position, so don't + // animate the dragged blocks. + if ( isPartOfSelection && isDragging ) { + return; + } + // Make sure the other blocks move under the selected block(s). const zIndex = isPartOfSelection ? '1' : '';