diff --git a/packages/block-editor/src/components/link-control/search-input.js b/packages/block-editor/src/components/link-control/search-input.js
new file mode 100644
index 0000000000000..addf781d378ed
--- /dev/null
+++ b/packages/block-editor/src/components/link-control/search-input.js
@@ -0,0 +1,187 @@
+/**
+ * WordPress dependencies
+ */
+import { forwardRef, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import deprecated from '@wordpress/deprecated';
+
+/**
+ * Internal dependencies
+ */
+import { URLInput } from '../';
+import LinkControlSearchResults from './search-results';
+import { CREATE_TYPE } from './constants';
+import useSearchHandler from './use-search-handler';
+
+// Must be a function as otherwise URLInput will default
+// to the fetchLinkSuggestions passed in block editor settings
+// which will cause an unintended http request.
+const noopSearchHandler = () => Promise.resolve( [] );
+
+const noop = () => {};
+
+const LinkControlSearchInput = forwardRef(
+ (
+ {
+ value,
+ children,
+ currentLink = {},
+ className = null,
+ placeholder = null,
+ withCreateSuggestion = false,
+ onCreateSuggestion = noop,
+ onChange = noop,
+ onSelect = noop,
+ showSuggestions = true,
+ renderSuggestions = ( props ) => (
+
+ ),
+ fetchSuggestions = null,
+ allowDirectEntry = true,
+ showInitialSuggestions = false,
+ suggestionsQuery = {},
+ withURLSuggestion = true,
+ createSuggestionButtonText,
+ hideLabelFromVision = false,
+ suffix,
+ isEntity = false,
+ },
+ ref
+ ) => {
+ const genericSearchHandler = useSearchHandler(
+ suggestionsQuery,
+ allowDirectEntry,
+ withCreateSuggestion,
+ withURLSuggestion
+ );
+
+ const searchHandler = showSuggestions
+ ? fetchSuggestions || genericSearchHandler
+ : noopSearchHandler;
+
+ const [ focusedSuggestion, setFocusedSuggestion ] = useState();
+
+ /**
+ * Handles the user moving between different suggestions. Does not handle
+ * choosing an individual item.
+ *
+ * @param {string} selection the url of the selected suggestion.
+ * @param {Object} suggestion the suggestion object.
+ */
+ const onInputChange = ( selection, suggestion ) => {
+ onChange( selection );
+ setFocusedSuggestion( suggestion );
+ };
+
+ const handleRenderSuggestions = ( props ) =>
+ renderSuggestions( {
+ ...props,
+ withCreateSuggestion,
+ createSuggestionButtonText,
+ suggestionsQuery,
+ handleSuggestionClick: ( suggestion ) => {
+ if ( props.handleSuggestionClick ) {
+ props.handleSuggestionClick( suggestion );
+ }
+ onSuggestionSelected( suggestion );
+ },
+ } );
+
+ const onSuggestionSelected = async ( selectedSuggestion ) => {
+ let suggestion = selectedSuggestion;
+ if ( CREATE_TYPE === selectedSuggestion.type ) {
+ // Create a new page and call onSelect with the output from the onCreateSuggestion callback.
+ try {
+ suggestion = await onCreateSuggestion(
+ selectedSuggestion.title
+ );
+ if ( suggestion?.url ) {
+ onSelect( suggestion );
+ }
+ } catch ( e ) {}
+ return;
+ }
+
+ if (
+ allowDirectEntry ||
+ ( suggestion && Object.keys( suggestion ).length >= 1 )
+ ) {
+ // Strip out id, url, kind, and type from the current link to prevent
+ // entity metadata from persisting when switching to a different link type.
+ // For example, when changing from an entity link (kind: 'post-type', type: 'page')
+ // to a custom URL (type: 'link', no kind), we need to ensure the old 'kind'
+ // doesn't carry over. We do want to preserve other properites like title, though.
+ const { id, url, kind, type, ...restLinkProps } =
+ currentLink ?? {};
+ onSelect(
+ // Some direct entries don't have types or IDs, and we still need to clear the previous ones.
+ { ...restLinkProps, ...suggestion },
+ suggestion
+ );
+ }
+ };
+
+ const _placeholder = placeholder ?? __( 'Search or type URL' );
+
+ const label =
+ hideLabelFromVision && placeholder !== ''
+ ? _placeholder
+ : __( 'Link' );
+
+ return (
+
+
+ { __( 'Insert Link' ) }
+
+ {
+ const hasSuggestion = suggestion || focusedSuggestion;
+
+ // If there is no suggestion and the value (ie: any manually entered URL) is empty
+ // then don't allow submission otherwise we get empty links.
+ if ( ! hasSuggestion && ! value?.trim()?.length ) {
+ event.preventDefault();
+ } else {
+ onSuggestionSelected(
+ hasSuggestion || { url: value }
+ );
+ }
+ } }
+ inputRef={ ref }
+ suffix={ suffix }
+ disabled={ isEntity }
+ />
+ { children }
+
+ );
+ }
+);
+
+export default LinkControlSearchInput;
+
+export const __experimentalLinkControlSearchInput = ( props ) => {
+ deprecated( 'wp.blockEditor.__experimentalLinkControlSearchInput', {
+ since: '6.8',
+ } );
+
+ return ;
+};
diff --git a/packages/components/src/modal/index.tsx b/packages/components/src/modal/index.tsx
new file mode 100644
index 0000000000000..ed4693ee6bb1f
--- /dev/null
+++ b/packages/components/src/modal/index.tsx
@@ -0,0 +1,421 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ createPortal,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ forwardRef,
+ useLayoutEffect,
+ createContext,
+ useContext,
+} from '@wordpress/element';
+import {
+ useInstanceId,
+ useFocusReturn,
+ useFocusOnMount,
+ useConstrainedTabbing,
+ useMergeRefs,
+} from '@wordpress/compose';
+import { __ } from '@wordpress/i18n';
+import { close } from '@wordpress/icons';
+import { getScrollContainer } from '@wordpress/dom';
+
+/**
+ * Internal dependencies
+ */
+import * as ariaHelper from './aria-helper';
+import Button from '../button';
+import StyleProvider from '../style-provider';
+import type { ModalProps } from './types';
+import { withIgnoreIMEEvents } from '../utils/with-ignore-ime-events';
+import { Spacer } from '../spacer';
+import { useModalExitAnimation } from './use-modal-exit-animation';
+
+// Used to track and dismiss the prior modal when another opens unless nested.
+type Dismissers = Set<
+ React.RefObject< ModalProps[ 'onRequestClose' ] | undefined >
+>;
+const ModalContext = createContext< Dismissers >( new Set() );
+ModalContext.displayName = 'ModalContext';
+
+// Used to track body class names applied while modals are open.
+const bodyOpenClasses = new Map< string, number >();
+
+function UnforwardedModal(
+ props: ModalProps,
+ forwardedRef: React.ForwardedRef< HTMLDivElement >
+) {
+ const {
+ bodyOpenClassName = 'modal-open',
+ role = 'dialog',
+ title = null,
+ focusOnMount = true,
+ shouldCloseOnEsc = true,
+ shouldCloseOnClickOutside = true,
+ isDismissible = true,
+ /* Accessibility. */
+ aria = {
+ labelledby: undefined,
+ describedby: undefined,
+ },
+ onRequestClose,
+ icon,
+ closeButtonLabel,
+ children,
+ style,
+ overlayClassName: overlayClassnameProp,
+ className,
+ contentLabel,
+ onKeyDown,
+ isFullScreen = false,
+ size,
+ headerActions = null,
+ __experimentalHideHeader = false,
+ } = props;
+
+ const ref = useRef< HTMLDivElement >();
+
+ const instanceId = useInstanceId( Modal );
+ const headingId = title
+ ? `components-modal-header-${ instanceId }`
+ : aria.labelledby;
+
+ // The focus hook does not support 'firstContentElement' but this is a valid
+ // value for the Modal's focusOnMount prop. The following code ensures the focus
+ // hook will focus the first focusable node within the element to which it is applied.
+ // When `firstContentElement` is passed as the value of the focusOnMount prop,
+ // the focus hook is applied to the Modal's content element.
+ // Otherwise, the focus hook is applied to the Modal's ref. This ensures that the
+ // focus hook will focus the first element in the Modal's **content** when
+ // `firstContentElement` is passed.
+ const focusOnMountRef = useFocusOnMount(
+ focusOnMount === 'firstContentElement' ? 'firstElement' : focusOnMount
+ );
+ const constrainedTabbingRef = useConstrainedTabbing();
+ const focusReturnRef = useFocusReturn();
+ const contentRef = useRef< HTMLDivElement >( null );
+ const childrenContainerRef = useRef< HTMLDivElement >( null );
+
+ const [ hasScrolledContent, setHasScrolledContent ] = useState( false );
+ const [ hasScrollableContent, setHasScrollableContent ] = useState( false );
+
+ let sizeClass;
+ if ( isFullScreen || size === 'fill' ) {
+ sizeClass = 'is-full-screen';
+ } else if ( size ) {
+ sizeClass = `has-size-${ size }`;
+ }
+
+ // Determines whether the Modal content is scrollable and updates the state.
+ const isContentScrollable = useCallback( () => {
+ if ( ! contentRef.current ) {
+ return;
+ }
+
+ const closestScrollContainer = getScrollContainer( contentRef.current );
+
+ if ( contentRef.current === closestScrollContainer ) {
+ setHasScrollableContent( true );
+ } else {
+ setHasScrollableContent( false );
+ }
+ }, [ contentRef ] );
+
+ // Accessibly isolates/unisolates the modal.
+ useEffect( () => {
+ ariaHelper.modalize( ref.current );
+ return () => ariaHelper.unmodalize();
+ }, [] );
+
+ // Keeps a fresh ref for the subsequent effect.
+ const onRequestCloseRef = useRef< ModalProps[ 'onRequestClose' ] >();
+ useEffect( () => {
+ onRequestCloseRef.current = onRequestClose;
+ }, [ onRequestClose ] );
+
+ // The list of `onRequestClose` callbacks of open (non-nested) Modals. Only
+ // one should remain open at a time and the list enables closing prior ones.
+ const dismissers = useContext( ModalContext );
+ // Used for the tracking and dismissing any nested modals.
+ const [ nestedDismissers ] = useState< Dismissers >( () => new Set() );
+
+ // Updates the stack tracking open modals at this level and calls
+ // onRequestClose for any prior and/or nested modals as applicable.
+ useEffect( () => {
+ // add this modal instance to the dismissers set
+ dismissers.add( onRequestCloseRef );
+ // request that all the other modals close themselves
+ for ( const dismisser of dismissers ) {
+ if ( dismisser !== onRequestCloseRef ) {
+ dismisser.current?.();
+ }
+ }
+ return () => {
+ // request that all the nested modals close themselves
+ for ( const dismisser of nestedDismissers ) {
+ dismisser.current?.();
+ }
+ // remove this modal instance from the dismissers set
+ dismissers.delete( onRequestCloseRef );
+ };
+ }, [ dismissers, nestedDismissers ] );
+
+ // Adds/removes the value of bodyOpenClassName to body element.
+ useEffect( () => {
+ const theClass = bodyOpenClassName;
+ const oneMore = 1 + ( bodyOpenClasses.get( theClass ) ?? 0 );
+ bodyOpenClasses.set( theClass, oneMore );
+ document.body.classList.add( bodyOpenClassName );
+ return () => {
+ const oneLess = bodyOpenClasses.get( theClass )! - 1;
+ if ( oneLess === 0 ) {
+ document.body.classList.remove( theClass );
+ bodyOpenClasses.delete( theClass );
+ } else {
+ bodyOpenClasses.set( theClass, oneLess );
+ }
+ };
+ }, [ bodyOpenClassName ] );
+
+ const { closeModal, frameRef, frameStyle, overlayClassname } =
+ useModalExitAnimation();
+
+ // Calls the isContentScrollable callback when the Modal children container resizes.
+ useLayoutEffect( () => {
+ if ( ! window.ResizeObserver || ! childrenContainerRef.current ) {
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver( isContentScrollable );
+ resizeObserver.observe( childrenContainerRef.current );
+
+ isContentScrollable();
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [ isContentScrollable, childrenContainerRef ] );
+
+ function handleEscapeKeyDown(
+ event: React.KeyboardEvent< HTMLDivElement >
+ ) {
+ if (
+ shouldCloseOnEsc &&
+ ( event.code === 'Escape' || event.key === 'Escape' ) &&
+ ! event.defaultPrevented
+ ) {
+ event.preventDefault();
+ closeModal().then( () => onRequestClose( event ) );
+ }
+ }
+
+ const onContentContainerScroll = useCallback(
+ ( e: React.UIEvent< HTMLDivElement > ) => {
+ const scrollY = e?.currentTarget?.scrollTop ?? -1;
+
+ if ( ! hasScrolledContent && scrollY > 0 ) {
+ setHasScrolledContent( true );
+ } else if ( hasScrolledContent && scrollY <= 0 ) {
+ setHasScrolledContent( false );
+ }
+ },
+ [ hasScrolledContent ]
+ );
+
+ let pressTarget: EventTarget | null = null;
+ const overlayPressHandlers: {
+ onPointerDown: React.PointerEventHandler< HTMLDivElement >;
+ onPointerUp: React.PointerEventHandler< HTMLDivElement >;
+ } = {
+ onPointerDown: ( event ) => {
+ if ( event.target === event.currentTarget ) {
+ pressTarget = event.target;
+ // Avoids focus changing so that focus return works as expected.
+ event.preventDefault();
+ }
+ },
+ // Closes the modal with two exceptions. 1. Opening the context menu on
+ // the overlay. 2. Pressing on the overlay then dragging the pointer
+ // over the modal and releasing. Due to the modal being a child of the
+ // overlay, such a gesture is a `click` on the overlay and cannot be
+ // excepted by a `click` handler. Thus the tactic of handling
+ // `pointerup` and comparing its target to that of the `pointerdown`.
+ onPointerUp: ( { target, button } ) => {
+ const isSameTarget = target === pressTarget;
+ pressTarget = null;
+ if ( button === 0 && isSameTarget ) {
+ closeModal().then( () => onRequestClose() );
+ }
+ },
+ };
+
+ const modal = (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+
+
+
+
+ { ! __experimentalHideHeader && (
+
+
+ { icon && (
+
+ { icon }
+
+ ) }
+ { title && (
+
+ { title }
+
+ ) }
+
+ { headerActions }
+ { isDismissible && (
+ <>
+
+
+ ) }
+
+
+ { children }
+
+
+
+
+
+ );
+
+ return createPortal(
+
+ { modal }
+ ,
+ document.body
+ );
+}
+
+/**
+ * Modals give users information and choices related to a task they’re trying to
+ * accomplish. They can contain critical information, require decisions, or
+ * involve multiple tasks.
+ *
+ * ```jsx
+ * import { Button, Modal } from '@wordpress/components';
+ * import { useState } from '@wordpress/element';
+ *
+ * const MyModal = () => {
+ * const [ isOpen, setOpen ] = useState( false );
+ * const openModal = () => setOpen( true );
+ * const closeModal = () => setOpen( false );
+ *
+ * return (
+ * <>
+ *
+ * { isOpen && (
+ *
+ *
+ *
+ * ) }
+ * >
+ * );
+ * };
+ * ```
+ */
+export const Modal = forwardRef( UnforwardedModal );
+
+export default Modal;