diff --git a/package-lock.json b/package-lock.json
index 916fa37e967c27..0a213ecba942a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27803,9 +27803,10 @@
}
},
"node_modules/hpq": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/hpq/-/hpq-1.3.0.tgz",
- "integrity": "sha512-fvYTvdCFOWQupGxqkahrkA+ERBuMdzkxwtUdKrxR6rmMd4Pfl+iZ1QiQYoaZ0B/v0y59MOMnz3XFUWbT50/NWA=="
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/hpq/-/hpq-1.4.0.tgz",
+ "integrity": "sha512-ycJQMRaRPBcfnoT1gS5I1XCvbbw9KO94Y0vkwksuOjcJMqNZtb03MF2tCItLI2mQbkZWSSeFinoRDPmjzv4tKg==",
+ "license": "MIT"
},
"node_modules/html-dom-parser": {
"version": "5.1.2",
@@ -50794,7 +50795,7 @@
"change-case": "^4.1.2",
"colord": "^2.9.3",
"fast-deep-equal": "^3.1.3",
- "hpq": "^1.3.0",
+ "hpq": "^1.4.0",
"is-plain-object": "^5.0.0",
"marked": "^18.0.3",
"memize": "^2.1.1",
diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md
index 8902e9efe58a48..f6df4b0b1a607b 100644
--- a/packages/block-editor/CHANGELOG.md
+++ b/packages/block-editor/CHANGELOG.md
@@ -22,6 +22,7 @@
### Bug Fixes
+- `isBlockSelected`: Return `false` when called without a client ID, instead of matching the `undefined` client ID of an empty selection ([#81212](https://github.com/WordPress/gutenberg/pull/81212)).
- Gate the HEIC canvas conversion fallback on `window.__clientSideMediaProcessing` instead of the redundant `window.__heicUploadSupport` flag, fixing client-side HEIC conversion in Safari on core WordPress installs ([#80452](https://github.com/WordPress/gutenberg/pull/80452)).
- `URLInput`: Request suggestions for a value the field is mounted with, instead of waiting for the input to be focused, and stop requesting initial suggestions on mount when `disableSuggestions` is set ([#80721](https://github.com/WordPress/gutenberg/pull/80721)).
- `URLInput`: Collapse a text selection reaching the start of the field before letting an up arrow press through to the editor, so selecting to the start and pressing up no longer navigates out of the field instead of collapsing the caret ([#80780](https://github.com/WordPress/gutenberg/pull/80780)).
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index 5904f6ad045cec..b99fad3b26b655 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -1313,7 +1313,9 @@ export function isBlockSelected( state, clientId ) {
return false;
}
- return selectionStart.clientId === clientId;
+ // Both sides are `undefined` when nothing is selected and the caller
+ // passes an optional client ID.
+ return !! clientId && selectionStart.clientId === clientId;
}
/**
diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js
index 017099630c9b39..6f6bc2e5a9d8d0 100644
--- a/packages/block-editor/src/store/test/selectors.js
+++ b/packages/block-editor/src/store/test/selectors.js
@@ -1945,6 +1945,17 @@ describe( 'selectors', () => {
expect( isBlockSelected( state, '23' ) ).toBe( false );
} );
+
+ it( 'should return false if there is no client ID', () => {
+ const state = {
+ selection: {
+ selectionStart: {},
+ selectionEnd: {},
+ },
+ };
+
+ expect( isBlockSelected( state, undefined ) ).toBe( false );
+ } );
} );
describe( 'hasSelectedInnerBlock', () => {
diff --git a/packages/block-library/src/paragraph/index.js b/packages/block-library/src/paragraph/index.js
index b3d66b336e1872..0b9a10ee35c190 100644
--- a/packages/block-library/src/paragraph/index.js
+++ b/packages/block-library/src/paragraph/index.js
@@ -16,7 +16,7 @@ import save from './save';
import transforms from './transforms';
import { unlock } from '../lock-unlock';
-const { fieldsKey, formKey, editableRootKey } = unlock( blocksPrivateApis );
+const { fieldsKey, formKey } = unlock( blocksPrivateApis );
const { name } = metadata;
@@ -24,9 +24,6 @@ export { metadata, name };
export const settings = {
icon,
- // Opt into the editing host behaviour privately. It's a Symbol setting
- // rather than a public `supports` key so it stays an internal detail.
- [ editableRootKey ]: true,
example: {
attributes: {
content: __(
diff --git a/packages/blocks/CHANGELOG.md b/packages/blocks/CHANGELOG.md
index 57cac4bb3957d6..245fe4e041c63d 100644
--- a/packages/blocks/CHANGELOG.md
+++ b/packages/blocks/CHANGELOG.md
@@ -5,6 +5,7 @@
### Internal
- Update `memize` to 2.1.1 ([#80764](https://github.com/WordPress/gutenberg/pull/80764)).
+- Update `hpq` to 1.4.0 for its bundled TypeScript types ([#81199](https://github.com/WordPress/gutenberg/pull/81199)).
## 15.24.0 (2026-07-14)
diff --git a/packages/blocks/package.json b/packages/blocks/package.json
index bb8180fabec476..9587c9bf974504 100644
--- a/packages/blocks/package.json
+++ b/packages/blocks/package.json
@@ -68,7 +68,7 @@
"change-case": "^4.1.2",
"colord": "^2.9.3",
"fast-deep-equal": "^3.1.3",
- "hpq": "^1.3.0",
+ "hpq": "^1.4.0",
"is-plain-object": "^5.0.0",
"marked": "^18.0.3",
"memize": "^2.1.1",
diff --git a/packages/blocks/src/api/matchers.ts b/packages/blocks/src/api/matchers.ts
index 9c588dbc285845..a0c6a97cee8039 100644
--- a/packages/blocks/src/api/matchers.ts
+++ b/packages/blocks/src/api/matchers.ts
@@ -1,7 +1,6 @@
/**
* External dependencies
*/
-// @ts-expect-error `hpq` does not ship type declarations.
export { attr, prop, text, query } from 'hpq';
/**
diff --git a/packages/blocks/src/api/parser/get-block-attributes.ts b/packages/blocks/src/api/parser/get-block-attributes.ts
index 33c5c74650471b..5032d76f6e0700 100644
--- a/packages/blocks/src/api/parser/get-block-attributes.ts
+++ b/packages/blocks/src/api/parser/get-block-attributes.ts
@@ -1,8 +1,8 @@
/**
* External dependencies
*/
-// @ts-expect-error `hpq` does not ship type declarations.
import { parse as hpqParse } from 'hpq';
+import type { MatcherObj } from 'hpq';
import memoize from 'memize';
/**
@@ -39,9 +39,9 @@ import type { BlockAttribute, BlockType } from '../../types';
* @return Enhanced hpq matcher.
*/
export const toBooleanAttributeMatcher =
- ( matcher: ( value: unknown ) => unknown ) =>
- ( value: unknown ): boolean =>
- matcher( value ) !== undefined;
+ ( matcher: ( domNode: Element ) => unknown ) =>
+ ( domNode: Element ): boolean =>
+ matcher( domNode ) !== undefined;
/**
* Returns true if value is of the given JSON schema type, or false otherwise.
@@ -205,12 +205,12 @@ export const matcherFromSource = memoize(
): ( ( domNode: Element ) => unknown ) | undefined => {
switch ( sourceConfig.source ) {
case 'attribute': {
- let matcher = attr(
+ const matcher = attr(
sourceConfig.selector,
- sourceConfig.attribute
+ sourceConfig.attribute!
);
if ( sourceConfig.type === 'boolean' ) {
- matcher = toBooleanAttributeMatcher( matcher );
+ return toBooleanAttributeMatcher( matcher );
}
return matcher;
}
@@ -228,6 +228,11 @@ export const matcherFromSource = memoize(
case 'node':
return node( sourceConfig.selector );
case 'query':
+ /*
+ * Sub-matchers may be undefined for unknown source types. hpq
+ * tolerates this and matches such keys as undefined, but its
+ * types don't allow for it.
+ */
const subMatchers = Object.fromEntries(
Object.entries( sourceConfig.query! ).map(
( [ key, subSourceConfig ] ) => [
@@ -235,12 +240,12 @@ export const matcherFromSource = memoize(
matcherFromSource( subSourceConfig ),
]
)
- );
- return query( sourceConfig.selector, subMatchers );
+ ) as MatcherObj;
+ return query( sourceConfig.selector!, subMatchers );
case 'tag': {
const matcher = prop( sourceConfig.selector, 'nodeName' );
- return ( domNode: Node ) =>
- ( matcher( domNode ) as string )?.toLowerCase();
+ return ( domNode: Element ) =>
+ matcher( domNode )?.toLowerCase();
}
default:
// eslint-disable-next-line no-console
@@ -259,8 +264,8 @@ export const matcherFromSource = memoize(
*
* @return Parsed DOM node.
*/
-function parseHtml( innerHTML: string | Node ): Node {
- return hpqParse( innerHTML, ( h: Node ) => h );
+function parseHtml( innerHTML: string | Node ): Element {
+ return hpqParse( innerHTML as string | Element, ( h: Element ) => h );
}
/**
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 869b3cf2e41f4f..9ae15cb596843b 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -19,6 +19,7 @@
- `GradientPicker`: Add `selectedSlug` prop for slug-based selection and pass the selected preset's slug to `onChange`, so two presets sharing a gradient keep their identity ([#80554](https://github.com/WordPress/gutenberg/pull/80554)).
- `SandBox`: Add `allowPopups` prop to opt into `allow-popups` in the iframe's sandbox attribute ([#69617](https://github.com/WordPress/gutenberg/pull/69617)).
+- `SandBox`: Add `allowForms` prop to opt into `allow-forms` in the iframe's sandbox attribute ([#76471](https://github.com/WordPress/gutenberg/pull/76471)).
- Validated form controls: Only move focus to the invalid control for trusted `invalid` events (form submission, `reportValidity()`). Consumers can now dispatch a synthetic `invalid` event to reveal a control's error message without disturbing the user's place in the form ([#80685](https://github.com/WordPress/gutenberg/pull/80685)).
### Bug Fixes
diff --git a/packages/components/src/sandbox/index.tsx b/packages/components/src/sandbox/index.tsx
index ece32df2ec80b4..1de1e5c8b4f7cf 100644
--- a/packages/components/src/sandbox/index.tsx
+++ b/packages/components/src/sandbox/index.tsx
@@ -268,6 +268,7 @@ function IsolatedSandBox( {
onFocus,
tabIndex,
allowPopups = false,
+ allowForms = false,
}: SandBoxContentProps ) {
const ref = useRef< HTMLIFrameElement >( null );
const [ width, setWidth ] = useState( 0 );
@@ -275,6 +276,7 @@ function IsolatedSandBox( {
const sandbox = clsx( 'allow-scripts', 'allow-presentation', {
'allow-popups': allowPopups,
+ 'allow-forms': allowForms,
} );
const srcDoc = useMemo(
@@ -393,6 +395,7 @@ function SameOriginSandBox( {
onFocus,
tabIndex,
allowPopups = false,
+ allowForms = false,
}: SandBoxContentProps ) {
const ref = useRef< HTMLIFrameElement >( null );
const [ width, setWidth ] = useState( 0 );
@@ -404,6 +407,7 @@ function SameOriginSandBox( {
'allow-presentation',
{
'allow-popups': allowPopups,
+ 'allow-forms': allowForms,
}
);
diff --git a/packages/components/src/sandbox/stories/index.story.tsx b/packages/components/src/sandbox/stories/index.story.tsx
index 77ff4cbfa83b21..9617436b0f7ee6 100644
--- a/packages/components/src/sandbox/stories/index.story.tsx
+++ b/packages/components/src/sandbox/stories/index.story.tsx
@@ -36,4 +36,11 @@ const Template: StoryFn< typeof SandBox > = ( args ) => ;
export const Default = Template.bind( {} );
Default.args = {
html: '
Arbitrary HTML content
',
+ allowForms: false,
+};
+
+export const WithForm = Template.bind( {} );
+WithForm.args = {
+ html: '',
+ allowForms: true,
};
diff --git a/packages/components/src/sandbox/test/index.tsx b/packages/components/src/sandbox/test/index.tsx
index 0c216a820c04a6..c103ca14d7b0e4 100644
--- a/packages/components/src/sandbox/test/index.tsx
+++ b/packages/components/src/sandbox/test/index.tsx
@@ -70,6 +70,27 @@ describe( 'SandBox', () => {
);
} );
+ it( 'should not include allow-forms by default', () => {
+ render( );
+
+ const iframe = screen.getByTitle< HTMLIFrameElement >( 'No Forms' );
+
+ expect( iframe.getAttribute( 'sandbox' ) ).not.toContain(
+ 'allow-forms'
+ );
+ } );
+
+ it( 'should include allow-forms when allowForms is set', () => {
+ render( );
+
+ const iframe = screen.getByTitle< HTMLIFrameElement >( 'Forms' );
+
+ expect( iframe ).toHaveAttribute(
+ 'sandbox',
+ 'allow-scripts allow-presentation allow-forms'
+ );
+ } );
+
it( 'should set srcdoc with the provided html content', () => {
render( );
diff --git a/packages/components/src/sandbox/types.ts b/packages/components/src/sandbox/types.ts
index 9c7e7454771df2..4b8e532d07e499 100644
--- a/packages/components/src/sandbox/types.ts
+++ b/packages/components/src/sandbox/types.ts
@@ -21,6 +21,16 @@ export type SandBoxProps = {
* @default false
*/
allowPopups?: boolean;
+ /**
+ * Whether to include `allow-forms` in the iframe's sandbox attribute.
+ * When true, content inside the iframe is allowed to submit forms.
+ *
+ * Enable this for previews whose content includes forms that should be
+ * submittable.
+ *
+ * @default false
+ */
+ allowForms?: boolean;
/**
* The HTML to render in the body of the iframe document.
*
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index 51de65f10053a4..7e9978c1470e06 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -11,9 +11,11 @@
- DataViews: Generalize the ordering filter operators (`on`, `notOn`, `before`, `after`, `beforeInc`, `afterInc`, `between`) from dates to temporal values, so they also compare times of day. Comparisons for `date` and `datetime` are unchanged. [#80830](https://github.com/WordPress/gutenberg/pull/80830)
- DataViews: Add Shift+Click range selection through a shared `useSelectionProps` hook that layouts can adopt, wired up in the table and grid layouts.[#80046](https://github.com/WordPress/gutenberg/pull/80046)
- DataViewsPicker: Add Shift+Click range selection to the `picker-table`, `picker-grid`, and `picker-activity` layouts. [#80413](https://github.com/WordPress/gutenberg/pull/80413)
+- DataViews: Add an `aspectRatio` layout option to the `grid` and `table` layouts so consumers can configure the aspect ratio of item media previews from a set of preset ratios, instead of the hard-coded square. Defaults to `1/1`, so existing consumers are unaffected. [#79329](https://github.com/WordPress/gutenberg/pull/79329)
### Bug Fix
+- DataViews: Pass only the eligible items to a bulk action's `callback`. A bulk action is offered when any one selected item is eligible for it, so the callback could run against items it had declared, through `isEligible`, that it could not handle. [#81198](https://github.com/WordPress/gutenberg/pull/81198)
- DataViews: Render the filter chip for an incomplete `between` range as if no value were set — matching how the filter itself does not apply — instead of showing a dangling bound or the literal string "undefined". A `null` bound, produced when an unfilled bound round-trips through JSON persistence, is now treated as unfilled too. [#80830](https://github.com/WordPress/gutenberg/pull/80830)
- DataForms: Stop the `card` and `details` layouts from hijacking focus when they reveal validation errors. Errors for every field in the container are now shown once focus leaves it, instead of on each internal blur, and revealing them no longer moves focus, so the natural tab sequence is preserved. [#80685](https://github.com/WordPress/gutenberg/pull/80685)
- DataForms: Complete the `richtext` control's autocomplete semantics by associating the textbox with its suggestions list for assistive technology. [#80403](https://github.com/WordPress/gutenberg/pull/80403)
diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md
index 0f5296f04c43af..1c273bef5f87f2 100644
--- a/packages/dataviews/README.md
+++ b/packages/dataviews/README.md
@@ -257,12 +257,14 @@ Properties:
| `styles` | ✓ | ✓ | | | | |
| `badgeFields` | | | ✓ | ✓ | | |
| `previewSize` | | | ✓ | ✓ | | |
+| `aspectRatio` | ✓ | | ✓ | | | |
`table` and `pickerTable` layouts:
- `density`: one of `comfortable`, `balanced`, or `compact`. Configures the size and spacing of the layout.
- `enableMoving`: whether the table columns should display moving controls.
- `styles`: additional `width`, `maxWidth`, `minWidth`, `align` styles for each field column. The `align` property accepts `'start'`, `'center'`, or `'end'`.
+- `aspectRatio` (`table` only): one of the preset ratios `'1/1'`, `'4/3'`, `'3/4'`, `'3/2'`, `'2/3'`, `'16/9'`, or `'9/16'`, applied to the primary column's media preview. Defaults to `'1/1'`.
**For column alignment (`align` property), follow these guidelines:**
Right-align (`'end'`) whenever the cell value is fundamentally quantitative—numbers, decimals, currency, percentages—so that digits and decimal points line up, aiding comparison and calculation. Otherwise, default to left-alignment (`'start'`) for all other types (text, codes, labels, dates).
@@ -272,6 +274,7 @@ Right-align (`'end'`) whenever the cell value is fundamentally quantitative—nu
- `badgeFields`: a list of field's `id` to render without label and styled as badges.
- `density`: one of `comfortable`, `balanced`, or `compact`. Configures the gap between items in the grid.
- `previewSize`: a `number` representing the size of the preview.
+- `aspectRatio` (`grid` only): one of the preset ratios `'1/1'`, `'4/3'`, `'3/4'`, `'3/2'`, `'2/3'`, `'16/9'`, or `'9/16'`, applied uniformly to every item preview, keeping rows aligned. Defaults to `'1/1'`.
`list` layout:
diff --git a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
index 6e41fbb9c41ace..722030c71a56ce 100644
--- a/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
+++ b/packages/dataviews/src/components/dataviews-bulk-actions/index.tsx
@@ -246,7 +246,7 @@ function ActionButton< Item >( {
action={ action }
onClick={ async () => {
setActionInProgress( action.id );
- await action.callback( selectedItems, {
+ await action.callback( selectedEligibleItems, {
registry,
} );
setActionInProgress( null );
diff --git a/packages/dataviews/src/components/dataviews-layouts/grid/composite-grid.tsx b/packages/dataviews/src/components/dataviews-layouts/grid/composite-grid.tsx
index 0039b53d8f36f5..4fbb76c8e73873 100644
--- a/packages/dataviews/src/components/dataviews-layouts/grid/composite-grid.tsx
+++ b/packages/dataviews/src/components/dataviews-layouts/grid/composite-grid.tsx
@@ -2,7 +2,12 @@
* External dependencies
*/
import clsx from 'clsx';
-import type { ComponentProps, ReactElement, HTMLAttributes } from 'react';
+import type {
+ ComponentProps,
+ ReactElement,
+ HTMLAttributes,
+ CSSProperties,
+} from 'react';
/**
* WordPress dependencies
@@ -27,6 +32,7 @@ import {
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';
+import { MEDIA_ASPECT_RATIOS } from '../../../constants';
import ItemActions from '../../dataviews-item-actions';
import DataViewsSelectionCheckbox from '../../dataviews-selection-checkbox';
import DataViewsContext from '../../dataviews-context';
@@ -364,6 +370,19 @@ export default function CompositeGrid< Item >( {
const { paginationInfo, resizeObserverRef } =
useContext( DataViewsContext );
const gridColumns = useGridColumns();
+ // Consumer-configured aspect ratio for item previews, validated against
+ // the presets (like `density`) so arbitrary values are ignored, and
+ // surfaced to CSS as a custom property the media field's stylesheet
+ // reads. Always set (with the square default), so an identically-named
+ // variable set by a consumer on an ancestor can't leak into the previews
+ // when the view doesn't configure a ratio.
+ const gridStyle = {
+ '--wp-dataviews-media-aspect-ratio':
+ view.layout?.aspectRatio &&
+ MEDIA_ASPECT_RATIOS.includes( view.layout.aspectRatio )
+ ? view.layout.aspectRatio
+ : '1/1',
+ } as CSSProperties;
const hasBulkActions = useSomeItemHasAPossibleBulkAction( actions, data );
const titleField = fields.find(
( field ) => field.id === view?.titleField
@@ -432,6 +451,7 @@ export default function CompositeGrid< Item >( {
}
) }
previewSize={ view.layout?.previewSize }
+ style={ gridStyle }
aria-busy={ isLoading }
ref={ resizeObserverRef }
/>
@@ -523,6 +543,7 @@ export default function CompositeGrid< Item >( {
! isInfiniteScroll && (
( {
@@ -19,6 +19,7 @@ function ColumnPrimary< Item >( {
level,
titleField,
mediaField,
+ mediaAspectRatio,
descriptionField,
onClickItem,
renderItemLink,
@@ -28,6 +29,7 @@ function ColumnPrimary< Item >( {
level?: number;
titleField?: NormalizedField< Item >;
mediaField?: NormalizedField< Item >;
+ mediaAspectRatio?: MediaAspectRatio;
descriptionField?: NormalizedField< Item >;
onClickItem?: ( item: Item ) => void;
renderItemLink?: (
@@ -37,6 +39,21 @@ function ColumnPrimary< Item >( {
) => ReactElement;
isItemClickable: ( item: Item ) => boolean;
} ) {
+ // Srcset/size hint for the media render. The preview box is 32px square
+ // by default; when the view configures `layout.aspectRatio`, the box
+ // keeps the 32px height while the ratio derives its width, clamped by
+ // the stylesheet's 60px `max-width`, so widen the hint to match the
+ // rendered size and avoid picking an undersized (blurry) source.
+ let mediaSizes = '32px';
+ if ( mediaAspectRatio ) {
+ const [ ratioWidth, ratioHeight ] = mediaAspectRatio
+ .split( '/' )
+ .map( Number );
+ mediaSizes = `${ Math.min(
+ 60,
+ Math.round( ( 32 * ratioWidth ) / ratioHeight )
+ ) }px`;
+ }
return (
{ mediaField && (
@@ -57,7 +74,7 @@ function ColumnPrimary< Item >( {
) }
diff --git a/packages/dataviews/src/components/dataviews-layouts/table/index.tsx b/packages/dataviews/src/components/dataviews-layouts/table/index.tsx
index 4e8e5bc18eae83..9e535d5565283b 100644
--- a/packages/dataviews/src/components/dataviews-layouts/table/index.tsx
+++ b/packages/dataviews/src/components/dataviews-layouts/table/index.tsx
@@ -2,7 +2,7 @@
* External dependencies
*/
import clsx from 'clsx';
-import type { ComponentProps, ReactElement } from 'react';
+import type { ComponentProps, CSSProperties, ReactElement } from 'react';
/**
* WordPress dependencies
@@ -24,7 +24,7 @@ import { isAppleOS } from '@wordpress/keycodes';
import DataViewsContext from '../../dataviews-context';
import DataViewsSelectionCheckbox from '../../dataviews-selection-checkbox';
import ItemActions from '../../dataviews-item-actions';
-import { sortValues } from '../../../constants';
+import { MEDIA_ASPECT_RATIOS, sortValues } from '../../../constants';
import {
useSomeItemHasAPossibleBulkAction,
useHasAPossibleBulkAction,
@@ -33,6 +33,7 @@ import {
} from '../../dataviews-bulk-actions';
import type {
Action,
+ MediaAspectRatio,
NormalizedField,
ViewTable as ViewTableType,
ViewTableProps,
@@ -76,6 +77,7 @@ interface TableRowProps< Item > {
view: ViewTableType;
titleField?: NormalizedField< Item >;
mediaField?: NormalizedField< Item >;
+ mediaAspectRatio?: MediaAspectRatio;
descriptionField?: NormalizedField< Item >;
selection: string[];
getItemId: ( item: Item ) => string;
@@ -127,6 +129,7 @@ function TableRow< Item >( {
view,
titleField,
mediaField,
+ mediaAspectRatio,
descriptionField,
selection,
getItemId,
@@ -204,6 +207,7 @@ function TableRow< Item >( {
level={ level }
titleField={ showTitle ? titleField : undefined }
mediaField={ showMedia ? mediaField : undefined }
+ mediaAspectRatio={ mediaAspectRatio }
descriptionField={
showDescription ? descriptionField : undefined
}
@@ -390,6 +394,21 @@ function ViewTable< Item >( {
};
const isInfiniteScroll = view.infiniteScrollEnabled && ! dataByGroup;
const isRtl = isRTL();
+ // Consumer-configured aspect ratio for the primary column's media preview,
+ // validated against the presets (like `density`) so arbitrary values are
+ // ignored, and surfaced to CSS as a custom property the media stylesheet
+ // reads. The property is always set (with the square default), so an
+ // identically-named variable set by a consumer on an ancestor can't leak
+ // into the previews when the view doesn't configure a ratio. The sizing
+ // itself only engages behind the `has-media-aspect-ratio` modifier below.
+ const mediaAspectRatio =
+ view.layout?.aspectRatio &&
+ MEDIA_ASPECT_RATIOS.includes( view.layout.aspectRatio )
+ ? view.layout.aspectRatio
+ : undefined;
+ const tableStyle = {
+ '--wp-dataviews-media-aspect-ratio': mediaAspectRatio ?? '1/1',
+ } as CSSProperties;
if ( ! hasData ) {
return (
( {
),
'has-bulk-actions': hasBulkActions,
'is-refreshing': ! isInfiniteScroll && isDelayedLoading,
+ 'has-media-aspect-ratio': !! mediaAspectRatio,
} ) }
+ style={ tableStyle }
aria-busy={ isLoading }
aria-describedby={ tableNoticeId }
role={ isInfiniteScroll ? 'feed' : undefined }
@@ -618,6 +639,9 @@ function ViewTable< Item >( {
view={ view }
titleField={ titleField }
mediaField={ mediaField }
+ mediaAspectRatio={
+ mediaAspectRatio
+ }
descriptionField={
descriptionField
}
@@ -662,6 +686,7 @@ function ViewTable< Item >( {
view={ view }
titleField={ titleField }
mediaField={ mediaField }
+ mediaAspectRatio={ mediaAspectRatio }
descriptionField={ descriptionField }
selection={ selection }
getItemId={ getItemId }
diff --git a/packages/dataviews/src/components/dataviews-layouts/table/style.scss b/packages/dataviews/src/components/dataviews-layouts/table/style.scss
index 87d912433a1869..a2092af7ac9825 100644
--- a/packages/dataviews/src/components/dataviews-layouts/table/style.scss
+++ b/packages/dataviews/src/components/dataviews-layouts/table/style.scss
@@ -302,6 +302,27 @@
}
}
+// Only when the view configures `layout.aspectRatio` (the table then carries
+// the `has-media-aspect-ratio` modifier): the preview box takes a fixed
+// height while the configured ratio sets its width, keeping rows uniform.
+// Sized on the wrapper rather than the img so custom media field renders are
+// covered too; ratios wider than ~17/9 at this height are clipped by the base
+// rule's `max-width` rather than allowed to crowd out the title. Views
+// without `aspectRatio` keep the base sizing above, unchanged.
+.dataviews-view-table.has-media-aspect-ratio .dataviews-column-primary__media {
+ height: var(--wpds-dimension-size-md);
+ aspect-ratio: var(--wp-dataviews-media-aspect-ratio, 1/1);
+ // Release the base rule's `min-width`: it equals the fixed height, so for
+ // ratios narrower than 1/1 the ratio-derived width would fall below it
+ // and the minimum would win, forcing the box back to a square.
+ min-width: 0;
+
+ img {
+ width: 100%;
+ height: 100%;
+ }
+}
+
.dataviews-view-table__cell-content-wrapper,
.dataviews-view-table__primary-column-content {
&:not(.dataviews-column-primary__media) {
diff --git a/packages/dataviews/src/components/dataviews-layouts/utils/grid-items.tsx b/packages/dataviews/src/components/dataviews-layouts/utils/grid-items.tsx
index f48181f6a9274e..fcbca0ecb02d9d 100644
--- a/packages/dataviews/src/components/dataviews-layouts/utils/grid-items.tsx
+++ b/packages/dataviews/src/components/dataviews-layouts/utils/grid-items.tsx
@@ -19,7 +19,7 @@ export const GridItems = forwardRef<
className?: string;
previewSize: number | undefined;
} & ComponentPropsWithoutRef< 'div' >
->( ( { className, previewSize, ...props }, ref ) => {
+>( ( { className, previewSize, style, ...props }, ref ) => {
return (
diff --git a/packages/dataviews/src/constants.ts b/packages/dataviews/src/constants.ts
index 1a9061ca4eb7ed..6ef728fe044cff 100644
--- a/packages/dataviews/src/constants.ts
+++ b/packages/dataviews/src/constants.ts
@@ -57,3 +57,17 @@ export const LAYOUT_PICKER_TABLE = 'pickerTable';
export const LAYOUT_PICKER_ACTIVITY = 'pickerActivity';
export const DAYS_OF_WEEK: DayNumber[] = [ 0, 1, 2, 3, 4, 5, 6 ];
+
+// The preset aspect ratios available for item media previews. Source of
+// truth for the `MediaAspectRatio` type (derived from this array), and used
+// by layouts to validate the configured `layout.aspectRatio` before
+// applying it.
+export const MEDIA_ASPECT_RATIOS = [
+ '1/1',
+ '4/3',
+ '3/4',
+ '3/2',
+ '2/3',
+ '16/9',
+ '9/16',
+] as const;
diff --git a/packages/dataviews/src/dataviews/test/dataviews.tsx b/packages/dataviews/src/dataviews/test/dataviews.tsx
index 3122ee20e14193..729d28680e773b 100644
--- a/packages/dataviews/src/dataviews/test/dataviews.tsx
+++ b/packages/dataviews/src/dataviews/test/dataviews.tsx
@@ -629,6 +629,54 @@ describe( 'DataViews component', () => {
).toBeChecked();
expect( onClickItem ).not.toHaveBeenCalled();
} );
+
+ it( 'passes only eligible items to a bulk action callback', async () => {
+ const restore = jest.fn();
+ render(
+ item.id === 1,
+ callback: restore,
+ },
+ {
+ id: 'trash',
+ label: 'Trash',
+ supportsBulk: true,
+ // Makes the second item selectable even though it
+ // is not eligible for the restore action.
+ isEligible: ( item: Data ) => item.id !== 1,
+ callback: jest.fn(),
+ },
+ ] }
+ />
+ );
+ const user = userEvent.setup();
+ await user.click(
+ screen.getByRole( 'checkbox', { name: data[ 0 ].title } )
+ );
+ await user.click(
+ screen.getByRole( 'checkbox', { name: data[ 1 ].title } )
+ );
+
+ await user.click(
+ screen.getByRole( 'button', { name: 'Restore' } )
+ );
+
+ expect( restore ).toHaveBeenCalledTimes( 1 );
+ expect(
+ restore.mock.calls[ 0 ][ 0 ].map( ( item: Data ) => item.id )
+ ).toEqual( [ 1 ] );
+ } );
} );
describe( 'in grid view', () => {
diff --git a/packages/dataviews/src/types/dataviews.ts b/packages/dataviews/src/types/dataviews.ts
index c02236c4822146..1e510586b3b06d 100644
--- a/packages/dataviews/src/types/dataviews.ts
+++ b/packages/dataviews/src/types/dataviews.ts
@@ -18,6 +18,7 @@ import type {
SortDirection,
} from './field-api';
import type { SetSelection } from './private';
+import type { MEDIA_ASPECT_RATIOS } from '../constants';
/**
* The filters applied to the dataset.
@@ -236,6 +237,14 @@ export interface ColumnStyle {
export type Density = 'compact' | 'balanced' | 'comfortable';
+/**
+ * The preset aspect ratios available for item media previews, mirroring
+ * Core's default `aspect-ratio` presets. Derived from the
+ * `MEDIA_ASPECT_RATIOS` constant, which layouts also use to validate the
+ * configured value at runtime, so the two can't drift apart.
+ */
+export type MediaAspectRatio = ( typeof MEDIA_ASPECT_RATIOS )[ number ];
+
export interface ViewTable extends ViewBase {
type: 'table';
@@ -254,6 +263,13 @@ export interface ViewTable extends ViewBase {
* Whether the view allows column moving.
*/
enableMoving?: boolean;
+
+ /**
+ * A fixed aspect ratio for the primary column's media preview, one of
+ * the preset ratios. Applied uniformly to every row. Defaults to
+ * `'1/1'`.
+ */
+ aspectRatio?: MediaAspectRatio;
};
}
@@ -297,6 +313,13 @@ export interface ViewGrid extends ViewBase {
* The density of the grid layout.
*/
density?: Density;
+
+ /**
+ * A fixed aspect ratio for the grid item previews (the media field),
+ * one of the preset ratios. Applied uniformly to every item so rows
+ * stay aligned. Defaults to `'1/1'`.
+ */
+ aspectRatio?: MediaAspectRatio;
};
}
diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md
index fe2b9e25ee0430..43484c21906c41 100644
--- a/packages/editor/CHANGELOG.md
+++ b/packages/editor/CHANGELOG.md
@@ -15,12 +15,14 @@
### Bug Fixes
+- Device Preview: Keep tablet and mobile iframe widths inside their responsive breakpoints so media queries remain accurate at browser zoom levels.
- Document tools: Fix icon button focus styles to use the design system `outset-ring__focus` mixin ([#81115](https://github.com/WordPress/gutenberg/pull/81115)).
- `mediaUpload`: Add an `isTransportOnly` parameter, set by the `@wordpress/upload-media` queue, which owns progress tracking and save locking for its own items and uses this function only as its server transport. Fixes the progress snackbar showing "1 of 2" for a single HEIC upload in Safari ([#80369](https://github.com/WordPress/gutenberg/issues/80369)).
### Internal
- Update `date-fns` to 4.4.0 ([#80763](https://github.com/WordPress/gutenberg/pull/80763)).
+
## 14.51.0 (2026-07-14)
### New Features
diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js
index 1fb03398d47aa1..89f24c9bb2f6b6 100644
--- a/packages/editor/src/store/test/actions.js
+++ b/packages/editor/src/store/test/actions.js
@@ -145,7 +145,7 @@ describe( 'Post actions', () => {
} );
describe( 'setDeviceType', () => {
- it( 'sets the canvas width using custom rem viewport settings', () => {
+ it( 'sets the canvas one pixel inside a custom rem viewport breakpoint', () => {
const registry = createRegistryWithStores();
registry.dispatch( blockEditorStore ).updateSettings( {
@@ -163,7 +163,7 @@ describe( 'Post actions', () => {
expect(
unlock( registry.select( editorStore ) ).getCanvasWidth()
- ).toBe( 1024 );
+ ).toBe( 1023 );
expect( registry.select( editorStore ).getDeviceType() ).toBe(
'Tablet'
);
diff --git a/packages/editor/src/store/test/private-selectors.js b/packages/editor/src/store/test/private-selectors.js
index ac9df38ab78f4d..b48f1ec407d27a 100644
--- a/packages/editor/src/store/test/private-selectors.js
+++ b/packages/editor/src/store/test/private-selectors.js
@@ -278,16 +278,16 @@ describe( 'getCanvasHeight', () => {
};
}
- it( 'returns the portrait height at the mobile preset width', () => {
- // Mobile aspect ratio is 8/5 (portrait): 480 * 8/5 = 768.
+ it( 'keeps the portrait aspect ratio at the inset mobile preview width', () => {
+ // Mobile aspect ratio is 8/5 (portrait): 479 * 8/5 = 766 (rounded).
setupRegistry();
- expect( getCanvasHeight( { canvasWidth: 480 } ) ).toBe( 768 );
+ expect( getCanvasHeight( { canvasWidth: 479 } ) ).toBe( 766 );
} );
- it( 'returns the portrait height at the tablet preset width', () => {
- // Tablet aspect ratio is 4/3 (portrait): 782 * 4/3 = 1043.
+ it( 'keeps the portrait aspect ratio at the inset tablet preview width', () => {
+ // Tablet aspect ratio is 4/3 (portrait): 781 * 4/3 = 1041 (rounded).
setupRegistry();
- expect( getCanvasHeight( { canvasWidth: 782 } ) ).toBe( 1043 );
+ expect( getCanvasHeight( { canvasWidth: 781 } ) ).toBe( 1041 );
} );
it( 'returns undefined for desktop (no aspect ratio applies)', () => {
@@ -297,12 +297,12 @@ describe( 'getCanvasHeight', () => {
it( 'returns undefined when zoom-out is active', () => {
setupRegistry( { isZoomOut: true } );
- expect( getCanvasHeight( { canvasWidth: 480 } ) ).toBeUndefined();
+ expect( getCanvasHeight( { canvasWidth: 479 } ) ).toBeUndefined();
} );
it( 'returns undefined when the width is dragged within a device band but is not the preset', () => {
// 400 resolves to Mobile (at or below the 480 breakpoint) but is not the
- // 480 preset, so the device height does not apply and the canvas fills.
+ // 479 preset, so the device height does not apply and the canvas fills.
setupRegistry();
expect( getCanvasHeight( { canvasWidth: 400 } ) ).toBeUndefined();
} );
@@ -313,12 +313,12 @@ describe( 'getCanvasHeight', () => {
} );
it( 'uses custom viewport breakpoints when provided', () => {
- // Custom mobile preset 640: 640 * 8/5 = 1024.
+ // Custom mobile preset 639: 639 * 8/5 = 1022 (rounded).
setupRegistry( {
viewport: { mobile: '640px', tablet: '1024px' },
} );
- expect( getCanvasHeight( { canvasWidth: 640 } ) ).toBe( 1024 );
- // Custom tablet preset 1024: 1024 * 4/3 = 1365 (rounded).
- expect( getCanvasHeight( { canvasWidth: 1024 } ) ).toBe( 1365 );
+ expect( getCanvasHeight( { canvasWidth: 639 } ) ).toBe( 1022 );
+ // Custom tablet preset 1023: 1023 * 4/3 = 1364.
+ expect( getCanvasHeight( { canvasWidth: 1023 } ) ).toBe( 1364 );
} );
} );
diff --git a/packages/editor/src/utils/device-type.js b/packages/editor/src/utils/device-type.js
index 1f53ddc59dde94..2bc65969e1fdfb 100644
--- a/packages/editor/src/utils/device-type.js
+++ b/packages/editor/src/utils/device-type.js
@@ -20,6 +20,7 @@ const VIEWPORT_KEY_BY_DEVICE_TYPE = {
const DESKTOP_DEVICE_TYPE = 'Desktop';
const TABLET_DEVICE_TYPE = 'Tablet';
const MOBILE_DEVICE_TYPE = 'Mobile';
+const DEVICE_PREVIEW_WIDTH_OFFSET = 1;
/**
* Maps a device preview type to its corresponding viewport style state. Used
@@ -43,24 +44,19 @@ export const VIEWPORT_STATE_BY_DEVICE_TYPE = {
*/
export function getDeviceTypeByCanvasWidth( canvasWidth, viewportSettings ) {
const width = getViewportBreakpointValueInPixels( canvasWidth );
+ const breakpoints = getViewportBreakpoints( viewportSettings );
// Mobile
if (
width &&
- width <=
- getViewportBreakpointValueInPixels(
- getCanvasWidthByDeviceType( 'Mobile', viewportSettings )
- )
+ width <= getViewportBreakpointValueInPixels( breakpoints.mobile )
) {
return MOBILE_DEVICE_TYPE;
}
// Tablet
if (
width &&
- width <=
- getViewportBreakpointValueInPixels(
- getCanvasWidthByDeviceType( 'Tablet', viewportSettings )
- )
+ width <= getViewportBreakpointValueInPixels( breakpoints.tablet )
) {
return TABLET_DEVICE_TYPE;
}
@@ -69,18 +65,39 @@ export function getDeviceTypeByCanvasWidth( canvasWidth, viewportSettings ) {
}
/**
- * Get the canvas width by device type.
+ * Gets the canvas width for a device preview. The preview is inset from its
+ * breakpoint to avoid browser zoom rounding the iframe viewport outside the
+ * intended media query.
*
* @param {string} deviceType The device type.
* @param {Object} viewportSettings Optional viewport breakpoint settings.
- * @return {number|undefined} The canvas width in pixels.
+ * @return {number|undefined} The device preview width in pixels.
*/
export function getCanvasWidthByDeviceType( deviceType, viewportSettings ) {
const viewportKey = VIEWPORT_KEY_BY_DEVICE_TYPE[ deviceType ];
- if ( viewportKey ) {
- return getViewportBreakpointValueInPixels(
- getViewportBreakpoints( viewportSettings )[ viewportKey ]
- );
+ if ( ! viewportKey ) {
+ return undefined;
}
+
+ const breakpoints = getViewportBreakpoints( viewportSettings );
+ const width = getViewportBreakpointValueInPixels(
+ breakpoints[ viewportKey ]
+ );
+
+ if ( width === undefined ) {
+ return undefined;
+ }
+
+ let lowerBreakpoint = 0;
+ if ( deviceType === TABLET_DEVICE_TYPE ) {
+ lowerBreakpoint =
+ getViewportBreakpointValueInPixels( breakpoints.mobile ) ?? 0;
+ }
+ const offset = Math.min(
+ DEVICE_PREVIEW_WIDTH_OFFSET,
+ ( width - lowerBreakpoint ) / 2
+ );
+
+ return width - offset;
}
diff --git a/packages/editor/src/utils/test/device-type.js b/packages/editor/src/utils/test/device-type.js
index 824febc0e5a136..25dd15d0f3c0ab 100644
--- a/packages/editor/src/utils/test/device-type.js
+++ b/packages/editor/src/utils/test/device-type.js
@@ -7,10 +7,7 @@ import {
} from '../device-type';
describe( 'device type utilities', () => {
- it( 'uses default viewport breakpoints when viewport settings are not provided', () => {
- expect( getCanvasWidthByDeviceType( 'Mobile' ) ).toBe( 480 );
- expect( getCanvasWidthByDeviceType( 'Tablet' ) ).toBe( 782 );
-
+ it( 'classifies widths using default viewport breakpoints when viewport settings are not provided', () => {
expect( getDeviceTypeByCanvasWidth( 480 ) ).toBe( 'Mobile' );
expect( getDeviceTypeByCanvasWidth( 481 ) ).toBe( 'Tablet' );
expect( getDeviceTypeByCanvasWidth( 782 ) ).toBe( 'Tablet' );
@@ -18,10 +15,29 @@ describe( 'device type utilities', () => {
expect( getDeviceTypeByCanvasWidth( undefined ) ).toBe( 'Desktop' );
} );
- it( 'uses default viewport breakpoints when viewport settings are provided', () => {
- expect( getCanvasWidthByDeviceType( 'Mobile', {} ) ).toBe( 480 );
- expect( getCanvasWidthByDeviceType( 'Tablet', {} ) ).toBe( 782 );
+ it( 'places default device preview widths one pixel inside their breakpoints', () => {
+ expect( getCanvasWidthByDeviceType( 'Mobile' ) ).toBe( 479 );
+ expect( getCanvasWidthByDeviceType( 'Tablet' ) ).toBe( 781 );
+ expect( getCanvasWidthByDeviceType( 'Desktop' ) ).toBeUndefined();
+ } );
+
+ it( 'keeps a tablet preview inside a tablet range narrower than one pixel', () => {
+ const viewportSettings = {
+ mobile: '480px',
+ tablet: '480.5px',
+ };
+ const previewWidth = getCanvasWidthByDeviceType(
+ 'Tablet',
+ viewportSettings
+ );
+
+ expect( previewWidth ).toBe( 480.25 );
+ expect(
+ getDeviceTypeByCanvasWidth( previewWidth, viewportSettings )
+ ).toBe( 'Tablet' );
+ } );
+ it( 'classifies widths using default viewport breakpoints when viewport settings are empty', () => {
expect( getDeviceTypeByCanvasWidth( '480px', {} ) ).toBe( 'Mobile' );
expect( getDeviceTypeByCanvasWidth( 782, {} ) ).toBe( 'Tablet' );
} );
@@ -33,10 +49,10 @@ describe( 'device type utilities', () => {
};
expect( getCanvasWidthByDeviceType( 'Mobile', viewportSettings ) ).toBe(
- 640
+ 639
);
expect( getCanvasWidthByDeviceType( 'Tablet', viewportSettings ) ).toBe(
- 1024
+ 1023
);
expect( getDeviceTypeByCanvasWidth( 640, viewportSettings ) ).toBe(
@@ -57,7 +73,7 @@ describe( 'device type utilities', () => {
};
expect( getCanvasWidthByDeviceType( 'Tablet', viewportSettings ) ).toBe(
- 1024
+ 1023
);
expect( getDeviceTypeByCanvasWidth( 640, viewportSettings ) ).toBe(
'Mobile'
@@ -76,7 +92,7 @@ describe( 'device type utilities', () => {
undefined
);
expect( getCanvasWidthByDeviceType( 'Tablet', viewportSettings ) ).toBe(
- 1024
+ 1023
);
expect( getDeviceTypeByCanvasWidth( 800, viewportSettings ) ).toBe(
'Tablet'
@@ -93,7 +109,7 @@ describe( 'device type utilities', () => {
};
expect( getCanvasWidthByDeviceType( 'Mobile', viewportSettings ) ).toBe(
- 1024
+ 1023
);
expect( getCanvasWidthByDeviceType( 'Tablet', viewportSettings ) ).toBe(
undefined
diff --git a/packages/media-fields/CHANGELOG.md b/packages/media-fields/CHANGELOG.md
index 19aa37c5e3dbe1..b44e4ec21c0eec 100644
--- a/packages/media-fields/CHANGELOG.md
+++ b/packages/media-fields/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### Bug Fixes
+
+- `attached_to`: Reserve room for the suggestions so the field's DataForm panel dropdown is placed with space for them, instead of being sized to the row and then crushing them into a box too small to scroll comfortably. Also don't expand the suggestion list until the user searches, debounce the search so a request isn't fired per keystroke, allow re-attaching straight after detaching, and drop the detach link from the help text in favour of the field's own reset button ([#81122](https://github.com/WordPress/gutenberg/issues/81122)).
+
## 0.16.0 (2026-07-14)
### Enhancements
diff --git a/packages/media-fields/src/attached_to/edit.tsx b/packages/media-fields/src/attached_to/edit.tsx
index 345c3b14c15e34..6f09ce51f0f722 100644
--- a/packages/media-fields/src/attached_to/edit.tsx
+++ b/packages/media-fields/src/attached_to/edit.tsx
@@ -5,10 +5,10 @@ import {
__experimentalFetchLinkSuggestions as fetchLinkSuggestions,
store as coreStore,
} from '@wordpress/core-data';
-import { Button, ComboboxControl } from '@wordpress/components';
+import { ComboboxControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import { useState, createInterpolateElement } from '@wordpress/element';
-import { debounce } from '@wordpress/compose';
+import { useState } from '@wordpress/element';
+import { useDebounce, useEvent } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
import type { DataFormControlProps } from '@wordpress/dataviews';
@@ -76,7 +76,6 @@ export default function MediaAttachedToEdit( {
_embedded: { ...data?._embedded, 'wp:attached-to': undefined },
} );
setValue( null );
- setOptions( [] );
};
const onValueChange = async ( filterValue: string ) => {
@@ -104,6 +103,12 @@ export default function MediaAttachedToEdit( {
setIsLoading( false );
};
+ // `onValueChange` closes over state, so it's wrapped in `useEvent` to give
+ // `useDebounce` a stable function. Debouncing an inline function instead
+ // rebuilds the timer on every render, so nothing is debounced and each
+ // keystroke fires its own request.
+ const debouncedValueChange = useDebounce( useEvent( onValueChange ), 300 );
+
/**
* Handle selection.
*
@@ -150,38 +155,22 @@ export default function MediaAttachedToEdit( {
}
};
- const help = createInterpolateElement(
- __(
- 'Search for a post or page to attach this media to or .'
- ),
- {
- button: (
-
- ),
- }
- );
-
return (
- onValueChange( filterValue as string ),
- 300
- ) }
+ onFilterValueChange={ ( filterValue: unknown ) =>
+ debouncedValueChange( filterValue as string )
+ }
onChange={ handleSelectOption }
hideLabelFromVision
+ // Opening the panel shouldn't imply the user has decided to change
+ // the attachment, so wait for them to search before showing the list.
+ expandOnFocus={ false }
/>
);
}
diff --git a/packages/media-fields/src/attached_to/style.scss b/packages/media-fields/src/attached_to/style.scss
new file mode 100644
index 00000000000000..ab3f07a194656c
--- /dev/null
+++ b/packages/media-fields/src/attached_to/style.scss
@@ -0,0 +1,36 @@
+@use "@wordpress/base-styles/variables" as *;
+
+// Height of the suggestion list: three rows, the most `fetchLinkSuggestions`
+// returns while it is called with `isInitialSuggestions` (see the @TODO in
+// edit.tsx).
+$suggestions-height: $button-size-compact * 3;
+
+// When this field is edited from a DataForm panel it renders inside a popover
+// that floating-ui measures and places when it opens. `ComboboxControl` renders
+// its suggestions inline, so they arrive after that measurement: the popover
+// can't grow, and the list ends up scrolling inside a box too small to scroll
+// comfortably — worst low on screen, where the space is smallest.
+//
+// See https://github.com/WordPress/gutenberg/issues/81122.
+.components-popover__content:has(.dataviews-media-field__attached-to) {
+ // Reserve the space the suggestions will need before they exist. The popover
+ // is absolutely positioned, so it contains this margin rather than collapsing
+ // it, and floating-ui measures the reserved height when it decides which way
+ // to open — and caps the popover's height against it too, so the cap already
+ // allows for the suggestions. The margin is transparent, so nothing is
+ // visibly reserved.
+ margin-bottom: $suggestions-height;
+
+ // Once the suggestions are there they occupy that space instead, so the
+ // popover's total height is unchanged and it has no reason to move.
+ &:has(.components-form-token-field__suggestions-list) {
+ margin-bottom: 0;
+ }
+
+ // Hold the list at the reserved height rather than letting it grow and shrink
+ // with the number of matches, which would resize the popover as you type.
+ .components-form-token-field__suggestions-list {
+ height: $suggestions-height;
+ max-height: none;
+ }
+}
diff --git a/packages/media-fields/src/style.scss b/packages/media-fields/src/style.scss
index 718cb578932c8e..1d3bb5f0f035da 100644
--- a/packages/media-fields/src/style.scss
+++ b/packages/media-fields/src/style.scss
@@ -1,3 +1,4 @@
+@use "./attached_to/style.scss" as *;
@use "./author/style.scss" as *;
@use "./filename/style.scss" as *;
@use "./media_thumbnail/style.scss" as *;
diff --git a/storybook/components-manifest.yml b/storybook/components-manifest.yml
index 1813f92f5f1f2a..d1a62f1cc31d59 100644
--- a/storybook/components-manifest.yml
+++ b/storybook/components-manifest.yml
@@ -803,6 +803,7 @@
- style
- name: SandBox
props:
+ - allowForms
- allowPopups
- allowSameOrigin
- html
diff --git a/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js b/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js
index 91ffb282bdb5e8..03b933d43704b6 100644
--- a/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js
+++ b/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js
@@ -736,69 +736,6 @@ test.describe( 'Autocomplete (@firefox, @webkit)', () => {
await expect( page.getByRole( 'listbox' ) ).toBeHidden();
} );
- test( 'should mirror the suggestions list reference onto the editing host', async ( {
- editor,
- page,
- } ) => {
- // The editing host, which `RichText` mirrors these attributes onto by
- // hand, only takes over once the block has a sibling.
- await editor.canvas
- .getByRole( 'button', { name: 'Add default block' } )
- .click();
- await page.keyboard.type( 'A first paragraph.' );
- await page.keyboard.press( 'Enter' );
- await page.keyboard.type( 'hello @fr' );
-
- await expect(
- page.getByRole( 'option', {
- name: 'Frodo Baggins',
- selected: true,
- } )
- ).toBeVisible();
-
- const editingHost = editor.canvas.getByRole( 'textbox', {
- name: 'Editor canvas',
- } );
-
- // The popover renders outside the canvas, so the list is mirrored back
- // into it — these IDs have to resolve in the host's own document.
- const listBoxId = await editor.canvas
- .getByRole( 'listbox' )
- .getAttribute( 'id' );
- const optionId = await editor.canvas
- .getByRole( 'option', { name: 'Frodo Baggins' } )
- .getAttribute( 'id' );
-
- await expect( editingHost ).toHaveAttribute(
- 'aria-autocomplete',
- 'list'
- );
- await expect( editingHost ).toHaveAttribute(
- 'aria-haspopup',
- 'listbox'
- );
- await expect( editingHost ).toHaveAttribute(
- 'aria-controls',
- listBoxId
- );
- await expect( editingHost ).toHaveAttribute( 'aria-owns', listBoxId );
- await expect( editingHost ).toHaveAttribute(
- 'aria-activedescendant',
- optionId
- );
-
- await page.keyboard.press( 'Escape' );
- await expect( page.getByRole( 'listbox' ) ).toBeHidden();
-
- // Only an omitted value clears an attribute here: a `null` would land
- // on the host as the string "null".
- await expect( editingHost ).not.toHaveAttribute( 'aria-controls' );
- await expect( editingHost ).not.toHaveAttribute( 'aria-owns' );
- await expect( editingHost ).not.toHaveAttribute(
- 'aria-activedescendant'
- );
- } );
-
test( 'should re-trigger autocomplete when backspacing into a completed mention', async ( {
editor,
page,
diff --git a/test/e2e/specs/editor/various/core-settings.spec.js b/test/e2e/specs/editor/various/core-settings.spec.js
index 9dddc273e6b16e..363fa96dbc454b 100644
--- a/test/e2e/specs/editor/various/core-settings.spec.js
+++ b/test/e2e/specs/editor/various/core-settings.spec.js
@@ -32,9 +32,9 @@ test.describe( 'Settings', () => {
);
await admin.visitAdminPage( 'options-general.php' );
- await page
- .getByRole( 'textbox', { name: 'Tagline' } )
- .fill( 'Just another Gutenberg site' );
+ const taglineField = page.getByRole( 'textbox', { name: 'Tagline' } );
+ const originalTagline = await taglineField.inputValue();
+ await taglineField.fill( 'Just another Gutenberg site' );
await page.getByRole( 'button', { name: 'Save Changes' } ).click();
const optionsAfter = await getOptionsValues(
@@ -48,5 +48,11 @@ test.describe( 'Settings', () => {
const optionAfter = [ id, optionsAfter[ id ] ];
expect( optionAfter ).toStrictEqual( optionBefore );
} );
+
+ // Restore the tagline: other tests render it through the site
+ // tagline block, and the changed value would leak into them.
+ await admin.visitAdminPage( 'options-general.php' );
+ await taglineField.fill( originalTagline );
+ await page.getByRole( 'button', { name: 'Save Changes' } ).click();
} );
} );
diff --git a/test/e2e/specs/editor/various/editable-root-compat.spec.js b/test/e2e/specs/editor/various/editable-root-compat.spec.js
deleted file mode 100644
index b3faed45aee334..00000000000000
--- a/test/e2e/specs/editor/various/editable-root-compat.spec.js
+++ /dev/null
@@ -1,296 +0,0 @@
-/**
- * WordPress dependencies
- */
-const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
-
-test.describe( 'Editable root block event handler compatibility', () => {
- test.beforeEach( async ( { admin } ) => {
- await admin.createNewPost();
- } );
-
- test( 'delivers keyboard events to a block wrapperProps handler', async ( {
- editor,
- page,
- } ) => {
- // A third party adds an event handler to every block through
- // wrapperProps, the surface host mode would otherwise bypass.
- await page.evaluate( () => {
- window.__extKeys = [];
- const { createElement } = window.wp.element;
- window.wp.hooks.addFilter(
- 'editor.BlockListBlock',
- 'test/compat-events',
- ( BlockListBlock ) => ( props ) =>
- createElement( BlockListBlock, {
- ...props,
- wrapperProps: {
- ...props.wrapperProps,
- onKeyDown: ( event ) =>
- window.__extKeys.push( event.key ),
- },
- } )
- );
- } );
-
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'a' },
- } );
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'b' },
- } );
-
- // Move to the first paragraph so the wrapper becomes the editing host.
- await page.keyboard.press( 'ArrowUp' );
-
- await page.evaluate( () => ( window.__extKeys = [] ) );
- await page.keyboard.type( 'x' );
-
- await expect
- .poll( () => page.evaluate( () => window.__extKeys ) )
- .toContain( 'x' );
- } );
-
- test( 'lets a block wrapperProps handler cancel the default action', async ( {
- editor,
- page,
- } ) => {
- await page.evaluate( () => {
- const { createElement } = window.wp.element;
- window.wp.hooks.addFilter(
- 'editor.BlockListBlock',
- 'test/compat-events-prevent',
- ( BlockListBlock ) => ( props ) =>
- createElement( BlockListBlock, {
- ...props,
- wrapperProps: {
- ...props.wrapperProps,
- onKeyDown: ( event ) => {
- if ( event.key === 'b' ) {
- event.preventDefault();
- }
- },
- },
- } )
- );
- } );
-
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'a' },
- } );
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'a' },
- } );
-
- await page.keyboard.press( 'ArrowUp' );
- await page.keyboard.press( 'End' );
-
- // 'a' types; 'b' is canceled by the handler.
- await page.keyboard.type( 'ab' );
-
- const [ firstParagraph ] = await editor.getBlocks();
- expect( firstParagraph.attributes.content ).toBe( 'aa' );
- } );
-
- test( 'passes a synthetic-like event with a working nativeEvent', async ( {
- editor,
- page,
- } ) => {
- await page.evaluate( () => {
- window.__extInput = [];
- const { createElement } = window.wp.element;
- window.wp.hooks.addFilter(
- 'editor.BlockListBlock',
- 'test/compat-events-synthetic',
- ( BlockListBlock ) => ( props ) =>
- createElement( BlockListBlock, {
- ...props,
- wrapperProps: {
- ...props.wrapperProps,
- onBeforeInput: ( event ) => {
- window.__extInput.push( {
- data: event.data,
- isSynthetic:
- typeof event.persist === 'function',
- nativeEventType:
- event.nativeEvent?.constructor?.name,
- isDefaultPrevented:
- event.isDefaultPrevented(),
- // The real event is wrapped, so these
- // are faithful, not a scripted copy's.
- isTrusted: event.isTrusted,
- // Not on React's synthetic event, so
- // reached through the native event.
- hasTargetRanges:
- event.nativeEvent.getTargetRanges()
- .length > 0,
- } );
- },
- },
- } )
- );
- } );
-
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'a' },
- } );
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'b' },
- } );
- await page.keyboard.press( 'ArrowUp' );
- await page.evaluate( () => ( window.__extInput = [] ) );
- await page.keyboard.type( 'x' );
-
- const record = await page
- .evaluate( () => window.__extInput )
- .then( ( entries ) => entries.at( -1 ) );
- expect( record ).toMatchObject( {
- data: 'x',
- isSynthetic: true,
- nativeEventType: 'InputEvent',
- isDefaultPrevented: false,
- isTrusted: true,
- hasTargetRanges: true,
- } );
- } );
-
- test( 'stops propagation between nested block handlers', async ( {
- editor,
- page,
- } ) => {
- // The inner (list item) handler stops propagation; the outer (list)
- // handler must not see the event, like React bubbling.
- await page.evaluate( () => {
- window.__extPath = [];
- const { createElement } = window.wp.element;
- window.wp.hooks.addFilter(
- 'editor.BlockListBlock',
- 'test/compat-events-nested',
- ( BlockListBlock ) => ( props ) =>
- createElement( BlockListBlock, {
- ...props,
- wrapperProps: {
- ...props.wrapperProps,
- onKeyDown: ( event ) => {
- window.__extPath.push( props.block.name );
- if ( props.block.name === 'core/list-item' ) {
- event.stopPropagation();
- }
- },
- },
- } )
- );
- } );
-
- await editor.insertBlock( {
- name: 'core/list',
- innerBlocks: [
- {
- name: 'core/list-item',
- attributes: { content: 'item' },
- },
- {
- name: 'core/list-item',
- attributes: { content: 'item two' },
- },
- ],
- } );
-
- // Select the first list item so the wrapper hosts.
- await editor.canvas
- .getByRole( 'textbox', { name: 'List text' } )
- .first()
- .click();
- await page.evaluate( () => ( window.__extPath = [] ) );
- await page.keyboard.type( 'x' );
-
- await expect
- .poll( () => page.evaluate( () => window.__extPath ) )
- .toEqual( [ 'core/list-item' ] );
- } );
-
- test( 'delivers input events to a block wrapperProps handler', async ( {
- editor,
- page,
- } ) => {
- await page.evaluate( () => {
- window.__extInputData = [];
- const { createElement } = window.wp.element;
- window.wp.hooks.addFilter(
- 'editor.BlockListBlock',
- 'test/compat-events-input',
- ( BlockListBlock ) => ( props ) =>
- createElement( BlockListBlock, {
- ...props,
- wrapperProps: {
- ...props.wrapperProps,
- onInput: ( event ) =>
- window.__extInputData.push( event.data ),
- },
- } )
- );
- } );
-
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'a' },
- } );
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'b' },
- } );
-
- await page.keyboard.press( 'ArrowUp' );
- await page.evaluate( () => ( window.__extInputData = [] ) );
- await page.keyboard.type( 'x' );
-
- await expect
- .poll( () => page.evaluate( () => window.__extInputData ) )
- .toContain( 'x' );
- } );
-
- test( 'does not double up an event React already delivers', async ( {
- editor,
- page,
- } ) => {
- // The code block is plain text, not part of the rich-text writing flow,
- // so it will never be an editableRoot host: React delivers the event to
- // the block as usual. The host bridge must recognise the event isn't on
- // the host and stay out of the way, so the handler runs once, not twice.
- await page.evaluate( () => {
- window.__extCount = 0;
- const { createElement } = window.wp.element;
- window.wp.hooks.addFilter(
- 'editor.BlockListBlock',
- 'test/compat-events-count',
- ( BlockListBlock ) => ( props ) =>
- createElement( BlockListBlock, {
- ...props,
- wrapperProps: {
- ...props.wrapperProps,
- onKeyDown: () => ( window.__extCount += 1 ),
- },
- } )
- );
- } );
-
- await editor.insertBlock( {
- name: 'core/code',
- attributes: { content: 'a' },
- } );
-
- await page.evaluate( () => ( window.__extCount = 0 ) );
- await page.keyboard.press( 'x' );
-
- // One keydown, one call. A second would mean the bridge fired on top of
- // React's delivery.
- await expect
- .poll( () => page.evaluate( () => window.__extCount ) )
- .toBe( 1 );
- } );
-} );
diff --git a/test/e2e/specs/editor/various/editable-root.spec.js b/test/e2e/specs/editor/various/editable-root.spec.js
deleted file mode 100644
index b947ee843dae0e..00000000000000
--- a/test/e2e/specs/editor/various/editable-root.spec.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * WordPress dependencies
- */
-const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
-
-test.describe( 'editableRoot host mode', () => {
- test.beforeEach( async ( { admin } ) => {
- await admin.createNewPost();
- } );
-
- test( 'wrapper becomes the editing host for a paragraph with siblings', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'a' },
- } );
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: { content: 'b' },
- } );
- await page.keyboard.press( 'ArrowUp' );
-
- // Host mode: the selected block has a contentEditable ancestor above it
- // (the canvas wrapper), which does not happen when the block is edited
- // on its own element.
- await expect
- .poll( () =>
- editor.canvas
- .locator( ':root' )
- .evaluate(
- ( root ) =>
- !! root.ownerDocument.querySelector(
- '[contenteditable="true"] [data-block]'
- )
- )
- )
- .toBe( true );
- } );
-
- test( 'a heading (no support) is not hosted', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: { content: 'a' },
- } );
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: { content: 'b' },
- } );
- await page.keyboard.press( 'ArrowUp' );
-
- await expect
- .poll( () =>
- editor.canvas
- .locator( ':root' )
- .evaluate(
- ( root ) =>
- !! root.ownerDocument.querySelector(
- '[contenteditable="true"] [data-block]'
- )
- )
- )
- .toBe( false );
- } );
-} );
diff --git a/test/e2e/specs/editor/various/multi-block-selection.spec.js b/test/e2e/specs/editor/various/multi-block-selection.spec.js
index 5e1c61783a7c49..3a733d82cbec63 100644
--- a/test/e2e/specs/editor/various/multi-block-selection.spec.js
+++ b/test/e2e/specs/editor/various/multi-block-selection.spec.js
@@ -241,7 +241,7 @@ test.describe( 'Multi-block selection (@firefox, @webkit)', () => {
.toEqual( [ 1 ] );
} );
- test( 'should keep the editing host semantics across a cross-block selection', async ( {
+ test( 'should present the editing host semantics during a cross-block selection', async ( {
page,
editor,
pageUtils,
@@ -253,16 +253,13 @@ test.describe( 'Multi-block selection (@firefox, @webkit)', () => {
await page.keyboard.press( 'Enter' );
await page.keyboard.type( '2' );
- // The wrapper hosts editing for the selected block: it must present
- // as a named multiline textbox for as long as it is the editing
- // host, including while a selection crosses blocks.
+ // Without a cross-block selection, the block is edited on its own
+ // element and the canvas wrapper is not an editing host.
const host = editor.canvas.locator( 'body' );
- await expect( host ).toHaveAttribute( 'contenteditable', 'true' );
- await expect( host ).toHaveAttribute( 'role', 'textbox' );
- await expect( host ).toHaveAttribute( 'aria-multiline', 'true' );
- await expect( host ).toHaveAttribute( 'aria-label', 'Editor canvas' );
+ await expect( host ).not.toHaveAttribute( 'contenteditable', 'true' );
- // Extend the selection across blocks: the host semantics remain.
+ // Extend the selection across blocks: the wrapper becomes the
+ // editing host and must present as a named multiline textbox.
await pageUtils.pressKeys( 'shift+ArrowUp' );
await expect
.poll( () =>
@@ -281,18 +278,9 @@ test.describe( 'Multi-block selection (@firefox, @webkit)', () => {
'Multiple selected blocks'
);
- // Collapse into a block: the block still hosts, so the semantics
- // remain and the generic host name returns.
- await page.keyboard.press( 'ArrowLeft' );
- await expect( host ).toHaveAttribute( 'contenteditable', 'true' );
- await expect( host ).toHaveAttribute( 'role', 'textbox' );
- await expect( host ).toHaveAttribute( 'aria-label', 'Editor canvas' );
-
- // Move to the post title: the editability and the textbox semantics
+ // Collapse into a block: the editability and the textbox semantics
// are removed together.
- await editor.canvas
- .getByRole( 'textbox', { name: 'Add title' } )
- .click();
+ await page.keyboard.press( 'ArrowLeft' );
await expect( host ).toHaveAttribute( 'contenteditable', 'false' );
await expect( host ).not.toHaveAttribute( 'role' );
await expect( host ).not.toHaveAttribute( 'aria-multiline' );
diff --git a/test/e2e/specs/site-editor/template-activate.spec.js b/test/e2e/specs/site-editor/template-activate.spec.js
index 19b9a448ea4d22..75aefee07719f6 100644
--- a/test/e2e/specs/site-editor/template-activate.spec.js
+++ b/test/e2e/specs/site-editor/template-activate.spec.js
@@ -77,7 +77,12 @@ test.describe( 'Template Activate', () => {
.first()
.click();
- await expect( editor.canvas.getByText( 'gutenberg' ) ).toBeVisible();
+ // The site title, rendered by the header template part. Exact, so a
+ // site tagline containing the word does not make the locator
+ // ambiguous.
+ await expect(
+ editor.canvas.getByText( 'gutenberg', { exact: true } )
+ ).toBeVisible();
await editor.insertBlock( {
name: 'core/paragraph',