diff --git a/docs/manifest.json b/docs/manifest.json
index 73803ab67cd01b..a47ca5c5d9dcfb 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -1847,6 +1847,12 @@
"markdown_source": "../packages/list-reusable-blocks/README.md",
"parent": "packages"
},
+ {
+ "title": "@wordpress/media-fields",
+ "slug": "packages-media-fields",
+ "markdown_source": "../packages/media-fields/README.md",
+ "parent": "packages"
+ },
{
"title": "@wordpress/media-utils",
"slug": "packages-media-utils",
diff --git a/package-lock.json b/package-lock.json
index 747e7357df14c1..c8fc5288844428 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17905,6 +17905,10 @@
"resolved": "packages/list-reusable-blocks",
"link": true
},
+ "node_modules/@wordpress/media-fields": {
+ "resolved": "packages/media-fields",
+ "link": true
+ },
"node_modules/@wordpress/media-utils": {
"resolved": "packages/media-utils",
"link": true
@@ -54523,6 +54527,29 @@
"react-dom": "^18.0.0"
}
},
+ "packages/media-fields": {
+ "name": "@wordpress/media-fields",
+ "version": "0.1.0",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@wordpress/components": "file:../components",
+ "@wordpress/core-data": "file:../core-data",
+ "@wordpress/data": "file:../data",
+ "@wordpress/dataviews": "file:../dataviews",
+ "@wordpress/element": "file:../element",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/icons": "file:../icons",
+ "@wordpress/primitives": "file:../primitives",
+ "@wordpress/url": "file:../url"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ }
+ },
"packages/media-utils": {
"name": "@wordpress/media-utils",
"version": "5.36.0",
@@ -54537,6 +54564,7 @@
"@wordpress/element": "file:../element",
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
+ "@wordpress/media-fields": "file:../media-fields",
"@wordpress/private-apis": "file:../private-apis"
},
"engines": {
diff --git a/packages/media-fields/README.md b/packages/media-fields/README.md
new file mode 100644
index 00000000000000..e28ec17a999f0d
--- /dev/null
+++ b/packages/media-fields/README.md
@@ -0,0 +1,52 @@
+# Media Fields
+
+This package provides reusable field definitions for displaying and editing media attachment properties in WordPress DataViews. It's primarily intended for internal use within Gutenberg and may change significantly between releases.
+
+## Usage
+
+### Available Fields
+
+This package exports field definitions for common media attachment properties:
+
+- `altTextField` - Alternative text for images
+- `captionField` - Media caption text
+- `descriptionField` - Detailed description
+- `filenameField` - File name (read-only)
+- `filesizeField` - File size with human-readable formatting
+- `mediaDimensionsField` - Image dimensions (width × height)
+- `mediaThumbnailField` - Thumbnail preview
+- `mimeTypeField` - MIME type display
+
+### Using Media Fields in DataViews
+
+```jsx
+import {
+ altTextField,
+ captionField,
+ filesizeField,
+} from '@wordpress/media-fields';
+import { DataViews } from '@wordpress/dataviews';
+
+const fields = [
+ altTextField,
+ captionField,
+ filesizeField,
+];
+
+export function MyMediaLibrary( { items } ) {
+ return (
+
+ );
+}
+```
+
+## Contributing to this package
+
+This package is part of the Gutenberg project. To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md).
+
+

