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: ( -