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

View File

@@ -0,0 +1,155 @@
# `CircularOptionPicker`
<div class="callout callout-alert">
This component is not exported, and therefore can only be used internally to the `@wordpress/components` package.
</div>
`CircularOptionPicker` is a component that displays a set of options as circular buttons.
## Usage
```jsx
import { useState } from 'react';
import { CircularOptionPicker } from '../circular-option-picker';
const Example = () => {
const [ currentColor, setCurrentColor ] = useState();
const colors = [
{ color: '#f00', name: 'Red' },
{ color: '#0f0', name: 'Green' },
{ color: '#00f', name: 'Blue' },
];
const colorOptions = (
<>
{ colors.map( ( { color, name }, index ) => {
return (
<CircularOptionPicker.Option
key={ `${ color }-${ index }` }
tooltipText={ name }
style={ { backgroundColor: color, color } }
isSelected={ index === currentColor }
onClick={ () => setCurrentColor( index ) }
aria-label={ name }
/>
);
} ) }
</>
);
return (
<CircularOptionPicker
options={ colorOptions }
actions={
<CircularOptionPicker.ButtonAction
onClick={ () => setCurrentColor( undefined ) }
>
{ 'Clear' }
</CircularOptionPicker.ButtonAction>
}
/>
);
};
```
## Props
### `className`: `string`
A CSS class to apply to the wrapper element.
- Required: No
### `actions`: `ReactNode`
The action(s) to be rendered after the options, such as a 'clear' button as seen in `ColorPalette`.
Usually a `CircularOptionPicker.ButtonAction` or `CircularOptionPicker.DropdownLinkAction` component.
- Required: No
### `options`: `ReactNode`
The options to be rendered, such as color swatches.
Usually a `CircularOptionPicker.Option` component.
- Required: No
### `children`: `ReactNode`
The child elements.
- Required: No
### `asButtons`: `boolean`
Whether the control should present as a set of buttons, each with its own tab stop.
- Required: No
- Default: `false`
### `loop`: `boolean`
Prevents keyboard interaction from wrapping around. Only used when `asButtons` is not true.
- Required: No
- Default: `true`
## Subcomponents
### `CircularOptionPicker.ButtonAction`
A `ButtonAction` is an action that is rendered as a button alongside the options themselves.
A common use case is a 'clear' button to deselect the currently selected option.
#### Props
##### `className`: `string`
A CSS class to apply to the underlying `Button` component.
- Required: No
##### `children`: `ReactNode`
The button's children.
- Required: No
##### Inherited props
`CircularOptionPicker.ButtonAction` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`.
### `CircularOptionPicker.DropdownLinkAction`
`CircularOptionPicker.DropdownLinkAction` is an action that's hidden behind a dropdown toggle. The button is formatted as a link and rendered as an `anchor` element.
#### Props
##### `className`: `string`
A CSS class to apply to the underlying `Dropdown` component.
- Required: No
##### `linkText`: `string`
The text to be displayed on the button.
- Required: Yes
##### `dropdownProps`: `object`
The props for the underlying `Dropdown` component.
Inherits all of the [`Dropdown` props](/packages/components/src/dropdown/README.md#props), except for `className` and `renderToggle`.
- Required: Yes
##### `buttonProps`: `object`
Props for the underlying `Button` component.
Inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href`, `target`, and `children`.
- Required: No

View File

@@ -0,0 +1,60 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* Internal dependencies
*/
import Button from '../button';
import Dropdown from '../dropdown';
import type { DropdownLinkActionProps } from './types';
import type { WordPressComponentProps } from '../context';
import type { ButtonAsButtonProps } from '../button/types';
export function DropdownLinkAction( {
buttonProps,
className,
dropdownProps,
linkText,
}: DropdownLinkActionProps ) {
return (
<Dropdown
className={ classnames(
'components-circular-option-picker__dropdown-link-action',
className
) }
renderToggle={ ( { isOpen, onToggle } ) => (
<Button
aria-expanded={ isOpen }
aria-haspopup="true"
onClick={ onToggle }
variant="link"
{ ...buttonProps }
>
{ linkText }
</Button>
) }
{ ...dropdownProps }
/>
);
}
export function ButtonAction( {
className,
children,
...additionalProps
}: WordPressComponentProps< ButtonAsButtonProps, 'button', false > ) {
return (
<Button
className={ classnames(
'components-circular-option-picker__clear',
className
) }
variant="tertiary"
{ ...additionalProps }
>
{ children }
</Button>
);
}

