fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

284
node_modules/@wordpress/components/src/modal/README.md generated vendored Normal file
View File

@@ -0,0 +1,284 @@
# Modal
Modals give users information and choices related to a task theyre trying to accomplish. They can contain critical information, require decisions, or involve multiple tasks.
![An alert modal for trashing a post](https://wordpress.org/gutenberg/files/2019/04/Modal.png)
## Design guidelines
### Usage
A modal is a type of floating window that appears in front of content to provide critical information or ask for a decision. Modals disable all other functionality when they appear. A modal remains on screen until the user confirms it, dismisses it, or takes the required action.
While modals can be an effective way to disclose additional controls or information, they can also be a source of interruption for the user. For this reason, always question whether a modal is necessary, and work to avoid the situations in which they are required.
#### Principles
- **Focused**. Modals pull user attention away from the rest of the screen to focus their attention, ensuring that the modals content is addressed.
- **Direct**. Modal text should communicate important information and be dedicated to helping the user appropriately complete a task.
- **Helpful**. Modals should appear in response to a user task or an action to offer relevant and contextual information.
#### When to use
Modals are used for:
- Errors that block normal operation.
- Critical information that requires a specific user task, decision, or acknowledgement.
- Contextual information that appears in response to a user task or action.
### Anatomy
![A modal diagram with labels](https://wordpress.org/gutenberg/files/2019/04/Modal-diagram.png)
1. Container
2. Title
3. Supporting text
4. Buttons
5. Scrim
6. Close button (optional)
### Modal box and scrim
A modal is a type of window. Access to the rest of the UI is disabled until the modal is addressed. All modals are interruptive by design their purpose is to have the user focus on content, so the modal surface appears in front of all other surfaces.
To clarify that the rest of the screen is inaccessible and to focus attention on the modal, surfaces behind the modal are scrimmed — they get a temporary overlay to obscure their content and make it less prominent.
### Title
A modals purpose is communicated through its title and button text.
All modals should have a title for accessibility reasons (the `contentLabel` prop can be used to set titles that aren't visible).
Titles should:
- Contain a brief, clear statement or question
- Avoid apologies (“Sorry for the interruption”), alarm (“Warning!”), or ambiguity (“Are you sure?”).
![A modal that asks "Trash post?"](https://wordpress.org/gutenberg/files/2019/04/Modal-do-1.png)
**Do**
This modal title poses a specific question, concisely explains the purpose the request, and provides clear actions.
![A modal that asks "Are you sure?"](https://wordpress.org/gutenberg/files/2019/04/Modal-dont-1.png)
**Dont**
This modal creates ambiguity, and therefore unease — it leaves the user unsure about how to respond, or causes them to second-guess their answer.
### Buttons
#### Side-by-side buttons (recommended)
Side-by-side buttons display two text buttons next to one another.
![A modal with two buttons next to each other](https://wordpress.org/gutenberg/files/2019/04/Modal-buttons.png)
#### Stacked or full-width buttons
Use stacked buttons when you need to accommodate longer button text. Always place confirming actions above dismissive actions.
![A modal with two buttons stacked on top of each other](https://wordpress.org/gutenberg/files/2019/04/Modal-buttons-stacked.png)
### Behavior
Modals appear without warning, requiring users to stop their current task. They should be used sparingly — not every choice or setting warrants this kind of abrupt interruption.
#### Position
Modals retain focus until dismissed or the user completes an action, like choosing a setting. They shouldnt be obscured by other elements or appear partially on screen.
#### Scrolling
Most modal content should avoid scrolling. Scrolling is permissible if the modal content exceeds the height of the modal (e.g. a list component with many rows). When a modal scrolls, the modal title is pinned at the top and the buttons are pinned at the bottom. This ensures that content remains visible alongside the title and buttons, even while scrolling.
Modals dont scroll with elements outside of the modal, like the background.
When viewing a scrollable list of options, the modal title and buttons remain fixed.
#### Dismissing modals
Modals are dismissible in three ways:
- Tapping outside of the modal
- Tapping the “Cancel” button
- Tapping the “Close” icon button, or pressing the `esc` key
If the users ability to dismiss a modal is disabled, they must choose a modal action to proceed.
## Development guidelines
The modal is used to create an accessible modal over an application.
**Note:** The API for this modal has been mimicked to resemble [`react-modal`](https://github.com/reactjs/react-modal).
### Usage
The following example shows you how to properly implement a modal. For the modal to properly work it's important you implement the close logic for the modal properly.
```jsx
import { useState } from 'react';
import { Button, Modal } from '@wordpress/components';
const MyModal = () => {
const [ isOpen, setOpen ] = useState( false );
const openModal = () => setOpen( true );
const closeModal = () => setOpen( false );
return (
<>
<Button variant="secondary" onClick={ openModal }>
Open Modal
</Button>
{ isOpen && (
<Modal title="This is my modal" onRequestClose={ closeModal }>
<Button variant="secondary" onClick={ closeModal }>
My custom close button
</Button>
</Modal>
) }
</>
);
};
```
### Props
The set of props accepted by the component will be specified below.
Props not included in this set will be applied to the input elements.
#### `aria.describedby`: `string`
If this property is added, it will be added to the modal content `div` as `aria-describedby`.
- Required: No
#### `aria.labelledby`: `string`
If this property is added, it will be added to the modal content `div` as `aria-labelledby`.
Use this when you are rendering the title yourself within the modal's content area instead of using the `title` prop. This ensures the title is usable by assistive technology.
Titles are required for accessibility reasons, see `contentLabel` and `title` for other ways to provide a title.
- Required: No
- Default: if the `title` prop is provided, this will default to the id of the element that renders `title`
#### `bodyOpenClassName`: `string`
Class name added to the body element when the modal is open.
- Required: No
- Default: `modal-open`
#### `className`: `string`
If this property is added, it will an additional class name to the modal content `div`.
- Required: No
#### `contentLabel`: `string`
If this property is added, it will be added to the modal content `div` as `aria-label`.
Titles are required for accessibility reasons, see `aria.labelledby` and `title` for other ways to provide a title.
- Required: No
#### `focusOnMount`: `boolean | 'firstElement'` | 'firstContentElement'
If this property is true, it will focus the first tabbable element rendered in the modal.
If this property is false, focus will not be transferred and it is the responsibility of the consumer to ensure accessible focus management.
If set to `firstElement` focus will be placed on the first tabbable element anywhere within the Modal.
If set to `firstContentElement` focus will be placed on the first tabbable element within the Modal's **content** (i.e. children). Note that it is the responsibility of the consumer to ensure there is at least one tabbable element within the children **or the focus will be lost**.
- Required: No
- Default: `true`
#### headerActions
An optional React node intended to contain additional actions or other elements related to the modal, for example, buttons. Content is rendered in the top right corner of the modal and to the left of the close button, if visible.
- Required: No
- Default: `null`
#### `isDismissible`: `boolean`
If this property is set to false, the modal will not display a close icon and cannot be dismissed.
- Required: No
- Default: `true`
#### `isFullScreen`: `boolean`
This property when set to `true` will render a full screen modal.
- Required: No
- Default: `false`
#### `size`: `'small' | 'medium' | 'large' | 'fill'`
If this property is added it will cause the modal to render at a preset width, or expand to fill the screen. This prop will be ignored if `isFullScreen` is set to `true`.
- Required: No
Note: `Modal`'s width can also be controlled by adjusting the width of the modal's contents via CSS.
#### `onRequestClose`: ``
This function is called to indicate that the modal should be closed.
- Required: Yes
#### `overlayClassName`: `string`
If this property is added, it will an additional class name to the modal overlay `div`.
- Required: No
#### `role`: `AriaRole`
If this property is added, it will override the default role of the modal.
- Required: No
- Default: `dialog`
#### `shouldCloseOnClickOutside`: `boolean`
If this property is added, it will determine whether the modal requests to close when a mouse click occurs outside of the modal content.
- Required: No
- Default: `true`
#### `shouldCloseOnEsc`: `boolean`
If this property is added, it will determine whether the modal requests to close when the escape key is pressed.
- Required: No
- Default: `true`
#### `style`: `CSSProperties`
If this property is added, it will be added to the modal frame `div`.
- Required: No
#### `title`: `string`
This property is used as the modal header's title.
Titles are required for accessibility reasons, see `aria.labelledby` and `contentLabel` for other ways to provide a title.
- Required: No
#### `__experimentalHideHeader`: `boolean`
When set to `true`, the Modal's header (including the icon, title and close button) will not be rendered.
_Warning_: This property is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
- Required: No
- Default: `false`
## Related components
- To notify a user with a message of medium importance, use `Notice`.

View File

@@ -0,0 +1,63 @@
const LIVE_REGION_ARIA_ROLES = new Set( [
'alert',
'status',
'log',
'marquee',
'timer',
] );
const hiddenElementsByDepth: Element[][] = [];
/**
* Hides all elements in the body element from screen-readers except
* the provided element and elements that should not be hidden from
* screen-readers.
*
* The reason we do this is because `aria-modal="true"` currently is bugged
* in Safari, and support is spotty in other browsers overall. In the future
* we should consider removing these helper functions in favor of
* `aria-modal="true"`.
*
* @param modalElement The element that should not be hidden.
*/
export function modalize( modalElement?: HTMLDivElement ) {
const elements = Array.from( document.body.children );
const hiddenElements: Element[] = [];
hiddenElementsByDepth.push( hiddenElements );
for ( const element of elements ) {
if ( element === modalElement ) continue;
if ( elementShouldBeHidden( element ) ) {
element.setAttribute( 'aria-hidden', 'true' );
hiddenElements.push( element );
}
}
}
/**
* Determines if the passed element should not be hidden from screen readers.
*
* @param element The element that should be checked.
*
* @return Whether the element should not be hidden from screen-readers.
*/
export function elementShouldBeHidden( element: Element ) {
const role = element.getAttribute( 'role' );
return ! (
element.tagName === 'SCRIPT' ||
element.hasAttribute( 'aria-hidden' ) ||
element.hasAttribute( 'aria-live' ) ||
( role && LIVE_REGION_ARIA_ROLES.has( role ) )
);
}
/**
* Accessibly reveals the elements hidden by the latest modal.
*/
export function unmodalize() {
const hiddenElements = hiddenElementsByDepth.pop();
if ( ! hiddenElements ) return;
for ( const element of hiddenElements )
element.removeAttribute( 'aria-hidden' );
}

399
node_modules/@wordpress/components/src/modal/index.tsx generated vendored Normal file
View File

@@ -0,0 +1,399 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import type {
ForwardedRef,
KeyboardEvent,
MutableRefObject,
UIEvent,
} from 'react';
/**
* 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';
// Used to track and dismiss the prior modal when another opens unless nested.
const ModalContext = createContext<
MutableRefObject< ModalProps[ 'onRequestClose' ] | undefined >[]
>( [] );
// Used to track body class names applied while modals are open.
const bodyOpenClasses = new Map< string, number >();
function UnforwardedModal(
props: ModalProps,
forwardedRef: 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,
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 refOnRequestClose = useRef< ModalProps[ 'onRequestClose' ] >();
useEffect( () => {
refOnRequestClose.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 = useRef< typeof dismissers >( [] );
// Updates the stack tracking open modals at this level and calls
// onRequestClose for any prior and/or nested modals as applicable.
useEffect( () => {
dismissers.push( refOnRequestClose );
const [ first, second ] = dismissers;
if ( second ) first?.current?.();
const nested = nestedDismissers.current;
return () => {
nested[ 0 ]?.current?.();
dismissers.shift();
};
}, [ dismissers ] );
// 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 ] );
// 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: KeyboardEvent< HTMLDivElement > ) {
if (
// Ignore keydowns from IMEs
event.nativeEvent.isComposing ||
// Workaround for Mac Safari where the final Enter/Backspace of an IME composition
// is `isComposing=false`, even though it's technically still part of the composition.
// These can only be detected by keyCode.
event.keyCode === 229
) {
return;
}
if (
shouldCloseOnEsc &&
( event.code === 'Escape' || event.key === 'Escape' ) &&
! event.defaultPrevented
) {
event.preventDefault();
if ( onRequestClose ) {
onRequestClose( event );
}
}
}
const onContentContainerScroll = useCallback(
( e: 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 ) onRequestClose();
},
};
const modal = (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
ref={ useMergeRefs( [ ref, forwardedRef ] ) }
className={ classnames(
'components-modal__screen-overlay',
overlayClassName
) }
onKeyDown={ handleEscapeKeyDown }
{ ...( shouldCloseOnClickOutside ? overlayPressHandlers : {} ) }
>
<StyleProvider document={ document }>
<div
className={ classnames(
'components-modal__frame',
sizeClass,
className
) }
style={ style }
ref={ useMergeRefs( [
constrainedTabbingRef,
focusReturnRef,
focusOnMount !== 'firstContentElement'
? focusOnMountRef
: null,
] ) }
role={ role }
aria-label={ contentLabel }
aria-labelledby={ contentLabel ? undefined : headingId }
aria-describedby={ aria.describedby }
tabIndex={ -1 }
onKeyDown={ onKeyDown }
>
<div
className={ classnames( 'components-modal__content', {
'hide-header': __experimentalHideHeader,
'is-scrollable': hasScrollableContent,
'has-scrolled-content': hasScrolledContent,
} ) }
role="document"
onScroll={ onContentContainerScroll }
ref={ contentRef }
aria-label={
hasScrollableContent
? __( 'Scrollable section' )
: undefined
}
tabIndex={ hasScrollableContent ? 0 : undefined }
>
{ ! __experimentalHideHeader && (
<div className="components-modal__header">
<div className="components-modal__header-heading-container">
{ icon && (
<span
className="components-modal__icon-container"
aria-hidden
>
{ icon }
</span>
) }
{ title && (
<h1
id={ headingId }
className="components-modal__header-heading"
>
{ title }
</h1>
) }
</div>
{ headerActions }
{ isDismissible && (
<Button
onClick={ onRequestClose }
icon={ close }
label={
closeButtonLabel || __( 'Close' )
}
/>
) }
</div>
) }
<div
ref={ useMergeRefs( [
childrenContainerRef,
focusOnMount === 'firstContentElement'
? focusOnMountRef
: null,
] ) }
>
{ children }
</div>
</div>
</div>
</StyleProvider>
</div>
);
return createPortal(
<ModalContext.Provider value={ nestedDismissers.current }>
{ modal }
</ModalContext.Provider>,
document.body
);
}
/**
* Modals give users information and choices related to a task theyre 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 (
* <>
* <Button variant="secondary" onClick={ openModal }>
* Open Modal
* </Button>
* { isOpen && (
* <Modal title="This is my modal" onRequestClose={ closeModal }>
* <Button variant="secondary" onClick={ closeModal }>
* My custom close button
* </Button>
* </Modal>
* ) }
* </>
* );
* };
* ```
*/
export const Modal = forwardRef( UnforwardedModal );
export default Modal;

View File

@@ -0,0 +1,125 @@
/**
* External dependencies
*/
import type { StoryFn, Meta } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
import { starEmpty, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import Button from '../../button';
import InputControl from '../../input-control';
import Modal from '../';
import type { ModalProps } from '../types';
const meta: Meta< typeof Modal > = {
component: Modal,
title: 'Components/Modal',
argTypes: {
children: {
control: { type: null },
},
onKeyDown: {
control: { type: null },
},
focusOnMount: {
options: [ true, false, 'firstElement', 'firstContentElement' ],
control: { type: 'select' },
},
role: {
control: { type: 'text' },
},
onRequestClose: {
action: 'onRequestClose',
},
isDismissible: {
control: { type: 'boolean' },
},
},
parameters: {
controls: { expanded: true },
},
};
export default meta;
const Template: StoryFn< typeof Modal > = ( { onRequestClose, ...args } ) => {
const [ isOpen, setOpen ] = useState( false );
const openModal = () => setOpen( true );
const closeModal: ModalProps[ 'onRequestClose' ] = ( event ) => {
setOpen( false );
onRequestClose( event );
};
return (
<>
<Button variant="secondary" onClick={ openModal }>
Open Modal
</Button>
{ isOpen && (
<Modal onRequestClose={ closeModal } { ...args }>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et magna
aliqua. Ut enim ad minim veniam, quis nostrud
exercitation ullamco laboris nisi ut aliquip ex ea ea
commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur. Excepteur sint occaecat cupidatat
non proident, sunt in culpa qui officia deserunt mollit
anim id est laborum.
</p>
<InputControl style={ { marginBottom: '20px' } } />
<Button variant="secondary" onClick={ closeModal }>
Close Modal
</Button>
</Modal>
) }
</>
);
};
export const Default: StoryFn< typeof Modal > = Template.bind( {} );
Default.args = {
title: 'Title',
};
Default.parameters = {
docs: {
source: {
code: '',
},
},
};
export const WithsizeSmall: StoryFn< typeof Modal > = Template.bind( {} );
WithsizeSmall.args = {
size: 'small',
};
WithsizeSmall.storyName = 'With size: small';
const LikeButton = () => {
const [ isLiked, setIsLiked ] = useState( false );
return (
<Button
icon={ isLiked ? starFilled : starEmpty }
label="Like"
onClick={ () => setIsLiked( ! isLiked ) }
/>
);
};
export const WithHeaderActions: StoryFn< typeof Modal > = Template.bind( {} );
WithHeaderActions.args = {
...Default.args,
headerActions: <LikeButton />,
isDismissible: false,
};
WithHeaderActions.parameters = {
...Default.parameters,
};

169
node_modules/@wordpress/components/src/modal/style.scss generated vendored Normal file
View File

@@ -0,0 +1,169 @@
// The scrim behind the modal window.
.components-modal__screen-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba($black, 0.35);
z-index: z-index(".components-modal__screen-overlay");
display: flex;
// backdrop-filter: blur($grid-unit);
// This animates the appearance of the white background.
@include edit-post__fade-in-animation();
}
// The modal window element.
.components-modal__frame {
// Use the entire viewport on smaller screens.
margin: $grid-unit-50 0 0 0;
width: 100%;
background: $white;
box-shadow: $shadow-modal;
border-radius: $grid-unit-05 $grid-unit-05 0 0;
overflow: hidden;
// Have the content element fill the vertical space yet not overflow.
display: flex;
// Animate the modal frame/contents appearing on the page.
animation: components-modal__appear-animation 0.1s ease-out;
animation-fill-mode: forwards;
@include reduce-motion("animation");
// Show a centered modal on bigger screens.
@include break-small() {
border-radius: $grid-unit-05;
margin: auto;
width: auto;
min-width: $modal-min-width;
max-width: calc(100% - #{$grid-unit-20 * 2});
max-height: calc(100% - #{$header-height * 2});
&.is-full-screen {
@include break-small() {
width: calc(100% - #{ $grid-unit-20 * 2 });
height: calc(100% - #{ $grid-unit-20 * 2 });
max-height: none;
}
@include break-medium() {
width: calc(100% - #{ $grid-unit-50 * 2 });
height: calc(100% - #{ $grid-unit-50 * 2 });
max-width: none;
}
}
&.has-size-small,
&.has-size-medium,
&.has-size-large {
width: 100%;
}
// The following widths were selected to align with existing baselines
// found elsewhere in the editor.
// See https://github.com/WordPress/gutenberg/pull/54471#issuecomment-1723818809
&.has-size-small {
max-width: $modal-width-small;
}
&.has-size-medium {
max-width: $modal-width-medium;
}
&.has-size-large {
max-width: $modal-width-large;
}
}
@include break-large() {
max-height: 70%;
}
}
@keyframes components-modal__appear-animation {
from {
transform: translateY($grid-unit-40);
}
to {
transform: translateY(0);
}
}
// Fix header to the top so it is always there to provide context to the modal
// if the content needs to be scrolled (for example, on the keyboard shortcuts
// modal screen).
.components-modal__header {
box-sizing: border-box;
border-bottom: $border-width solid transparent;
padding: $grid-unit-30 $grid-unit-40 $grid-unit-10;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: $header-height + $grid-unit-15;
width: 100%;
z-index: z-index(".components-modal__header");
position: absolute;
top: 0;
left: 0;
.components-modal__header-heading {
font-size: 1.2rem;
font-weight: 600;
}
h1 {
line-height: 1;
margin: 0;
}
.components-button {
position: relative;
left: $grid-unit-10;
}
.components-modal__content.has-scrolled-content:not(.hide-header) & {
border-bottom-color: $gray-300;
}
& + p {
margin-top: 0;
}
}
.components-modal__header-heading-container {
align-items: center;
flex-grow: 1;
display: flex;
flex-direction: row;
justify-content: left;
}
.components-modal__header-icon-container {
display: inline-block;
svg {
max-width: $button-size;
max-height: $button-size;
padding: $grid-unit-10;
}
}
// Modal contents.
.components-modal__content {
flex: 1;
margin-top: $header-height + $grid-unit-15;
// Small top padding required to avoid cutting off the visible outline when the first child element is focusable.
padding: $grid-unit-05 $grid-unit-40 $grid-unit-40;
overflow: auto;
&.hide-header {
margin-top: 0;
padding-top: $grid-unit-40;
}
&.is-scrollable:focus-visible {
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
// Windows High Contrast mode will show this outline, but not the box-shadow.
outline: 2px solid transparent;
outline-offset: -2px;
}
}

View File

@@ -0,0 +1,69 @@
/**
* Internal dependencies
*/
import { elementShouldBeHidden } from '../aria-helper';
describe( 'aria-helper', () => {
describe( 'elementShouldBeHidden', () => {
it( 'should return true when a div element without attributes is passed', () => {
const element = document.createElement( 'div' );
expect( elementShouldBeHidden( element ) ).toBe( true );
} );
it( 'should return false when a script element without attributes is passed', () => {
const element = document.createElement( 'script' );
expect( elementShouldBeHidden( element ) ).toBe( false );
} );
it( 'should return false when an element has the aria-hidden attribute with value "true"', () => {
const element = document.createElement( 'div' );
element.setAttribute( 'aria-hidden', 'true' );
expect( elementShouldBeHidden( element ) ).toBe( false );
} );
it( 'should return false when an element has the aria-hidden attribute with value "false"', () => {
const element = document.createElement( 'div' );
element.setAttribute( 'aria-hidden', 'false' );
expect( elementShouldBeHidden( element ) ).toBe( false );
} );
it( 'should return false when an element has the role attribute with value "alert"', () => {
const element = document.createElement( 'div' );
element.setAttribute( 'role', 'alert' );
expect( elementShouldBeHidden( element ) ).toBe( false );
} );
it( 'should return false when an element has the role attribute with value "status"', () => {
const element = document.createElement( 'div' );
element.setAttribute( 'role', 'status' );
expect( elementShouldBeHidden( element ) ).toBe( false );
} );
it( 'should return false when an element has the role attribute with value "log"', () => {
const element = document.createElement( 'div' );
element.setAttribute( 'role', 'log' );
expect( elementShouldBeHidden( element ) ).toBe( false );
} );
it( 'should return false when an element has the role attribute with value "marquee"', () => {
const element = document.createElement( 'div' );
element.setAttribute( 'role', 'marquee' );
expect( elementShouldBeHidden( element ) ).toBe( false );
} );
it( 'should return false when an element has the role attribute with value "timer"', () => {
const element = document.createElement( 'div' );
element.setAttribute( 'role', 'timer' );
expect( elementShouldBeHidden( element ) ).toBe( false );
} );
} );
} );

View File

@@ -0,0 +1,480 @@
/**
* External dependencies
*/
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import Modal from '../';
import type { ModalProps } from '../types';
const noop = () => {};
describe( 'Modal', () => {
it( 'applies the aria-describedby attribute when provided', () => {
render(
<Modal
aria={ { describedby: 'description-id' } }
onRequestClose={ noop }
>
{ /* eslint-disable-next-line no-restricted-syntax */ }
<p id="description-id">Description</p>
</Modal>
);
expect( screen.getByRole( 'dialog' ) ).toHaveAttribute(
'aria-describedby',
'description-id'
);
} );
it( 'applies the aria-labelledby attribute when provided', () => {
render(
<Modal aria={ { labelledby: 'title-id' } } onRequestClose={ noop }>
{ /* eslint-disable-next-line no-restricted-syntax */ }
<h1 id="title-id">Modal Title Text</h1>
</Modal>
);
expect( screen.getByRole( 'dialog' ) ).toHaveAccessibleName(
'Modal Title Text'
);
} );
it( 'prefers the aria label of the title prop over the aria.labelledby prop', () => {
render(
<Modal
title="Modal Title Attribute"
aria={ { labelledby: 'title-id' } }
onRequestClose={ noop }
>
{ /* eslint-disable-next-line no-restricted-syntax */ }
<h1 id="title-id">Modal Title Text</h1>
</Modal>
);
expect( screen.getByRole( 'dialog' ) ).toHaveAccessibleName(
'Modal Title Attribute'
);
} );
it( 'hides the header when the `__experimentalHideHeader` prop is used', () => {
render(
<Modal
title="Test Title"
__experimentalHideHeader
onRequestClose={ noop }
>
<p>Modal content</p>
</Modal>
);
const dialog = screen.getByRole( 'dialog' );
const title = within( dialog ).queryByText( 'Test Title' );
expect( title ).not.toBeInTheDocument();
} );
it( 'should call onRequestClose when the escape key is pressed', async () => {
const user = userEvent.setup();
const onRequestClose = jest.fn();
render(
<Modal onRequestClose={ onRequestClose }>
<p>Modal content</p>
</Modal>
);
await user.keyboard( '[Escape]' );
expect( onRequestClose ).toHaveBeenCalled();
} );
it( 'should return focus when dismissed by clicking outside', async () => {
const user = userEvent.setup();
const ReturnDemo = () => {
const [ isShown, setIsShown ] = useState( false );
return (
<div>
<button onClick={ () => setIsShown( true ) }>📣</button>
{ isShown && (
<Modal onRequestClose={ () => setIsShown( false ) }>
<p>Modal content</p>
</Modal>
) }
</div>
);
};
render( <ReturnDemo /> );
const opener = screen.getByRole( 'button' );
await user.click( opener );
const modalFrame = screen.getByRole( 'dialog' );
expect( modalFrame ).toHaveFocus();
// Disable reason: No semantic query can reach the overlay.
// eslint-disable-next-line testing-library/no-node-access
await user.click( modalFrame.parentElement! );
expect( opener ).toHaveFocus();
} );
it( 'should request closing of any non nested modal when opened', async () => {
const user = userEvent.setup();
const onRequestClose = jest.fn();
const DismissAdjacent = () => {
const [ isShown, setIsShown ] = useState( false );
return (
<>
<Modal onRequestClose={ onRequestClose }>
<button onClick={ () => setIsShown( true ) }>💥</button>
</Modal>
{ isShown && (
<Modal onRequestClose={ () => setIsShown( false ) }>
<p>Adjacent modal content</p>
</Modal>
) }
</>
);
};
render( <DismissAdjacent /> );
await user.click( screen.getByRole( 'button', { name: '💥' } ) );
expect( onRequestClose ).toHaveBeenCalled();
} );
it( 'should support nested modals', async () => {
const user = userEvent.setup();
const onRequestClose = jest.fn();
const NestSupport = () => {
const [ isShown, setIsShown ] = useState( false );
return (
<>
<Modal onRequestClose={ onRequestClose }>
<button onClick={ () => setIsShown( true ) }>🪆</button>
{ isShown && (
<Modal onRequestClose={ () => setIsShown( false ) }>
<p>Nested modal content</p>
</Modal>
) }
</Modal>
</>
);
};
render( <NestSupport /> );
await user.click( screen.getByRole( 'button', { name: '🪆' } ) );
expect( onRequestClose ).not.toHaveBeenCalled();
} );
it( 'should request closing of nested modal when outer modal unmounts', async () => {
const user = userEvent.setup();
const onRequestClose = jest.fn();
const RequestCloseOfNested = () => {
const [ isShown, setIsShown ] = useState( true );
return (
<>
{ isShown && (
<Modal
onKeyDown={ ( { key } ) => {
if ( key === 'o' ) setIsShown( false );
} }
onRequestClose={ noop }
>
<Modal onRequestClose={ onRequestClose }>
<p>Nested modal content</p>
</Modal>
</Modal>
) }
</>
);
};
render( <RequestCloseOfNested /> );
await user.keyboard( 'o' );
expect( onRequestClose ).toHaveBeenCalled();
} );
it( 'should accessibly hide and show siblings including outer modals', async () => {
const user = userEvent.setup();
const AriaDemo = () => {
const [ isOuterShown, setIsOuterShown ] = useState( false );
const [ isInnerShown, setIsInnerShown ] = useState( false );
return (
<>
<button onClick={ () => setIsOuterShown( true ) }>
Start
</button>
{ isOuterShown && (
<Modal
onRequestClose={ () => setIsOuterShown( false ) }
>
<button onClick={ () => setIsInnerShown( true ) }>
Nest
</button>
{ isInnerShown && (
<Modal
onRequestClose={ () =>
setIsInnerShown( false )
}
>
<p>Nested modal content</p>
</Modal>
) }
</Modal>
) }
</>
);
};
const { container } = render( <AriaDemo /> );
// Opens outer modal > hides container.
await user.click( screen.getByRole( 'button', { name: 'Start' } ) );
expect( container ).toHaveAttribute( 'aria-hidden', 'true' );
// Disable reason: No semantic query can reach the overlay.
// eslint-disable-next-line testing-library/no-node-access
const outer = screen.getByRole( 'dialog' ).parentElement!;
// Opens inner modal > hides outer modal.
await user.click( screen.getByRole( 'button', { name: 'Nest' } ) );
expect( outer ).toHaveAttribute( 'aria-hidden', 'true' );
// Closes inner modal > Unhides outer modal and container stays hidden.
await user.keyboard( '[Escape]' );
expect( outer ).not.toHaveAttribute( 'aria-hidden' );
expect( container ).toHaveAttribute( 'aria-hidden', 'true' );
// Closes outer modal > Unhides container.
await user.keyboard( '[Escape]' );
expect( container ).not.toHaveAttribute( 'aria-hidden' );
} );
it( 'should render `headerActions` React nodes', async () => {
render(
<Modal
headerActions={ <button>A sweet button</button> }
onRequestClose={ noop }
>
<p>Modal content</p>
</Modal>
);
expect(
screen.getByText( 'A sweet button', { selector: 'button' } )
).toBeInTheDocument();
} );
describe( 'Focus handling', () => {
let originalGetClientRects: () => DOMRectList;
const FocusMountDemo = ( {
focusOnMount,
}: Pick< ModalProps, 'focusOnMount' > ) => {
const [ isShown, setIsShown ] = useState( false );
return (
<>
<button onClick={ () => setIsShown( true ) }>
Toggle Modal
</button>
{ isShown && (
<Modal
focusOnMount={ focusOnMount }
onRequestClose={ () => setIsShown( false ) }
>
<p>Modal content</p>
<a href="https://wordpress.org">
First Focusable Content Element
</a>
<a href="https://wordpress.org">
Another Focusable Content Element
</a>
</Modal>
) }
</>
);
};
beforeEach( () => {
/**
* The test environment does not have a layout engine, so we need to mock
* the getClientRects method. This ensures that the focusable elements can be
* found by the `focusOnMount` logic which depends on layout information
* to determine if the element is visible or not.
* See https://github.com/WordPress/gutenberg/blob/trunk/packages/dom/src/focusable.js#L55-L61.
*/
// @ts-expect-error We're not trying to comply to the DOM spec, only mocking
window.HTMLElement.prototype.getClientRects = function () {
return [ 'trick-jsdom-into-having-size-for-element-rect' ];
};
} );
afterEach( () => {
// Restore original HTMLElement prototype.
// See beforeEach for details.
window.HTMLElement.prototype.getClientRects =
originalGetClientRects;
} );
it( 'should focus the Modal dialog by default when `focusOnMount` prop is not provided', async () => {
const user = userEvent.setup();
render( <FocusMountDemo /> );
const opener = screen.getByRole( 'button', {
name: 'Toggle Modal',
} );
await user.click( opener );
expect( screen.getByRole( 'dialog' ) ).toHaveFocus();
} );
it( 'should focus the Modal dialog when `true` passed as value for `focusOnMount` prop', async () => {
const user = userEvent.setup();
render( <FocusMountDemo focusOnMount={ true } /> );
const opener = screen.getByRole( 'button', {
name: 'Toggle Modal',
} );
await user.click( opener );
expect( screen.getByRole( 'dialog' ) ).toHaveFocus();
} );
it( 'should focus the first focusable element in the contents (if found) when `firstContentElement` passed as value for `focusOnMount` prop', async () => {
const user = userEvent.setup();
render( <FocusMountDemo focusOnMount="firstContentElement" /> );
const opener = screen.getByRole( 'button' );
await user.click( opener );
expect(
screen.getByText( 'First Focusable Content Element' )
).toHaveFocus();
} );
it( 'should focus the first element anywhere within the Modal when `firstElement` passed as value for `focusOnMount` prop', async () => {
const user = userEvent.setup();
render( <FocusMountDemo focusOnMount="firstElement" /> );
const opener = screen.getByRole( 'button' );
await user.click( opener );
expect(
screen.getByRole( 'button', { name: 'Close' } )
).toHaveFocus();
} );
it( 'should not move focus when `false` passed as value for `focusOnMount` prop', async () => {
const user = userEvent.setup();
render( <FocusMountDemo focusOnMount={ false } /> );
const opener = screen.getByRole( 'button', {
name: 'Toggle Modal',
} );
await user.click( opener );
expect( opener ).toHaveFocus();
} );
} );
describe( 'Body class name', () => {
const overrideClass = 'is-any-open';
const BodyClassDemo = () => {
const [ isAShown, setIsAShown ] = useState( false );
const [ isA1Shown, setIsA1Shown ] = useState( false );
const [ isBShown, setIsBShown ] = useState( false );
const [ isClassOverriden, setIsClassOverriden ] = useState( false );
useEffect( () => {
const toggles: ( e: KeyboardEvent ) => void = ( {
key,
metaKey,
} ) => {
if ( key === 'a' ) {
if ( metaKey ) return setIsA1Shown( ( v ) => ! v );
return setIsAShown( ( v ) => ! v );
}
if ( key === 'b' ) return setIsBShown( ( v ) => ! v );
if ( key === 'c' )
return setIsClassOverriden( ( v ) => ! v );
};
document.addEventListener( 'keydown', toggles );
return () =>
void document.removeEventListener( 'keydown', toggles );
}, [] );
return (
<>
{ isAShown && (
<Modal
bodyOpenClassName={
isClassOverriden ? overrideClass : 'is-A-open'
}
onRequestClose={ () => setIsAShown( false ) }
>
<p>Modal A contents</p>
{ isA1Shown && (
<Modal
title="Nested"
onRequestClose={ () =>
setIsA1Shown( false )
}
>
<p>Modal A1 contents</p>
</Modal>
) }
</Modal>
) }
{ isBShown && (
<Modal
bodyOpenClassName={
isClassOverriden ? overrideClass : 'is-B-open'
}
onRequestClose={ () => setIsBShown( false ) }
>
<p>Modal B contents</p>
</Modal>
) }
</>
);
};
it( 'is added and removed when modal opens and closes including when closed due to another modal opening', async () => {
const user = userEvent.setup();
const { baseElement } = render( <BodyClassDemo /> );
await user.keyboard( 'a' ); // Opens modal A.
expect( baseElement ).toHaveClass( 'is-A-open' );
await user.keyboard( 'b' ); // Opens modal B > closes modal A.
expect( baseElement ).toHaveClass( 'is-B-open' );
expect( baseElement ).not.toHaveClass( 'is-A-open' );
await user.keyboard( 'b' ); // Closes modal B.
expect( baseElement ).not.toHaveClass( 'is-B-open' );
} );
it( 'is removed even when prop changes while nested modal is open', async () => {
const user = userEvent.setup();
const { baseElement } = render( <BodyClassDemo /> );
await user.keyboard( 'a' ); // Opens modal A.
await user.keyboard( '{Meta>}a{/Meta}' ); // Opens nested modal.
await user.keyboard( 'c' ); // Changes `bodyOpenClassName`.
await user.keyboard( 'a' ); // Closes modal A.
expect( baseElement ).not.toHaveClass( 'is-A-open' );
} );
} );
} );

165
node_modules/@wordpress/components/src/modal/types.ts generated vendored Normal file
View File

@@ -0,0 +1,165 @@
/**
* External dependencies
*/
import type {
AriaRole,
CSSProperties,
ReactNode,
KeyboardEventHandler,
KeyboardEvent,
SyntheticEvent,
} from 'react';
/**
* WordPress dependencies
*/
import type { useFocusOnMount } from '@wordpress/compose';
export type ModalProps = {
aria?: {
/**
* If this property is added, it will be added to the modal content
* `div` as `aria-describedby`.
*/
describedby?: string;
/**
* If this property is added, it will be added to the modal content
* `div` as `aria-labelledby`. Use this when you are rendering the title
* yourself within the modal's content area instead of using the `title`
* prop. This ensures the title is usable by assistive technology.
*
* Titles are required for accessibility reasons, see `contentLabel` and
* `title` for other ways to provide a title.
*/
labelledby?: string;
};
/**
* Class name added to the body element when the modal is open.
*
* @default 'modal-open'
*/
bodyOpenClassName?: string;
/**
* The children elements.
*/
children: ReactNode;
/**
* If this property is added, it will an additional class name to the modal
* content `div`.
*/
className?: string;
/**
* Label on the close button.
*
* @default `__( 'Close' )`
*/
closeButtonLabel?: string;
/**
* If this property is added, it will be added to the modal content `div` as
* `aria-label`.
*
* Titles are required for accessibility reasons, see `aria.labelledby` and
* `title` for other ways to provide a title.
*/
contentLabel?: string;
/**
* If this property is true, it will focus the first tabbable element
* rendered in the modal.
*
* @default true
*/
focusOnMount?:
| Parameters< typeof useFocusOnMount >[ 0 ]
| 'firstContentElement';
/**
* Elements that are injected into the modal header to the left of the close button (if rendered).
* Hidden if `__experimentalHideHeader` is `true`.
*
* @default null
*/
headerActions?: ReactNode;
/**
* If this property is added, an icon will be added before the title.
*/
icon?: JSX.Element;
/**
* If this property is set to false, the modal will not display a close icon
* and cannot be dismissed.
*
* @default true
*/
isDismissible?: boolean;
/**
* This property when set to `true` will render a full screen modal.
*
* @default false
*/
isFullScreen?: boolean;
/**
* If this property is added it will cause the modal to render at a preset
* width, or expand to fill the screen. This prop will be ignored if
* `isFullScreen` is set to `true`.
*
* Note: `Modal`'s width can also be controlled by adjusting the width of the
* modal's contents, or via CSS using the `style` prop.
*/
size?: 'small' | 'medium' | 'large' | 'fill';
/**
* Handle the key down on the modal frame `div`.
*/
onKeyDown?: KeyboardEventHandler< HTMLDivElement >;
/**
* This function is called to indicate that the modal should be closed.
*/
onRequestClose: (
event?: KeyboardEvent< HTMLDivElement > | SyntheticEvent
) => void;
/**
* If this property is added, it will an additional class name to the modal
* overlay `div`.
*/
overlayClassName?: string;
/**
* If this property is added, it will override the default role of the
* modal.
*
* @default 'dialog'
*/
role?: AriaRole;
/**
* If this property is added, it will determine whether the modal requests
* to close when a mouse click occurs outside of the modal content.
*
* @default true
*/
shouldCloseOnClickOutside?: boolean;
/**
* If this property is added, it will determine whether the modal requests
* to close when the escape key is pressed.
*
* @default true
*/
shouldCloseOnEsc?: boolean;
/**
* If this property is added, it will be added to the modal frame `div`.
*/
style?: CSSProperties;
/**
* This property is used as the modal header's title.
*
* Titles are required for accessibility reasons, see `aria.labelledby` and
* `contentLabel` for other ways to provide a title.
*/
title?: string;
/**
* When set to `true`, the Modal's header (including the icon, title and
* close button) will not be rendered.
*
* _Warning_: This property is still experimental. “Experimental” means this
* is an early implementation subject to drastic and breaking changes.
*
* @default false
*/
__experimentalHideHeader?: boolean;
};