diff --git a/packages/media-fields/package.json b/packages/media-fields/package.json
new file mode 100644
index 00000000000000..78db528ccc1d18
--- /dev/null
+++ b/packages/media-fields/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@wordpress/media-fields",
+ "version": "0.1.0",
+ "description": "Reusable field definitions for displaying and editing media attachment properties in WordPress.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "gutenberg",
+ "media",
+ "fields",
+ "dataviews"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/media-fields/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/media-fields"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "exports": {
+ ".": {
+ "types": "./build-types/index.d.ts",
+ "import": "./build-module/index.js",
+ "require": "./build/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "react-native": "src/index",
+ "types": "build-types",
+ "sideEffects": [
+ "build-style/**",
+ "src/**/*.scss"
+ ],
+ "dependencies": {
+ "@wordpress/components": "file:../components",
+ "@wordpress/core-data": "file:../core-data",
+ "@wordpress/data": "file:../data",
+ "@wordpress/dataviews": "file:../dataviews",
+ "@wordpress/element": "file:../element",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/icons": "file:../icons",
+ "@wordpress/primitives": "file:../primitives",
+ "@wordpress/url": "file:../url"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/media-fields/src/alt_text/index.tsx b/packages/media-fields/src/alt_text/index.tsx
new file mode 100644
index 00000000000000..6b399c797a8a5d
--- /dev/null
+++ b/packages/media-fields/src/alt_text/index.tsx
@@ -0,0 +1,30 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextareaControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+import type { Attachment, Updatable } from '@wordpress/core-data';
+
+const altTextField: Partial< Field< Updatable< Attachment > > > = {
+ id: 'alt_text',
+ type: 'text',
+ label: __( 'Alt text' ),
+ isVisible: ( item ) => item?.media_type === 'image',
+ render: ( { item } ) => item?.alt_text || '-',
+ Edit: ( { field, onChange, data } ) => {
+ return (
+ onChange( { alt_text: value } ) }
+ rows={ 2 }
+ __nextHasNoMarginBottom
+ />
+ );
+ },
+ enableSorting: false,
+ filterBy: false,
+};
+
+export default altTextField;
diff --git a/packages/media-fields/src/caption/index.tsx b/packages/media-fields/src/caption/index.tsx
new file mode 100644
index 00000000000000..00e93449c8a937
--- /dev/null
+++ b/packages/media-fields/src/caption/index.tsx
@@ -0,0 +1,35 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextareaControl } from '@wordpress/components';
+import type { Attachment, Updatable } from '@wordpress/core-data';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { getRawContent } from '../utils/get-raw-content';
+
+const captionField: Partial< Field< Updatable< Attachment > > > = {
+ id: 'caption',
+ type: 'text',
+ label: __( 'Caption' ),
+ getValue: ( { item } ) => getRawContent( item?.caption ),
+ render: ( { item } ) => getRawContent( item?.caption ) || '-',
+ Edit: ( { field, onChange, data } ) => {
+ return (
+ onChange( { caption: value } ) }
+ rows={ 2 }
+ __nextHasNoMarginBottom
+ />
+ );
+ },
+ enableSorting: false,
+ filterBy: false,
+};
+
+export default captionField;
diff --git a/packages/media-fields/src/description/index.tsx b/packages/media-fields/src/description/index.tsx
new file mode 100644
index 00000000000000..3516ce027ce737
--- /dev/null
+++ b/packages/media-fields/src/description/index.tsx
@@ -0,0 +1,37 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextareaControl } from '@wordpress/components';
+import type { Attachment, Updatable } from '@wordpress/core-data';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { getRawContent } from '../utils/get-raw-content';
+
+const descriptionField: Partial< Field< Updatable< Attachment > > > = {
+ id: 'description',
+ type: 'text',
+ label: __( 'Description' ),
+ getValue: ( { item } ) => getRawContent( item?.description ),
+ render: ( { item } ) => (
+ { getRawContent( item?.description ) || '-' }
+ ),
+ Edit: ( { field, onChange, data } ) => {
+ return (
+ onChange( { description: value } ) }
+ rows={ 5 }
+ __nextHasNoMarginBottom
+ />
+ );
+ },
+ enableSorting: false,
+ filterBy: false,
+};
+
+export default descriptionField;
diff --git a/packages/media-fields/src/filename/index.ts b/packages/media-fields/src/filename/index.ts
new file mode 100644
index 00000000000000..1e67b4920d0416
--- /dev/null
+++ b/packages/media-fields/src/filename/index.ts
@@ -0,0 +1,26 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { getFilename } from '@wordpress/url';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { MediaItem } from '../types';
+import FileNameView from './view';
+
+const filenameField: Partial< Field< MediaItem > > = {
+ id: 'filename',
+ type: 'text',
+ label: __( 'File name' ),
+ getValue: ( { item }: { item: MediaItem } ) =>
+ getFilename( item?.source_url || '' ),
+ render: FileNameView,
+ enableSorting: false,
+ filterBy: false,
+ readOnly: true,
+};
+
+export default filenameField;
diff --git a/packages/media-fields/src/filename/view.tsx b/packages/media-fields/src/filename/view.tsx
new file mode 100644
index 00000000000000..d7a45fa81214fd
--- /dev/null
+++ b/packages/media-fields/src/filename/view.tsx
@@ -0,0 +1,39 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ Tooltip,
+ __experimentalTruncate as Truncate,
+} from '@wordpress/components';
+import { useMemo } from '@wordpress/element';
+import { getFilename } from '@wordpress/url';
+import type { DataViewRenderFieldProps } from '@wordpress/dataviews';
+/**
+ * Internal dependencies
+ */
+import type { MediaItem } from '../types';
+
+// Hard-coded truncate length to match the available area in the media sidebar.
+// Longer file names will be truncated and wrapped in a tooltip showing the full name.
+const TRUNCATE_LENGTH = 15;
+
+export default function FileNameView( {
+ item,
+}: DataViewRenderFieldProps< MediaItem > ) {
+ const fileName = useMemo(
+ () => ( item?.source_url ? getFilename( item.source_url ) : null ),
+ [ item?.source_url ]
+ );
+
+ if ( ! fileName ) {
+ return '';
+ }
+
+ return fileName.length > TRUNCATE_LENGTH ? (
+
+ { fileName }
+
+ ) : (
+ <>{ fileName }>
+ );
+}
diff --git a/packages/media-fields/src/filesize/index.tsx b/packages/media-fields/src/filesize/index.tsx
new file mode 100644
index 00000000000000..0bf7e83188a64f
--- /dev/null
+++ b/packages/media-fields/src/filesize/index.tsx
@@ -0,0 +1,96 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf, _x } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { MediaItem } from '../types';
+
+const KB_IN_BYTES = 1024;
+const MB_IN_BYTES = 1024 * KB_IN_BYTES;
+const GB_IN_BYTES = 1024 * MB_IN_BYTES;
+const TB_IN_BYTES = 1024 * GB_IN_BYTES;
+const PB_IN_BYTES = 1024 * TB_IN_BYTES;
+const EB_IN_BYTES = 1024 * PB_IN_BYTES;
+const ZB_IN_BYTES = 1024 * EB_IN_BYTES;
+const YB_IN_BYTES = 1024 * ZB_IN_BYTES;
+
+function getBytesString(
+ bytes: number,
+ unitSymbol: string,
+ decimals = 2
+): string {
+ return sprintf(
+ // translators: 1: Actual bytes of a file. 2: The unit symbol (e.g. MB).
+ _x( '%1$s %2$s', 'file size' ),
+ bytes.toLocaleString( undefined, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: decimals,
+ } ),
+ unitSymbol
+ );
+}
+
+/**
+ * Converts bytes to a human-readable file size string with a specified number of decimal places.
+ *
+ * This logic is comparable to core's `size_format()` function.
+ *
+ * @param bytes The size in bytes.
+ * @param decimals The number of decimal places to include in the result.
+ * @return The human-readable file size string.
+ */
+function formatFileSize( bytes: number, decimals = 2 ): string {
+ if ( bytes === 0 ) {
+ return getBytesString( 0, _x( 'B', 'unit symbol' ), decimals );
+ }
+ const quant = {
+ /* translators: Unit symbol for yottabyte. */
+ [ _x( 'YB', 'unit symbol' ) ]: YB_IN_BYTES,
+ /* translators: Unit symbol for zettabyte. */
+ [ _x( 'ZB', 'unit symbol' ) ]: ZB_IN_BYTES,
+ /* translators: Unit symbol for exabyte. */
+ [ _x( 'EB', 'unit symbol' ) ]: EB_IN_BYTES,
+ /* translators: Unit symbol for petabyte. */
+ [ _x( 'PB', 'unit symbol' ) ]: PB_IN_BYTES,
+ /* translators: Unit symbol for terabyte. */
+ [ _x( 'TB', 'unit symbol' ) ]: TB_IN_BYTES,
+ /* translators: Unit symbol for gigabyte. */
+ [ _x( 'GB', 'unit symbol' ) ]: GB_IN_BYTES,
+ /* translators: Unit symbol for megabyte. */
+ [ _x( 'MB', 'unit symbol' ) ]: MB_IN_BYTES,
+ /* translators: Unit symbol for kilobyte. */
+ [ _x( 'KB', 'unit symbol' ) ]: KB_IN_BYTES,
+ /* translators: Unit symbol for byte. */
+ [ _x( 'B', 'unit symbol' ) ]: 1,
+ };
+
+ for ( const [ unit, mag ] of Object.entries( quant ) ) {
+ if ( bytes >= mag ) {
+ return getBytesString( bytes / mag, unit, decimals );
+ }
+ }
+
+ return ''; // Fallback in case no unit matches, though this should not happen.
+}
+
+const filesizeField: Partial< Field< MediaItem > > = {
+ id: 'filesize',
+ type: 'text',
+ label: __( 'File size' ),
+ getValue: ( { item }: { item: MediaItem } ) =>
+ item?.media_details?.filesize
+ ? formatFileSize( item?.media_details?.filesize )
+ : '',
+ isVisible: ( item: MediaItem ) => {
+ return !! item?.media_details?.filesize;
+ },
+ enableSorting: false,
+ filterBy: false,
+ readOnly: true,
+};
+
+export default filesizeField;
diff --git a/packages/media-fields/src/index.ts b/packages/media-fields/src/index.ts
new file mode 100644
index 00000000000000..06be4ca933066a
--- /dev/null
+++ b/packages/media-fields/src/index.ts
@@ -0,0 +1,15 @@
+export { default as altTextField } from './alt_text';
+export { default as captionField } from './caption';
+export { default as descriptionField } from './description';
+export { default as filenameField } from './filename';
+export { default as filesizeField } from './filesize';
+export { default as mediaDimensionsField } from './media_dimensions';
+export { default as mediaThumbnailField } from './media_thumbnail';
+export { default as mimeTypeField } from './mime_type';
+
+export type {
+ MediaItem,
+ MediaKind,
+ MediaType,
+ MediaItemUpdatable,
+} from './types';
diff --git a/packages/media-fields/src/media_dimensions/index.ts b/packages/media-fields/src/media_dimensions/index.ts
new file mode 100644
index 00000000000000..280acfe0a545d2
--- /dev/null
+++ b/packages/media-fields/src/media_dimensions/index.ts
@@ -0,0 +1,29 @@
+/**
+ * WordPress dependencies
+ */
+import { __, _x, sprintf } from '@wordpress/i18n';
+import type { Attachment, Updatable } from '@wordpress/core-data';
+import type { Field } from '@wordpress/dataviews';
+
+const mediaDimensionsField: Partial< Field< Updatable< Attachment > > > = {
+ id: 'media_dimensions',
+ type: 'text',
+ label: __( 'Dimensions' ),
+ getValue: ( { item } ) =>
+ item?.media_details?.width && item?.media_details?.height
+ ? sprintf(
+ // translators: 1: Width. 2: Height.
+ _x( '%1$s × %2$s', 'image dimensions' ),
+ item?.media_details?.width?.toString(),
+ item?.media_details?.height?.toString()
+ )
+ : '',
+ isVisible: ( item ) => {
+ return !! ( item?.media_details?.width && item?.media_details?.height );
+ },
+ enableSorting: false,
+ filterBy: false,
+ readOnly: true,
+};
+
+export default mediaDimensionsField;
diff --git a/packages/media-fields/src/media_thumbnail/index.tsx b/packages/media-fields/src/media_thumbnail/index.tsx
new file mode 100644
index 00000000000000..b710a10b15dbc4
--- /dev/null
+++ b/packages/media-fields/src/media_thumbnail/index.tsx
@@ -0,0 +1,22 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { MediaItem } from '../types';
+import MediaThumbnailView from './view';
+
+const mediaThumbnailField: Partial< Field< MediaItem > > = {
+ id: 'media_thumbnail',
+ type: 'media',
+ label: __( 'Thumbnail' ),
+ render: MediaThumbnailView,
+ enableSorting: false,
+ filterBy: false,
+};
+
+export default mediaThumbnailField;
diff --git a/packages/media-fields/src/media_thumbnail/style.scss b/packages/media-fields/src/media_thumbnail/style.scss
new file mode 100644
index 00000000000000..f7ea7ab291cefa
--- /dev/null
+++ b/packages/media-fields/src/media_thumbnail/style.scss
@@ -0,0 +1,49 @@
+@use "@wordpress/base-styles/mixins";
+@use "@wordpress/base-styles/colors";
+@use "@wordpress/base-styles/variables";
+
+.dataviews-media-field__media-thumbnail {
+ display: flex;
+ align-items: center;
+ position: relative;
+ height: 100%;
+}
+
+.dataviews-media-field__media-thumbnail--image {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.dataviews-media-field__media-thumbnail__stack {
+ color: colors.$gray-700;
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+}
+
+.dataviews-media-field__media-thumbnail--icon {
+ color: colors.$gray-700;
+ fill: currentColor;
+}
+
+.dataviews-media-field__media-thumbnail__filename {
+ box-sizing: border-box;
+ text-align: center;
+ padding: 0 variables.$grid-unit-20;
+ width: 100%;
+ container-type: inline-size;
+ @include mixins.body-small();
+
+ &__truncate {
+ // Use a top margin instead of gap on the parent, as the text will not
+ // always be rendered. This ensures the icon is vertically centered in all contexts.
+ margin-top: variables.$grid-unit-05;
+
+ // Hide filename when previews are very small, as rendered in list views.
+ @container (max-width: 90px) {
+ display: none !important;
+ }
+ }
+}
diff --git a/packages/media-fields/src/media_thumbnail/view.tsx b/packages/media-fields/src/media_thumbnail/view.tsx
new file mode 100644
index 00000000000000..ac8e662ed39cc0
--- /dev/null
+++ b/packages/media-fields/src/media_thumbnail/view.tsx
@@ -0,0 +1,104 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import {
+ __experimentalTruncate as Truncate,
+ __experimentalVStack as VStack,
+ Icon,
+} from '@wordpress/components';
+import type { Attachment } from '@wordpress/core-data';
+import { getFilename } from '@wordpress/url';
+import type { DataViewRenderFieldProps } from '@wordpress/dataviews';
+/**
+ * Internal dependencies
+ */
+import { getMediaTypeFromMimeType } from '../utils/get-media-type-from-mime-type';
+import type { MediaItem } from '../types';
+
+export default function MediaThumbnailView( {
+ item,
+ config,
+}: DataViewRenderFieldProps< MediaItem > ) {
+ const _featuredMedia = useSelect(
+ ( select ) => {
+ // Avoid the network request if it's not needed. `featured_media` is
+ // 0 for images and media without featured media.
+ if ( ! item.featured_media ) {
+ return;
+ }
+ return select( coreStore ).getEntityRecord< Attachment >(
+ 'postType',
+ 'attachment',
+ item.featured_media
+ );
+ },
+ [ item.featured_media ]
+ );
+ const featuredMedia = item.featured_media ? _featuredMedia : item;
+
+ // Fetching.
+ if ( ! featuredMedia ) {
+ return null;
+ }
+
+ const filename = getFilename( featuredMedia.source_url || '' );
+
+ if (
+ // Ensure the featured media is an image.
+ getMediaTypeFromMimeType( featuredMedia.mime_type ).type === 'image'
+ ) {
+ return (
+
+

+ )
+ .map(
+ ( size ) =>
+ `${ size.source_url } ${ size.width }w`
+ )
+ .join( ', ' )
+ : undefined
+ }
+ sizes={ config?.sizes || '100vw' }
+ alt={ featuredMedia.alt_text || featuredMedia.title.raw }
+ />
+
+ );
+ }
+
+ return (
+
+
+
+ { !! filename && (
+
+
+ { filename }
+
+
+ ) }
+
+
+ );
+}
diff --git a/packages/media-fields/src/mime_type/index.ts b/packages/media-fields/src/mime_type/index.ts
new file mode 100644
index 00000000000000..949f4c7af7161b
--- /dev/null
+++ b/packages/media-fields/src/mime_type/index.ts
@@ -0,0 +1,19 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Attachment, Updatable } from '@wordpress/core-data';
+import type { Field } from '@wordpress/dataviews';
+
+const mimeTypeField: Partial< Field< Updatable< Attachment > > > = {
+ id: 'mime_type',
+ type: 'text',
+ label: __( 'File type' ),
+ getValue: ( { item } ) => item?.mime_type || '',
+ render: ( { item } ) => item?.mime_type || '-',
+ enableSorting: true,
+ filterBy: false,
+ readOnly: true,
+};
+
+export default mimeTypeField;
diff --git a/packages/media-fields/src/stories/index.story.tsx b/packages/media-fields/src/stories/index.story.tsx
new file mode 100644
index 00000000000000..410fbe0a978246
--- /dev/null
+++ b/packages/media-fields/src/stories/index.story.tsx
@@ -0,0 +1,290 @@
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+import { DataForm, DataViews, type Form } from '@wordpress/dataviews';
+import type { Field, View } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import {
+ altTextField,
+ captionField,
+ descriptionField,
+ filenameField,
+ filesizeField,
+ mediaDimensionsField,
+ mediaThumbnailField,
+ mimeTypeField,
+ type MediaItem,
+} from '../index';
+
+export default {
+ title: 'Fields/Media Fields',
+ component: DataForm,
+};
+
+// Sample data for media fields
+const sampleMediaItem: MediaItem = {
+ id: 123,
+ date: '2024-01-15T10:30:00',
+ date_gmt: '2024-01-15T10:30:00',
+ guid: {
+ raw: 'https://cldup.com/cXyG__fTLN.jpg',
+ rendered: 'https://cldup.com/cXyG__fTLN.jpg',
+ },
+ modified: '2024-01-15T10:30:00',
+ modified_gmt: '2024-01-15T10:30:00',
+ slug: 'sample-image',
+ status: 'publish',
+ type: 'attachment',
+ link: 'https://example.com/sample-image/',
+ title: {
+ raw: 'Sample Image',
+ rendered: 'Sample Image',
+ },
+ author: 1,
+ featured_media: 0,
+ comment_status: 'open',
+ ping_status: 'closed',
+ template: '',
+ meta: {},
+ permalink_template: 'https://example.com/?attachment_id=123',
+ generated_slug: 'sample-image',
+ class_list: [ 'post-123', 'attachment' ],
+ alt_text: 'A beautiful sample image',
+ caption: {
+ raw: 'A caption for the image',
+ rendered: 'A caption for the image
\n',
+ },
+ description: {
+ raw: 'This is a detailed description of the sample image. It contains useful information about what the image depicts and its context.',
+ rendered:
+ 'This is a detailed description of the sample image. It contains useful information about what the image depicts and its context.
',
+ },
+ mime_type: 'image/jpeg',
+ media_type: 'image',
+ post: null,
+ source_url: 'https://cldup.com/cXyG__fTLN.jpg',
+ media_details: {
+ file: 'sample-image.jpg',
+ width: 1920,
+ height: 1080,
+ filesize: 524288,
+ image_meta: {
+ aperture: '2.8',
+ credit: '',
+ camera: 'Sample Camera',
+ caption: '',
+ created_timestamp: '1705315800',
+ copyright: '',
+ focal_length: '50',
+ iso: '100',
+ shutter_speed: '0.004',
+ title: '',
+ orientation: '1',
+ keywords: [],
+ },
+ sizes: {
+ thumbnail: {
+ file: 'sample-image-150x150.jpg',
+ width: 150,
+ height: 150,
+ filesize: 8192,
+ mime_type: 'image/jpeg',
+ source_url: 'https://cldup.com/cXyG__fTLN.jpg',
+ },
+ medium: {
+ file: 'sample-image-300x169.jpg',
+ width: 300,
+ height: 169,
+ filesize: 24576,
+ mime_type: 'image/jpeg',
+ source_url: 'https://cldup.com/cXyG__fTLN.jpg',
+ },
+ },
+ },
+ missing_image_sizes: [],
+};
+
+// Sample data for a non-image file (ZIP)
+const sampleMediaItemZip: MediaItem = {
+ id: 101,
+ date: '2025-11-07T00:28:54',
+ date_gmt: '2025-11-07T00:28:54',
+ guid: {
+ raw: 'http://localhost:8888/wp-content/uploads/2025/11/gutenberg-v22-0-0.zip',
+ rendered:
+ 'http://localhost:8888/wp-content/uploads/2025/11/gutenberg-v22-0-0.zip',
+ },
+ modified: '2025-11-07T00:28:54',
+ modified_gmt: '2025-11-07T00:28:54',
+ slug: 'gutenberg-v22-0-0',
+ status: 'publish',
+ type: 'attachment',
+ link: 'http://localhost:8888/gutenberg-v22-0-0/',
+ title: {
+ raw: 'gutenberg-v22-0-0',
+ rendered: 'gutenberg-v22-0-0',
+ },
+ author: 1,
+ featured_media: 0,
+ comment_status: 'open',
+ ping_status: 'closed',
+ template: '',
+ meta: {},
+ permalink_template: 'http://localhost:8888/?attachment_id=101',
+ generated_slug: 'gutenberg-v22-0-0',
+ class_list: [ 'post-101', 'attachment' ],
+ alt_text: '',
+ caption: {
+ raw: '',
+ rendered: 'gutenberg-v22-0-0
\n',
+ },
+ description: {
+ raw: '',
+ rendered: '',
+ },
+ mime_type: 'application/zip',
+ media_type: 'file',
+ post: 1,
+ source_url:
+ 'http://localhost:8888/wp-content/uploads/2025/11/gutenberg-v22-0-0.zip',
+ media_details: {
+ file: 'gutenberg-v22-0-0.zip',
+ filesize: 19988723,
+ width: 0,
+ height: 0,
+ image_meta: {
+ aperture: '',
+ credit: '',
+ camera: '',
+ caption: '',
+ created_timestamp: '',
+ copyright: '',
+ focal_length: '',
+ iso: '',
+ shutter_speed: '',
+ title: '',
+ orientation: '',
+ keywords: [],
+ },
+ sizes: {},
+ },
+ missing_image_sizes: [],
+};
+
+// Create a showcase of all media fields.
+const showcaseFields = [
+ mediaThumbnailField,
+ filenameField,
+ altTextField,
+ captionField,
+ descriptionField,
+ mimeTypeField,
+ mediaDimensionsField,
+ filesizeField,
+] as Field< any >[];
+
+const DataFormsComponent = ( { type }: { type: 'regular' | 'panel' } ) => {
+ const [ data, setData ] = useState< MediaItem >( sampleMediaItem );
+
+ const handleChange = ( updates: Partial< MediaItem > ) => {
+ setData( ( prev: MediaItem ) => ( { ...prev, ...updates } ) );
+ };
+
+ // Form configuration for the media fields showcase.
+ const showcaseForm: Form = {
+ layout: {
+ type,
+ },
+ fields: [
+ 'media_thumbnail',
+ 'alt_text',
+ 'caption',
+ 'description',
+ 'filename',
+ 'mime_type',
+ 'media_dimensions',
+ 'filesize',
+ ],
+ };
+
+ return (
+
+
Media Fields
+
+ This story demonstrates all the media fields from the
+ @wordpress/media-fields package within a DataForm.
+
+
+
+
+ );
+};
+
+export const DataFormsPreview = {
+ render: DataFormsComponent,
+ argTypes: {
+ type: {
+ control: { type: 'select' },
+ description: 'Choose the layout type.',
+ options: [ 'regular', 'panel' ],
+ },
+ },
+ args: {
+ type: 'regular',
+ },
+};
+
+export const DataViewsPreview = () => {
+ const [ view, setView ] = useState< View >( {
+ type: 'table',
+ fields: showcaseFields
+ .map( ( f ) => f.id )
+ .filter( ( id ) => id !== 'media_thumbnail' ),
+ descriptionField: undefined,
+ mediaField: 'media_thumbnail',
+ showTitle: false,
+ } );
+ const [ data ] = useState< MediaItem[] >( [
+ sampleMediaItem,
+ sampleMediaItemZip,
+ ] );
+
+ const paginationInfo = {
+ totalItems: 2,
+ totalPages: 1,
+ };
+
+ const defaultLayouts = {
+ table: {},
+ list: {},
+ grid: {},
+ };
+
+ return (
+
+
Media Fields DataViews Preview
+
+ This story demonstrates all the media fields from the
+ @wordpress/media-fields package, rendered in a DataViews
+ component, allowing preview of view state and layout switching.
+
+
setView( nextView ) }
+ paginationInfo={ paginationInfo }
+ defaultLayouts={ defaultLayouts }
+ />
+
+ );
+};
diff --git a/packages/media-fields/src/style.scss b/packages/media-fields/src/style.scss
new file mode 100644
index 00000000000000..d2426224c06c34
--- /dev/null
+++ b/packages/media-fields/src/style.scss
@@ -0,0 +1 @@
+@use "./media_thumbnail/style.scss" as *;
diff --git a/packages/media-fields/src/types.ts b/packages/media-fields/src/types.ts
new file mode 100644
index 00000000000000..8709db58d52e8f
--- /dev/null
+++ b/packages/media-fields/src/types.ts
@@ -0,0 +1,24 @@
+/**
+ * WordPress dependencies
+ */
+import type { Attachment, Updatable, Post } from '@wordpress/core-data';
+
+export type MediaKind = 'image' | 'video' | 'audio' | 'application';
+
+export interface MediaType {
+ type: MediaKind;
+ label: string;
+ icon: JSX.Element;
+}
+
+// TODO: Update the Attachment type separately.
+export interface MediaItem extends Attachment< 'edit' > {
+ // featured_media is not in the Attachment type. See https://github.com/WordPress/gutenberg/blob/trunk/packages/core-data/src/entity-types/attachment.ts#L10
+ featured_media: number;
+ _embedded?: {
+ // TODO: Include wp:attached-to properly, and backport PHP changes from wordpress-develop to support this.
+ 'wp:attached-to'?: Post[] | Partial< Post >[];
+ };
+}
+
+export type MediaItemUpdatable = Updatable< Attachment >;
diff --git a/packages/media-fields/src/utils/get-media-type-from-mime-type.ts b/packages/media-fields/src/utils/get-media-type-from-mime-type.ts
new file mode 100644
index 00000000000000..973620e9d46863
--- /dev/null
+++ b/packages/media-fields/src/utils/get-media-type-from-mime-type.ts
@@ -0,0 +1,54 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { audio, video, image, file } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { MediaType } from '../types';
+
+/**
+ * Get the media type from a mime type, including an icon.
+ * TODO - media types should be formalized somewhere.
+ *
+ * References:
+ * https://developer.wordpress.org/reference/functions/wp_mime_type_icon/
+ * https://developer.wordpress.org/reference/hooks/mime_types/
+ * https://developer.wordpress.org/reference/functions/wp_get_mime_types/
+ *
+ * @param mimeType - The mime type to get the media type from.
+ * @return The media type.
+ */
+export function getMediaTypeFromMimeType( mimeType: string ): MediaType {
+ if ( mimeType.startsWith( 'image/' ) ) {
+ return {
+ type: 'image',
+ label: __( 'Image' ),
+ icon: image,
+ };
+ }
+
+ if ( mimeType.startsWith( 'video/' ) ) {
+ return {
+ type: 'video',
+ label: __( 'Video' ),
+ icon: video,
+ };
+ }
+
+ if ( mimeType.startsWith( 'audio/' ) ) {
+ return {
+ type: 'audio',
+ label: __( 'Audio' ),
+ icon: audio,
+ };
+ }
+
+ return {
+ type: 'application',
+ label: __( 'Application' ),
+ icon: file,
+ };
+}
diff --git a/packages/media-fields/src/utils/get-raw-content.ts b/packages/media-fields/src/utils/get-raw-content.ts
new file mode 100644
index 00000000000000..fe24e6997cbe82
--- /dev/null
+++ b/packages/media-fields/src/utils/get-raw-content.ts
@@ -0,0 +1,32 @@
+/**
+ * Utility function to extract raw content from either a string or an object
+ * containing raw and rendered properties.
+ *
+ * This handles the inconsistency in WordPress REST API responses where
+ * some fields like caption and description can be either:
+ * - A simple string
+ * - An object with { raw: string, rendered: string }
+ *
+ * @param content - The content to extract raw value from
+ * @return The raw content string, or empty string if content is falsy
+ */
+export function getRawContent(
+ content: string | { raw: string; rendered: string } | undefined | null
+): string {
+ if ( ! content ) {
+ return '';
+ }
+
+ // If it's a string, return it directly
+ if ( typeof content === 'string' ) {
+ return content;
+ }
+
+ // If it's an object with raw property, return the raw value
+ if ( typeof content === 'object' && 'raw' in content ) {
+ return content.raw || '';
+ }
+
+ // Fallback to empty string
+ return '';
+}
diff --git a/packages/media-fields/src/utils/get-rendered-content.ts b/packages/media-fields/src/utils/get-rendered-content.ts
new file mode 100644
index 00000000000000..45248c038bf67b
--- /dev/null
+++ b/packages/media-fields/src/utils/get-rendered-content.ts
@@ -0,0 +1,32 @@
+/**
+ * Utility function to extract rendered content from either a string or an object
+ * containing raw and rendered properties.
+ *
+ * This handles the inconsistency in WordPress REST API responses where
+ * some fields like caption and description can be either:
+ * - A simple string
+ * - An object with { raw: string, rendered: string }
+ *
+ * @param content - The content to extract raw value from
+ * @return The rendered content string, falling back to raw or empty string if content is falsy
+ */
+export function getRenderedContent(
+ content: string | { raw: string; rendered: string } | undefined | null
+): string {
+ if ( ! content ) {
+ return '';
+ }
+
+ // If it's a string, return it directly
+ if ( typeof content === 'string' ) {
+ return content;
+ }
+
+ // If it's an object with raw property, return the raw value
+ if ( typeof content === 'object' ) {
+ return content.rendered || content.raw || '';
+ }
+
+ // Fallback to empty string
+ return '';
+}
diff --git a/packages/media-fields/tsconfig.json b/packages/media-fields/tsconfig.json
new file mode 100644
index 00000000000000..97f7a88b282cbd
--- /dev/null
+++ b/packages/media-fields/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig.json",
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "types": [
+ "gutenberg-env",
+ "gutenberg-test-env",
+ "jest",
+ "@testing-library/jest-dom"
+ ]
+ },
+ "references": [
+ { "path": "../components" },
+ { "path": "../core-data" },
+ { "path": "../data" },
+ { "path": "../dataviews" },
+ { "path": "../element" },
+ { "path": "../i18n" },
+ { "path": "../icons" },
+ { "path": "../primitives" },
+ { "path": "../url" }
+ ],
+ "exclude": [
+ "src/**/*.android.js",
+ "src/**/*.ios.js",
+ "src/**/*.native.js",
+ "src/**/react-native-*",
+ "src/**/stories/**/*.js", // only exclude js files, tsx files should be checked
+ "src/**/test/**/*.js" // only exclude js files, ts{x} files should be checked
+ ]
+}
diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json
index 59316b3a04105f..4fd5fda26e7b15 100644
--- a/packages/media-utils/package.json
+++ b/packages/media-utils/package.json
@@ -35,6 +35,9 @@
},
"wpScript": true,
"types": "build-types",
+ "sideEffects": [
+ "build-style/**"
+ ],
"dependencies": {
"@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/blob": "file:../blob",
@@ -45,6 +48,7 @@
"@wordpress/element": "file:../element",
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
+ "@wordpress/media-fields": "file:../media-fields",
"@wordpress/private-apis": "file:../private-apis"
},
"peerDependencies": {
diff --git a/packages/media-utils/src/components/media-upload-modal/index.tsx b/packages/media-utils/src/components/media-upload-modal/index.tsx
index b0e20d9e8ac574..9b4f4b8b08bc63 100644
--- a/packages/media-utils/src/components/media-upload-modal/index.tsx
+++ b/packages/media-utils/src/components/media-upload-modal/index.tsx
@@ -10,12 +10,22 @@ import {
import { resolveSelect } from '@wordpress/data';
import { Modal, DropZone, FormFileUpload, Button } from '@wordpress/components';
import { upload as uploadIcon } from '@wordpress/icons';
+import { DataViewsPicker } from '@wordpress/dataviews';
+import type { View, Field, ActionButton } from '@wordpress/dataviews';
+import {
+ altTextField,
+ captionField,
+ descriptionField,
+ filenameField,
+ filesizeField,
+ mediaDimensionsField,
+ mediaThumbnailField,
+ mimeTypeField,
+} from '@wordpress/media-fields';
/**
* Internal dependencies
*/
-import { DataViewsPicker } from '@wordpress/dataviews';
-import type { View, Field, ActionButton } from '@wordpress/dataviews';
import type { Attachment, RestAttachment } from '../../utils/types';
import { transformAttachment } from '../../utils/transform-attachment';
import { uploadMedia } from '../../utils/upload-media';
@@ -151,8 +161,9 @@ export function MediaUploadModal( {
const [ view, setView ] = useState< View >( () => ( {
type: LAYOUT_PICKER_GRID,
fields: [],
+ showTitle: false,
titleField: 'title',
- mediaField: 'url',
+ mediaField: 'media_thumbnail',
search: '',
page: 1,
perPage: 20,
@@ -211,23 +222,6 @@ export function MediaUploadModal( {
const fields: Field< RestAttachment >[] = useMemo(
() => [
- {
- id: 'url',
- type: 'media' as const,
- label: __( 'Media' ),
- render: ( { item }: { item: RestAttachment } ) => (
-
- ),
- },
{
id: 'title',
type: 'text' as const,
@@ -237,13 +231,16 @@ export function MediaUploadModal( {
return titleValue || __( '(no title)' );
},
},
- {
- id: 'alt',
- type: 'text' as const,
- label: __( 'Alt text' ),
- getValue: ( { item }: { item: RestAttachment } ) =>
- item.alt_text,
- },
+ // Media field definitions from @wordpress/media-fields
+ // Cast is safe because RestAttachment has the same properties as Attachment
+ mediaThumbnailField as Field< RestAttachment >,
+ altTextField as Field< RestAttachment >,
+ captionField as Field< RestAttachment >,
+ descriptionField as Field< RestAttachment >,
+ filenameField as Field< RestAttachment >,
+ filesizeField as Field< RestAttachment >,
+ mediaDimensionsField as Field< RestAttachment >,
+ mimeTypeField as Field< RestAttachment >,
],
[]
);
@@ -319,8 +316,14 @@ export function MediaUploadModal( {
const defaultLayouts = useMemo(
() => ( {
- [ LAYOUT_PICKER_GRID ]: {},
- [ LAYOUT_PICKER_TABLE ]: {},
+ [ LAYOUT_PICKER_GRID ]: {
+ fields: [],
+ showTitle: false,
+ },
+ [ LAYOUT_PICKER_TABLE ]: {
+ fields: [ 'filename', 'filesize', 'media_dimensions' ],
+ showTitle: true,
+ },
} ),
[]
);
diff --git a/packages/media-utils/src/style.scss b/packages/media-utils/src/style.scss
new file mode 100644
index 00000000000000..b96131f28a97af
--- /dev/null
+++ b/packages/media-utils/src/style.scss
@@ -0,0 +1 @@
+@use "@wordpress/media-fields/build-style/style.css" as *;
diff --git a/packages/media-utils/tsconfig.json b/packages/media-utils/tsconfig.json
index 5d6d8272564eef..1ec018e909f6b6 100644
--- a/packages/media-utils/tsconfig.json
+++ b/packages/media-utils/tsconfig.json
@@ -15,6 +15,7 @@
{ "path": "../element" },
{ "path": "../i18n" },
{ "path": "../icons" },
+ { "path": "../media-fields" },
{ "path": "../private-apis" }
]
}
diff --git a/storybook/main.js b/storybook/main.js
index 91da1025ac5fec..47d71dba2cfdfa 100644
--- a/storybook/main.js
+++ b/storybook/main.js
@@ -33,12 +33,13 @@ const stories = [
'../packages/block-editor/src/**/stories/*.story.@(js|jsx|tsx|mdx)',
'../packages/components/src/**/stories/*.story.@(jsx|tsx)',
'../packages/components/src/**/stories/*.mdx',
- '../packages/icons/src/**/stories/*.story.@(jsx|tsx|mdx)',
- '../packages/edit-site/src/**/stories/*.story.@(jsx|tsx|mdx)',
- '../packages/global-styles-ui/src/**/stories/*.story.@(jsx|tsx|mdx)',
- '../packages/dataviews/src/**/stories/*.story.@(jsx|tsx|mdx)',
- '../packages/fields/src/**/stories/*.story.@(jsx|tsx|mdx)',
+ '../packages/icons/src/**/stories/*.story.@(js|tsx|mdx)',
+ '../packages/edit-site/src/**/stories/*.story.@(js|tsx|mdx)',
+ '../packages/global-styles-ui/src/**/stories/*.story.@(js|tsx|mdx)',
+ '../packages/dataviews/src/**/stories/*.story.@(js|tsx|mdx)',
+ '../packages/fields/src/**/stories/*.story.@(js|tsx|mdx)',
'../packages/image-cropper/src/**/stories/*.story.@(js|tsx|mdx)',
+ '../packages/media-fields/src/**/stories/*.story.@(js|tsx|mdx)',
'../packages/theme/src/**/stories/*.story.@(tsx|mdx)',
'../packages/ui/src/**/stories/*.story.@(ts|tsx)',
].filter( Boolean );
diff --git a/storybook/package-styles/config.js b/storybook/package-styles/config.js
index a682e751b000ce..285ae07bf1baf0 100644
--- a/storybook/package-styles/config.js
+++ b/storybook/package-styles/config.js
@@ -15,6 +15,8 @@ import dataviewsLtr from '../package-styles/dataviews-ltr.lazy.scss';
import dataviewsRtl from '../package-styles/dataviews-rtl.lazy.scss';
import fieldsLtr from '../package-styles/fields-ltr.lazy.scss';
import fieldsRtl from '../package-styles/fields-rtl.lazy.scss';
+import mediaFieldsLtr from '../package-styles/media-fields-ltr.lazy.scss';
+import mediaFieldsRtl from '../package-styles/media-fields-rtl.lazy.scss';
/**
* Stylesheets to lazy load when the story's context.componentId matches the
@@ -62,8 +64,8 @@ const CONFIG = [
},
{
componentIdMatcher: /^fields-/,
- ltr: [ componentsLtr, dataviewsLtr, fieldsLtr ],
- rtl: [ componentsRtl, dataviewsRtl, fieldsRtl ],
+ ltr: [ componentsLtr, dataviewsLtr, fieldsLtr, mediaFieldsLtr ],
+ rtl: [ componentsRtl, dataviewsRtl, fieldsRtl, mediaFieldsRtl ],
},
];
diff --git a/storybook/package-styles/media-fields-ltr.lazy.scss b/storybook/package-styles/media-fields-ltr.lazy.scss
new file mode 100644
index 00000000000000..9c1238af7eaec5
--- /dev/null
+++ b/storybook/package-styles/media-fields-ltr.lazy.scss
@@ -0,0 +1 @@
+@import "../../packages/media-fields/build-style/style.css";
diff --git a/storybook/package-styles/media-fields-rtl.lazy.scss b/storybook/package-styles/media-fields-rtl.lazy.scss
new file mode 100644
index 00000000000000..88f2c2e710ec39
--- /dev/null
+++ b/storybook/package-styles/media-fields-rtl.lazy.scss
@@ -0,0 +1 @@
+@import "../../packages/media-fields/build-style/style-rtl.css";
diff --git a/tsconfig.json b/tsconfig.json
index 05529ae60906ee..e29b1775e260dc 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -46,6 +46,7 @@
{ "path": "packages/latex-to-mathml" },
{ "path": "packages/lazy-import" },
{ "path": "packages/lazy-editor" },
+ { "path": "packages/media-fields" },
{ "path": "packages/media-utils" },
{ "path": "packages/notices" },
{ "path": "packages/plugins" },