diff --git a/docs/contributors/versions-in-wordpress.md b/docs/contributors/versions-in-wordpress.md index 8c4debb8b696f6..62347f2d644a61 100644 --- a/docs/contributors/versions-in-wordpress.md +++ b/docs/contributors/versions-in-wordpress.md @@ -6,6 +6,7 @@ If anything looks incorrect here, please bring it up in #core-editor in [WordPre | Gutenberg Versions | WordPress Version | | ------------------ | ----------------- | +| 18.6-19.3 | 6.7 | | 17.8-18.5 | 6.6.1 | | 17.8-18.5 | 6.6 | | 16.8-17.7 | 6.5.5 | diff --git a/docs/reference-guides/filters/block-filters.md b/docs/reference-guides/filters/block-filters.md index 637cecadf1402b..f1952ef9bf86f8 100644 --- a/docs/reference-guides/filters/block-filters.md +++ b/docs/reference-guides/filters/block-filters.md @@ -294,6 +294,31 @@ Used to filter an individual transform result from block transformation. All of Called immediately after the default parsing of a block's attributes and before validation to allow a plugin to manipulate attribute values in time for validation and/or the initial values rendering of the block in the editor. +The callback function for this filter accepts 4 parameters: +- `blockAttributes` (`Object`): All block attributes. +- `blockType` (`Object`): The block type. +- `innerHTML` (`string`): Raw block content. +- `attributes` (`object`): Known block attributes (from delimiters). + +In the example below, we use the `blocks.getBlockAttributes` filter to lock the position of all paragraph blocks on a page. + +```js +// Our filter function +function lockParagraphs( blockAttributes, blockType, innerHTML, attributes ) { + if('core/paragraph' === blockType.name) { + blockAttributes['lock'] = {move: true} + } + return blockAttributes; +} + +// Add the filter +wp.hooks.addFilter( + 'blocks.getBlockAttributes', + 'my-plugin/lock-paragraphs', + lockParagraphs +); +``` + ### `editor.BlockEdit` Used to modify the block's `edit` component. It receives the original block `BlockEdit` component and returns a new wrapped component. diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 75d7e8879572d4..a6f3b8980cfd7a 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -839,6 +839,52 @@ _Related_ - +### useBlockBindingsUtils + +Retrieves the existing utils needed to update the block `bindings` metadata. They can be used to create, modify, or remove connections from the existing block attributes. + +It contains the following utils: + +- `updateBlockBindings`: Updates the value of the bindings connected to block attributes. It can be used to remove a specific binding by setting the value to `undefined`. +- `removeAllBlockBindings`: Removes the bindings property of the `metadata` attribute. + +_Usage_ + +```js +import { useBlockBindingsUtils } from '@wordpress/block-editor'; +const { updateBlockBindings, removeAllBlockBindings } = useBlockBindingsUtils(); + +// Update url and alt attributes. +updateBlockBindings( { + url: { + source: 'core/post-meta', + args: { + key: 'url_custom_field', + }, + }, + alt: { + source: 'core/post-meta', + args: { + key: 'text_custom_field', + }, + }, +} ); + +// Remove binding from url attribute. +updateBlockBindings( { url: undefined } ); + +// Remove bindings from all attributes. +removeAllBlockBindings(); +``` + +_Returns_ + +- `?WPBlockBindingsUtils`: Object containing the block bindings utils. + +_Changelog_ + +`6.7.0` Introduced in WordPress core. + ### useBlockCommands Undocumented declaration. diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 2cecd941dfa3bb..783b45da932a3d 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -609,7 +609,6 @@ function BlockListBlockProvider( props ) { isBlockBeingDragged, isDragging, __unstableHasActiveBlockOverlayActive, - __unstableGetEditorMode, getSelectedBlocksInitialCaretPosition, } = unlock( select( blockEditorStore ) ); const blockWithoutAttributes = @@ -680,8 +679,6 @@ function BlockListBlockProvider( props ) { blocksWithSameName.length && blocksWithSameName[ 0 ] !== clientId; - const editorMode = __unstableGetEditorMode(); - return { ...previewContext, mode: getBlockMode( clientId ), @@ -708,7 +705,6 @@ function BlockListBlockProvider( props ) { ) && hasSelectedInnerBlock( clientId ), blockApiVersion: blockType?.apiVersion || 1, blockTitle: match?.title || blockType?.title, - editorMode, isSubtreeDisabled: blockEditingMode === 'disabled' && isBlockSubtreeDisabled( clientId ), @@ -755,7 +751,6 @@ function BlockListBlockProvider( props ) { themeSupportsLayout, isTemporarilyEditingAsBlocks, blockEditingMode, - editorMode, mayDisplayControls, mayDisplayParentControls, index, @@ -808,7 +803,6 @@ function BlockListBlockProvider( props ) { hasOverlay, initialPosition, blockEditingMode, - editorMode, isHighlighted, isMultiSelected, isPartiallySelected, diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index 1cf1d4908b0768..45fc1d9eb5ea12 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -85,7 +85,6 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { name, blockApiVersion, blockTitle, - editorMode, isSelected, isSubtreeDisabled, hasOverlay, @@ -113,7 +112,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { useBlockRefProvider( clientId ), useFocusHandler( clientId ), useEventHandlers( { clientId, isSelected } ), - useZoomOutModeExit( { editorMode } ), + useZoomOutModeExit(), useIsHovered( { clientId } ), useIntersectionObserver(), useMovingAnimation( { triggerAnimationOnChange: index, clientId } ), diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-zoom-out-mode-exit.js b/packages/block-editor/src/components/block-list/use-block-props/use-zoom-out-mode-exit.js index 92c54bac9b806d..494694952110bb 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-zoom-out-mode-exit.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-zoom-out-mode-exit.js @@ -12,25 +12,27 @@ import { unlock } from '../../../lock-unlock'; /** * Allows Zoom Out mode to be exited by double clicking in the selected block. - * - * @param {string} clientId Block client ID. */ -export function useZoomOutModeExit( { editorMode } ) { - const { getSettings, isZoomOut } = unlock( useSelect( blockEditorStore ) ); +export function useZoomOutModeExit() { + const { getSettings, isZoomOut, __unstableGetEditorMode } = unlock( + useSelect( blockEditorStore ) + ); + const { __unstableSetEditorMode, resetZoomLevel } = unlock( useDispatch( blockEditorStore ) ); return useRefEffect( ( node ) => { - // In "compose" mode. - const composeMode = editorMode === 'zoom-out' && isZoomOut(); + function onDoubleClick( event ) { + // In "compose" mode. + const composeMode = + __unstableGetEditorMode() === 'zoom-out' && isZoomOut(); - if ( ! composeMode ) { - return; - } + if ( ! composeMode ) { + return; + } - function onDoubleClick( event ) { if ( ! event.defaultPrevented ) { event.preventDefault(); @@ -52,6 +54,12 @@ export function useZoomOutModeExit( { editorMode } ) { node.removeEventListener( 'dblclick', onDoubleClick ); }; }, - [ editorMode, getSettings, __unstableSetEditorMode ] + [ + getSettings, + __unstableSetEditorMode, + __unstableGetEditorMode, + isZoomOut, + resetZoomLevel, + ] ); } diff --git a/packages/block-editor/src/components/block-popover/index.js b/packages/block-editor/src/components/block-popover/index.js index 47022e336e4869..66f82162c36bcb 100644 --- a/packages/block-editor/src/components/block-popover/index.js +++ b/packages/block-editor/src/components/block-popover/index.js @@ -8,6 +8,7 @@ import clsx from 'clsx'; */ import { useMergeRefs } from '@wordpress/compose'; import { Popover } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; import { forwardRef, useMemo, @@ -21,6 +22,8 @@ import { import { useBlockElement } from '../block-list/use-block-props/use-block-refs'; import usePopoverScroll from './use-popover-scroll'; import { rectUnion, getVisibleElementBounds } from '../../utils/dom'; +import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; const MAX_POPOVER_RECOMPUTE_COUNTER = Number.MAX_SAFE_INTEGER; @@ -74,12 +77,39 @@ function BlockPopover( }; }, [ selectedElement ] ); + const { isZoomOut, sectionRootClientId, getParentSectionBlock } = useSelect( + ( select ) => { + const { + isZoomOut: isZoomOutSelector, + getSectionRootClientId, + getParentSectionBlock: getParentSectionBlockFn, + } = unlock( select( blockEditorStore ) ); + + const root = getSectionRootClientId(); + return { + sectionRootClientId: root, + isZoomOut: isZoomOutSelector(), + getParentSectionBlock: getParentSectionBlockFn, + }; + }, + [] + ); + + // These elements are used to position the zoom out view vertical toolbar + // correctly, relative to the selected section. + const rootSectionElement = useBlockElement( sectionRootClientId ); + const parentSectionElement = useBlockElement( + getParentSectionBlock( clientId ) ?? clientId + ); + const popoverAnchor = useMemo( () => { if ( // popoverDimensionsRecomputeCounter is by definition always equal or greater // than 0. This check is only there to satisfy the correctness of the // exhaustive-deps rule for the `useMemo` hook. popoverDimensionsRecomputeCounter < 0 || + ! rootSectionElement || + ! parentSectionElement || ! selectedElement || ( bottomClientId && ! lastSelectedElement ) ) { @@ -88,6 +118,36 @@ function BlockPopover( return { getBoundingClientRect() { + // The zoom out view has a vertical block toolbar that should always + // be on the edge of the canvas, aligned to the top of the currently + // selected section. This condition changes the anchor of the toolbar + // to the section instead of the block to handle blocksn that are + // not full width and nested blocks to keep section height. + if ( isZoomOut ) { + // Compute the height based on the parent section of the + // selected block, because the selected block may be + // shorter than the section. + const rootSectionElementRect = + getVisibleElementBounds( rootSectionElement ); + const parentSectionElementRect = + getVisibleElementBounds( parentSectionElement ); + const anchorHeight = + parentSectionElementRect.bottom - + parentSectionElementRect.top; + + // Always use the width of the section root element to make sure + // the toolbar is always on the edge of the canvas. + const andchorWidth = + rootSectionElementRect.right - + rootSectionElementRect.left; + return new window.DOMRectReadOnly( + rootSectionElementRect.left, + parentSectionElementRect.top, + andchorWidth, + anchorHeight + ); + } + return lastSelectedElement ? rectUnion( getVisibleElementBounds( selectedElement ), @@ -102,6 +162,7 @@ function BlockPopover( lastSelectedElement, selectedElement, popoverDimensionsRecomputeCounter, + isZoomOut, ] ); if ( ! selectedElement || ( bottomClientId && ! lastSelectedElement ) ) { diff --git a/packages/block-editor/src/components/block-tools/index.js b/packages/block-editor/src/components/block-tools/index.js index 099323925384b8..50f8cb51b2d0da 100644 --- a/packages/block-editor/src/components/block-tools/index.js +++ b/packages/block-editor/src/components/block-tools/index.js @@ -34,18 +34,30 @@ function selector( select ) { getSettings, __unstableGetEditorMode, isTyping, - } = select( blockEditorStore ); + isSectionBlock, + getParentSectionBlock, + isDragging, + } = unlock( select( blockEditorStore ) ); const clientId = getSelectedBlockClientId() || getFirstMultiSelectedBlockClientId(); const editorMode = __unstableGetEditorMode(); + const isZoomOut = editorMode === 'zoom-out'; + let zoomOutToolbarClientId; + if ( isZoomOut ) { + zoomOutToolbarClientId = isSectionBlock( clientId ) + ? clientId + : getParentSectionBlock( clientId ); + } return { clientId, + zoomOutToolbarClientId, hasFixedToolbar: getSettings().hasFixedToolbar, isTyping: isTyping(), - isZoomOutMode: editorMode === 'zoom-out', + isZoomOutMode: isZoomOut, + isDragging: isDragging(), }; } @@ -63,10 +75,14 @@ export default function BlockTools( { __unstableContentRef, ...props } ) { - const { clientId, hasFixedToolbar, isTyping, isZoomOutMode } = useSelect( - selector, - [] - ); + const { + clientId, + zoomOutToolbarClientId, + hasFixedToolbar, + isTyping, + isZoomOutMode, + isDragging, + } = useSelect( selector, [] ); const isMatch = useShortcutEventMatch(); const { getBlocksByClientId, @@ -221,10 +237,10 @@ export default function BlockTools( { /> ) } - { showZoomOutToolbar && ( + { showZoomOutToolbar && !! zoomOutToolbarClientId && ( ) } @@ -241,7 +257,7 @@ export default function BlockTools( { name="__unstable-block-tools-after" ref={ blockToolbarAfterRef } /> - { isZoomOutMode && ( + { isZoomOutMode && ! isDragging && ( diff --git a/packages/block-editor/src/components/block-tools/style.scss b/packages/block-editor/src/components/block-tools/style.scss index a3d9153273e983..b7586a6715dcc6 100644 --- a/packages/block-editor/src/components/block-tools/style.scss +++ b/packages/block-editor/src/components/block-tools/style.scss @@ -202,11 +202,3 @@ border: none; } } - -.block-editor-block-tools__zoom-out-mode-inserter-button { - visibility: hidden; - - &.is-visible { - visibility: visible; - } -} diff --git a/packages/block-editor/src/components/block-tools/use-show-block-tools.js b/packages/block-editor/src/components/block-tools/use-show-block-tools.js index 02a8f0583bcddf..7fb6cb334a795b 100644 --- a/packages/block-editor/src/components/block-tools/use-show-block-tools.js +++ b/packages/block-editor/src/components/block-tools/use-show-block-tools.js @@ -8,6 +8,8 @@ import { isUnmodifiedDefaultBlock } from '@wordpress/blocks'; * Internal dependencies */ import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; +import { isZoomOutMode } from '../../store/private-selectors'; /** * Source of truth for which block tools are showing in the block editor. @@ -24,7 +26,8 @@ export function useShowBlockTools() { getSettings, __unstableGetEditorMode, isTyping, - } = select( blockEditorStore ); + isSectionBlock, + } = unlock( select( blockEditorStore ) ); const clientId = getSelectedBlockClientId() || getFirstMultiSelectedBlockClientId(); @@ -43,11 +46,9 @@ export function useShowBlockTools() { isEmptyDefaultBlock; const isZoomOut = editorMode === 'zoom-out'; const _showZoomOutToolbar = - isZoomOut && - block?.attributes?.align === 'full' && - ! _showEmptyBlockSideInserter; + clientId && isZoomOut && ! _showEmptyBlockSideInserter; const _showBlockToolbarPopover = - ! _showZoomOutToolbar && + ( ! isZoomOutMode || ! isSectionBlock( clientId ) ) && ! getSettings().hasFixedToolbar && ! _showEmptyBlockSideInserter && hasSelectedBlock && diff --git a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js index 8ea80a53830135..961552caa66e01 100644 --- a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js +++ b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js @@ -6,17 +6,11 @@ import clsx from 'clsx'; /** * WordPress dependencies */ -import { useState } from '@wordpress/element'; import { Button } from '@wordpress/components'; import { plus } from '@wordpress/icons'; import { _x } from '@wordpress/i18n'; -function ZoomOutModeInserterButton( { isVisible, onClick } ) { - const [ - zoomOutModeInserterButtonHovered, - setZoomOutModeInserterButtonHovered, - ] = useState( false ); - +function ZoomOutModeInserterButton( { onClick } ) { return ( ); - renderAndValidate( ); + await renderAndValidate( ); await press.Tab(); expect( screen.getByText( 'Before' ) ).toHaveFocus(); @@ -260,7 +260,7 @@ describe.each( [ ); }; - renderAndValidate( ); + await renderAndValidate( ); const { item1, item2, item3 } = getOneDimensionalItems(); @@ -289,7 +289,7 @@ describe.each( [ ); }; - renderAndValidate( ); + await renderAndValidate( ); const { item1, item2, item3 } = getOneDimensionalItems(); expect( item2 ).toBeEnabled(); @@ -310,7 +310,7 @@ describe.each( [ } ) } /> ); - renderAndValidate( ); + await renderAndValidate( ); const { item1, item2, item3 } = getOneDimensionalItems(); expect( item1.id ).toMatch( 'test-id-1' ); @@ -327,7 +327,7 @@ describe.each( [ } ) } /> ); - renderAndValidate( ); + await renderAndValidate( ); const { item2 } = getOneDimensionalItems(); await press.Tab(); @@ -341,37 +341,37 @@ describe.each( [ ] )( '%s', ( _when, rtl ) => { const { previous, next, first, last } = getKeys( rtl ); - function useOneDimensionalTest( initialState?: InitialState ) { + async function useOneDimensionalTest( initialState?: InitialState ) { const Test = () => ( ); - renderAndValidate( ); + await renderAndValidate( ); return getOneDimensionalItems(); } - function useTwoDimensionalTest( initialState?: InitialState ) { + async function useTwoDimensionalTest( initialState?: InitialState ) { const Test = () => ( ); - renderAndValidate( ); + await renderAndValidate( ); return getTwoDimensionalItems(); } - function useShiftTest( shift: boolean ) { + async function useShiftTest( shift: boolean ) { const Test = () => ( ); - renderAndValidate( ); + await renderAndValidate( ); return getShiftTestItems(); } describe( 'In one dimension', () => { test( 'All directions work with no orientation', async () => { - const { item1, item2, item3 } = useOneDimensionalTest(); + const { item1, item2, item3 } = await useOneDimensionalTest(); await press.Tab(); expect( item1 ).toHaveFocus(); @@ -406,7 +406,7 @@ describe.each( [ } ); test( 'Only left/right work with horizontal orientation', async () => { - const { item1, item2, item3 } = useOneDimensionalTest( { + const { item1, item2, item3 } = await useOneDimensionalTest( { orientation: 'horizontal', } ); @@ -435,7 +435,7 @@ describe.each( [ } ); test( 'Only up/down work with vertical orientation', async () => { - const { item1, item2, item3 } = useOneDimensionalTest( { + const { item1, item2, item3 } = await useOneDimensionalTest( { orientation: 'vertical', } ); @@ -464,7 +464,7 @@ describe.each( [ } ); test( 'Focus wraps with loop enabled', async () => { - const { item1, item2, item3 } = useOneDimensionalTest( { + const { item1, item2, item3 } = await useOneDimensionalTest( { loop: true, } ); @@ -488,7 +488,7 @@ describe.each( [ describe( 'In two dimensions', () => { test( 'All directions work as standard', async () => { const { itemA1, itemA2, itemA3, itemB1, itemB2, itemC1, itemC3 } = - useTwoDimensionalTest(); + await useTwoDimensionalTest(); await press.Tab(); expect( itemA1 ).toHaveFocus(); @@ -524,7 +524,7 @@ describe.each( [ test( 'Focus wraps around rows/columns with loop enabled', async () => { const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } = - useTwoDimensionalTest( { loop: true } ); + await useTwoDimensionalTest( { loop: true } ); await press.Tab(); expect( itemA1 ).toHaveFocus(); @@ -548,7 +548,7 @@ describe.each( [ test( 'Focus moves between rows/columns with wrap enabled', async () => { const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } = - useTwoDimensionalTest( { wrap: true } ); + await useTwoDimensionalTest( { wrap: true } ); await press.Tab(); expect( itemA1 ).toHaveFocus(); @@ -577,7 +577,7 @@ describe.each( [ } ); test( 'Focus wraps around start/end with loop and wrap enabled', async () => { - const { itemA1, itemC3 } = useTwoDimensionalTest( { + const { itemA1, itemC3 } = await useTwoDimensionalTest( { loop: true, wrap: true, } ); @@ -595,7 +595,8 @@ describe.each( [ } ); test( 'Focus shifts if vertical neighbour unavailable when shift enabled', async () => { - const { itemA1, itemB1, itemB2, itemC1 } = useShiftTest( true ); + const { itemA1, itemB1, itemB2, itemC1 } = + await useShiftTest( true ); await press.Tab(); expect( itemA1 ).toHaveFocus(); @@ -616,7 +617,7 @@ describe.each( [ } ); test( 'Focus does not shift if vertical neighbour unavailable when shift not enabled', async () => { - const { itemA1, itemB1, itemB2 } = useShiftTest( false ); + const { itemA1, itemB1, itemB2 } = await useShiftTest( false ); await press.Tab(); expect( itemA1 ).toHaveFocus(); diff --git a/packages/components/src/composite/row.tsx b/packages/components/src/composite/row.tsx index a082af03ad6785..1a88da557785e9 100644 --- a/packages/components/src/composite/row.tsx +++ b/packages/components/src/composite/row.tsx @@ -20,11 +20,11 @@ export const CompositeRow = forwardRef< WordPressComponentProps< CompositeRowProps, 'div', false > >( function CompositeRow( props, ref ) { const context = useCompositeContext(); - return ( - - ); + + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. The `store` prop is documented, but its type is + // obfuscated to discourage its use outside of the component's internals. + const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; + + return ; } ); diff --git a/packages/components/src/composite/typeahead.tsx b/packages/components/src/composite/typeahead.tsx index 771d58bcb6c25c..519c59ea374e5d 100644 --- a/packages/components/src/composite/typeahead.tsx +++ b/packages/components/src/composite/typeahead.tsx @@ -20,11 +20,11 @@ export const CompositeTypeahead = forwardRef< WordPressComponentProps< CompositeTypeaheadProps, 'div', false > >( function CompositeTypeahead( props, ref ) { const context = useCompositeContext(); - return ( - - ); + + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. The `store` prop is documented, but its type is + // obfuscated to discourage its use outside of the component's internals. + const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; + + return ; } ); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index cc3c0265c42208..e82d6da70279e8 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -40,7 +40,11 @@ export { isDefinedBorder as __experimentalIsDefinedBorder, isEmptyBorder as __experimentalIsEmptyBorder, } from './border-box-control'; -export { BorderControl as __experimentalBorderControl } from './border-control'; +export { + /** @deprecated Import `BorderControl` instead. */ + BorderControl as __experimentalBorderControl, + BorderControl, +} from './border-control'; export { /** @deprecated Import `BoxControl` instead. */ default as __experimentalBoxControl, @@ -125,14 +129,23 @@ export { default as __experimentalNavigationGroup } from './navigation/group'; export { default as __experimentalNavigationItem } from './navigation/item'; export { default as __experimentalNavigationMenu } from './navigation/menu'; export { + /** @deprecated Import `Navigator` instead. */ NavigatorProvider as __experimentalNavigatorProvider, + /** @deprecated Import `Navigator` and use `Navigator.Screen` instead. */ NavigatorScreen as __experimentalNavigatorScreen, + /** @deprecated Import `Navigator` and use `Navigator.Button` instead. */ NavigatorButton as __experimentalNavigatorButton, + /** @deprecated Import `Navigator` and use `Navigator.BackButton` instead. */ NavigatorBackButton as __experimentalNavigatorBackButton, + /** @deprecated Import `Navigator` and use `Navigator.BackButton` instead. */ NavigatorToParentButton as __experimentalNavigatorToParentButton, - useNavigator as __experimentalUseNavigator, } from './navigator/legacy'; -export { Navigator, useNavigator } from './navigator'; +export { + Navigator, + useNavigator, + /** @deprecated Import `useNavigator` instead. */ + useNavigator as __experimentalUseNavigator, +} from './navigator'; export { default as Notice } from './notice'; export { default as __experimentalNumberControl } from './number-control'; export { default as NoticeList } from './notice/list'; diff --git a/packages/components/src/navigator/README.md b/packages/components/src/navigator/README.md index 00b1cfaeebe0f7..b56a82e0524eef 100644 --- a/packages/components/src/navigator/README.md +++ b/packages/components/src/navigator/README.md @@ -70,6 +70,8 @@ The children elements. #### `Navigator.Screen` +##### Props + ###### `path`: `string` The screen's path, matched against the current path stored in the navigator. @@ -94,7 +96,9 @@ The children elements. - Required: Yes -##### `Navigator.Button` +#### `Navigator.Button` + +##### Props ###### `path`: `string` @@ -119,7 +123,9 @@ The children elements. `Navigator.Button` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`. -##### `Navigator.BackButton` +#### `Navigator.BackButton` + +##### Props ###### `children`: `string` @@ -131,10 +137,12 @@ The children elements. `Navigator.BackButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`. -###### `useNavigator` +#### `useNavigator` You can retrieve a `navigator` instance by using the `useNavigator` hook. +##### Props + The `navigator` instance has a few properties: ###### `goTo`: `( path: string, options: NavigateOptions ) => void` diff --git a/packages/components/src/search-control/index.tsx b/packages/components/src/search-control/index.tsx index aac905e137e025..c41eda9b209b6c 100644 --- a/packages/components/src/search-control/index.tsx +++ b/packages/components/src/search-control/index.tsx @@ -67,7 +67,7 @@ function UnforwardedSearchControl( ) { // @ts-expect-error The `disabled` prop is not yet supported in the SearchControl component. // Work with the design team (@WordPress/gutenberg-design) if you need this feature. - delete restProps.disabled; + const { disabled, ...filteredRestProps } = restProps; const searchRef = useRef< HTMLInputElement >( null ); const instanceId = useInstanceId( @@ -117,7 +117,7 @@ function UnforwardedSearchControl( /> } - { ...restProps } + { ...filteredRestProps } /> ); diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx index e5f113d93b7d0e..0f7e0d2c6ac75f 100644 --- a/packages/components/src/tabs/stories/index.story.tsx +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -70,6 +70,112 @@ const Template: StoryFn< typeof Tabs > = ( props ) => { export const Default = Template.bind( {} ); +export const SizeAndOverflowPlayground: StoryFn< typeof Tabs > = ( props ) => { + const [ fullWidth, setFullWidth ] = useState( false ); + return ( +
+
+