View File

@@ -0,0 +1,12 @@
/**
* WordPress dependencies
*/
import { createContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import type { CircularOptionPickerContextProps } from './types';
export const CircularOptionPickerContext =
createContext< CircularOptionPickerContextProps >( {} );

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* Internal dependencies
*/
import type { OptionGroupProps } from './types';
export function OptionGroup( {
className,
options,
...additionalProps
}: OptionGroupProps ) {
const role =
'aria-label' in additionalProps || 'aria-labelledby' in additionalProps
? 'group'
: undefined;
return (
<div
{ ...additionalProps }
role={ role }
className={ classnames(
'components-circular-option-picker__option-group',
'components-circular-option-picker__swatches',
className
) }
>
{ options }
</div>
);
}

View File

@@ -0,0 +1,123 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import type { ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
import { forwardRef, useContext } from '@wordpress/element';
import { Icon, check } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { CircularOptionPickerContext } from './circular-option-picker-context';
import Button from '../button';
import { CompositeItem } from '../composite/v2';
import Tooltip from '../tooltip';
import type { OptionProps, CircularOptionPickerCompositeStore } from './types';
function UnforwardedOptionAsButton(
props: {
id?: string;
className?: string;
isPressed?: boolean;
},
forwardedRef: ForwardedRef< any >
) {
const { isPressed, ...additionalProps } = props;
return (
<Button
{ ...additionalProps }
aria-pressed={ isPressed }
ref={ forwardedRef }
/>
);
}
const OptionAsButton = forwardRef( UnforwardedOptionAsButton );
function UnforwardedOptionAsOption(
props: {
id: string;
className?: string;
isSelected?: boolean;
compositeStore: CircularOptionPickerCompositeStore;
},
forwardedRef: ForwardedRef< any >
) {
const { id, isSelected, compositeStore, ...additionalProps } = props;
const activeId = compositeStore.useState( 'activeId' );
if ( isSelected && ! activeId ) {
compositeStore.setActiveId( id );
}
return (
<CompositeItem
render={
<Button
{ ...additionalProps }
role="option"
aria-selected={ !! isSelected }
ref={ forwardedRef }
/>
}
store={ compositeStore }
id={ id }
/>
);
}
const OptionAsOption = forwardRef( UnforwardedOptionAsOption );
export function Option( {
className,
isSelected,
selectedIconProps = {},
tooltipText,
...additionalProps
}: OptionProps ) {
const { baseId, compositeStore } = useContext(
CircularOptionPickerContext
);
const id = useInstanceId(
Option,
baseId || 'components-circular-option-picker__option'
);
const commonProps = {
id,
className: 'components-circular-option-picker__option',
...additionalProps,
};
const optionControl = compositeStore ? (
<OptionAsOption
{ ...commonProps }
compositeStore={ compositeStore }
isSelected={ isSelected }
/>
) : (
<OptionAsButton { ...commonProps } isPressed={ isSelected } />
);
return (
<div
className={ classnames(
className,
'components-circular-option-picker__option-wrapper'
) }
>
{ tooltipText ? (
<Tooltip text={ tooltipText }>{ optionControl }</Tooltip>
) : (
optionControl
) }
{ isSelected && <Icon icon={ check } { ...selectedIconProps } /> }
</div>
);
}

View File

@@ -0,0 +1,185 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
import { isRTL } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { CircularOptionPickerContext } from './circular-option-picker-context';
import { Composite, useCompositeStore } from '../composite/v2';
import type {
CircularOptionPickerProps,
ListboxCircularOptionPickerProps,
ButtonsCircularOptionPickerProps,
} from './types';
import { Option } from './circular-option-picker-option';
import { OptionGroup } from './circular-option-picker-option-group';
import {
ButtonAction,
DropdownLinkAction,
} from './circular-option-picker-actions';
/**
*`CircularOptionPicker` is a component that displays a set of options as circular buttons.
*
* ```jsx
* import { CircularOptionPicker } from '../circular-option-picker';
* import { useState } from '@wordpress/element';
*
* const Example = () => {
* const [ currentColor, setCurrentColor ] = useState();
* const colors = [
* { color: '#f00', name: 'Red' },
* { color: '#0f0', name: 'Green' },
* { color: '#00f', name: 'Blue' },
* ];
* const colorOptions = (
* <>
* { colors.map( ( { color, name }, index ) => {
* return (
* <CircularOptionPicker.Option
* key={ `${ color }-${ index }` }
* tooltipText={ name }
* style={ { backgroundColor: color, color } }
* isSelected={ index === currentColor }
* onClick={ () => setCurrentColor( index ) }
* aria-label={ name }
* />
* );
* } ) }
* </>
* );
* return (
* <CircularOptionPicker
* options={ colorOptions }
* actions={
* <CircularOptionPicker.ButtonAction
* onClick={ () => setCurrentColor( undefined ) }
* >
* { 'Clear' }
* </CircularOptionPicker.ButtonAction>
* }
* />
* );
* };
* ```
*/
function ListboxCircularOptionPicker(
props: ListboxCircularOptionPickerProps
) {
const {
actions,
options,
baseId,
className,
loop = true,
children,
...additionalProps
} = props;
const compositeStore = useCompositeStore( {
focusLoop: loop,
rtl: isRTL(),
} );
const compositeContext = {
baseId,
compositeStore,
};
return (
<div className={ className }>
<CircularOptionPickerContext.Provider value={ compositeContext }>
<Composite
{ ...additionalProps }
id={ baseId }
store={ compositeStore }
role={ 'listbox' }
>
{ options }
</Composite>
{ children }
{ actions }
</CircularOptionPickerContext.Provider>
</div>
);
}
function ButtonsCircularOptionPicker(
props: ButtonsCircularOptionPickerProps
) {
const { actions, options, children, baseId, ...additionalProps } = props;
return (
<div { ...additionalProps } id={ baseId }>
<CircularOptionPickerContext.Provider value={ { baseId } }>
{ options }
{ children }
{ actions }
</CircularOptionPickerContext.Provider>
</div>
);
}
function CircularOptionPicker( props: CircularOptionPickerProps ) {
const {
asButtons,
actions: actionsProp,
options: optionsProp,
children,
className,
...additionalProps
} = props;
const baseId = useInstanceId(
CircularOptionPicker,
'components-circular-option-picker',
additionalProps.id
);
const OptionPickerImplementation = asButtons
? ButtonsCircularOptionPicker
: ListboxCircularOptionPicker;
const actions = actionsProp ? (
<div className="components-circular-option-picker__custom-clear-wrapper">
{ actionsProp }
</div>
) : undefined;
const options = (
<div className={ 'components-circular-option-picker__swatches' }>
{ optionsProp }
</div>
);
return (
<OptionPickerImplementation
{ ...additionalProps }
baseId={ baseId }
className={ classnames(
'components-circular-option-picker',
className
) }
actions={ actions }
options={ options }
>
{ children }
</OptionPickerImplementation>
);
}
CircularOptionPicker.Option = Option;
CircularOptionPicker.OptionGroup = OptionGroup;
CircularOptionPicker.ButtonAction = ButtonAction;
CircularOptionPicker.DropdownLinkAction = DropdownLinkAction;
export default CircularOptionPicker;

View File

@@ -0,0 +1,13 @@
/**
* Internal dependencies
*/
import CircularOptionPicker from './circular-option-picker';
export { Option } from './circular-option-picker-option';
export { OptionGroup } from './circular-option-picker-option-group';
export {
ButtonAction,
DropdownLinkAction,
} from './circular-option-picker-actions';
export default CircularOptionPicker;

View File

@@ -0,0 +1,164 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState, createContext, useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import CircularOptionPicker from '..';
const CircularOptionPickerStoryContext = createContext< {
currentColor?: string;
setCurrentColor?: ( v: string | undefined ) => void;
} >( {} );
const meta: Meta< typeof CircularOptionPicker > = {
title: 'Components/CircularOptionPicker',
component: CircularOptionPicker,
subcomponents: {
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
'CircularOptionPicker.Option': CircularOptionPicker.Option,
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
'CircularOptionPicker.OptionGroup': CircularOptionPicker.OptionGroup,
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
'CircularOptionPicker.ButtonAction': CircularOptionPicker.ButtonAction,
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
'CircularOptionPicker.DropdownLinkAction':
CircularOptionPicker.DropdownLinkAction,
},
argTypes: {
actions: { control: { type: null } },
options: { control: { type: null } },
children: { control: { type: 'text' } },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: { expanded: true },
docs: {
canvas: { sourceState: 'shown' },
source: { excludeDecorators: true },
},
},
decorators: [
// Share current color state between main component, `actions` and `options`
( Story ) => {
const [ currentColor, setCurrentColor ] = useState< string >();
return (
<CircularOptionPickerStoryContext.Provider
value={ {
currentColor,
setCurrentColor,
} }
>
<Story />
</CircularOptionPickerStoryContext.Provider>
);
},
],
};
export default meta;
const colors = [
{ color: '#f00', name: 'Red' },
{ color: '#0f0', name: 'Green' },
{ color: '#0af', name: 'Blue' },
];
const DefaultOptions = () => {
const { currentColor, setCurrentColor } = useContext(
CircularOptionPickerStoryContext
);
return (
<>
{ colors.map( ( { color, name }, index ) => {
return (
<CircularOptionPicker.Option
key={ `${ color }-${ index }` }
tooltipText={ name }
style={ { backgroundColor: color, color } }
isSelected={ color === currentColor }
onClick={ () => {
setCurrentColor?.( color );
} }
aria-label={ name }
/>
);
} ) }
</>
);
};
const DefaultActions = () => {
const { setCurrentColor } = useContext( CircularOptionPickerStoryContext );
return (
<CircularOptionPicker.ButtonAction
onClick={ () => setCurrentColor?.( undefined ) }
>
{ 'Clear' }
</CircularOptionPicker.ButtonAction>
);
};
const Template: StoryFn< typeof CircularOptionPicker > = ( props ) => (
<CircularOptionPicker { ...props } />
);
export const Default = Template.bind( {} );
Default.args = {
'aria-label': 'Circular Option Picker',
options: <DefaultOptions />,
};
export const AsButtons = Template.bind( {} );
AsButtons.args = {
...Default.args,
asButtons: true,
};
export const WithLoopingDisabled = Template.bind( {} );
WithLoopingDisabled.args = {
...Default.args,
loop: false,
};
WithLoopingDisabled.parameters = {
docs: {
source: {
code: `<CircularOptionPicker
aria-label="${ WithLoopingDisabled.args[ 'aria-label' ] }"
loop={false}
options={<DefaultOptions />}
/>`,
},
},
};
export const WithButtonAction = Template.bind( {} );
WithButtonAction.args = {
...Default.args,
actions: <DefaultActions />,
};
WithButtonAction.storyName = 'With ButtonAction';
export const WithDropdownLinkAction = Template.bind( {} );
WithDropdownLinkAction.args = {
...Default.args,
actions: (
<CircularOptionPicker.DropdownLinkAction
dropdownProps={ {
popoverProps: { position: 'top right' },
renderContent: () => (
<div>This is an example of a DropdownLinkAction.</div>
),
} }
linkText="Learn More"
></CircularOptionPicker.DropdownLinkAction>
),
};
WithDropdownLinkAction.storyName = 'With DropdownLinkAction';

View File

@@ -0,0 +1,154 @@
$color-palette-circle-size: 28px;
$color-palette-circle-spacing: 12px;
.components-circular-option-picker {
display: inline-block;
width: 100%;
min-width: 188px;
.components-circular-option-picker__custom-clear-wrapper {
display: flex;
justify-content: flex-end;
margin-top: $grid-unit-15;
}
.components-circular-option-picker__swatches {
display: flex;
flex-wrap: wrap;
gap: $color-palette-circle-spacing;
position: relative;
z-index: z-index(".components-circular-option-picker__swatches");
}
// Make sure that the .components-circular-option-picker__swatches element
// renders visually on top of its siblings. This is necessary to make sure
// that the tooltip rendered when hovering an `Option` always appears on top.
> *:not(.components-circular-option-picker__swatches) {
position: relative;
z-index: z-index("> *:not(.components-circular-option-picker__swatches)");
}
}
.components-circular-option-picker__option-wrapper {
display: inline-block;
height: $color-palette-circle-size;
width: $color-palette-circle-size;
vertical-align: top;
transform: scale(1);
transition: 100ms transform ease;
will-change: transform;
@include reduce-motion("transition");
&:hover {
transform: scale(1.2);
}
// Ensure that the <div> that <Dropdown> wraps our toggle button with is full height
& > div {
height: 100%;
width: 100%;
}
}
.components-circular-option-picker__option-wrapper::before {
content: "";
position: absolute;
top: 1px;
left: 1px;
bottom: 1px;
right: 1px;
border-radius: $radius-round;
// Show a thin circular outline in Windows high contrast mode, otherwise the button is invisible.
z-index: z-index(".components-circular-option-picker__option-wrapper::before");
// Need to disable the lint rule because given that we are in the presence of a data URL that needs quotes we need to wrap it with single quotes.
/* stylelint-disable-next-line function-url-quotes */
background: url('data:image/svg+xml,%3Csvg width="28" height="28" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M6 8V6H4v2h2zM8 8V6h2v2H8zM10 16H8v-2h2v2zM12 16v-2h2v2h-2zM12 18v-2h-2v2H8v2h2v-2h2zM14 18v2h-2v-2h2zM16 18h-2v-2h2v2z" fill="%23555D65"/%3E%3Cpath fill-rule="evenodd" clip-rule="evenodd" d="M18 18h2v-2h-2v-2h2v-2h-2v-2h2V8h-2v2h-2V8h-2v2h2v2h-2v2h2v2h2v2zm-2-4v-2h2v2h-2z" fill="%23555D65"/%3E%3Cpath d="M18 18v2h-2v-2h2z" fill="%23555D65"/%3E%3Cpath fill-rule="evenodd" clip-rule="evenodd" d="M8 10V8H6v2H4v2h2v2H4v2h2v2H4v2h2v2H4v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v-2h-2v-2h2v-2h-2v-2h2v-2h-2v-2h2V8h-2V6h2V4h-2v2h-2V4h-2v2h-2V4h-2v2h-2V4h-2v2h2v2h-2v2H8zm0 2v-2H6v2h2zm2 0v-2h2v2h-2zm0 2v-2H8v2H6v2h2v2H6v2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h-2v-2h2v-2h-2v-2h2v-2h-2v-2h2V8h-2V6h-2v2h-2V6h-2v2h-2v2h2v2h-2v2h-2z" fill="%23555D65"/%3E%3Cpath fill-rule="evenodd" clip-rule="evenodd" d="M4 0H2v2H0v2h2v2H0v2h2v2H0v2h2v2H0v2h2v2H0v2h2v2H0v2h2v2H0v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v-2h-2v-2h2v-2h-2v-2h2v-2h-2v-2h2v-2h-2v-2h2V8h-2V6h2V4h-2V2h2V0h-2v2h-2V0h-2v2h-2V0h-2v2h-2V0h-2v2h-2V0h-2v2H8V0H6v2H4V0zm0 4V2H2v2h2zm2 0V2h2v2H6zm0 2V4H4v2H2v2h2v2H2v2h2v2H2v2h2v2H2v2h2v2H2v2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h-2v-2h2v-2h-2v-2h2v-2h-2v-2h2v-2h-2v-2h2V8h-2V6h2V4h-2V2h-2v2h-2V2h-2v2h-2V2h-2v2h-2V2h-2v2H8v2H6z" fill="%23555D65"/%3E%3C/svg%3E');
}
.components-circular-option-picker__option {
display: inline-block;
vertical-align: top;
height: 100%;
width: 100%;
border: none;
border-radius: 50%;
background: transparent;
box-shadow: inset 0 0 0 ($color-palette-circle-size * 0.5);
transition: 100ms box-shadow ease;
@include reduce-motion("transition");
cursor: pointer;
&:hover {
// Override default button hover style.
box-shadow: inset 0 0 0 ($color-palette-circle-size * 0.5) !important;
}
&[aria-pressed="true"],
&[aria-selected="true"] {
box-shadow: inset 0 0 0 4px;
position: relative;
z-index: z-index(".components-circular-option-picker__option.is-pressed");
overflow: visible;
& + svg {
position: absolute;
left: 2px;
top: 2px;
border-radius: 50%;
z-index: z-index(".components-circular-option-picker__option.is-pressed + svg");
pointer-events: none;
}
}
&::after {
content: "";
position: absolute;
top: -1px;
left: -1px;
bottom: -1px;
right: -1px;
border-radius: $radius-round;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
// Show a thin circular outline in Windows high contrast mode, otherwise the button is invisible.
border: 1px solid transparent;
box-sizing: inherit;
}
&:focus {
&::after {
content: "";
border-radius: $radius-round;
box-shadow: inset 0 0 0 2px $white;
// Make sure it's always centered
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
// Make sure dimensions account for border width
border: #{ $border-width * 2 } solid $gray-700;
width: calc(100% + #{ ( $border-width * 2 ) * 2 });
height: calc(100% + #{ ( $border-width * 2 ) * 2 });
}
}
&.components-button:focus {
background-color: transparent;
box-shadow: inset 0 0 0 ($color-palette-circle-size * 0.5);
outline: none;
}
}
.components-circular-option-picker__button-action .components-circular-option-picker__option {
color: $white;
background: $white;
}
.components-circular-option-picker__dropdown-link-action {
margin-right: $grid-unit-20;
.components-button {
line-height: 22px;
}
}

View File

@@ -0,0 +1,127 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { press } from '@ariakit/test';
/**
* Internal dependencies
*/
import CircularOptionPicker from '..';
const SINGLE_OPTION = [ <CircularOptionPicker.Option key={ 'option' } /> ];
const MULTIPLE_OPTIONS = [
<CircularOptionPicker.Option
key={ 'option-1' }
aria-label={ 'Option One' }
/>,
<CircularOptionPicker.Option
key={ 'option-2' }
aria-label={ 'Option Two' }
/>,
];
const DEFAULT_PROPS = {
'aria-label': 'Circular Option Picker',
options: SINGLE_OPTION,
};
function getOption( name: string ) {
return screen.getByRole( 'option', { name } );
}
describe( 'CircularOptionPicker', () => {
describe( 'when `asButtons` is not set', () => {
it( 'should render as a listbox', async () => {
render( <CircularOptionPicker { ...DEFAULT_PROPS } /> );
expect( screen.getByRole( 'listbox' ) ).toBeInTheDocument();
expect( screen.getByRole( 'option' ) ).toBeInTheDocument();
expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
} );
} );
describe( 'when `asButtons` is false', () => {
it( 'should render as a listbox', async () => {
render(
<CircularOptionPicker
{ ...DEFAULT_PROPS }
asButtons={ false }
/>
);
expect( screen.getByRole( 'listbox' ) ).toBeInTheDocument();
expect( screen.getByRole( 'option' ) ).toBeInTheDocument();
expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
} );
} );
describe( 'when `asButtons` is true', () => {
it( 'should render as buttons', async () => {
render(
<CircularOptionPicker { ...DEFAULT_PROPS } asButtons={ true } />
);
expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
expect( screen.queryByRole( 'option' ) ).not.toBeInTheDocument();
expect( screen.getByRole( 'button' ) ).toBeInTheDocument();
} );
} );
describe( 'when `loop` is not set', () => {
it( 'should loop', async () => {
render(
<CircularOptionPicker
{ ...DEFAULT_PROPS }
options={ MULTIPLE_OPTIONS }
/>
);
await press.Tab();
expect( getOption( 'Option One' ) ).toHaveFocus();
await press.ArrowRight();
expect( getOption( 'Option Two' ) ).toHaveFocus();
await press.ArrowRight();
expect( getOption( 'Option One' ) ).toHaveFocus();
} );
} );
describe( 'when `loop` is true', () => {
it( 'should loop', async () => {
render(
<CircularOptionPicker
{ ...DEFAULT_PROPS }
options={ MULTIPLE_OPTIONS }
loop={ true }
/>
);
await press.Tab();
expect( getOption( 'Option One' ) ).toHaveFocus();
await press.ArrowRight();
expect( getOption( 'Option Two' ) ).toHaveFocus();
await press.ArrowRight();
expect( getOption( 'Option One' ) ).toHaveFocus();
} );
} );
describe( 'when `loop` is false', () => {
it( 'should not loop', async () => {
render(
<CircularOptionPicker
{ ...DEFAULT_PROPS }
loop={ false }
options={ MULTIPLE_OPTIONS }
/>
);
await press.Tab();
expect( getOption( 'Option One' ) ).toHaveFocus();
await press.ArrowRight();
expect( getOption( 'Option Two' ) ).toHaveFocus();
await press.ArrowRight();
expect( getOption( 'Option Two' ) ).toHaveFocus();
} );
} );
} );

View File

@@ -0,0 +1,130 @@
/**
* External dependencies
*/
import type { ReactNode } from 'react';
/**
* WordPress dependencies
*/
import type { Icon } from '@wordpress/icons';
/**
* Internal dependencies
*/
import type { ButtonAsButtonProps } from '../button/types';
import type { DropdownProps } from '../dropdown/types';
import type { WordPressComponentProps } from '../context';
import type { CompositeStore } from '../composite/v2';
type CommonCircularOptionPickerProps = {
/**
* An ID to apply to the component.
*/
id?: string;
/**
* A CSS class to apply to the wrapper element.
*/
className?: string;
/**
* The action(s) to be rendered after the options,
* such as a 'clear' button as seen in `ColorPalette`.
* Usually a `CircularOptionPicker.ButtonAction` or
* `CircularOptionPicker.DropdownLinkAction` component.
*/
actions?: ReactNode;
/**
* The options to be rendered, such as color swatches.
* Usually a `CircularOptionPicker.Option` component.
*/
options: ReactNode;
/**
* The child elements.
*/
children?: ReactNode;
};
type WithBaseId = {
baseId: string;
};
type FullListboxCircularOptionPickerProps = CommonCircularOptionPickerProps & {
/**
* Whether the control should present as a set of buttons,
* each with its own tab stop.
*/
asButtons?: false;
/**
* Prevents keyboard interaction from wrapping around.
* Only used when `asButtons` is not true.
*
* @default true
*/
loop?: boolean;
} & (
| {
'aria-label': string;
'aria-labelledby'?: never;
}
| {
'aria-label'?: never;
'aria-labelledby': string;
}
);
export type ListboxCircularOptionPickerProps = WithBaseId &
Omit< FullListboxCircularOptionPickerProps, 'asButtons' >;
type FullButtonsCircularOptionPickerProps = CommonCircularOptionPickerProps & {
/**
* Whether the control should present as a set of buttons,
* each with its own tab stop.
*
* @default false
*/
asButtons: true;
};
export type ButtonsCircularOptionPickerProps = WithBaseId &
Omit< FullButtonsCircularOptionPickerProps, 'asButtons' >;
export type CircularOptionPickerProps =
| FullListboxCircularOptionPickerProps
| FullButtonsCircularOptionPickerProps;
export type DropdownLinkActionProps = {
buttonProps?: Omit<
WordPressComponentProps< ButtonAsButtonProps, 'button', false >,
'children'
>;
linkText: string;
dropdownProps: Omit< DropdownProps, 'className' | 'renderToggle' >;
className?: string;
};
export type OptionGroupProps = {
className?: string;
options: ReactNode;
};
export type OptionProps = Omit<
WordPressComponentProps< ButtonAsButtonProps, 'button', false >,
'isPressed' | 'className'
> & {
className?: string;
tooltipText?: string;
isSelected?: boolean;
// `icon` is explicitly defined as 'check' by CircleOptionPicker.Option
// and is not intended to be overridden.
// `size` relies on the `Icon` component's default size of `24` to fit
// `CircularOptionPicker`'s design, and should not be explicitly set.
selectedIconProps?: Omit<
React.ComponentProps< typeof Icon >,
'icon' | 'size'
>;
};
export type CircularOptionPickerCompositeStore = CompositeStore;
export type CircularOptionPickerContextProps = {
baseId?: string;
compositeStore?: CircularOptionPickerCompositeStore;
};