-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Fields: Add MediaEdit component #73537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
946a630
adbe37c
0d13667
6f05b62
99b45f3
b71390c
a793790
e513c54
17707d0
2214576
56b9d30
1b02c08
9a1795b
4868842
d6fad37
b158865
57c8efc
f16bee5
b69ea5b
ada84e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,306 @@ | ||
| /** | ||
| * WordPress dependencies | ||
| */ | ||
| import { | ||
| Button, | ||
| Icon, | ||
| __experimentalText as Text, | ||
| __experimentalTruncate as Truncate, | ||
| __experimentalVStack as VStack, | ||
| Tooltip, | ||
| VisuallyHidden, | ||
| } from '@wordpress/components'; | ||
| import { store as coreStore, type Attachment } from '@wordpress/core-data'; | ||
| import { useSelect } from '@wordpress/data'; | ||
| import { useCallback, useState } from '@wordpress/element'; | ||
| import { __ } from '@wordpress/i18n'; | ||
| import { archive, audio, video, file } from '@wordpress/icons'; | ||
| import { | ||
| MediaUpload, | ||
| privateApis as mediaUtilsPrivateApis, | ||
| } from '@wordpress/media-utils'; | ||
|
|
||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import { unlock } from '../../lock-unlock'; | ||
| import type { MediaEditProps } from '../../types'; | ||
|
|
||
| const { MediaUploadModal } = unlock( mediaUtilsPrivateApis ); | ||
|
|
||
| /** | ||
| * Conditional Media component that uses MediaUploadModal when experiment is enabled, | ||
| * otherwise falls back to media-utils MediaUpload. | ||
| * | ||
| * @param root0 Component props. | ||
| * @param root0.render Render prop function that receives { open } object. | ||
| * @param root0.multiple Whether to allow multiple media selections. | ||
| * @return The component. | ||
| */ | ||
| function ConditionalMediaUpload( { render, multiple, ...props }: any ) { | ||
| const [ isModalOpen, setIsModalOpen ] = useState( false ); | ||
| if ( ( window as any ).__experimentalDataViewsMediaModal ) { | ||
| return ( | ||
| <> | ||
| { render && render( { open: () => setIsModalOpen( true ) } ) } | ||
| { isModalOpen && ( | ||
| <MediaUploadModal | ||
| { ...props } | ||
| multiple={ multiple } | ||
| isOpen={ isModalOpen } | ||
| onClose={ () => { | ||
| setIsModalOpen( false ); | ||
| props.onClose?.(); | ||
| } } | ||
| onSelect={ ( media: any ) => { | ||
| setIsModalOpen( false ); | ||
| props.onSelect?.( media ); | ||
| } } | ||
| /> | ||
| ) } | ||
| </> | ||
| ); | ||
| } | ||
| // Fallback to media-utils MediaUpload when experiment is disabled. | ||
| return ( | ||
| <MediaUpload | ||
| { ...props } | ||
| render={ render } | ||
| multiple={ multiple ? 'add' : undefined } | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| function MediaPickerButton( { | ||
| open, | ||
| children, | ||
| label, | ||
| showTooltip = false, | ||
| }: { | ||
| open: () => void; | ||
| children: React.ReactNode; | ||
| label: string; | ||
| showTooltip?: boolean; | ||
| } ) { | ||
| const mediaPickerButton = ( | ||
| <div | ||
| className="fields__media-edit-picker-button" | ||
| role="button" | ||
| tabIndex={ 0 } | ||
| onClick={ open } | ||
| onKeyDown={ ( event ) => { | ||
| if ( event.key === 'Enter' || event.key === ' ' ) { | ||
| event.preventDefault(); | ||
| open(); | ||
| } | ||
| } } | ||
| aria-label={ label } | ||
| > | ||
| { children } | ||
| </div> | ||
|
Comment on lines
+86
to
+100
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can this be a real button? Or do you want to turn this into a drop zone next? From the design, it looks like a drop zone to me.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good question. I didn't think of it much because it was existing implementation to have the wrapper as a div. For now it could be a button I think, but also I had in mind that this also would become a drop zone. I'm not sure if the drop zone affects the tag we use, but we can update then if needed. |
||
| ); | ||
| if ( ! showTooltip ) { | ||
| return mediaPickerButton; | ||
| } | ||
| return <Tooltip text={ label }>{ mediaPickerButton }</Tooltip>; | ||
| } | ||
|
|
||
| const archiveMimeTypes = [ | ||
| 'application/zip', | ||
| 'application/x-zip-compressed', | ||
| 'application/x-rar-compressed', | ||
| 'application/x-7z-compressed', | ||
| 'application/x-tar', | ||
| 'application/x-gzip', | ||
| ]; | ||
|
|
||
| function MediaPreview( { | ||
| url, | ||
| attachment, | ||
| }: { | ||
| url: string; | ||
| attachment: Attachment< 'view' > | null; | ||
| } ) { | ||
| if ( ! attachment ) { | ||
| return null; | ||
| } | ||
| const attachmentTitle = attachment.title.rendered; | ||
| const mimeType = attachment.mime_type; | ||
| let preview: JSX.Element = <Icon icon={ file } />; | ||
| if ( mimeType.startsWith( 'image/' ) ) { | ||
| preview = ( | ||
| <img | ||
| className="fields__media-edit-thumbnail" | ||
| alt={ attachment.alt_text || '' } | ||
| src={ url } | ||
| /> | ||
| ); | ||
| } else if ( mimeType.startsWith( 'audio/' ) ) { | ||
| preview = <Icon icon={ audio } />; | ||
| } else if ( mimeType.startsWith( 'video/' ) ) { | ||
| preview = <Icon icon={ video } />; | ||
| } else if ( archiveMimeTypes.includes( mimeType ) ) { | ||
| preview = <Icon icon={ archive } />; | ||
| } | ||
| return ( | ||
| <> | ||
| { preview } | ||
| <Truncate className="fields__media-edit-filename"> | ||
| { attachmentTitle } | ||
| </Truncate> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * A media edit control component that provides a media picker UI with upload functionality | ||
| * for selecting WordPress media attachments. Supports both the traditional WordPress media | ||
| * library and the experimental DataViews media modal. | ||
| * | ||
| * This component is intended to be used as the `Edit` property of a field definition when | ||
| * registering fields with `registerEntityField` from `@wordpress/editor`. | ||
| * | ||
| * @template Item - The type of the item being edited. | ||
| * | ||
| * @param {MediaEditProps<Item>} props - The component props. | ||
| * @param {Item} props.data - The item being edited. | ||
| * @param {Object} props.field - The field configuration with getValue and setValue methods. | ||
| * @param {Function} props.onChange - Callback function when the media selection changes. | ||
|
Comment on lines
+166
to
+168
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is missing other DataFormControlProps:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The challenge with a label for this control is that in core (quick edit) we use the @jameskoster any ideas for these?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For hiding the label when in regular layout, we should use
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. btw, fine to be follow-ups 👍
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It should support one like in the designs. For validation can we not use the standard design? I think we're still missing a way to display the validation message in panel layout when the label is hidden, but that's something to address 'globally'. |
||
| * @param {string[]} [props.allowedTypes] - Array of allowed media types. Default `['image']`. | ||
| * @param {boolean} [props.multiple] - Whether to allow multiple media selections. Default `false`. | ||
| * | ||
| * @return {JSX.Element} The media edit control component. | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * import { MediaEdit } from '@wordpress/fields'; | ||
| * import type { DataFormControlProps } from '@wordpress/dataviews'; | ||
| * | ||
| * const featuredImageField = { | ||
| * id: 'featured_media', | ||
| * type: 'media', | ||
| * label: 'Featured Image', | ||
| * Edit: (props: DataFormControlProps<MyPostType>) => ( | ||
| * <MediaEdit | ||
| * {...props} | ||
| * allowedTypes={['image']} | ||
| * /> | ||
| * ), | ||
| * }; | ||
| * ``` | ||
| */ | ||
| export default function MediaEdit< Item >( { | ||
| data, | ||
| field, | ||
| onChange, | ||
| allowedTypes = [ 'image' ], | ||
| multiple, | ||
| }: MediaEditProps< Item > ) { | ||
| const value = field.getValue( { item: data } ); | ||
| const attachments = useSelect( | ||
| ( select ) => { | ||
| if ( ! value ) { | ||
| return null; | ||
| } | ||
| const normalizedValue = Array.isArray( value ) ? value : [ value ]; | ||
| const { getEntityRecords } = select( coreStore ); | ||
| return getEntityRecords( 'postType', 'attachment', { | ||
| include: normalizedValue, | ||
| } ) as Attachment< 'view' >[] | null; | ||
| }, | ||
| [ value ] | ||
| ); | ||
| const onChangeControl = useCallback( | ||
| ( newValue: number | number[] | undefined ) => | ||
| onChange( field.setValue( { item: data, value: newValue } ) ), | ||
| [ data, field, onChange ] | ||
| ); | ||
| const removeItem = ( itemId: number ) => { | ||
| const currentIds = Array.isArray( value ) ? value : [ value ]; | ||
| const newIds = currentIds.filter( ( id ) => id !== itemId ); | ||
| onChangeControl( newIds.length ? newIds : undefined ); | ||
| }; | ||
| return ( | ||
| <fieldset className="fields__media-edit" data-field-id={ field.id }> | ||
| <ConditionalMediaUpload | ||
| onSelect={ ( selectedMedia: any ) => { | ||
| if ( multiple ) { | ||
| const newIds = Array.isArray( selectedMedia ) | ||
| ? selectedMedia.map( ( m: any ) => m.id ) | ||
| : [ selectedMedia.id ]; | ||
| onChangeControl( newIds ); | ||
| } else { | ||
| onChangeControl( selectedMedia.id ); | ||
| } | ||
| } } | ||
| allowedTypes={ allowedTypes } | ||
| value={ value } | ||
| multiple={ multiple } | ||
| title={ field.label } | ||
| render={ ( { open }: any ) => { | ||
| const addButtonLabel = attachments?.length | ||
| ? __( 'Add files' ) | ||
| : field.placeholder || __( 'Choose file' ); | ||
| return ( | ||
| <VStack spacing={ 2 }> | ||
| <VisuallyHidden as="label"> | ||
| { field.label } | ||
| </VisuallyHidden> | ||
| { !! attachments?.length && ( | ||
| <VStack spacing={ 2 }> | ||
| { attachments.map( ( attachment ) => ( | ||
| <div | ||
| key={ attachment.id } | ||
| className="fields__media-edit-row" | ||
| > | ||
| <MediaPickerButton | ||
| open={ open } | ||
| label={ __( 'Replace' ) } | ||
| showTooltip | ||
| > | ||
| <MediaPreview | ||
| url={ | ||
| attachment.source_url | ||
| } | ||
| attachment={ attachment } | ||
| /> | ||
| </MediaPickerButton> | ||
| <Button | ||
| __next40pxDefaultSize | ||
| className="fields__media-edit-remove" | ||
| text={ __( 'Remove' ) } | ||
| variant="secondary" | ||
| onClick={ ( | ||
| event: React.MouseEvent< HTMLButtonElement > | ||
| ) => { | ||
| event.stopPropagation(); | ||
| removeItem( attachment.id ); | ||
| } } | ||
| /> | ||
| </div> | ||
| ) ) } | ||
| </VStack> | ||
| ) } | ||
| { ( multiple || ! attachments?.length ) && ( | ||
| <MediaPickerButton | ||
| open={ open } | ||
| label={ addButtonLabel } | ||
| > | ||
| <span className="fields__media-edit-placeholder"> | ||
| { addButtonLabel } | ||
| </span> | ||
| </MediaPickerButton> | ||
| ) } | ||
|
|
||
| { field.description && ( | ||
| <Text variant="muted"> | ||
| { field.description } | ||
| </Text> | ||
| ) } | ||
| </VStack> | ||
| ); | ||
| } } | ||
| /> | ||
| </fieldset> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.