+ This story helps understand how the TabList component + behaves under different conditions. The container below + (with the dotted red border) can be horizontally resized, + and it has a bit of padding to be out of the way of the + TabList. +

+

+ The button will toggle between full width (adding{ ' ' } + width: 100%) and the default width. +

+

Try the following:

+
    +
  • + Small container that causes tabs to + overflow with scroll. +
  • +
  • + Large container that exceeds the normal + width of the tabs. +
      +
    • + + With width: 100% + { ' ' } + set on the TabList (tabs fill up the space). +
    • +
    • + + Without width: 100% + { ' ' } + (defaults to auto) set on the + TabList (tabs take up space proportional to + their content). +
    • +
    +
  • +
+
+ + +
+ + + Label with multiple words + + Short + + Hippopotomonstrosesquippedaliophobia + + Tab 4 + Tab 5 + +
+ +

Selected tab: Tab 1

+

(Label with multiple words)

+
+ +

Selected tab: Tab 2

+

(Short)

+
+ +

Selected tab: Tab 3

+

(Hippopotomonstrosesquippedaliophobia)

+
+ +

Selected tab: Tab 4

+
+ +

Selected tab: Tab 5

+
+
+
+ ); +}; +SizeAndOverflowPlayground.args = { + defaultTabId: 'tab4', +}; + const VerticalTemplate: StoryFn< typeof Tabs > = ( props ) => { return ( diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts index c00943b180f637..283d6421f5b768 100644 --- a/packages/components/src/tabs/styles.ts +++ b/packages/components/src/tabs/styles.ts @@ -16,32 +16,40 @@ export const TabListWrapper = styled.div` align-items: stretch; flex-direction: row; text-align: center; + overflow-x: auto; &[aria-orientation='vertical'] { flex-direction: column; text-align: start; } - @media not ( prefers-reduced-motion ) { - &.is-animation-enabled::after { - transition-property: transform; - transition-duration: 0.2s; - transition-timing-function: ease-out; - } + :where( [aria-orientation='horizontal'] ) { + width: fit-content; } + --direction-factor: 1; - --direction-origin-x: left; + --direction-start: left; + --direction-end: right; --indicator-start: var( --indicator-left ); &:dir( rtl ) { --direction-factor: -1; - --direction-origin-x: right; + --direction-start: right; + --direction-end: left; --indicator-start: var( --indicator-right ); } - &::after { + + @media not ( prefers-reduced-motion ) { + &.is-animation-enabled::before { + transition-property: transform; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } + } + &::before { content: ''; position: absolute; pointer-events: none; - transform-origin: var( --direction-origin-x ) top; + transform-origin: var( --direction-start ) top; // Windows high contrast mode. outline: 2px solid transparent; @@ -52,7 +60,31 @@ export const TabListWrapper = styled.div` when scaling in the transform, see: https://stackoverflow.com/a/52159123 */ --antialiasing-factor: 100; &:not( [aria-orientation='vertical'] ) { - &::after { + --fade-width: 4rem; + --fade-gradient-base: transparent 0%, black var( --fade-width ); + --fade-gradient-composed: var( --fade-gradient-base ), black 60%, + transparent 50%; + &.is-overflowing-first { + mask-image: linear-gradient( + to var( --direction-end ), + var( --fade-gradient-base ) + ); + } + &.is-overflowing-last { + mask-image: linear-gradient( + to var( --direction-start ), + var( --fade-gradient-base ) + ); + } + &.is-overflowing-first.is-overflowing-last { + mask-image: linear-gradient( + to right, + var( --fade-gradient-composed ) + ), + linear-gradient( to left, var( --fade-gradient-composed ) ); + } + + &::before { bottom: 0; height: 0; width: calc( var( --antialiasing-factor ) * 1px ); @@ -71,8 +103,7 @@ export const TabListWrapper = styled.div` ${ COLORS.theme.accent }; } } - &[aria-orientation='vertical']::after { - z-index: -1; + &[aria-orientation='vertical']::before { top: 0; left: 0; width: 100%; @@ -87,14 +118,14 @@ export const TabListWrapper = styled.div` export const Tab = styled( Ariakit.Tab )` & { + scroll-margin: 24px; + flex-grow: 1; + flex-shrink: 0; display: inline-flex; align-items: center; position: relative; border-radius: 0; - min-height: ${ space( - 12 - ) }; // Avoid fixed height to allow for long strings that go in multiple lines. - height: auto; + height: ${ space( 12 ) }; background: transparent; border: none; box-shadow: none; @@ -104,7 +135,6 @@ export const Tab = styled( Ariakit.Tab )` margin-left: 0; font-weight: 500; text-align: inherit; - hyphens: auto; color: ${ COLORS.theme.foreground }; &[aria-disabled='true'] { @@ -123,7 +153,7 @@ export const Tab = styled( Ariakit.Tab )` } // Focus. - &::before { + &::after { content: ''; position: absolute; top: ${ space( 3 ) }; @@ -146,7 +176,7 @@ export const Tab = styled( Ariakit.Tab )` } } - &:focus-visible::before { + &:focus-visible::after { opacity: 1; } } @@ -156,6 +186,10 @@ export const Tab = styled( Ariakit.Tab )` 10 ) }; // Avoid fixed height to allow for long strings that go in multiple lines. } + + [aria-orientation='horizontal'] & { + justify-content: center; + } `; export const TabPanel = styled( Ariakit.TabPanel )` diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index 2977d6a6283708..ae8daf60fc237c 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -8,7 +8,8 @@ import { useStoreState } from '@ariakit/react'; * WordPress dependencies */ import warning from '@wordpress/warning'; -import { forwardRef, useState } from '@wordpress/element'; +import { forwardRef, useLayoutEffect, useState } from '@wordpress/element'; +import { useMergeRefs } from '@wordpress/compose'; /** * Internal dependencies @@ -20,33 +21,58 @@ import type { WordPressComponentProps } from '../context'; import clsx from 'clsx'; import { useTrackElementOffsetRect } from '../utils/element-rect'; import { useOnValueUpdate } from '../utils/hooks/use-on-value-update'; +import { useTrackOverflow } from './use-track-overflow'; + +const SCROLL_MARGIN = 24; export const TabList = forwardRef< HTMLDivElement, WordPressComponentProps< TabListProps, 'div', false > >( function TabList( { children, ...otherProps }, ref ) { - const context = useTabsContext(); + const { store } = useTabsContext() ?? {}; + + const selectedId = useStoreState( store, 'selectedId' ); + const activeId = useStoreState( store, 'activeId' ); + const selectOnMove = useStoreState( store, 'selectOnMove' ); + const items = useStoreState( store, 'items' ); + const [ parent, setParent ] = useState< HTMLElement | null >(); + const refs = useMergeRefs( [ ref, setParent ] ); + const overflow = useTrackOverflow( parent, { + first: items?.at( 0 )?.element, + last: items?.at( -1 )?.element, + } ); - const tabStoreState = useStoreState( context?.store ); - const selectedId = tabStoreState?.selectedId; - const indicatorPosition = useTrackElementOffsetRect( - context?.store.item( selectedId )?.element + const selectedTabPosition = useTrackElementOffsetRect( + store?.item( selectedId )?.element ); const [ animationEnabled, setAnimationEnabled ] = useState( false ); - useOnValueUpdate( - selectedId, - ( { previousValue } ) => previousValue && setAnimationEnabled( true ) - ); + useOnValueUpdate( selectedId, ( { previousValue } ) => { + if ( previousValue ) { + setAnimationEnabled( true ); + } + } ); - if ( ! context || ! tabStoreState ) { - warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); - return null; - } + // Make sure selected tab is scrolled into view. + useLayoutEffect( () => { + if ( ! parent || ! selectedTabPosition ) { + return; + } + + const { scrollLeft: parentScroll } = parent; + const parentWidth = parent.getBoundingClientRect().width; + const { left: childLeft, width: childWidth } = selectedTabPosition; - const { store } = context; - const { activeId, selectOnMove } = tabStoreState; - const { setActiveId } = store; + const parentRightEdge = parentScroll + parentWidth; + const childRightEdge = childLeft + childWidth; + const rightOverflow = childRightEdge + SCROLL_MARGIN - parentRightEdge; + const leftOverflow = parentScroll - ( childLeft - SCROLL_MARGIN ); + if ( leftOverflow > 0 ) { + parent.scrollLeft = parentScroll - leftOverflow; + } else if ( rightOverflow > 0 ) { + parent.scrollLeft = parentScroll + rightOverflow; + } + }, [ parent, selectedTabPosition ] ); const onBlur = () => { if ( ! selectOnMove ) { @@ -58,35 +84,43 @@ export const TabList = forwardRef< // that the selected tab will receive keyboard focus when tabbing back into // the tablist. if ( selectedId !== activeId ) { - setActiveId( selectedId ); + store?.setActiveId( selectedId ); } }; + if ( ! store ) { + warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); + return null; + } + return ( { - if ( event.pseudoElement === '::after' ) { + if ( event.pseudoElement === '::before' ) { setAnimationEnabled( false ); } } } /> } onBlur={ onBlur } + tabIndex={ -1 } { ...otherProps } style={ { - '--indicator-top': indicatorPosition.top, - '--indicator-right': indicatorPosition.right, - '--indicator-left': indicatorPosition.left, - '--indicator-width': indicatorPosition.width, - '--indicator-height': indicatorPosition.height, + '--indicator-top': selectedTabPosition.top, + '--indicator-right': selectedTabPosition.right, + '--indicator-left': selectedTabPosition.left, + '--indicator-width': selectedTabPosition.width, + '--indicator-height': selectedTabPosition.height, ...otherProps.style, } } className={ clsx( - animationEnabled ? 'is-animation-enabled' : '', + overflow.first && 'is-overflowing-first', + overflow.last && 'is-overflowing-last', + animationEnabled && 'is-animation-enabled', otherProps.className ) } > diff --git a/packages/components/src/tabs/use-track-overflow.ts b/packages/components/src/tabs/use-track-overflow.ts new file mode 100644 index 00000000000000..5f6504e6875212 --- /dev/null +++ b/packages/components/src/tabs/use-track-overflow.ts @@ -0,0 +1,76 @@ +/* eslint-disable jsdoc/require-param */ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import { useEvent } from '@wordpress/compose'; + +/** + * Tracks if an element contains overflow and on which end by tracking the + * first and last child elements with an `IntersectionObserver` in relation + * to the parent element. + * + * Note that the returned value will only indicate whether the first or last + * element is currently "going out of bounds" but not whether it happens on + * the X or Y axis. + */ +export function useTrackOverflow( + parent: HTMLElement | undefined | null, + children: { + first: HTMLElement | undefined | null; + last: HTMLElement | undefined | null; + } +) { + const [ first, setFirst ] = useState( false ); + const [ last, setLast ] = useState( false ); + const [ observer, setObserver ] = useState< IntersectionObserver >(); + + const callback: IntersectionObserverCallback = useEvent( ( entries ) => { + for ( const entry of entries ) { + if ( entry.target === children.first ) { + setFirst( ! entry.isIntersecting ); + } + if ( entry.target === children.last ) { + setLast( ! entry.isIntersecting ); + } + } + } ); + + useEffect( () => { + if ( ! parent || ! window.IntersectionObserver ) { + return; + } + const newObserver = new IntersectionObserver( callback, { + root: parent, + threshold: 0.9, + } ); + setObserver( newObserver ); + + return () => newObserver.disconnect(); + }, [ callback, parent ] ); + + useEffect( () => { + if ( ! observer ) { + return; + } + + if ( children.first ) { + observer.observe( children.first ); + } + if ( children.last ) { + observer.observe( children.last ); + } + + return () => { + if ( children.first ) { + observer.unobserve( children.first ); + } + if ( children.last ) { + observer.unobserve( children.last ); + } + }; + }, [ children.first, children.last, observer ] ); + + return { first, last }; +} +/* eslint-enable jsdoc/require-param */ diff --git a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap index e9b4f4ca22ab85..6885263d09b23d 100644 --- a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap +++ b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap @@ -60,6 +60,55 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = outline-offset: -2px; } +@media not ( prefers-reduced-motion ) { + .emotion-8.is-animation-enabled::before { + transition-property: transform,border-radius; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } +} + +.emotion-8::before { + content: ''; + position: absolute; + pointer-events: none; + background: #1e1e1e; + outline: 2px solid transparent; + outline-offset: -3px; + --antialiasing-factor: 100; + border-radius: calc( + 1px / + ( + var( --selected-width, 0 ) / + var( --antialiasing-factor ) + ) + )/1px; + left: -1px; + width: calc( var( --antialiasing-factor ) * 1px ); + height: calc( var( --selected-height, 0 ) * 1px ); + transform-origin: left top; + -webkit-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); + -moz-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); + -ms-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); + transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); +} + .emotion-10 { display: -webkit-inline-box; display: -webkit-inline-flex; @@ -150,17 +199,7 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = line-height: 1; } -.emotion-15 { - background: #1e1e1e; - border-radius: 1px; - position: absolute; - inset: 0; - z-index: 1; - outline: 2px solid transparent; - outline-offset: -3px; -} - -.emotion-18 { +.emotion-17 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -204,22 +243,22 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = } @media not ( prefers-reduced-motion ) { - .emotion-18 { + .emotion-17 { -webkit-transition: background 160ms linear,color 160ms linear,font-weight 60ms linear; transition: background 160ms linear,color 160ms linear,font-weight 60ms linear; } } -.emotion-18::-moz-focus-inner { +.emotion-17::-moz-focus-inner { border: 0; } -.emotion-18[disabled] { +.emotion-17[disabled] { opacity: 0.4; cursor: default; } -.emotion-18:active { +.emotion-17:active { background: #fff; } @@ -280,12 +319,6 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = -
-
-
-
{ if ( showTooltip && text ) { return ( @@ -58,7 +51,6 @@ function ToggleGroupControlOptionBase( >, forwardedRef: ForwardedRef< any > ) { - const shouldReduceMotion = useReducedMotion(); const toggleGroupControlContext = useToggleGroupControlContext(); const id = useInstanceId( @@ -107,7 +99,6 @@ function ToggleGroupControlOptionBase( ), [ cx, isDeselectable, isIcon, isPressed, size, className ] ); - const backdropClasses = useMemo( () => cx( styles.backdropView ), [ cx ] ); const buttonOnClick = () => { if ( isDeselectable && isPressed ) { @@ -124,8 +115,15 @@ function ToggleGroupControlOptionBase( ref: forwardedRef, }; + const labelRef = useRef< HTMLDivElement | null >( null ); + useLayoutEffect( () => { + if ( isPressed && labelRef.current ) { + toggleGroupControlContext.setSelectedElement( labelRef.current ); + } + }, [ isPressed, toggleGroupControlContext ] ); + return ( - + ) } - { /* Animated backdrop using framer motion's shared layout animation */ } - { isPressed ? ( - - - - ) : null } ); } diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts index 020468991225c1..c0248f9b3f7f22 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts +++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts @@ -119,14 +119,3 @@ const isIconStyles = ( { padding-right: 0; `; }; - -export const backdropView = css` - background: ${ COLORS.gray[ 900 ] }; - border-radius: ${ CONFIG.radiusXSmall }; - position: absolute; - inset: 0; - z-index: 1; - // Windows High Contrast mode will show this outline, but not the box-shadow. - outline: 2px solid transparent; - outline-offset: -3px; -`; diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx index b3f56bccd07c5f..7ce762b6e71df2 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx @@ -26,6 +26,7 @@ function UnforwardedToggleGroupControlAsButtonGroup( size, value: valueProp, id: idProp, + setSelectedElement, ...otherProps }: WordPressComponentProps< ToggleGroupControlMainControlProps, @@ -54,16 +55,23 @@ function UnforwardedToggleGroupControlAsButtonGroup( } ); const groupContextValue = useMemo( - () => - ( { - baseId, - value: selectedValue, - setValue: setSelectedValue, - isBlock: ! isAdaptiveWidth, - isDeselectable: true, - size, - } ) as ToggleGroupControlContextProps, - [ baseId, selectedValue, setSelectedValue, isAdaptiveWidth, size ] + (): ToggleGroupControlContextProps => ( { + baseId, + value: selectedValue, + setValue: setSelectedValue, + isBlock: ! isAdaptiveWidth, + isDeselectable: true, + size, + setSelectedElement, + } ), + [ + baseId, + selectedValue, + setSelectedValue, + isAdaptiveWidth, + size, + setSelectedElement, + ] ); return ( diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx index 6baadd65dc5ff6..342f9f128defd9 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx @@ -10,6 +10,7 @@ import { useStoreState } from '@ariakit/react'; */ import { useInstanceId } from '@wordpress/compose'; import { forwardRef, useMemo } from '@wordpress/element'; +import { isRTL } from '@wordpress/i18n'; /** * Internal dependencies @@ -32,6 +33,7 @@ function UnforwardedToggleGroupControlAsRadioGroup( size, value: valueProp, id: idProp, + setSelectedElement, ...otherProps }: WordPressComponentProps< ToggleGroupControlMainControlProps, @@ -65,21 +67,31 @@ function UnforwardedToggleGroupControlAsRadioGroup( defaultValue, value, setValue: wrappedOnChangeProp, + rtl: isRTL(), } ); const selectedValue = useStoreState( radio, 'value' ); const setValue = radio.setValue; const groupContextValue = useMemo( - () => - ( { - baseId, - isBlock: ! isAdaptiveWidth, - size, - value: selectedValue, - setValue, - } ) as ToggleGroupControlContextProps, - [ baseId, isAdaptiveWidth, size, selectedValue, setValue ] + (): ToggleGroupControlContextProps => ( { + baseId, + isBlock: ! isAdaptiveWidth, + size, + // @ts-expect-error - This is wrong and we should fix it. + value: selectedValue, + // @ts-expect-error - This is wrong and we should fix it. + setValue, + setSelectedElement, + } ), + [ + baseId, + isAdaptiveWidth, + selectedValue, + setSelectedElement, + setValue, + size, + ] ); return ( diff --git a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx index 1c86c93548f6df..5f8da76676293e 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx @@ -2,13 +2,11 @@ * External dependencies */ import type { ForwardedRef } from 'react'; -import { LayoutGroup } from 'framer-motion'; /** * WordPress dependencies */ -import { useInstanceId } from '@wordpress/compose'; -import { useMemo } from '@wordpress/element'; +import { useLayoutEffect, useMemo, useState } from '@wordpress/element'; /** * Internal dependencies @@ -22,6 +20,68 @@ import { VisualLabelWrapper } from './styles'; import * as styles from './styles'; import { ToggleGroupControlAsRadioGroup } from './as-radio-group'; import { ToggleGroupControlAsButtonGroup } from './as-button-group'; +import { useTrackElementOffsetRect } from '../../utils/element-rect'; +import { useOnValueUpdate } from '../../utils/hooks/use-on-value-update'; +import { useEvent, useMergeRefs } from '@wordpress/compose'; + +/** + * A utility used to animate something (e.g. an indicator for the selected option + * of a component). + * + * It works by tracking the position and size (i.e., the "rect") of a given subelement, + * typically the one that corresponds to the selected option, relative to its offset + * parent. Then it: + * + * - Keeps CSS variables with that information in the parent, so that the animation + * can be implemented with them. + * - Adds a `is-animation-enabled` CSS class when the element changes, so that the + * target (e.g. the indicator) can be animated to its new position. + * - Removes the `is-animation-enabled` class when the animation is done. + */ +function useSubelementAnimation( + subelement?: HTMLElement | null, + { + parent = subelement?.offsetParent as HTMLElement | null | undefined, + prefix = 'subelement', + transitionEndFilter, + }: { + parent?: HTMLElement | null | undefined; + prefix?: string; + transitionEndFilter?: ( event: TransitionEvent ) => boolean; + } = {} +) { + const rect = useTrackElementOffsetRect( subelement ); + + const setProperties = useEvent( () => { + ( Object.keys( rect ) as Array< keyof typeof rect > ).forEach( + ( property ) => + property !== 'element' && + parent?.style.setProperty( + `--${ prefix }-${ property }`, + String( rect[ property ] ) + ) + ); + } ); + useLayoutEffect( () => { + setProperties(); + }, [ rect, setProperties ] ); + useOnValueUpdate( rect.element, ( { previousValue } ) => { + // Only enable the animation when moving from one element to another. + if ( rect.element && previousValue ) { + parent?.classList.add( 'is-animation-enabled' ); + } + } ); + useLayoutEffect( () => { + function onTransitionEnd( event: TransitionEvent ) { + if ( transitionEndFilter?.( event ) ?? true ) { + parent?.classList.remove( 'is-animation-enabled' ); + } + } + parent?.addEventListener( 'transitionend', onTransitionEnd ); + return () => + parent?.removeEventListener( 'transitionend', onTransitionEnd ); + }, [ parent, transitionEndFilter ] ); +} function UnconnectedToggleGroupControl( props: WordPressComponentProps< ToggleGroupControlProps, 'div', false >, @@ -44,10 +104,18 @@ function UnconnectedToggleGroupControl( ...otherProps } = useContextSystem( props, 'ToggleGroupControl' ); - const baseId = useInstanceId( ToggleGroupControl, 'toggle-group-control' ); const normalizedSize = __next40pxDefaultSize && size === 'default' ? '__unstable-large' : size; + const [ selectedElement, setSelectedElement ] = useState< HTMLElement >(); + const [ controlElement, setControlElement ] = useState< HTMLElement >(); + const refs = useMergeRefs( [ setControlElement, forwardedRef ] ); + useSubelementAnimation( value ? selectedElement : undefined, { + parent: controlElement, + prefix: 'selected', + transitionEndFilter: ( event ) => event.pseudoElement === '::before', + } ); + const cx = useCx(); const classes = useMemo( @@ -81,15 +149,16 @@ function UnconnectedToggleGroupControl( ) } - { children } + { children } ); diff --git a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts index 8d01c150a45eaf..ee6122126f557f 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts +++ b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts @@ -26,6 +26,47 @@ export const toggleGroupControl = ( { ${ toggleGroupControlSize( size ) } ${ ! isDeselectable && enclosingBorders( isBlock ) } + + @media not ( prefers-reduced-motion ) { + &.is-animation-enabled::before { + transition-property: transform, border-radius; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } + } + + &::before { + content: ''; + position: absolute; + pointer-events: none; + background: ${ COLORS.gray[ 900 ] }; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + outline-offset: -3px; + + /* Using a large value to avoid antialiasing rounding issues + when scaling in the transform, see: https://stackoverflow.com/a/52159123 */ + --antialiasing-factor: 100; + /* Adjusting the border radius to match the scaling in the x axis. */ + border-radius: calc( + ${ CONFIG.radiusXSmall } / + ( + var( --selected-width, 0 ) / + var( --antialiasing-factor ) + ) + ) / ${ CONFIG.radiusXSmall }; + left: -1px; // Correcting for border. + width: calc( var( --antialiasing-factor ) * 1px ); + height: calc( var( --selected-height, 0 ) * 1px ); + transform-origin: left top; + transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) + scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); + } `; const enclosingBorders = ( isBlock: ToggleGroupControlProps[ 'isBlock' ] ) => { diff --git a/packages/components/src/toggle-group-control/types.ts b/packages/components/src/toggle-group-control/types.ts index d49ef3cbb77cb4..2a4af680263dba 100644 --- a/packages/components/src/toggle-group-control/types.ts +++ b/packages/components/src/toggle-group-control/types.ts @@ -137,9 +137,11 @@ export type ToggleGroupControlContextProps = { size: ToggleGroupControlProps[ 'size' ]; value: ToggleGroupControlProps[ 'value' ]; setValue: ( newValue: string | number | undefined ) => void; + setSelectedElement: ( element: HTMLElement | undefined ) => void; }; export type ToggleGroupControlMainControlProps = Pick< ToggleGroupControlProps, 'children' | 'isAdaptiveWidth' | 'label' | 'size' | 'onChange' | 'value' ->; +> & + Pick< ToggleGroupControlContextProps, 'setSelectedElement' >; diff --git a/packages/components/src/utils/element-rect.ts b/packages/components/src/utils/element-rect.ts index a96c25ecfac949..7c83db4428ca0f 100644 --- a/packages/components/src/utils/element-rect.ts +++ b/packages/components/src/utils/element-rect.ts @@ -9,6 +9,10 @@ import { useEvent, useResizeObserver } from '@wordpress/compose'; * The position and dimensions of an element, relative to its offset parent. */ export type ElementOffsetRect = { + /** + * The element the rect belongs to. + */ + element: HTMLElement | undefined; /** * The distance from the top edge of the offset parent to the top edge of * the element. @@ -43,6 +47,7 @@ export type ElementOffsetRect = { * An `ElementOffsetRect` object with all values set to zero. */ export const NULL_ELEMENT_OFFSET_RECT = { + element: undefined, top: 0, right: 0, bottom: 0, @@ -75,9 +80,11 @@ export function getElementOffsetRect( if ( rect.width === 0 || rect.height === 0 ) { return; } + const offsetParent = element.offsetParent; const offsetParentRect = - element.offsetParent?.getBoundingClientRect() ?? - NULL_ELEMENT_OFFSET_RECT; + offsetParent?.getBoundingClientRect() ?? NULL_ELEMENT_OFFSET_RECT; + const offsetParentScrollX = offsetParent?.scrollLeft ?? 0; + const offsetParentScrollY = offsetParent?.scrollTop ?? 0; // Computed widths and heights have subpixel precision, and are not affected // by distortions. @@ -90,13 +97,22 @@ export function getElementOffsetRect( const scaleY = computedHeight / rect.height; return { + element, // To obtain the adjusted values for the position: // 1. Compute the element's position relative to the offset parent. // 2. Correct for the scale factor. - top: ( rect.top - offsetParentRect?.top ) * scaleY, - right: ( offsetParentRect?.right - rect.right ) * scaleX, - bottom: ( offsetParentRect?.bottom - rect.bottom ) * scaleY, - left: ( rect.left - offsetParentRect?.left ) * scaleX, + // 3. Adjust for the scroll position of the offset parent. + top: + ( rect.top - offsetParentRect?.top ) * scaleY + offsetParentScrollY, + right: + ( offsetParentRect?.right - rect.right ) * scaleX - + offsetParentScrollX, + bottom: + ( offsetParentRect?.bottom - rect.bottom ) * scaleY - + offsetParentScrollY, + left: + ( rect.left - offsetParentRect?.left ) * scaleX + + offsetParentScrollX, // Computed dimensions don't need any adjustments. width: computedWidth, height: computedHeight, @@ -109,6 +125,9 @@ const POLL_RATE = 100; * Tracks the position and dimensions of an element, relative to its offset * parent. The element can be changed dynamically. * + * When no element is provided (`null` or `undefined`), the hook will return + * a "null" rect, in which all values are `0` and `element` is `undefined`. + * * **Note:** sometimes, the measurement will fail (see `getElementOffsetRect`'s * documentation for more details). When that happens, this hook will attempt * to measure again after a frame, and if that fails, it will poll every 100 @@ -145,10 +164,12 @@ export function useTrackElementOffsetRect( } } ); - useLayoutEffect( - () => setElement( targetElement ), - [ setElement, targetElement ] - ); + useLayoutEffect( () => { + setElement( targetElement ); + if ( ! targetElement ) { + setIndicatorPosition( NULL_ELEMENT_OFFSET_RECT ); + } + }, [ setElement, targetElement ] ); return indicatorPosition; } diff --git a/packages/components/src/utils/hooks/use-on-value-update.ts b/packages/components/src/utils/hooks/use-on-value-update.ts index 05c7173d092fac..15cfc321359e7c 100644 --- a/packages/components/src/utils/hooks/use-on-value-update.ts +++ b/packages/components/src/utils/hooks/use-on-value-update.ts @@ -3,7 +3,7 @@ * WordPress dependencies */ import { useEvent } from '@wordpress/compose'; -import { useRef, useEffect } from '@wordpress/element'; +import { useRef, useLayoutEffect } from '@wordpress/element'; /** * Context object for the `onUpdate` callback of `useOnValueUpdate`. @@ -27,7 +27,7 @@ export function useOnValueUpdate< T >( ) { const previousValueRef = useRef( value ); const updateCallbackEvent = useEvent( onUpdate ); - useEffect( () => { + useLayoutEffect( () => { if ( previousValueRef.current !== value ) { updateCallbackEvent( { previousValue: previousValueRef.current, diff --git a/packages/customize-widgets/src/components/error-boundary/index.js b/packages/customize-widgets/src/components/error-boundary/index.js index 49867787afd059..0fff18a616d11c 100644 --- a/packages/customize-widgets/src/components/error-boundary/index.js +++ b/packages/customize-widgets/src/components/error-boundary/index.js @@ -11,12 +11,7 @@ import { doAction } from '@wordpress/hooks'; function CopyButton( { text, children } ) { const ref = useCopyToClipboard( text ); return ( - ); diff --git a/packages/customize-widgets/src/components/inserter/index.js b/packages/customize-widgets/src/components/inserter/index.js index 41fc037cf673c9..4f271bef9e9a3f 100644 --- a/packages/customize-widgets/src/components/inserter/index.js +++ b/packages/customize-widgets/src/components/inserter/index.js @@ -37,9 +37,7 @@ function Inserter( { setIsOpened } ) { { __( 'Add a block' ) }
- + - + - - - + + { selectedFont && ( -
+
{ tabs.map( ( { id, title } ) => ( diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js index 8cb023fff08e96..b7666a66afe0b3 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js @@ -6,10 +6,8 @@ import { __experimentalConfirmDialog as ConfirmDialog, __experimentalHStack as HStack, __experimentalHeading as Heading, - __experimentalNavigatorProvider as NavigatorProvider, - __experimentalNavigatorScreen as NavigatorScreen, - __experimentalNavigatorBackButton as NavigatorBackButton, - __experimentalUseNavigator as useNavigator, + Navigator, + useNavigator, __experimentalSpacer as Spacer, __experimentalText as Text, __experimentalVStack as VStack, @@ -235,12 +233,12 @@ function InstalledFonts() { { ! isResolvingLibrary && ( <> - - + { notice && ( ) } - + - + - { /* eslint-enable jsx-a11y/no-redundant-roles */ } - - + + size.slug === slug ); + // Navigate to the font sizes list if the font size is not available. + useEffect( () => { + if ( ! fontSize ) { + goTo( '/typography/font-sizes/', { isBack: true } ); + } + }, [ fontSize, goTo ] ); + + if ( ! origin || ! slug || ! fontSize ) { + return null; + } + // Whether the font size is fluid. If not defined, use the global fluid value of the theme. const isFluid = - fontSize.fluid !== undefined ? !! fontSize.fluid : !! globalFluid; + fontSize?.fluid !== undefined ? !! fontSize.fluid : !! globalFluid; // Whether custom fluid values are used. - const isCustomFluid = typeof fontSize.fluid === 'object'; + const isCustomFluid = typeof fontSize?.fluid === 'object'; const handleNameChange = ( value ) => { updateFontSize( 'name', value ); @@ -107,9 +117,6 @@ function FontSize() { }; const handleRemoveFontSize = () => { - // Navigate to the font sizes list. - goBack(); - const newFontSizes = sizes.filter( ( size ) => size.slug !== slug ); setFontSizes( { ...fontSizes, diff --git a/packages/edit-site/src/components/global-styles/header.js b/packages/edit-site/src/components/global-styles/header.js index 1d9de84183aef7..1bbff8fb3d6a6d 100644 --- a/packages/edit-site/src/components/global-styles/header.js +++ b/packages/edit-site/src/components/global-styles/header.js @@ -7,7 +7,7 @@ import { __experimentalSpacer as Spacer, __experimentalHeading as Heading, __experimentalView as View, - __experimentalNavigatorBackButton as NavigatorBackButton, + Navigator, } from '@wordpress/components'; import { isRTL, __ } from '@wordpress/i18n'; import { chevronRight, chevronLeft } from '@wordpress/icons'; @@ -18,7 +18,7 @@ function ScreenHeader( { title, description, onBack } ) { - ; + return ; } function NavigationBackButtonAsItem( props ) { - return ; + return ; } export { NavigationButtonAsItem, NavigationBackButtonAsItem }; diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/index.js b/packages/edit-site/src/components/global-styles/screen-revisions/index.js index de27e92113b554..b980d199e7be30 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/index.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/index.js @@ -3,7 +3,7 @@ */ import { __, sprintf } from '@wordpress/i18n'; import { - __experimentalUseNavigator as useNavigator, + useNavigator, __experimentalConfirmDialog as ConfirmDialog, Spinner, } from '@wordpress/components'; @@ -72,7 +72,6 @@ function ScreenRevisions() { ); const onCloseRevisions = () => { - goTo( '/' ); // Return to global styles main panel. const canvasContainerView = editorCanvasContainerView === 'global-styles-revisions:style-book' ? 'style-book' diff --git a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js index ec1dd1a900c3bf..61bed62cff3d64 100644 --- a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js +++ b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js @@ -15,7 +15,7 @@ import { __experimentalUnitControl as UnitControl, __experimentalGrid as Grid, __experimentalDropdownContentWrapper as DropdownContentWrapper, - __experimentalUseNavigator as useNavigator, + useNavigator, __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption, __experimentalConfirmDialog as ConfirmDialog, @@ -96,6 +96,10 @@ export default function ShadowsEditPanel() { const [ isRenameModalVisible, setIsRenameModalVisible ] = useState( false ); const [ shadowName, setShadowName ] = useState( selectedShadow.name ); + if ( ! category || ! slug ) { + return null; + } + const onShadowChange = ( shadow ) => { setSelectedShadow( { ...selectedShadow, shadow } ); const updatedShadows = shadows.map( ( s ) => diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js index b1550d2a245131..fbc3e461e6abb6 100644 --- a/packages/edit-site/src/components/global-styles/ui.js +++ b/packages/edit-site/src/components/global-styles/ui.js @@ -2,9 +2,8 @@ * WordPress dependencies */ import { - __experimentalNavigatorProvider as NavigatorProvider, - __experimentalNavigatorScreen as NavigatorScreen, - __experimentalUseNavigator as useNavigator, + Navigator, + useNavigator, createSlotFill, DropdownMenu, MenuGroup, @@ -124,7 +123,7 @@ function GlobalStylesActionMenu() { function GlobalStylesNavigationScreen( { className, ...props } ) { return ( - @@ -385,7 +384,7 @@ function GlobalStylesUI() { - + ); } export { GlobalStylesMenuSlot }; diff --git a/packages/edit-site/src/components/routes/link.js b/packages/edit-site/src/components/routes/link.js index 4423eeeb1d6e8d..a34b37943a0799 100644 --- a/packages/edit-site/src/components/routes/link.js +++ b/packages/edit-site/src/components/routes/link.js @@ -33,14 +33,17 @@ export function useLink( params, state, shouldReplace = false ) { ...Object.keys( currentArgs ) ); + let extraParams = {}; if ( isPreviewingTheme() ) { - params = { - ...params, + extraParams = { wp_theme_preview: currentlyPreviewingTheme(), }; } - const newUrl = addQueryArgs( currentUrlWithoutArgs, params ); + const newUrl = addQueryArgs( currentUrlWithoutArgs, { + ...params, + ...extraParams, + } ); return { href: newUrl, diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js index e68474e19f407f..7b85c320e20c99 100644 --- a/packages/edit-site/src/components/style-book/index.js +++ b/packages/edit-site/src/components/style-book/index.js @@ -122,16 +122,18 @@ function StyleBook( { { showTabs ? (
- - { tabs.map( ( tab ) => ( - - { tab.title } - - ) ) } - +
+ + { tabs.map( ( tab ) => ( + + { tab.title } + + ) ) } + +
{ tabs.map( ( tab ) => ( { if ( !! newSelectedTabId ) { diff --git a/packages/editor/src/bindings/api.js b/packages/editor/src/bindings/api.js index 2cfed5168a143e..84003fab7eaf7b 100644 --- a/packages/editor/src/bindings/api.js +++ b/packages/editor/src/bindings/api.js @@ -2,8 +2,8 @@ * WordPress dependencies */ import { - privateApis as blocksPrivateApis, store as blocksStore, + registerBlockBindingsSource, } from '@wordpress/blocks'; import { dispatch } from '@wordpress/data'; @@ -25,7 +25,6 @@ import { unlock } from '../lock-unlock'; * ``` */ export function registerCoreBlockBindingsSources() { - const { registerBlockBindingsSource } = unlock( blocksPrivateApis ); registerBlockBindingsSource( patternOverrides ); registerBlockBindingsSource( postMeta ); } diff --git a/packages/editor/src/components/header/index.js b/packages/editor/src/components/header/index.js index b5ec9032aac596..f49ada48997ce5 100644 --- a/packages/editor/src/components/header/index.js +++ b/packages/editor/src/components/header/index.js @@ -57,15 +57,18 @@ function Header( { showIconLabels, hasFixedToolbar, isNestedEntity, + postType, } = useSelect( ( select ) => { const { get: getPreference } = select( preferencesStore ); const { getEditorMode, getEditorSettings, + getCurrentPostType, isPublishSidebarOpened: _isPublishSidebarOpened, } = select( editorStore ); return { + postType: getCurrentPostType(), isTextEditor: getEditorMode() === 'text', isPublishSidebarOpened: _isPublishSidebarOpened(), showIconLabels: getPreference( 'core', 'showIconLabels' ), @@ -75,6 +78,10 @@ function Header( { }; }, [] ); + const canBeZoomedOut = [ 'post', 'page', 'wp_template' ].includes( + postType + ); + const [ isBlockToolsCollapsed, setIsBlockToolsCollapsed ] = useState( true ); @@ -135,7 +142,9 @@ function Header( { ) } - { isEditorIframed && isWideViewport && } + { canBeZoomedOut && isEditorIframed && isWideViewport && ( + + ) } { - const { getBlockBindingsSource } = unlock( select( blocksStore ) ); const { getCurrentPostType, getEditedPostAttribute } = select( editorStore ); diff --git a/packages/interactivity-router/README.md b/packages/interactivity-router/README.md index 94b88e80886c90..efb52e59be2b5d 100644 --- a/packages/interactivity-router/README.md +++ b/packages/interactivity-router/README.md @@ -1,21 +1,32 @@ -# Interactivity Router +# `@wordpress/interactivity-router` -> **Note** -> This package is a extension of the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage. +The package `@wordpress/interactivity-router` enables loading content from other pages without a full page reload. Currently, the only supported mode is "region-based". Full "client-side navigation" is still in experimental phase. -This package defines an Interactivity API store with the `core/router` namespace, exposing state and actions like `navigate` and `prefetch` to handle client-side navigations. +The package defines an Interactivity API store with the `core/router` namespace, exposing state and 2 actions: `navigate` and `prefetch` to handle client-side navigation. + +The `@wordpress/interactivity-router` package was [introduced in WordPress Core in v6.5](https://make.wordpress.org/core/2024/02/19/merge-announcement-interactivity-api/). This means this package is already bundled in Core in any version of WordPress higher than v6.5. + +
## Usage -The package is intended to be imported dynamically in the `view.js` files of interactive blocks. +The package is intended to be imported dynamically in the `view.js` files of interactive blocks. This is done in in order to reduce the JS bundle size on the initial page load. ```js +/* view.js */ + import { store } from '@wordpress/interactivity'; -store( 'myblock', { +// This is how you would typically use the navigate() action in your block. +store( 'my-namespace/myblock', { actions: { - *navigate( e ) { + *goToPage( e ) { e.preventDefault(); + + // We import the package dynamically to reduce the initial JS bundle size. + // Async actions are defined as generators so the import() must be called with `yield`. const { actions } = yield import( '@wordpress/interactivity-router' ); @@ -25,52 +36,116 @@ store( 'myblock', { } ); ``` -## Frequently Asked Questions +Now, you can call `actions.navigate()` in your block's `view.js` file to navigate to a different page or e.g. pass it to a `data-wp-on--click` attribute. + +When loaded, this package [adds the following state and actions](https://github.com/WordPress/gutenberg/blob/ed7d78652526270b63976d7a970dba46a2bfcbb0/packages/interactivity-router/src/index.ts#L212) to the `core/router` store: + +```js +const { state, actions } = store( 'core/router', { + state: { + url: window.location.href, + navigation: { + hasStarted: false, + hasFinished: false, + texts: { + loading: '', + loaded: '', + }, + message: '', + }, + }, + actions: { + *navigate(href, options) {...}, + prefetch(url, options) {...}, + } +}) +``` + +
+ The core "Query Loop" block is using this package to provide the region-based navigation. +
+ +### Directives + +#### `data-wp-router-region` + +It defines a region that is updated on navigation. It requires a unique ID as the value and can only be used in root interactive elements, i.e., elements with `data-wp-interactive` that are not nested inside other elements with `data-wp-interactive`. + +Example: + +```html +
+ + +
+``` + +### Actions + +#### `navigate` + +Navigates to the specified page. -At this point, some of the questions you have about the Interactivity API may be: +This function normalizes the passed `href`, fetches the page HTML if needed, and updates any interactive regions whose contents have changed in the new page. It also creates a new entry in the browser session history. -### What is this? +**Params** -This is the base of a new standard to create interactive blocks. Read [the proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) to learn more about this. +```js +navigate( href: string, options: NavigateOptions = {} ) +``` -### Can I use it? +- `href`: The page `href`. +- `options`: Options object. + - `force`: If `true`, it forces re-fetching the URL. `navigate()` always caches the page, so if the page has been navigated to before, it will be used. Default is `false`. + - `html`: HTML string to be used instead of fetching the requested URL. + - `replace`: If `true`, it replaces the current entry in the browser session history. Default is `false`. + - `timeout`: Time until the navigation is aborted, in milliseconds. Default is `10000`. + - `loadingAnimation`: Whether an animation should be shown while navigating. Default to `true`. + - `screenReaderAnnouncement`: Whether a message for screen readers should be announced while navigating. Default to `true`. -You can test it, but it's still very experimental. +#### `prefetch` -### How do I get started? +Prefetches the page for the passed URL. The page is cached and can be used for navigation. -The best place to start with the Interactivity API is this [**Getting started guide**](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API. +The function normalizes the URL and stores internally the fetch promise, to avoid triggering a second fetch for an ongoing request. -### Where can I ask questions? +**Params** -The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is the best place to ask questions about the Interactivity API. +```js +prefetch( url: string, options: PrefetchOptions = {} ) +``` -### Where can I share my feedback about the API? +- `url`: The page `url`. +- `options`: Options object. -The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is also the best place to share your feedback about the Interactivity API. + - `force`: If `true`, forces fetching the URL again. + - `html`: HTML string to be used instead of fetching the requested URL. + +### State + +`state.url` is a reactive property synchronized with the current URL. +Properties under `state.navigation` are meant for loading bar animations. ## Installation Install the module: ```bash -npm install @wordpress/interactivity --save +npm install @wordpress/interactivity-router --save ``` -_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ - -## Docs & Examples +This step is only required if you use the Interactivity API outside WordPress. -**[Interactivity API Documentation](https://github.com/WordPress/gutenberg/tree/trunk/packages/interactivity/docs)** is the best place to learn about this proposal. Although it's still in progress, some key pages are already available: +Within WordPress, the package is already bundled in Core. To ensure it's enqueued, add `@wordpress/interactivity-router` to the dependency array of the script module. This process is often done automatically with tools like [`wp-scripts`](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/). -- **[Getting Started Guide](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md)**: Follow this Getting Started guide to learn how to scaffold a new project and create your first interactive blocks. -- **[API Reference](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/2-api-reference.md)**: Check this page for technical detailed explanations and examples of the directives and the store. +Furthermore, this package assumes your code will run in an **ES2015+** environment. If you're using an environment with limited or no support for such language features and APIs, you should include the polyfill shipped in [`@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code. -Here you have some more resources to learn/read more about the Interactivity API: +## License -- **[Interactivity API Discussions](https://github.com/WordPress/gutenberg/discussions/52882)** -- [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) -- Developer Hours sessions ([Americas](https://www.youtube.com/watch?v=RXNoyP2ZiS8&t=664s) & [APAC/EMEA](https://www.youtube.com/watch?v=6ghbrhyAcvA)) -- [wpmovies.dev](http://wpmovies.dev/) demo and its [wp-movies-demo](https://github.com/WordPress/wp-movies-demo) repo +Interactivity API proposal, as part of Gutenberg and the WordPress project is free software, and is released under the terms of the GNU General Public License version 2 or (at your option) any later version. See [LICENSE.md](https://github.com/WordPress/gutenberg/blob/trunk/LICENSE.md) for complete license. -

Code is Poetry.

+

Code is Poetry.

diff --git a/packages/patterns/src/components/pattern-overrides-controls.js b/packages/patterns/src/components/pattern-overrides-controls.js index 28dd8788a390c6..5207348c7bd7c6 100644 --- a/packages/patterns/src/components/pattern-overrides-controls.js +++ b/packages/patterns/src/components/pattern-overrides-controls.js @@ -4,7 +4,7 @@ import { useState, useId } from '@wordpress/element'; import { InspectorControls, - privateApis as blockEditorPrivateApis, + useBlockBindingsUtils, } from '@wordpress/block-editor'; import { BaseControl, Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -17,9 +17,6 @@ import { AllowOverridesModal, DisallowOverridesModal, } from './allow-overrides-modal'; -import { unlock } from '../lock-unlock'; - -const { useBlockBindingsUtils } = unlock( blockEditorPrivateApis ); function PatternOverridesControls( { attributes, diff --git a/packages/preferences/src/components/preferences-modal-tabs/index.js b/packages/preferences/src/components/preferences-modal-tabs/index.js index d87e565f5e3364..f73bdd7f029dc7 100644 --- a/packages/preferences/src/components/preferences-modal-tabs/index.js +++ b/packages/preferences/src/components/preferences-modal-tabs/index.js @@ -3,10 +3,7 @@ */ import { useViewportMatch } from '@wordpress/compose'; import { - __experimentalNavigatorProvider as NavigatorProvider, - __experimentalNavigatorScreen as NavigatorScreen, - __experimentalNavigatorButton as NavigatorButton, - __experimentalNavigatorBackButton as NavigatorBackButton, + Navigator, __experimentalItemGroup as ItemGroup, __experimentalItem as Item, __experimentalHStack as HStack, @@ -98,17 +95,14 @@ export default function PreferencesModalTabs( { sections } ) { ); } else { modalContent = ( - - + + { tabs.map( ( tab ) => { return ( - - + ); } ) } - + { sections.length && sections.map( ( section ) => { return ( - @@ -151,7 +145,7 @@ export default function PreferencesModalTabs( { sections } ) { size="small" gap="6" > - { section.content } - + ); } ) } - + ); } diff --git a/storybook/manager-head.html b/storybook/manager-head.html index 8525e48fffa585..dcafe36caefa72 100644 --- a/storybook/manager-head.html +++ b/storybook/manager-head.html @@ -3,6 +3,7 @@ const PREVIOUSLY_EXPERIMENTAL_COMPONENTS = [ 'alignmentmatrixcontrol', 'borderboxcontrol', + 'bordercontrol', 'boxcontrol', 'customselectcontrol-v2', 'dimensioncontrol', diff --git a/test/e2e/specs/editor/various/allowed-patterns.spec.js b/test/e2e/specs/editor/various/allowed-patterns.spec.js index 894f143d19bb82..83d44403d60ee2 100644 --- a/test/e2e/specs/editor/various/allowed-patterns.spec.js +++ b/test/e2e/specs/editor/various/allowed-patterns.spec.js @@ -54,7 +54,7 @@ test.describe( 'Allowed Patterns', () => { ); } ); - test( 'should show all patterns even if not allowed', async ( { + test( 'should hide patterns with only hidden blocks', async ( { admin, page, } ) => { @@ -77,11 +77,7 @@ test.describe( 'Allowed Patterns', () => { page .getByRole( 'listbox', { name: 'Block patterns' } ) .getByRole( 'option' ) - ).toHaveText( [ - 'Test: Single heading', - 'Test: Single paragraph', - 'Test: Paragraph inside group', - ] ); + ).toHaveText( [ 'Test: Single heading' ] ); } ); } ); } );