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,80 @@
# AlignmentMatrixControl
<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>
AlignmentMatrixControl components enable adjustments to horizontal and vertical alignments for UI.
## Usage
```jsx
import { useState } from 'react';
import { __experimentalAlignmentMatrixControl as AlignmentMatrixControl } from '@wordpress/components';
const Example = () => {
const [ alignment, setAlignment ] = useState( 'center center' );
return (
<AlignmentMatrixControl
value={ alignment }
onChange={ ( newAlignment ) => setAlignment( newAlignment ) }
/>
);
};
```
## Props
The component accepts the following props:
### className
The class that will be added to the classes of the underlying `grid` widget.
- Type: `string`
- Required: No
### id
Unique ID for the component.
- Type: `string`
- Required: No
### label
Accessible label. If provided, sets the `aria-label` attribute of the underlying `grid` widget.
- Type: `string`
- Required: No
- Default: `Alignment Matrix Control`
### defaultValue
If provided, sets the default alignment value.
- Type: `AlignmentMatrixControlValue`
- Required: No
- Default: `center center`
### value
The current alignment value.
- Type: `AlignmentMatrixControlValue`
- Required: No
### onChange
A function that receives the updated alignment value.
- Type: `( newValue: AlignmentMatrixControlValue ) => void`
- Required: No
### width
If provided, sets the width of the control.
- Type: `number`
- Required: No
- Default: `92`

View File

@@ -0,0 +1,41 @@
/**
* Internal dependencies
*/
import { CompositeItem } from '../composite/v2';
import Tooltip from '../tooltip';
import { VisuallyHidden } from '../visually-hidden';
/**
* Internal dependencies
*/
import { ALIGNMENT_LABEL } from './utils';
import {
Cell as CellView,
Point,
} from './styles/alignment-matrix-control-styles';
import type { AlignmentMatrixControlCellProps } from './types';
import type { WordPressComponentProps } from '../context';
export default function Cell( {
id,
isActive = false,
value,
...props
}: WordPressComponentProps< AlignmentMatrixControlCellProps, 'span', false > ) {
const tooltipText = ALIGNMENT_LABEL[ value ];
return (
<Tooltip text={ tooltipText }>
<CompositeItem
id={ id }
render={ <CellView { ...props } role="gridcell" /> }
>
{ /* VoiceOver needs a text content to be rendered within grid cell,
otherwise it'll announce the content as "blank". So we use a visually
hidden element instead of aria-label. */ }
<VisuallyHidden>{ value }</VisuallyHidden>
<Point isActive={ isActive } role="presentation" />
</CompositeItem>
</Tooltip>
);
}

View File

@@ -0,0 +1,62 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* Internal dependencies
*/
import { ALIGNMENTS, getAlignmentIndex } from './utils';
import {
Root,
Cell,
Point,
} from './styles/alignment-matrix-control-icon-styles';
import type { AlignmentMatrixControlIconProps } from './types';
import type { WordPressComponentProps } from '../context';
const BASE_SIZE = 24;
function AlignmentMatrixControlIcon( {
className,
disablePointerEvents = true,
size = BASE_SIZE,
style = {},
value = 'center',
...props
}: WordPressComponentProps< AlignmentMatrixControlIconProps, 'div', false > ) {
const alignIndex = getAlignmentIndex( value );
const scale = ( size / BASE_SIZE ).toFixed( 2 );
const classes = classnames(
'component-alignment-matrix-control-icon',
className
);
const styles = {
...style,
transform: `scale(${ scale })`,
};
return (
<Root
{ ...props }
className={ classes }
disablePointerEvents={ disablePointerEvents }
role="presentation"
style={ styles }
>
{ ALIGNMENTS.map( ( align, index ) => {
const isActive = alignIndex === index;
return (
<Cell key={ align }>
<Point isActive={ isActive } />
</Cell>
);
} ) }
</Root>
);
}
export default AlignmentMatrixControlIcon;

View File

@@ -0,0 +1,113 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { __, isRTL } from '@wordpress/i18n';
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
import Cell from './cell';
import { Composite, CompositeRow, useCompositeStore } from '../composite/v2';
import { Root, Row } from './styles/alignment-matrix-control-styles';
import AlignmentMatrixControlIcon from './icon';
import { GRID, getItemId, getItemValue } from './utils';
import type { WordPressComponentProps } from '../context';
import type { AlignmentMatrixControlProps } from './types';
/**
*
* AlignmentMatrixControl components enable adjustments to horizontal and vertical alignments for UI.
*
* ```jsx
* import { __experimentalAlignmentMatrixControl as AlignmentMatrixControl } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* const Example = () => {
* const [ alignment, setAlignment ] = useState( 'center center' );
*
* return (
* <AlignmentMatrixControl
* value={ alignment }
* onChange={ setAlignment }
* />
* );
* };
* ```
*/
export function AlignmentMatrixControl( {
className,
id,
label = __( 'Alignment Matrix Control' ),
defaultValue = 'center center',
value,
onChange,
width = 92,
...props
}: WordPressComponentProps< AlignmentMatrixControlProps, 'div', false > ) {
const baseId = useInstanceId(
AlignmentMatrixControl,
'alignment-matrix-control',
id
);
const compositeStore = useCompositeStore( {
defaultActiveId: getItemId( baseId, defaultValue ),
activeId: getItemId( baseId, value ),
setActiveId: ( nextActiveId ) => {
const nextValue = getItemValue( baseId, nextActiveId );
if ( nextValue ) onChange?.( nextValue );
},
rtl: isRTL(),
} );
const activeId = compositeStore.useState( 'activeId' );
const classes = classnames(
'component-alignment-matrix-control',
className
);
return (
<Composite
store={ compositeStore }
render={
<Root
{ ...props }
aria-label={ label }
className={ classes }
id={ baseId }
role="grid"
size={ width }
/>
}
>
{ GRID.map( ( cells, index ) => (
<CompositeRow render={ <Row role="row" /> } key={ index }>
{ cells.map( ( cell ) => {
const cellId = getItemId( baseId, cell );
const isActive = cellId === activeId;
return (
<Cell
id={ cellId }
isActive={ isActive }
key={ cell }
value={ cell }
/>
);
} ) }
</CompositeRow>
) ) }
</Composite>
);
}
AlignmentMatrixControl.Icon = AlignmentMatrixControlIcon;
export default AlignmentMatrixControl;

View File

@@ -0,0 +1,77 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
import { Icon } from '@wordpress/icons';
/**
* Internal dependencies
*/
import AlignmentMatrixControl from '..';
import { HStack } from '../../h-stack';
import type { AlignmentMatrixControlProps } from '../types';
const meta: Meta< typeof AlignmentMatrixControl > = {
title: 'Components (Experimental)/AlignmentMatrixControl',
component: AlignmentMatrixControl,
subcomponents: {
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
'AlignmentMatrixControl.Icon': AlignmentMatrixControl.Icon,
},
argTypes: {
onChange: { control: { type: null } },
value: { control: { type: null } },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const Template: StoryFn< typeof AlignmentMatrixControl > = ( {
defaultValue,
onChange,
...props
} ) => {
const [ value, setValue ] =
useState< AlignmentMatrixControlProps[ 'value' ] >();
return (
<AlignmentMatrixControl
{ ...props }
onChange={ ( ...changeArgs ) => {
setValue( ...changeArgs );
onChange?.( ...changeArgs );
} }
value={ value }
/>
);
};
export const Default = Template.bind( {} );
export const IconSubcomponent = () => {
return (
<HStack justify="flex-start">
<Icon
icon={
<AlignmentMatrixControl.Icon size={ 24 } value="top left" />
}
/>
<Icon
icon={
<AlignmentMatrixControl.Icon
size={ 24 }
value="center center"
/>
}
/>
</HStack>
);
};

View File

@@ -0,0 +1,77 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
import { css } from '@emotion/react';
/**
* Internal dependencies
*/
import {
rootBase,
pointBase,
Cell as CellBase,
} from './alignment-matrix-control-styles';
import type {
AlignmentMatrixControlIconProps,
AlignmentMatrixControlCellProps,
} from '../types';
const rootSize = () => {
const padding = 1.5;
const size = 24;
return css( {
gridTemplateRows: `repeat( 3, calc( ${ size - padding * 2 }px / 3))`,
padding,
maxHeight: size,
maxWidth: size,
} );
};
const rootPointerEvents = ( {
disablePointerEvents,
}: Pick< AlignmentMatrixControlIconProps, 'disablePointerEvents' > ) => {
return css( {
pointerEvents: disablePointerEvents ? 'none' : undefined,
} );
};
export const Wrapper = styled.div`
box-sizing: border-box;
padding: 2px;
`;
export const Root = styled.div`
transform-origin: top left;
height: 100%;
width: 100%;
${ rootBase };
${ rootSize };
${ rootPointerEvents };
`;
const pointActive = ( {
isActive,
}: Pick< AlignmentMatrixControlCellProps, 'isActive' > ) => {
const boxShadow = isActive ? `0 0 0 1px currentColor` : null;
return css`
box-shadow: ${ boxShadow };
color: currentColor;
*:hover > & {
color: currentColor;
}
`;
};
export const Point = styled.span`
height: 2px;
width: 2px;
${ pointBase };
${ pointActive };
`;
export const Cell = CellBase;

View File

@@ -0,0 +1,101 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
import { css } from '@emotion/react';
/**
* Internal dependencies
*/
import { COLORS, reduceMotion } from '../../utils';
import type {
AlignmentMatrixControlProps,
AlignmentMatrixControlCellProps,
} from '../types';
export const rootBase = () => {
return css`
border-radius: 2px;
box-sizing: border-box;
direction: ltr;
display: grid;
grid-template-columns: repeat( 3, 1fr );
outline: none;
`;
};
const rootSize = ( { size = 92 } ) => {
return css`
grid-template-rows: repeat( 3, calc( ${ size }px / 3 ) );
width: ${ size }px;
`;
};
export const Root = styled.div< {
size: AlignmentMatrixControlProps[ 'width' ];
} >`
${ rootBase };
border: 1px solid transparent;
cursor: pointer;
grid-template-columns: auto;
${ rootSize };
`;
export const Row = styled.div`
box-sizing: border-box;
display: grid;
grid-template-columns: repeat( 3, 1fr );
`;
const pointActive = ( {
isActive,
}: Pick< AlignmentMatrixControlCellProps, 'isActive' > ) => {
const boxShadow = isActive ? `0 0 0 2px ${ COLORS.gray[ 900 ] }` : null;
const pointColor = isActive ? COLORS.gray[ 900 ] : COLORS.gray[ 400 ];
const pointColorHover = isActive ? COLORS.gray[ 900 ] : COLORS.theme.accent;
return css`
box-shadow: ${ boxShadow };
color: ${ pointColor };
*:hover > & {
color: ${ pointColorHover };
}
`;
};
export const pointBase = (
props: Pick< AlignmentMatrixControlCellProps, 'isActive' >
) => {
return css`
background: currentColor;
box-sizing: border-box;
display: grid;
margin: auto;
transition: all 120ms linear;
${ reduceMotion( 'transition' ) }
${ pointActive( props ) }
`;
};
export const Point = styled.span`
height: 6px;
width: 6px;
${ pointBase }
`;
export const Cell = styled.span`
appearance: none;
border: none;
box-sizing: border-box;
margin: 0;
display: flex;
position: relative;
outline: none;
align-items: center;
justify-content: center;
padding: 0;
`;

View File

@@ -0,0 +1,143 @@
/**
* External dependencies
*/
import { render, screen, waitFor, within } from '@testing-library/react';
import { press, click } from '@ariakit/test';
/**
* Internal dependencies
*/
import AlignmentMatrixControl from '..';
const getControl = () => {
return screen.getByRole( 'grid' );
};
const getCell = ( name: string ) => {
return within( getControl() ).getByRole( 'gridcell', { name } );
};
const renderAndInitCompositeStore = async (
jsx: JSX.Element,
focusedCell = 'center center'
) => {
const view = render( jsx );
await waitFor( () => {
expect( getCell( focusedCell ) ).toHaveAttribute( 'tabindex', '0' );
} );
return view;
};
describe( 'AlignmentMatrixControl', () => {
describe( 'Basic rendering', () => {
it( 'should render', async () => {
await renderAndInitCompositeStore( <AlignmentMatrixControl /> );
expect( getControl() ).toBeInTheDocument();
} );
it( 'should be centered by default', async () => {
await renderAndInitCompositeStore( <AlignmentMatrixControl /> );
await press.Tab();
expect( getCell( 'center center' ) ).toHaveFocus();
} );
} );
describe( 'Should change value', () => {
describe( 'with Mouse', () => {
describe( 'on cell click', () => {
it.each( [
'top left',
'top center',
'top right',
'center left',
'center right',
'bottom left',
'bottom center',
'bottom right',
] )( '%s', async ( alignment ) => {
const spy = jest.fn();
await renderAndInitCompositeStore(
<AlignmentMatrixControl
value="center"
onChange={ spy }
/>
);
const cell = getCell( alignment );
await click( cell );
expect( cell ).toHaveFocus();
expect( spy ).toHaveBeenCalledWith( alignment );
} );
it( 'unless already focused', async () => {
const spy = jest.fn();
await renderAndInitCompositeStore(
<AlignmentMatrixControl
value="center"
onChange={ spy }
/>
);
const cell = getCell( 'center center' );
await click( cell );
expect( cell ).toHaveFocus();
expect( spy ).not.toHaveBeenCalled();
} );
} );
} );
describe( 'with Keyboard', () => {
describe( 'on arrow press', () => {
it.each( [
[ 'ArrowUp', 'top center' ],
[ 'ArrowLeft', 'center left' ],
[ 'ArrowDown', 'bottom center' ],
[ 'ArrowRight', 'center right' ],
] as const )( '%s', async ( keyRef, cellRef ) => {
const spy = jest.fn();
await renderAndInitCompositeStore(
<AlignmentMatrixControl onChange={ spy } />
);
await press.Tab();
await press[ keyRef ]();
expect( getCell( cellRef ) ).toHaveFocus();
expect( spy ).toHaveBeenCalledWith( cellRef );
} );
} );
describe( 'but not at at edge', () => {
it.each( [
[ 'ArrowUp', 'top left' ],
[ 'ArrowLeft', 'top left' ],
[ 'ArrowDown', 'bottom right' ],
[ 'ArrowRight', 'bottom right' ],
] as const )( '%s', async ( keyRef, cellRef ) => {
const spy = jest.fn();
await renderAndInitCompositeStore(
<AlignmentMatrixControl onChange={ spy } />
);
const cell = getCell( cellRef );
await click( cell );
await press[ keyRef ]();
expect( cell ).toHaveFocus();
expect( spy ).toHaveBeenCalledWith( cellRef );
} );
} );
} );
} );
} );

View File

@@ -0,0 +1,54 @@
export type AlignmentMatrixControlValue =
| 'top left'
| 'top center'
| 'top right'
| 'center left'
| 'center'
| 'center center'
| 'center right'
| 'bottom left'
| 'bottom center'
| 'bottom right';
export type AlignmentMatrixControlProps = {
/**
* Accessible label. If provided, sets the `aria-label` attribute of the
* underlying `grid` widget.
*
* @default 'Alignment Matrix Control'
*/
label?: string;
/**
* If provided, sets the default alignment value.
*
* @default 'center center'
*/
defaultValue?: AlignmentMatrixControlValue;
/**
* The current alignment value.
*/
value?: AlignmentMatrixControlValue;
/**
* A function that receives the updated alignment value.
*/
onChange?: ( newValue: AlignmentMatrixControlValue ) => void;
/**
* If provided, sets the width of the control.
*
* @default 92
*/
width?: number;
};
export type AlignmentMatrixControlIconProps = Pick<
AlignmentMatrixControlProps,
'value'
> & {
disablePointerEvents?: boolean;
size?: number;
};
export type AlignmentMatrixControlCellProps = {
isActive?: boolean;
value: NonNullable< AlignmentMatrixControlProps[ 'value' ] >;
};

View File

@@ -0,0 +1,101 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import type { AlignmentMatrixControlValue } from './types';
export const GRID: AlignmentMatrixControlValue[][] = [
[ 'top left', 'top center', 'top right' ],
[ 'center left', 'center center', 'center right' ],
[ 'bottom left', 'bottom center', 'bottom right' ],
];
// Stored as map as i18n __() only accepts strings (not variables)
export const ALIGNMENT_LABEL: Record< AlignmentMatrixControlValue, string > = {
'top left': __( 'Top Left' ),
'top center': __( 'Top Center' ),
'top right': __( 'Top Right' ),
'center left': __( 'Center Left' ),
'center center': __( 'Center' ),
center: __( 'Center' ),
'center right': __( 'Center Right' ),
'bottom left': __( 'Bottom Left' ),
'bottom center': __( 'Bottom Center' ),
'bottom right': __( 'Bottom Right' ),
};
// Transforms GRID into a flat Array of values.
export const ALIGNMENTS = GRID.flat();
/**
* Normalizes and transforms an incoming value to better match the alignment values
*
* @param value An alignment value to parse.
*
* @return The parsed value.
*/
function normalize( value?: string | null ) {
const normalized = value === 'center' ? 'center center' : value;
// Strictly speaking, this could be `string | null | undefined`,
// but will be validated shortly, so we're typecasting to an
// `AlignmentMatrixControlValue` to keep TypeScript happy.
const transformed = normalized?.replace(
'-',
' '
) as AlignmentMatrixControlValue;
return ALIGNMENTS.includes( transformed ) ? transformed : undefined;
}
/**
* Creates an item ID based on a prefix ID and an alignment value.
*
* @param prefixId An ID to prefix.
* @param value An alignment value.
*
* @return The item id.
*/
export function getItemId(
prefixId: string,
value?: AlignmentMatrixControlValue
) {
const normalized = normalize( value );
if ( ! normalized ) return;
const id = normalized.replace( ' ', '-' );
return `${ prefixId }-${ id }`;
}
/**
* Extracts an item value from its ID
*
* @param prefixId An ID prefix to remove
* @param id An item ID
* @return The item value
*/
export function getItemValue( prefixId: string, id?: string | null ) {
const value = id?.replace( prefixId + '-', '' );
return normalize( value );
}
/**
* Retrieves the alignment index from a value.
*
* @param alignment Value to check.
*
* @return The index of a matching alignment.
*/
export function getAlignmentIndex(
alignment: AlignmentMatrixControlValue = 'center'
) {
const normalized = normalize( alignment );
if ( ! normalized ) return undefined;
const index = ALIGNMENTS.indexOf( normalized );
return index > -1 ? index : undefined;
}

View File

@@ -0,0 +1,52 @@
# AnglePickerControl
`AnglePickerControl` is a React component to render a UI that allows users to pick an angle.
Users can choose an angle in a visual UI with the mouse by dragging an angle indicator inside a circle or by directly inserting the desired angle in a text field.
## Usage
```jsx
import { useState } from 'react';
import { AnglePickerControl } from '@wordpress/components';
function Example() {
const [ angle, setAngle ] = useState( 0 );
return (
<AnglePickerControl
value={ angle }
onChange={ setAngle }
__nextHasNoMarginBottom
/>
);
};
```
## Props
The component accepts the following props.
### `label`: `string`
Label to use for the angle picker.
- Required: No
- Default: `__( 'Angle' )`
### `value`: `number | string`
The current value of the input. The value represents an angle in degrees and should be a value between 0 and 360.
- Required: Yes
### `onChange`: `( value: number ) => void`
A function that receives the new value of the input.
- Required: Yes
### `__nextHasNoMarginBottom`: `boolean`
Start opting into the new margin-free styles that will become the default in a future version, currently scheduled to be WordPress 6.4. (The prop can be safely removed once this happens.)
- Required: No
- Default: `false`

View File

@@ -0,0 +1,126 @@
/**
* WordPress dependencies
*/
import { useEffect, useRef } from '@wordpress/element';
import { __experimentalUseDragging as useDragging } from '@wordpress/compose';
/**
* Internal dependencies
*/
import {
CircleRoot,
CircleIndicatorWrapper,
CircleIndicator,
} from './styles/angle-picker-control-styles';
import type { WordPressComponentProps } from '../context';
import type { AngleCircleProps } from './types';
type UseDraggingArgumentType = Parameters< typeof useDragging >[ 0 ];
type UseDraggingCallbackEvent =
| Parameters< UseDraggingArgumentType[ 'onDragStart' ] >[ 0 ]
| Parameters< UseDraggingArgumentType[ 'onDragMove' ] >[ 0 ]
| Parameters< UseDraggingArgumentType[ 'onDragEnd' ] >[ 0 ];
function AngleCircle( {
value,
onChange,
...props
}: WordPressComponentProps< AngleCircleProps, 'div' > ) {
const angleCircleRef = useRef< HTMLDivElement | null >( null );
const angleCircleCenter = useRef< { x: number; y: number } | undefined >();
const previousCursorValue = useRef< CSSStyleDeclaration[ 'cursor' ] >();
const setAngleCircleCenter = () => {
if ( angleCircleRef.current === null ) {
return;
}
const rect = angleCircleRef.current.getBoundingClientRect();
angleCircleCenter.current = {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
};
};
const changeAngleToPosition = ( event: UseDraggingCallbackEvent ) => {
if ( event === undefined ) {
return;
}
// Prevent (drag) mouse events from selecting and accidentally
// triggering actions from other elements.
event.preventDefault();
// Input control needs to lose focus and by preventDefault above, it doesn't.
( event.target as HTMLDivElement | null )?.focus();
if (
angleCircleCenter.current !== undefined &&
onChange !== undefined
) {
const { x: centerX, y: centerY } = angleCircleCenter.current;
onChange(
getAngle( centerX, centerY, event.clientX, event.clientY )
);
}
};
const { startDrag, isDragging } = useDragging( {
onDragStart: ( event ) => {
setAngleCircleCenter();
changeAngleToPosition( event );
},
onDragMove: changeAngleToPosition,
onDragEnd: changeAngleToPosition,
} );
useEffect( () => {
if ( isDragging ) {
if ( previousCursorValue.current === undefined ) {
previousCursorValue.current = document.body.style.cursor;
}
document.body.style.cursor = 'grabbing';
} else {
document.body.style.cursor = previousCursorValue.current || '';
previousCursorValue.current = undefined;
}
}, [ isDragging ] );
return (
<CircleRoot
ref={ angleCircleRef }
onMouseDown={ startDrag }
className="components-angle-picker-control__angle-circle"
{ ...props }
>
<CircleIndicatorWrapper
style={
value ? { transform: `rotate(${ value }deg)` } : undefined
}
className="components-angle-picker-control__angle-circle-indicator-wrapper"
tabIndex={ -1 }
>
<CircleIndicator className="components-angle-picker-control__angle-circle-indicator" />
</CircleIndicatorWrapper>
</CircleRoot>
);
}
function getAngle(
centerX: number,
centerY: number,
pointX: number,
pointY: number
) {
const y = pointY - centerY;
const x = pointX - centerX;
const angleInRadians = Math.atan2( y, x );
const angleInDeg = Math.round( angleInRadians * ( 180 / Math.PI ) ) + 90;
if ( angleInDeg < 0 ) {
return 360 + angleInDeg;
}
return angleInDeg;
}
export default AngleCircle;

View File

@@ -0,0 +1,126 @@
/**
* External dependencies
*/
import type { ForwardedRef } from 'react';
import classnames from 'classnames';
/**
* WordPress dependencies
*/
import deprecated from '@wordpress/deprecated';
import { forwardRef } from '@wordpress/element';
import { isRTL, __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { FlexBlock } from '../flex';
import { Spacer } from '../spacer';
import NumberControl from '../number-control';
import AngleCircle from './angle-circle';
import { Root, UnitText } from './styles/angle-picker-control-styles';
import type { WordPressComponentProps } from '../context';
import type { AnglePickerControlProps } from './types';
function UnforwardedAnglePickerControl(
props: WordPressComponentProps< AnglePickerControlProps, 'div' >,
ref: ForwardedRef< any >
) {
const {
__nextHasNoMarginBottom = false,
className,
label = __( 'Angle' ),
onChange,
value,
...restProps
} = props;
if ( ! __nextHasNoMarginBottom ) {
deprecated(
'Bottom margin styles for wp.components.AnglePickerControl',
{
since: '6.1',
hint: 'Set the `__nextHasNoMarginBottom` prop to true to start opting into the new styles, which will become the default in a future version.',
}
);
}
const handleOnNumberChange = ( unprocessedValue: string | undefined ) => {
if ( onChange === undefined ) {
return;
}
const inputValue =
unprocessedValue !== undefined && unprocessedValue !== ''
? parseInt( unprocessedValue, 10 )
: 0;
onChange( inputValue );
};
const classes = classnames( 'components-angle-picker-control', className );
const unitText = <UnitText>°</UnitText>;
const [ prefixedUnitText, suffixedUnitText ] = isRTL()
? [ unitText, null ]
: [ null, unitText ];
return (
<Root
{ ...restProps }
ref={ ref }
__nextHasNoMarginBottom={ __nextHasNoMarginBottom }
className={ classes }
gap={ 2 }
>
<FlexBlock>
<NumberControl
label={ label }
className="components-angle-picker-control__input-field"
max={ 360 }
min={ 0 }
onChange={ handleOnNumberChange }
size="__unstable-large"
step="1"
value={ value }
spinControls="none"
prefix={ prefixedUnitText }
suffix={ suffixedUnitText }
/>
</FlexBlock>
<Spacer marginBottom="1" marginTop="auto">
<AngleCircle
aria-hidden="true"
value={ value }
onChange={ onChange }
/>
</Spacer>
</Root>
);
}
/**
* `AnglePickerControl` is a React component to render a UI that allows users to
* pick an angle. Users can choose an angle in a visual UI with the mouse by
* dragging an angle indicator inside a circle or by directly inserting the
* desired angle in a text field.
*
* ```jsx
* import { useState } from '@wordpress/element';
* import { AnglePickerControl } from '@wordpress/components';
*
* function Example() {
* const [ angle, setAngle ] = useState( 0 );
* return (
* <AnglePickerControl
* value={ angle }
* onChange={ setAngle }
* __nextHasNoMarginBottom
* </>
* );
* }
* ```
*/
export const AnglePickerControl = forwardRef( UnforwardedAnglePickerControl );
export default AnglePickerControl;

View File

@@ -0,0 +1,57 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { AnglePickerControl } from '..';
const meta: Meta< typeof AnglePickerControl > = {
title: 'Components/AnglePickerControl',
component: AnglePickerControl,
argTypes: {
as: { control: { type: null } },
value: { control: { type: null } },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: {
expanded: true,
},
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const AnglePickerWithState: StoryFn< typeof AnglePickerControl > = ( {
onChange,
...args
} ) => {
const [ angle, setAngle ] = useState< number >( 0 );
const handleChange = ( newValue: number ) => {
setAngle( newValue );
onChange( newValue );
};
return (
<AnglePickerControl
{ ...args }
value={ angle }
onChange={ handleChange }
/>
);
};
export const Default = AnglePickerWithState.bind( {} );
Default.args = {
__nextHasNoMarginBottom: true,
};

View File

@@ -0,0 +1,76 @@
/**
* External dependencies
*/
import { css } from '@emotion/react';
import styled from '@emotion/styled';
/**
* Internal dependencies
*/
import { Flex } from '../../flex';
import { COLORS } from '../../utils';
import { space } from '../../utils/space';
import { Text } from '../../text';
import CONFIG from '../../utils/config-values';
import type { AnglePickerControlProps } from '../types';
const CIRCLE_SIZE = 32;
const INNER_CIRCLE_SIZE = 6;
const deprecatedBottomMargin = ( {
__nextHasNoMarginBottom,
}: Pick< AnglePickerControlProps, '__nextHasNoMarginBottom' > ) => {
return ! __nextHasNoMarginBottom
? css`
margin-bottom: ${ space( 2 ) };
`
: '';
};
export const Root = styled( Flex )`
${ deprecatedBottomMargin }
`;
export const CircleRoot = styled.div`
border-radius: 50%;
border: ${ CONFIG.borderWidth } solid ${ COLORS.ui.border };
box-sizing: border-box;
cursor: grab;
height: ${ CIRCLE_SIZE }px;
overflow: hidden;
width: ${ CIRCLE_SIZE }px;
:active {
cursor: grabbing;
}
`;
export const CircleIndicatorWrapper = styled.div`
box-sizing: border-box;
position: relative;
width: 100%;
height: 100%;
:focus-visible {
outline: none;
}
`;
export const CircleIndicator = styled.div`
background: ${ COLORS.theme.accent };
border-radius: 50%;
box-sizing: border-box;
display: block;
left: 50%;
top: 4px;
transform: translateX( -50% );
position: absolute;
width: ${ INNER_CIRCLE_SIZE }px;
height: ${ INNER_CIRCLE_SIZE }px;
`;
export const UnitText = styled( Text )`
color: ${ COLORS.theme.accent };
margin-right: ${ space( 3 ) };
`;

View File

@@ -0,0 +1,29 @@
export type AnglePickerControlProps = {
/**
* Start opting into the new margin-free styles that will become the default
* in a future version.
*
* @default false
*/
__nextHasNoMarginBottom?: boolean;
/**
* Label to use for the angle picker.
*
* @default __( 'Angle' )
*/
label?: string;
/**
* A function that receives the new value of the input.
*/
onChange: ( value: number ) => void;
/**
* The current value of the input. The value represents an angle in degrees
* and should be a value between 0 and 360.
*/
value: number | string;
};
export type AngleCircleProps = Pick<
AnglePickerControlProps,
'value' | 'onChange'
>;

View File

@@ -0,0 +1,53 @@
# Animate
Simple interface to introduce animations to components.
## Usage
```jsx
import { Animate, Notice } from '@wordpress/components';
const MyAnimatedNotice = () => (
<Animate type="slide-in" options={ { origin: 'top' } }>
{ ( { className } ) => (
<Notice className={ className } status="success">
<p>Animation finished.</p>
</Notice>
) }
</Animate>
);
```
## Props
| Name | Type | Default | Description |
| ---------- | ---------- | ----------- | -------------------------------------------------------------------------------------------- |
| `type` | `string` | `undefined` | Type of the animation to use. |
| `options` | `object` | `{}` | Options of the chosen animation. |
| `children` | `function` | `undefined` | A callback receiving a list of props ( `className` ) to apply to the DOM element to animate. |
## Available Animation Types
### appear
This animation is meant for popover/modal content, such as menus appearing. It shows the height and width of the animated element scaling from 0 to full size, from its point of origin.
#### Options
| Name | Type | Default | Description |
| -------- | -------- | ------------ | -------------------------------------------------------------------- |
| `origin` | `string` | `top center` | Point of origin (`top`, `bottom`,` middle right`, `left`, `center`). |
### loading
This animation is meant to be used to indicate that activity is happening in the background. It is an infinitely-looping fade from 50% to full opacity. This animation has no options, and should be removed as soon as its relevant operation is completed.
### slide-in
This animation is meant for sidebars and sliding menus. It shows the height and width of the animated element moving from a hidden position to its normal one.
#### Options
| Name | Type | Default | Description |
| -------- | -------- | ------- | ------------------------- |
| `origin` | `string` | `left` | Point of origin (`left`). |

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* Internal dependencies
*/
import type { AnimateProps, GetAnimateOptions } from './types';
/**
* @param type The animation type
* @return Default origin
*/
function getDefaultOrigin( type?: GetAnimateOptions[ 'type' ] ) {
return type === 'appear' ? 'top' : 'left';
}
/**
* @param options
*
* @return ClassName that applies the animations
*/
export function getAnimateClassName( options: GetAnimateOptions ) {
if ( options.type === 'loading' ) {
return classnames( 'components-animate__loading' );
}
const { type, origin = getDefaultOrigin( type ) } = options;
if ( type === 'appear' ) {
const [ yAxis, xAxis = 'center' ] = origin.split( ' ' );
return classnames( 'components-animate__appear', {
[ 'is-from-' + xAxis ]: xAxis !== 'center',
[ 'is-from-' + yAxis ]: yAxis !== 'middle',
} );
}
if ( type === 'slide-in' ) {
return classnames(
'components-animate__slide-in',
'is-from-' + origin
);
}
return undefined;
}
/**
* Simple interface to introduce animations to components.
*
* ```jsx
* import { Animate, Notice } from '@wordpress/components';
*
* const MyAnimatedNotice = () => (
* <Animate type="slide-in" options={ { origin: 'top' } }>
* { ( { className } ) => (
* <Notice className={ className } status="success">
* <p>Animation finished.</p>
* </Notice>
* ) }
* </Animate>
* );
* ```
*/
export function Animate( { type, options = {}, children }: AnimateProps ) {
return children( {
className: getAnimateClassName( {
type,
...options,
} as GetAnimateOptions ),
} );
}
export default Animate;

View File

@@ -0,0 +1,95 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* Internal dependencies
*/
import { Animate } from '..';
import Notice from '../../notice';
const meta: Meta< typeof Animate > = {
title: 'Components/Animate',
component: Animate,
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const Template: StoryFn< typeof Animate > = ( props ) => (
<Animate { ...props } />
);
export const Default = Template.bind( {} );
Default.args = {
children: ( { className } ) => (
<Notice className={ className } status="success">
<p>{ `No default animation. Use one of type = "appear", "slide-in", or "loading".` }</p>
</Notice>
),
};
export const AppearTopLeft = Template.bind( {} );
AppearTopLeft.args = {
type: 'appear',
options: { origin: 'top left' },
children: ( { className } ) => (
<Notice className={ className } status="success">
<p>Appear animation. Origin: top left.</p>
</Notice>
),
};
export const AppearTopRight = Template.bind( {} );
AppearTopRight.args = {
type: 'appear',
options: { origin: 'top right' },
children: ( { className } ) => (
<Notice className={ className } status="success">
<p>Appear animation. Origin: top right.</p>
</Notice>
),
};
export const AppearBottomLeft = Template.bind( {} );
AppearBottomLeft.args = {
type: 'appear',
options: { origin: 'bottom left' },
children: ( { className } ) => (
<Notice className={ className } status="success">
<p>Appear animation. Origin: bottom left.</p>
</Notice>
),
};
export const AppearBottomRight = Template.bind( {} );
AppearBottomRight.args = {
type: 'appear',
options: { origin: 'bottom right' },
children: ( { className } ) => (
<Notice className={ className } status="success">
<p>Appear animation. Origin: bottom right.</p>
</Notice>
),
};
export const Loading = Template.bind( {} );
Loading.args = {
type: 'loading',
children: ( { className } ) => (
<Notice className={ className } status="success">
<p>Loading animation.</p>
</Notice>
),
};
export const SlideIn = Template.bind( {} );
SlideIn.args = {
type: 'slide-in',
options: { origin: 'left' },
children: ( { className } ) => (
<Notice className={ className } status="success">
<p>Slide-in animation.</p>
</Notice>
),
};

View File

@@ -0,0 +1,65 @@
.components-animate__appear {
animation: components-animate__appear-animation 0.1s cubic-bezier(0, 0, 0.2, 1) 0s;
animation-fill-mode: forwards;
@include reduce-motion("animation");
&.is-from-top,
&.is-from-top.is-from-left {
transform-origin: top left;
}
&.is-from-top.is-from-right {
transform-origin: top right;
}
&.is-from-bottom,
&.is-from-bottom.is-from-left {
transform-origin: bottom left;
}
&.is-from-bottom.is-from-right {
transform-origin: bottom right;
}
}
@keyframes components-animate__appear-animation {
from {
transform: translateY(-2em) scaleY(0) scaleX(0);
}
to {
transform: translateY(0%) scaleY(1) scaleX(1);
}
}
.components-animate__slide-in {
animation: components-animate__slide-in-animation 0.1s cubic-bezier(0, 0, 0.2, 1);
animation-fill-mode: forwards;
@include reduce-motion("animation");
&.is-from-left {
transform: translateX(+100%);
}
&.is-from-right {
transform: translateX(-100%);
}
}
@keyframes components-animate__slide-in-animation {
100% {
transform: translateX(0%);
}
}
.components-animate__loading {
animation: components-animate__loading 1.6s ease-in-out infinite;
}
@keyframes components-animate__loading {
0% {
opacity: 0.5;
}
50% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,32 @@
export type AppearOptions = {
type: 'appear';
origin?:
| 'top'
| 'top left'
| 'top right'
| 'middle'
| 'middle left'
| 'middle right'
| 'bottom'
| 'bottom left'
| 'bottom right';
};
type SlideInOptions = { type: 'slide-in'; origin?: 'left' | 'right' };
type LoadingOptions = { type: 'loading'; origin?: never };
type NoAnimationOptions = { type?: never; origin?: never };
export type GetAnimateOptions =
| AppearOptions
| SlideInOptions
| LoadingOptions
| NoAnimationOptions;
// Create a new type that and distributes the `Pick` operator separately to
// every individual type of a union, thus preserving that same union.
type DistributiveTypeAndOptions< T extends { type?: any } > = T extends any
? Pick< T, 'type' > & { options?: Omit< T, 'type' > }
: never;
export type AnimateProps = DistributiveTypeAndOptions< GetAnimateOptions > & {
children: ( props: { className?: string } ) => JSX.Element;
};

View File

@@ -0,0 +1,14 @@
/**
* Framer Motion is used to create animated, interactive interfaces. The package is roughly ~30kb so
* this should ideally be loaded once across all Gutenberg packages. To give ourselves more flexibility
* in trying animation options, we avoid making this public API.
*
* @see https://www.framer.com/docs/animation/
*/
// eslint-disable-next-line no-restricted-imports
export {
motion as __unstableMotion,
AnimatePresence as __unstableAnimatePresence,
MotionContext as __unstableMotionContext,
} from 'framer-motion';

View File

@@ -0,0 +1,234 @@
# Autocomplete
This component is used to provide autocompletion support for a child input component.
## Props
The following props are used to control the behavior of the component.
### record
The rich text value object the autocomleter is being applied to.
- Required: Yes
- Type: `RichTextValue`
### onChange
A function to be called when an option is selected to insert into the existing text.
- Required: Yes
- Type: `( value: string ) => void`
### onReplace
A function to be called when an option is selected to replace the existing text.
- Required: Yes
- Type: `( values: RichTextValue[] ) => void`
### completers
An array of all of the completers to apply to the current element.
- Required: Yes
- Type: `Array< WPCompleter >`
### contentRef
A ref containing the editable element that will serve as the anchor for `Autocomplete`'s `Popover`.
- Required: Yes
- `MutableRefObject< HTMLElement | undefined >`
### children
A function that returns nodes to be rendered within the Autocomplete.
- Required: Yes
- Type: `Function`
### isSelected
Whether or not the Autocomplte componenet is selected, and if its `Popover` should be displayed.
- Required: Yes
- Type: `Boolean`
## Autocompleters
Autocompleters enable us to offer users options for completing text input. For example, Gutenberg includes a user autocompleter that provides a list of user names and completes a selection with a user mention like `@mary`.
Each completer declares:
- Its name.
- The text prefix that should trigger the display of completion options.
- Raw option data.
- How to render an option's label.
- An option's keywords, words that will be used to match an option with user input.
- What the completion of an option looks like, including whether it should be inserted in the text or used to replace the current block.
In addition, a completer may optionally declare:
- A class name to be applied to the completion menu.
- Whether it should apply to a specified text node.
- Whether the completer applies in a given context, defined via a Range before and a Range after the autocompletion trigger and query.
### The Completer Interface
#### name
The name of the completer. Useful for identifying a specific completer to be overridden via extensibility hooks.
- Type: `String`
- Required: Yes
#### options
The raw options for completion. May be an array, a function that returns an array, or a function that returns a promise for an array.
Options may be of any type or shape. The completer declares how those options are rendered and what their completions should be when selected.
- Type: `Array|Function`
- Required: Yes
#### triggerPrefix
The string prefix that should trigger the completer. For example, Gutenberg's block completer is triggered when the '/' character is entered.
- Type: `String`
- Required: Yes
#### getOptionLabel
A function that returns the label for a given option. A label may be a string or a mixed array of strings, elements, and components.
- Type: `Function`
- Required: Yes
#### getOptionKeywords
A function that returns the keywords for the specified option.
- Type: `Function`
- Required: No
#### isOptionDisabled
A function that returns whether or not the specified option should be disabled. Disabled options cannot be selected.
- Type: `Function`
- Required: No
#### getOptionCompletion
A function that takes an option and responds with how the option should be completed. By default, the result is a value to be inserted in the text. However, a completer may explicitly declare how a completion should be treated by returning an object with `action` and `value` properties. The `action` declares what should be done with the `value`.
There are currently two supported actions:
- "insert-at-caret" - Insert the `value` into the text (the default completion action).
- "replace" - Replace the current block with the block specified in the `value` property.
#### allowContext
A function that takes a Range before and a Range after the autocomplete trigger and query text and returns a boolean indicating whether the completer should be considered for that context.
- Type: `Function`
- Required: No
#### className
A class name to apply to the autocompletion popup menu.
- Type: `String`
- Required: No
#### isDebounced
Whether to apply debouncing for the autocompleter. Set to true to enable debouncing.
- Type: `Boolean`
- Required: No
## Usage
The `Autocomplete` component is not currently intended to be used as a standalone component. It is used by other packages to provide autocompletion support for the block editor.
The block editor provides a separate, wrapped version of `Autocomplete` that supports the addition of custom completers via a filter.
To implement your own completer in the block editor you will:
1. Define the completer
2. Write a callback that will add your completer to the list of existing completers
3. Add a filter to the `editor.Autocomplete.completers` hook that will call your callback
The following example will add a contrived "fruits" autocompleter to the block editor. Note that in the callback it's possible to limit this new completer to a specific block type. In this case, our "fruits" completer will only be available from the "core/paragraph" block type.
```js
( function () {
// Define the completer
const fruits = {
name: 'fruit',
// The prefix that triggers this completer
triggerPrefix: '~',
// The option data
options: [
{ visual: '🍎', name: 'Apple', id: 1 },
{ visual: '🍊', name: 'Orange', id: 2 },
{ visual: '🍇', name: 'Grapes', id: 3 },
{ visual: '🥭', name: 'Mango', id: 4 },
{ visual: '🍓', name: 'Strawberry', id: 5 },
{ visual: '🫐', name: 'Blueberry', id: 6 },
{ visual: '🍒', name: 'Cherry', id: 7 },
],
// Returns a label for an option like "🍊 Orange"
getOptionLabel: ( option ) => `${ option.visual } ${ option.name }`,
// Declares that options should be matched by their name
getOptionKeywords: ( option ) => [ option.name ],
// Declares that the Grapes option is disabled
isOptionDisabled: ( option ) => option.name === 'Grapes',
// Declares completions should be inserted as abbreviations
getOptionCompletion: ( option ) => option.visual,
};
// Define a callback that will add the custom completer to the list of completers
function appendTestCompleters( completers, blockName ) {
return blockName === 'core/paragraph'
? [ ...completers, fruits ]
: completers;
}
// Trigger our callback on the `editor.Autocomplete.completers` hook
wp.hooks.addFilter(
'editor.Autocomplete.completers',
'fruit-test/fruits',
appendTestCompleters,
11
);
} )();
```
Finally, enqueue your JavaScript file as you would any other, as in the following plugin example:
```php
<?php
/**
* Plugin Name: Fruit Autocompleter
* Plugin URI: https://github.com/WordPress/gutenberg
* Author: Gutenberg Team
*/
/**
* Registers a custom script for the plugin.
*/
function enqueue_fruit_autocompleter_plugin_script() {
wp_enqueue_script(
'fruit-autocompleter',
plugins_url( '/index.js', __FILE__ ),
array(
'wp-hooks',
),
);
}
add_action( 'init', 'enqueue_fruit_autocompleter_plugin_script' );
```

View File

@@ -0,0 +1,222 @@
/**
* External dependencies
*/
import {
View,
Animated,
StyleSheet,
Text,
TouchableOpacity,
ScrollView,
} from 'react-native';
/**
* WordPress dependencies
*/
import {
useLayoutEffect,
useEffect,
useRef,
useState,
useCallback,
} from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import {
Icon,
__unstableAutocompletionItemsFill as AutocompletionItemsFill,
} from '@wordpress/components';
import { usePreferredColorSchemeStyle } from '@wordpress/compose';
/**
* Internal dependencies
*/
import BackgroundView from './background-view';
import getDefaultUseItems from './get-default-use-items';
import styles from './style.scss';
const { compose: stylesCompose } = StyleSheet;
export function getAutoCompleterUI( autocompleter ) {
const useItems = autocompleter.useItems
? autocompleter.useItems
: getDefaultUseItems( autocompleter );
function AutocompleterUI( {
filterValue,
selectedIndex,
onChangeOptions,
onSelect,
value,
reset,
} ) {
const [ items ] = useItems( filterValue );
const filteredItems = items.filter( ( item ) => ! item.isDisabled );
const scrollViewRef = useRef();
const animationValue = useRef( new Animated.Value( 0 ) ).current;
const [ isVisible, setIsVisible ] = useState( false );
const { text } = value;
useEffect( () => {
if ( ! isVisible && text.length > 0 ) {
setIsVisible( true );
}
}, [ isVisible, text ] );
useLayoutEffect( () => {
onChangeOptions( items );
scrollViewRef.current?.scrollTo( { x: 0, animated: false } );
if ( isVisible && text.length > 0 ) {
startAnimation( true );
} else if ( isVisible && text.length === 0 ) {
startAnimation( false );
}
// Temporarily disabling exhaustive-deps to avoid introducing unexpected side effecst.
// See https://github.com/WordPress/gutenberg/pull/41820
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ items, isVisible, text ] );
const activeItemStyles = usePreferredColorSchemeStyle(
styles[ 'components-autocomplete__item-active' ],
styles[ 'components-autocomplete__item-active-dark' ]
);
const iconStyles = usePreferredColorSchemeStyle(
styles[ 'components-autocomplete__icon' ],
styles[ 'components-autocomplete__icon-active-dark' ]
);
const activeIconStyles = usePreferredColorSchemeStyle(
styles[ 'components-autocomplete__icon-active ' ],
styles[ 'components-autocomplete__icon-active-dark' ]
);
const textStyles = usePreferredColorSchemeStyle(
styles[ 'components-autocomplete__text' ],
styles[ 'components-autocomplete__text-dark' ]
);
const activeTextStyles = usePreferredColorSchemeStyle(
styles[ 'components-autocomplete__text-active' ],
styles[ 'components-autocomplete__text-active-dark' ]
);
const startAnimation = useCallback(
( show ) => {
Animated.timing( animationValue, {
toValue: show ? 1 : 0,
duration: show ? 200 : 100,
useNativeDriver: true,
} ).start( ( { finished } ) => {
if ( finished && ! show && isVisible ) {
setIsVisible( false );
reset();
}
} );
},
// Temporarily disabling exhaustive-deps to avoid introducing unexpected side effecst.
// See https://github.com/WordPress/gutenberg/pull/41820
// eslint-disable-next-line react-hooks/exhaustive-deps
[ isVisible ]
);
const contentStyles = {
transform: [
{
translateY: animationValue.interpolate( {
inputRange: [ 0, 1 ],
outputRange: [
styles[ 'components-autocomplete' ].height,
0,
],
} ),
},
],
};
if ( ! filteredItems.length > 0 || ! isVisible ) {
return null;
}
return (
<AutocompletionItemsFill>
<View style={ styles[ 'components-autocomplete' ] }>
<Animated.View style={ contentStyles }>
<BackgroundView>
<ScrollView
testID="autocompleter"
ref={ scrollViewRef }
horizontal
contentContainerStyle={
styles[ 'components-autocomplete__content' ]
}
showsHorizontalScrollIndicator={ false }
keyboardShouldPersistTaps="always"
accessibilityLabel={
// translators: Slash inserter autocomplete results
__( 'Slash inserter results' )
}
>
{ filteredItems.map( ( option, index ) => {
const isActive = index === selectedIndex;
const itemStyle = stylesCompose(
styles[
'components-autocomplete__item'
],
isActive && activeItemStyles
);
const textStyle = stylesCompose(
textStyles,
isActive && activeTextStyles
);
const iconStyle = stylesCompose(
iconStyles,
isActive && activeIconStyles
);
const iconSource =
option?.value?.icon?.src ||
option?.value?.icon;
return (
<TouchableOpacity
activeOpacity={ 0.5 }
style={ itemStyle }
key={ index }
onPress={ () => onSelect( option ) }
accessibilityLabel={ sprintf(
// translators: %s: Block name e.g. "Image block"
__( '%s block' ),
option?.value?.title
) }
>
<View
style={
styles[
'components-autocomplete__icon'
]
}
>
<Icon
icon={ iconSource }
size={ 24 }
style={ iconStyle }
/>
</View>
<Text style={ textStyle }>
{ option?.value?.title }
</Text>
</TouchableOpacity>
);
} ) }
</ScrollView>
</BackgroundView>
</Animated.View>
</View>
</AutocompletionItemsFill>
);
}
return AutocompleterUI;
}
export default getAutoCompleterUI;

View File

@@ -0,0 +1,207 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* WordPress dependencies
*/
import {
useLayoutEffect,
useRef,
useEffect,
useState,
} from '@wordpress/element';
import { useAnchor } from '@wordpress/rich-text';
import { useDebounce, useMergeRefs, useRefEffect } from '@wordpress/compose';
import { speak } from '@wordpress/a11y';
import { __, _n, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import getDefaultUseItems from './get-default-use-items';
import Button from '../button';
import Popover from '../popover';
import { VisuallyHidden } from '../visually-hidden';
import { createPortal } from 'react-dom';
import type { AutocompleterUIProps, KeyedOption, WPCompleter } from './types';
export function getAutoCompleterUI( autocompleter: WPCompleter ) {
const useItems = autocompleter.useItems
? autocompleter.useItems
: getDefaultUseItems( autocompleter );
function AutocompleterUI( {
filterValue,
instanceId,
listBoxId,
className,
selectedIndex,
onChangeOptions,
onSelect,
onReset,
reset,
contentRef,
}: AutocompleterUIProps ) {
const [ items ] = useItems( filterValue );
const popoverAnchor = useAnchor( {
editableContentElement: contentRef.current,
} );
const [ needsA11yCompat, setNeedsA11yCompat ] = useState( false );
const popoverRef = useRef< HTMLElement >( null );
const popoverRefs = useMergeRefs( [
popoverRef,
useRefEffect(
( node ) => {
if ( ! contentRef.current ) return;
// If the popover is rendered in a different document than
// the content, we need to duplicate the options list in the
// content document so that it's available to the screen
// readers, which check the DOM ID based aira-* attributes.
setNeedsA11yCompat(
node.ownerDocument !== contentRef.current.ownerDocument
);
},
[ contentRef ]
),
] );
useOnClickOutside( popoverRef, reset );
const debouncedSpeak = useDebounce( speak, 500 );
function announce( options: Array< KeyedOption > ) {
if ( ! debouncedSpeak ) {
return;
}
if ( !! options.length ) {
if ( filterValue ) {
debouncedSpeak(
sprintf(
/* translators: %d: number of results. */
_n(
'%d result found, use up and down arrow keys to navigate.',
'%d results found, use up and down arrow keys to navigate.',
options.length
),
options.length
),
'assertive'
);
} else {
debouncedSpeak(
sprintf(
/* translators: %d: number of results. */
_n(
'Initial %d result loaded. Type to filter all available results. Use up and down arrow keys to navigate.',
'Initial %d results loaded. Type to filter all available results. Use up and down arrow keys to navigate.',
options.length
),
options.length
),
'assertive'
);
}
} else {
debouncedSpeak( __( 'No results.' ), 'assertive' );
}
}
useLayoutEffect( () => {
onChangeOptions( items );
announce( items );
// Temporarily disabling exhaustive-deps to avoid introducing unexpected side effecst.
// See https://github.com/WordPress/gutenberg/pull/41820
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ items ] );
if ( items.length === 0 ) {
return null;
}
const ListBox = ( {
Component = 'div',
}: {
Component?: React.ElementType;
} ) => (
<Component
id={ listBoxId }
role="listbox"
className="components-autocomplete__results"
>
{ items.map( ( option, index ) => (
<Button
key={ option.key }
id={ `components-autocomplete-item-${ instanceId }-${ option.key }` }
role="option"
aria-selected={ index === selectedIndex }
disabled={ option.isDisabled }
className={ classnames(
'components-autocomplete__result',
className,
{
'is-selected': index === selectedIndex,
}
) }
onClick={ () => onSelect( option ) }
>
{ option.label }
</Button>
) ) }
</Component>
);
return (
<>
<Popover
focusOnMount={ false }
onClose={ onReset }
placement="top-start"
className="components-autocomplete__popover"
anchor={ popoverAnchor }
ref={ popoverRefs }
>
<ListBox />
</Popover>
{ contentRef.current &&
needsA11yCompat &&
createPortal(
<ListBox Component={ VisuallyHidden } />,
contentRef.current.ownerDocument.body
) }
</>
);
}
return AutocompleterUI;
}
function useOnClickOutside(
ref: React.RefObject< HTMLElement >,
handler: AutocompleterUIProps[ 'reset' ]
) {
useEffect( () => {
const listener = ( event: MouseEvent | TouchEvent ) => {
// Do nothing if clicking ref's element or descendent elements, or if the ref is not referencing an element
if (
! ref.current ||
ref.current.contains( event.target as Node )
) {
return;
}
handler( event );
};
document.addEventListener( 'mousedown', listener );
document.addEventListener( 'touchstart', listener );
return () => {
document.removeEventListener( 'mousedown', listener );
document.removeEventListener( 'touchstart', listener );
};
// Disable reason: `ref` is a ref object and should not be included in a
// hook's dependency list.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ handler ] );
}

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { View } from 'react-native';
/**
* WordPress dependencies
*/
import { usePreferredColorSchemeStyle } from '@wordpress/compose';
/**
* Internal dependencies
*/
import styles from './style.scss';
const BackgroundView = ( { children } ) => {
const backgroundStyles = usePreferredColorSchemeStyle(
styles[ 'components-autocomplete__background' ],
styles[ 'components-autocomplete__background-dark' ]
);
return <View style={ backgroundStyles }>{ children }</View>;
};
export default BackgroundView;

View File

@@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { BlurView } from '@react-native-community/blur';
/**
* Internal dependencies
*/
import styles from './style.scss';
const BackgroundView = ( { children } ) => {
return (
<BlurView
style={ styles[ 'components-autocomplete__background-blur' ] }
blurType="prominent"
blurAmount={ 10 }
>
{ children }
</BlurView>
);
};
export default BackgroundView;

View File

@@ -0,0 +1,122 @@
/**
* External dependencies
*/
import removeAccents from 'remove-accents';
/**
* WordPress dependencies
*/
import { debounce } from '@wordpress/compose';
import { useLayoutEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { escapeRegExp } from '../utils/strings';
import type { CancelablePromise, KeyedOption, WPCompleter } from './types';
function filterOptions(
search: RegExp,
options: Array< KeyedOption > = [],
maxResults = 10
) {
const filtered = [];
for ( let i = 0; i < options.length; i++ ) {
const option = options[ i ];
// Merge label into keywords.
let { keywords = [] } = option;
if ( 'string' === typeof option.label ) {
keywords = [ ...keywords, option.label ];
}
const isMatch = keywords.some( ( keyword ) =>
search.test( removeAccents( keyword ) )
);
if ( ! isMatch ) {
continue;
}
filtered.push( option );
// Abort early if max reached.
if ( filtered.length === maxResults ) {
break;
}
}
return filtered;
}
export default function getDefaultUseItems( autocompleter: WPCompleter ) {
return ( filterValue: string ) => {
const [ items, setItems ] = useState< Array< KeyedOption > >( [] );
/*
* We support both synchronous and asynchronous retrieval of completer options
* but internally treat all as async so we maintain a single, consistent code path.
*
* Because networks can be slow, and the internet is wonderfully unpredictable,
* we don't want two promises updating the state at once. This ensures that only
* the most recent promise will act on `optionsData`. This doesn't use the state
* because `setState` is batched, and so there's no guarantee that setting
* `activePromise` in the state would result in it actually being in `this.state`
* before the promise resolves and we check to see if this is the active promise or not.
*/
useLayoutEffect( () => {
const { options, isDebounced } = autocompleter;
const loadOptions = debounce(
() => {
const promise: CancelablePromise = Promise.resolve(
typeof options === 'function'
? options( filterValue )
: options
).then( ( optionsData ) => {
if ( promise.canceled ) {
return;
}
const keyedOptions = optionsData.map(
( optionData, optionIndex ) => ( {
key: `${ autocompleter.name }-${ optionIndex }`,
value: optionData,
label: autocompleter.getOptionLabel(
optionData
),
keywords: autocompleter.getOptionKeywords
? autocompleter.getOptionKeywords(
optionData
)
: [],
isDisabled: autocompleter.isOptionDisabled
? autocompleter.isOptionDisabled(
optionData
)
: false,
} )
);
// Create a regular expression to filter the options.
const search = new RegExp(
'(?:\\b|\\s|^)' + escapeRegExp( filterValue ),
'i'
);
setItems( filterOptions( search, keyedOptions ) );
} );
return promise;
},
isDebounced ? 250 : 0
);
const promise = loadOptions();
return () => {
loadOptions.cancel();
if ( promise ) {
promise.canceled = true;
}
};
}, [ filterValue ] );
return [ items ] as const;
};
}

View File

@@ -0,0 +1,476 @@
/**
* External dependencies
*/
import removeAccents from 'remove-accents';
/**
* WordPress dependencies
*/
import {
renderToString,
useEffect,
useState,
useRef,
useMemo,
} from '@wordpress/element';
import { __, _n } from '@wordpress/i18n';
import { useInstanceId, useMergeRefs, useRefEffect } from '@wordpress/compose';
import {
create,
slice,
insert,
isCollapsed,
getTextContent,
} from '@wordpress/rich-text';
import { speak } from '@wordpress/a11y';
import { isAppleOS } from '@wordpress/keycodes';
/**
* Internal dependencies
*/
import { getAutoCompleterUI } from './autocompleter-ui';
import { escapeRegExp } from '../utils/strings';
import type {
AutocompleteProps,
AutocompleterUIProps,
InsertOption,
KeyedOption,
OptionCompletion,
ReplaceOption,
UseAutocompleteProps,
WPCompleter,
} from './types';
const getNodeText = ( node: React.ReactNode ): string => {
if ( node === null ) {
return '';
}
switch ( typeof node ) {
case 'string':
case 'number':
return node.toString();
break;
case 'boolean':
return '';
break;
case 'object': {
if ( node instanceof Array ) {
return node.map( getNodeText ).join( '' );
}
if ( 'props' in node ) {
return getNodeText( node.props.children );
}
break;
}
default:
return '';
}
return '';
};
const EMPTY_FILTERED_OPTIONS: KeyedOption[] = [];
export function useAutocomplete( {
record,
onChange,
onReplace,
completers,
contentRef,
}: UseAutocompleteProps ) {
const instanceId = useInstanceId( useAutocomplete );
const [ selectedIndex, setSelectedIndex ] = useState( 0 );
const [ filteredOptions, setFilteredOptions ] = useState<
Array< KeyedOption >
>( EMPTY_FILTERED_OPTIONS );
const [ filterValue, setFilterValue ] =
useState< AutocompleterUIProps[ 'filterValue' ] >( '' );
const [ autocompleter, setAutocompleter ] = useState< WPCompleter | null >(
null
);
const [ AutocompleterUI, setAutocompleterUI ] = useState<
( ( props: AutocompleterUIProps ) => JSX.Element | null ) | null
>( null );
const backspacing = useRef( false );
function insertCompletion( replacement: React.ReactNode ) {
if ( autocompleter === null ) {
return;
}
const end = record.start;
const start =
end - autocompleter.triggerPrefix.length - filterValue.length;
const toInsert = create( { html: renderToString( replacement ) } );
onChange( insert( record, toInsert, start, end ) );
}
function select( option: KeyedOption ) {
const { getOptionCompletion } = autocompleter || {};
if ( option.isDisabled ) {
return;
}
if ( getOptionCompletion ) {
const completion = getOptionCompletion( option.value, filterValue );
const isCompletionObject = (
obj: OptionCompletion
): obj is InsertOption | ReplaceOption => {
return (
obj !== null &&
typeof obj === 'object' &&
'action' in obj &&
obj.action !== undefined &&
'value' in obj &&
obj.value !== undefined
);
};
const completionObject = isCompletionObject( completion )
? completion
: ( {
action: 'insert-at-caret',
value: completion,
} as InsertOption );
if ( 'replace' === completionObject.action ) {
onReplace( [ completionObject.value ] );
// When replacing, the component will unmount, so don't reset
// state (below) on an unmounted component.
return;
} else if ( 'insert-at-caret' === completionObject.action ) {
insertCompletion( completionObject.value );
}
}
// Reset autocomplete state after insertion rather than before
// so insertion events don't cause the completion menu to redisplay.
reset();
}
function reset() {
setSelectedIndex( 0 );
setFilteredOptions( EMPTY_FILTERED_OPTIONS );
setFilterValue( '' );
setAutocompleter( null );
setAutocompleterUI( null );
}
/**
* Load options for an autocompleter.
*
* @param {Array} options
*/
function onChangeOptions( options: Array< KeyedOption > ) {
setSelectedIndex(
options.length === filteredOptions.length ? selectedIndex : 0
);
setFilteredOptions( options );
}
function handleKeyDown( event: KeyboardEvent ) {
backspacing.current = event.key === 'Backspace';
if ( ! autocompleter ) {
return;
}
if ( filteredOptions.length === 0 ) {
return;
}
if (
event.defaultPrevented ||
// Ignore keydowns from IMEs
event.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;
}
switch ( event.key ) {
case 'ArrowUp': {
const newIndex =
( selectedIndex === 0
? filteredOptions.length
: selectedIndex ) - 1;
setSelectedIndex( newIndex );
// See the related PR as to why this is necessary: https://github.com/WordPress/gutenberg/pull/54902.
if ( isAppleOS() ) {
speak(
getNodeText( filteredOptions[ newIndex ].label ),
'assertive'
);
}
break;
}
case 'ArrowDown': {
const newIndex = ( selectedIndex + 1 ) % filteredOptions.length;
setSelectedIndex( newIndex );
if ( isAppleOS() ) {
speak(
getNodeText( filteredOptions[ newIndex ].label ),
'assertive'
);
}
break;
}
case 'Escape':
setAutocompleter( null );
setAutocompleterUI( null );
event.preventDefault();
break;
case 'Enter':
select( filteredOptions[ selectedIndex ] );
break;
case 'ArrowLeft':
case 'ArrowRight':
reset();
return;
default:
return;
}
// Any handled key should prevent original behavior. This relies on
// the early return in the default case.
event.preventDefault();
}
// textContent is a primitive (string), memoizing is not strictly necessary
// but this is a preemptive performance improvement, since the autocompleter
// is a potential bottleneck for the editor type metric.
const textContent = useMemo( () => {
if ( isCollapsed( record ) ) {
return getTextContent( slice( record, 0 ) );
}
return '';
}, [ record ] );
useEffect( () => {
if ( ! textContent ) {
if ( autocompleter ) reset();
return;
}
// Find the completer with the highest triggerPrefix index in the
// textContent.
const completer = completers.reduce< WPCompleter | null >(
( lastTrigger, currentCompleter ) => {
const triggerIndex = textContent.lastIndexOf(
currentCompleter.triggerPrefix
);
const lastTriggerIndex =
lastTrigger !== null
? textContent.lastIndexOf( lastTrigger.triggerPrefix )
: -1;
return triggerIndex > lastTriggerIndex
? currentCompleter
: lastTrigger;
},
null
);
if ( ! completer ) {
if ( autocompleter ) reset();
return;
}
const { allowContext, triggerPrefix } = completer;
const triggerIndex = textContent.lastIndexOf( triggerPrefix );
const textWithoutTrigger = textContent.slice(
triggerIndex + triggerPrefix.length
);
const tooDistantFromTrigger = textWithoutTrigger.length > 50; // 50 chars seems to be a good limit.
// This is a final barrier to prevent the effect from completing with
// an extremely long string, which causes the editor to slow-down
// significantly. This could happen, for example, if `matchingWhileBackspacing`
// is true and one of the "words" end up being too long. If that's the case,
// it will be caught by this guard.
if ( tooDistantFromTrigger ) return;
const mismatch = filteredOptions.length === 0;
const wordsFromTrigger = textWithoutTrigger.split( /\s/ );
// We need to allow the effect to run when not backspacing and if there
// was a mismatch. i.e when typing a trigger + the match string or when
// clicking in an existing trigger word on the page. We do that if we
// detect that we have one word from trigger in the current textual context.
//
// Ex.: "Some text @a" <-- "@a" will be detected as the trigger word and
// allow the effect to run. It will run until there's a mismatch.
const hasOneTriggerWord = wordsFromTrigger.length === 1;
// This is used to allow the effect to run when backspacing and if
// "touching" a word that "belongs" to a trigger. We consider a "trigger
// word" any word up to the limit of 3 from the trigger character.
// Anything beyond that is ignored if there's a mismatch. This allows
// us to "escape" a mismatch when backspacing, but still imposing some
// sane limits.
//
// Ex: "Some text @marcelo sekkkk" <--- "kkkk" caused a mismatch, but
// if the user presses backspace here, it will show the completion popup again.
const matchingWhileBackspacing =
backspacing.current && wordsFromTrigger.length <= 3;
if ( mismatch && ! ( matchingWhileBackspacing || hasOneTriggerWord ) ) {
if ( autocompleter ) reset();
return;
}
const textAfterSelection = getTextContent(
slice( record, undefined, getTextContent( record ).length )
);
if (
allowContext &&
! allowContext(
textContent.slice( 0, triggerIndex ),
textAfterSelection
)
) {
if ( autocompleter ) reset();
return;
}
if (
/^\s/.test( textWithoutTrigger ) ||
/\s\s+$/.test( textWithoutTrigger )
) {
if ( autocompleter ) reset();
return;
}
if ( ! /[\u0000-\uFFFF]*$/.test( textWithoutTrigger ) ) {
if ( autocompleter ) reset();
return;
}
const safeTrigger = escapeRegExp( completer.triggerPrefix );
const text = removeAccents( textContent );
const match = text
.slice( text.lastIndexOf( completer.triggerPrefix ) )
.match( new RegExp( `${ safeTrigger }([\u0000-\uFFFF]*)$` ) );
const query = match && match[ 1 ];
setAutocompleter( completer );
setAutocompleterUI( () =>
completer !== autocompleter
? getAutoCompleterUI( completer )
: AutocompleterUI
);
setFilterValue( query === null ? '' : query );
// Temporarily disabling exhaustive-deps to avoid introducing unexpected side effecst.
// See https://github.com/WordPress/gutenberg/pull/41820
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ textContent ] );
const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {};
const { className } = autocompleter || {};
const isExpanded = !! autocompleter && filteredOptions.length > 0;
const listBoxId = isExpanded
? `components-autocomplete-listbox-${ instanceId }`
: undefined;
const activeId = isExpanded
? `components-autocomplete-item-${ instanceId }-${ selectedKey }`
: null;
const hasSelection = record.start !== undefined;
return {
listBoxId,
activeId,
onKeyDown: handleKeyDown,
popover: hasSelection && AutocompleterUI && (
<AutocompleterUI
className={ className }
filterValue={ filterValue }
instanceId={ instanceId }
listBoxId={ listBoxId }
selectedIndex={ selectedIndex }
onChangeOptions={ onChangeOptions }
onSelect={ select }
value={ record }
contentRef={ contentRef }
reset={ reset }
/>
),
};
}
function useLastDifferentValue( value: UseAutocompleteProps[ 'record' ] ) {
const history = useRef< Set< typeof value > >( new Set() );
history.current.add( value );
// Keep the history size to 2.
if ( history.current.size > 2 ) {
history.current.delete( Array.from( history.current )[ 0 ] );
}
return Array.from( history.current )[ 0 ];
}
export function useAutocompleteProps( options: UseAutocompleteProps ) {
const ref = useRef< HTMLElement >( null );
const onKeyDownRef = useRef< ( event: KeyboardEvent ) => void >();
const { record } = options;
const previousRecord = useLastDifferentValue( record );
const { popover, listBoxId, activeId, onKeyDown } = useAutocomplete( {
...options,
contentRef: ref,
} );
onKeyDownRef.current = onKeyDown;
const mergedRefs = useMergeRefs( [
ref,
useRefEffect( ( element: HTMLElement ) => {
function _onKeyDown( event: KeyboardEvent ) {
onKeyDownRef.current?.( event );
}
element.addEventListener( 'keydown', _onKeyDown );
return () => {
element.removeEventListener( 'keydown', _onKeyDown );
};
}, [] ),
] );
// We only want to show the popover if the user has typed something.
const didUserInput = record.text !== previousRecord?.text;
if ( ! didUserInput ) {
return { ref: mergedRefs };
}
return {
ref: mergedRefs,
children: popover,
'aria-autocomplete': listBoxId ? 'list' : undefined,
'aria-owns': listBoxId,
'aria-activedescendant': activeId,
};
}
export default function Autocomplete( {
children,
isSelected,
...options
}: AutocompleteProps ) {
const { popover, ...props } = useAutocomplete( options );
return (
<>
{ children( props ) }
{ isSelected && popover }
</>
);
}

View File

@@ -0,0 +1,7 @@
@import "./style.native.scss";
.components-autocomplete {
width: 100%;
height: $mobile-header-toolbar-height;
bottom: $mobile-header-toolbar-height;
}

View File

@@ -0,0 +1,74 @@
.components-autocomplete {
width: 100%;
height: $mobile-header-toolbar-height;
overflow: hidden;
}
.components-autocomplete__background {
height: $mobile-header-toolbar-height;
background-color: $gray-0;
}
.components-autocomplete__background-dark {
background-color: $app-background-dark-alt;
}
.components-autocomplete__background-blur {
width: 100%;
height: $mobile-header-toolbar-height;
}
.components-autocomplete__content {
flex-grow: 1;
padding-left: 6px;
}
.components-autocomplete__item {
flex-direction: row;
align-items: center;
padding: 5px 12px 5px 8px;
margin-right: 2px;
margin-top: 3px;
margin-bottom: 3px;
}
.components-autocomplete__icon {
margin-right: 4px;
color: $gray-darken-30;
}
.components-autocomplete__icon-active {
color: $gray-dark;
}
.components-autocomplete__icon-active-dark {
color: $gray-20;
}
.components-autocomplete__text {
color: $gray-darken-30;
}
.components-autocomplete__text-dark {
color: $gray-20;
}
.components-autocomplete__text-active {
color: $gray-dark;
text-decoration: underline;
text-decoration-color: $gray-darken-30;
}
.components-autocomplete__text-active-dark {
color: $gray-20;
text-decoration-color: $gray-20;
}
.components-autocomplete__item-active {
border-radius: 22px;
background-color: transparentize($color: $gray-darken-20, $amount: 0.9);
}
.components-autocomplete__item-active-dark {
background-color: $dark-ultra-dim;
}

View File

@@ -0,0 +1,16 @@
.components-autocomplete__popover .components-popover__content {
padding: $grid-unit-20;
min-width: 220px;
}
.components-autocomplete__result.components-button {
display: flex;
height: auto;
min-height: $button-size;
text-align: left;
width: 100%;
&.is-selected {
box-shadow: 0 0 0 var(--wp-admin-border-width-focus) $components-color-accent;
}
}

View File

@@ -0,0 +1,98 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import { getAutoCompleterUI } from '../autocompleter-ui';
type FruitOption = { visual: string; name: string; id: number };
describe( 'AutocompleterUI', () => {
describe( 'click outside behavior', () => {
it( 'should call reset function when a click on another element occurs', async () => {
const user = userEvent.setup();
const resetSpy = jest.fn();
const autocompleter = {
name: 'fruit',
options: [
{ visual: '🍎', name: 'Apple', id: 1 },
{ visual: '🍊', name: 'Orange', id: 2 },
{ visual: '🍇', name: 'Grapes', id: 3 },
],
// The prefix that triggers this completer
triggerPrefix: '~',
getOptionLabel: ( option: FruitOption ) => (
<span>
<span className="icon">{ option.visual }</span>
{ option.name }
</span>
),
// Mock useItems function to return a autocomplete item.
useItems: ( filterValue: string ) => {
const options = autocompleter.options;
const keyedOptions = options.map(
( optionData, optionIndex ) => ( {
key: `${ autocompleter.name }-${ optionIndex }`,
value: optionData,
label: autocompleter.getOptionLabel( optionData ),
keywords: [],
isDisabled: false,
} )
);
const filteredOptions = keyedOptions.filter( ( option ) =>
option.value.name.includes( filterValue )
);
return [ filteredOptions ] as const;
},
};
const AutocompleterUI = getAutoCompleterUI( autocompleter );
const OtherElement = <div>Other Element</div>;
const Container = () => {
const contentRef = useRef< HTMLElement >( null );
return (
<div>
<AutocompleterUI
className={ 'test' }
filterValue={ 'Apple' }
instanceId={ 1 }
listBoxId={ '1' }
selectedIndex={ 0 }
onChangeOptions={ () => {} }
onSelect={ () => {} }
contentRef={ contentRef }
reset={ resetSpy }
/>
{ OtherElement }
</div>
);
};
render( <Container /> );
// Click on autocompleter.
await user.click( screen.getByText( 'Apple' ) );
expect( resetSpy ).toHaveBeenCalledTimes( 0 );
// Click on other element out side of the tree.
await user.click( screen.getByText( 'Other Element' ) );
expect( resetSpy ).toHaveBeenCalledTimes( 1 );
} );
} );
} );

View File

@@ -0,0 +1,208 @@
/**
* External dependencies
*/
import type { ReactElement } from 'react';
/**
* WordPress dependencies
*/
import type { RichTextValue } from '@wordpress/rich-text';
/**
* Internal dependencies
*/
import type { useAutocomplete } from '.';
// Insert the `value` into the text.
export type InsertOption = {
action: 'insert-at-caret';
value: React.ReactNode;
};
// Replace the current block with the block specified in the `value` property
export type ReplaceOption = { action: 'replace'; value: RichTextValue };
export type OptionCompletion = React.ReactNode | InsertOption | ReplaceOption;
type OptionLabel = string | ReactElement | Array< string | ReactElement >;
export type KeyedOption = {
key: string;
value: any;
label: OptionLabel;
keywords: Array< string >;
isDisabled: boolean;
};
export type WPCompleter< TCompleterOption = any > = {
/**
* The name of the completer. Useful for identifying a specific completer to
* be overridden via extensibility hooks.
*/
name: string;
/**
* The string prefix that should trigger the completer. For example,
* Gutenberg's block completer is triggered when the '/' character is
* entered.
*/
triggerPrefix: string;
/**
* The raw options for completion. May be an array, a function that returns
* an array, or a function that returns a promise for an array.
* Options may be of any type or shape. The completer declares how those
* options are rendered and what their completions should be when selected.
*/
options:
| ( (
query: string
) =>
| PromiseLike< readonly TCompleterOption[] >
| readonly TCompleterOption[] )
| readonly TCompleterOption[];
/**
* A function that returns the keywords for the specified option.
*/
getOptionKeywords?: ( option: TCompleterOption ) => Array< string >;
/**
* A function that returns whether or not the specified option is disabled.
* Disabled options cannot be selected.
*/
isOptionDisabled?: ( option: TCompleterOption ) => boolean;
/**
* A function that returns the label for a given option. A label may be a
* string or a mixed array of strings, elements, and components.
*/
getOptionLabel: ( option: TCompleterOption ) => OptionLabel;
/**
* A function that takes a Range before and a Range after the autocomplete
* trigger and query text and returns a boolean indicating whether the
* completer should be considered for that context.
*/
allowContext?: ( before: string, after: string ) => boolean;
/**
* A function that takes an option and returns how the option should
* be completed. By default, the result is a value to be inserted in the
* text.
* However, a completer may explicitly declare how a completion should be
* treated by returning an object with `action` and `value` properties. The
* `action` declares what should be done with the `value`.
*/
getOptionCompletion?: (
option: TCompleterOption,
query: string
) => OptionCompletion;
/**
* A function that returns an array of items to be displayed in the
* Autocomplete UI. These items have uniform shape and have been filtered by
* `AutocompleterUIProps.filterValue`.
*/
useItems?: ( filterValue: string ) => readonly [ Array< KeyedOption > ];
/**
* Whether or not changes to the `filterValue` should be debounced.
*/
isDebounced?: boolean;
/**
* A CSS class name to be applied to the completion menu.
*/
className?: string;
};
type ContentRef = React.RefObject< HTMLElement >;
export type AutocompleterUIProps = {
/**
* The value to filter the options by.
*/
filterValue: string;
/**
* An id unique to each instance of the component, used in the IDs of the
* buttons generated for individual options.
*/
instanceId: number;
/**
* The id of to be applied to the listbox of options.
*/
listBoxId: string | undefined;
/**
* The class to apply to the wrapper element.
*/
className?: string;
/**
* The index of the currently selected option.
*/
selectedIndex: number;
/**
* A function to be called when the filterValue changes.
*/
onChangeOptions: ( items: Array< KeyedOption > ) => void;
/**
* A function to be called when an option is selected.
*/
onSelect: ( option: KeyedOption ) => void;
/**
* A function to be called when the completer is reset
* (e.g. when the user hits the escape key).
*/
onReset?: () => void;
/**
* A function that defines the behavior of the completer when it is reset
*/
reset: ( event: Event ) => void;
// This is optional because it's still needed for mobile/native.
/**
* The rich text value object the autocompleter is being applied to.
*/
value?: RichTextValue;
/**
* A ref containing the editable element that will serve as the anchor for
* `Autocomplete`'s `Popover`.
*/
contentRef: ContentRef;
};
export type CancelablePromise< T = void > = Promise< T > & {
canceled?: boolean;
};
export type UseAutocompleteProps = {
/**
* The rich text value object the autocompleter is being applied to.
*/
record: RichTextValue & {
start: NonNullable< RichTextValue[ 'start' ] >;
end: NonNullable< RichTextValue[ 'end' ] >;
};
/**
* A function to be called when an option is selected to insert into the
* existing text.
*/
onChange: ( value: RichTextValue ) => void;
/**
* A function to be called when an option is selected to replace the
* existing text.
*/
onReplace: ( values: RichTextValue[] ) => void;
/**
* An array of all of the completers to apply to the current element.
*/
completers: Array< WPCompleter >;
/**
* A ref containing the editable element that will serve as the anchor for
* `Autocomplete`'s `Popover`.
*/
contentRef: ContentRef;
};
export type AutocompleteProps = UseAutocompleteProps & {
/**
* A function that returns nodes to be rendered within the Autocomplete.
*/
children: (
props: Omit< ReturnType< typeof useAutocomplete >, 'popover' >
) => React.ReactNode;
/**
* Whether or not the Autocomplte componenet is selected, and if its
* `Popover`
* should be displayed.
*/
isSelected: boolean;
};

View File

@@ -0,0 +1,116 @@
# BaseControl
`BaseControl` is a component used to generate labels and help text for components handling user inputs.
## Usage
```jsx
import { BaseControl, useBaseControlProps } from '@wordpress/components';
// Render a `BaseControl` for a textarea input
const MyCustomTextareaControl = ({ children, ...baseProps }) => (
// `useBaseControlProps` is a convenience hook to get the props for the `BaseControl`
// and the inner control itself. Namely, it takes care of generating a unique `id`,
// properly associating it with the `label` and `help` elements.
const { baseControlProps, controlProps } = useBaseControlProps( baseProps );
return (
<BaseControl { ...baseControlProps } __nextHasNoMarginBottom={ true }>
<textarea { ...controlProps }>
{ children }
</textarea>
</BaseControl>
);
);
```
## Props
The component accepts the following props:
### id
The HTML `id` of the control element (passed in as a child to `BaseControl`) to which labels and help text are being generated. This is necessary to accessibly associate the label with that element.
The recommended way is to use the `useBaseControlProps` hook, which takes care of generating a unique `id` for you. Otherwise, if you choose to pass an explicit `id` to this prop, you are responsible for ensuring the uniqueness of the `id`.
- Type: `String`
- Required: No
### label
If this property is added, a label will be generated using label property as the content.
- Type: `String`
- Required: No
### hideLabelFromVision
If true, the label will only be visible to screen readers.
- Type: `Boolean`
- Required: No
### help
Additional description for the control. It is preferable to use plain text for `help`, as it can be accessibly associated with the control using `aria-describedby`. When the `help` contains links, or otherwise non-plain text content, it will be associated with the control using `aria-details`.
- Type: `ReactNode`
- Required: No
### className
Any other classes to add to the wrapper div.
- Type: `String`
- Required: No
### children
The content to be displayed within the BaseControl.
- Type: `Element`
- Required: Yes
### __nextHasNoMarginBottom
Start opting into the new margin-free styles that will become the default in a future version.
- Type: `Boolean`
- Required: No
- Default: `false`
## BaseControl.VisualLabel
`BaseControl.VisualLabel` is used to render a purely visual label inside a `BaseControl` component.
It should only be used in cases where the children being rendered inside BaseControl are already accessibly labeled, e.g., a button, but we want an additional visual label for that section equivalent to the labels `BaseControl` would otherwise use if the `label` prop was passed.
## Usage
```jsx
import { BaseControl } from '@wordpress/components';
const MyBaseControl = () => (
<BaseControl help="This button is already accessibly labeled.">
<BaseControl.VisualLabel>Author</BaseControl.VisualLabel>
<Button>Select an author</Button>
</BaseControl>
);
```
### Props
#### className
Any other classes to add to the wrapper div.
- Type: `String`
- Required: No
#### children
The content to be displayed within the `BaseControl.VisualLabel`.
- Type: `Element`
- Required: Yes

View File

@@ -0,0 +1,45 @@
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
import BaseControl from '.';
import type { BaseControlProps } from './types';
/**
* Generate props for the `BaseControl` and the inner control itself.
*
* Namely, it takes care of generating a unique `id`, properly associating it with the `label` and `help` elements.
*
* @param props
*/
export function useBaseControlProps(
props: Omit< BaseControlProps, 'children' >
) {
const { help, id: preferredId, ...restProps } = props;
const uniqueId = useInstanceId(
BaseControl,
'wp-components-base-control',
preferredId
);
// ARIA descriptions can only contain plain text, so fall back to aria-details if not.
const helpPropName =
typeof help === 'string' ? 'aria-describedby' : 'aria-details';
return {
baseControlProps: {
id: uniqueId,
help,
...restProps,
},
controlProps: {
id: uniqueId,
...( !! help ? { [ helpPropName ]: `${ uniqueId }__help` } : {} ),
},
};
}

View File

@@ -0,0 +1,14 @@
/**
* External dependencies
*/
import { Text, View } from 'react-native';
export default function BaseControl( { label, help, children } ) {
return (
<View accessible={ true } accessibilityLabel={ label }>
{ label && <Text>{ label }</Text> }
{ children }
{ help && <Text>{ help }</Text> }
</View>
);
}

View File

@@ -0,0 +1,142 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* Internal dependencies
*/
import { VisuallyHidden } from '../visually-hidden';
import type { BaseControlProps, BaseControlVisualLabelProps } from './types';
import {
Wrapper,
StyledField,
StyledLabel,
StyledHelp,
StyledVisualLabel,
} from './styles/base-control-styles';
import type { WordPressComponentProps } from '../context';
import { contextConnectWithoutRef, useContextSystem } from '../context';
export { useBaseControlProps } from './hooks';
/**
* `BaseControl` is a component used to generate labels and help text for components handling user inputs.
*
* ```jsx
* import { BaseControl, useBaseControlProps } from '@wordpress/components';
*
* // Render a `BaseControl` for a textarea input
* const MyCustomTextareaControl = ({ children, ...baseProps }) => (
* // `useBaseControlProps` is a convenience hook to get the props for the `BaseControl`
* // and the inner control itself. Namely, it takes care of generating a unique `id`,
* // properly associating it with the `label` and `help` elements.
* const { baseControlProps, controlProps } = useBaseControlProps( baseProps );
*
* return (
* <BaseControl { ...baseControlProps } __nextHasNoMarginBottom={ true }>
* <textarea { ...controlProps }>
* { children }
* </textarea>
* </BaseControl>
* );
* );
* ```
*/
const UnconnectedBaseControl = (
props: WordPressComponentProps< BaseControlProps, null >
) => {
const {
__nextHasNoMarginBottom = false,
id,
label,
hideLabelFromVision = false,
help,
className,
children,
} = useContextSystem( props, 'BaseControl' );
return (
<Wrapper className={ className }>
<StyledField
className="components-base-control__field"
// TODO: Official deprecation for this should start after all internal usages have been migrated
__nextHasNoMarginBottom={ __nextHasNoMarginBottom }
>
{ label &&
id &&
( hideLabelFromVision ? (
<VisuallyHidden as="label" htmlFor={ id }>
{ label }
</VisuallyHidden>
) : (
<StyledLabel
className="components-base-control__label"
htmlFor={ id }
>
{ label }
</StyledLabel>
) ) }
{ label &&
! id &&
( hideLabelFromVision ? (
<VisuallyHidden as="label">{ label }</VisuallyHidden>
) : (
<VisualLabel>{ label }</VisualLabel>
) ) }
{ children }
</StyledField>
{ !! help && (
<StyledHelp
id={ id ? id + '__help' : undefined }
className="components-base-control__help"
__nextHasNoMarginBottom={ __nextHasNoMarginBottom }
>
{ help }
</StyledHelp>
) }
</Wrapper>
);
};
/**
* `BaseControl.VisualLabel` is used to render a purely visual label inside a `BaseControl` component.
*
* It should only be used in cases where the children being rendered inside `BaseControl` are already accessibly labeled,
* e.g., a button, but we want an additional visual label for that section equivalent to the labels `BaseControl` would
* otherwise use if the `label` prop was passed.
*
* @example
* import { BaseControl } from '@wordpress/components';
*
* const MyBaseControl = () => (
* <BaseControl help="This button is already accessibly labeled.">
* <BaseControl.VisualLabel>Author</BaseControl.VisualLabel>
* <Button>Select an author</Button>
* </BaseControl>
* );
*/
export const VisualLabel = ( {
className,
children,
...props
}: WordPressComponentProps< BaseControlVisualLabelProps, 'span' > ) => {
return (
<StyledVisualLabel
{ ...props }
className={ classnames(
'components-base-control__label',
className
) }
>
{ children }
</StyledVisualLabel>
);
};
export const BaseControl = Object.assign(
contextConnectWithoutRef( UnconnectedBaseControl, 'BaseControl' ),
{ VisualLabel }
);
export default BaseControl;

View File

@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* Internal dependencies
*/
import BaseControl, { useBaseControlProps } from '..';
import Button from '../../button';
const meta: Meta< typeof BaseControl > = {
title: 'Components/BaseControl',
component: BaseControl,
argTypes: {
children: { control: { type: null } },
help: { control: { type: 'text' } },
label: { control: { type: 'text' } },
},
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const BaseControlWithTextarea: StoryFn< typeof BaseControl > = ( props ) => {
const { baseControlProps, controlProps } = useBaseControlProps( props );
return (
<BaseControl { ...baseControlProps }>
<textarea style={ { display: 'block' } } { ...controlProps } />
</BaseControl>
);
};
export const Default: StoryFn< typeof BaseControl > =
BaseControlWithTextarea.bind( {} );
Default.args = {
__nextHasNoMarginBottom: true,
label: 'Label text',
};
export const WithHelpText = BaseControlWithTextarea.bind( {} );
WithHelpText.args = {
...Default.args,
help: 'Help text adds more explanation.',
};
/**
* `BaseControl.VisualLabel` is used to render a purely visual label inside a `BaseControl` component.
*
* It should only be used in cases where the children being rendered inside `BaseControl` are already accessibly labeled,
* e.g., a button, but we want an additional visual label for that section equivalent to the labels `BaseControl` would
* otherwise use if the `label` prop was passed.
*/
export const WithVisualLabel: StoryFn< typeof BaseControl > = ( props ) => {
// @ts-expect-error - Unclear how to fix, see also https://github.com/WordPress/gutenberg/pull/39468#discussion_r827150516
BaseControl.VisualLabel.displayName = 'BaseControl.VisualLabel';
return (
<BaseControl { ...props }>
<BaseControl.VisualLabel>Visual label</BaseControl.VisualLabel>
<div>
<Button variant="secondary">Select an author</Button>
</div>
</BaseControl>
);
};
WithVisualLabel.args = {
...Default.args,
help: 'This button is already accessibly labeled.',
label: undefined,
};

View File

@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
import { css } from '@emotion/react';
/**
* Internal dependencies
*/
import { baseLabelTypography, boxSizingReset, font, COLORS } from '../../utils';
import { space } from '../../utils/space';
export const Wrapper = styled.div`
font-family: ${ font( 'default.fontFamily' ) };
font-size: ${ font( 'default.fontSize' ) };
${ boxSizingReset }
`;
const deprecatedMarginField = ( { __nextHasNoMarginBottom = false } ) => {
return (
! __nextHasNoMarginBottom &&
css`
margin-bottom: ${ space( 2 ) };
`
);
};
export const StyledField = styled.div`
${ deprecatedMarginField }
.components-panel__row & {
margin-bottom: inherit;
}
`;
const labelStyles = css`
${ baseLabelTypography };
display: inline-block;
margin-bottom: ${ space( 2 ) };
/**
* Removes Chrome/Safari/Firefox user agent stylesheet padding from
* StyledLabel when it is rendered as a legend.
*/
padding: 0;
`;
export const StyledLabel = styled.label`
${ labelStyles }
`;
const deprecatedMarginHelp = ( { __nextHasNoMarginBottom = false } ) => {
return (
! __nextHasNoMarginBottom &&
css`
margin-bottom: revert;
`
);
};
export const StyledHelp = styled.p`
margin-top: ${ space( 2 ) };
margin-bottom: 0;
font-size: ${ font( 'helpText.fontSize' ) };
font-style: normal;
color: ${ COLORS.gray[ 700 ] };
${ deprecatedMarginHelp }
`;
export const StyledVisualLabel = styled.span`
${ labelStyles }
`;

View File

@@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
/**
* Internal dependencies
*/
import BaseControl from '..';
import { useBaseControlProps } from '../hooks';
import type { BaseControlProps } from '../types';
const MyBaseControl = ( props: Omit< BaseControlProps, 'children' > ) => {
const { baseControlProps, controlProps } = useBaseControlProps( props );
return (
<BaseControl { ...baseControlProps } __nextHasNoMarginBottom={ true }>
<textarea { ...controlProps } />
</BaseControl>
);
};
describe( 'BaseControl', () => {
it( 'should render help text as description', () => {
render( <MyBaseControl label="Text" help="My help text" /> );
expect(
screen.getByRole( 'textbox', {
description: 'My help text',
} )
).toBeInTheDocument();
} );
it( 'should render help as aria-details when not plain text', () => {
render(
<MyBaseControl
label="Text"
help={ <a href="/foo">My help text</a> }
/>
);
const textarea = screen.getByRole( 'textbox' );
const help = screen.getByRole( 'link', {
name: 'My help text',
} );
expect( textarea ).toHaveAttribute( 'aria-details' );
expect(
// eslint-disable-next-line testing-library/no-node-access
help.closest( `#${ textarea.getAttribute( 'aria-details' ) }` )
).toBeVisible();
} );
} );

View File

@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import type { ReactNode } from 'react';
export type BaseControlProps = {
/**
* Start opting into the new margin-free styles that will become the default in a future version.
*
* @default false
*/
__nextHasNoMarginBottom?: boolean;
/**
* The HTML `id` of the control element (passed in as a child to `BaseControl`) to which labels and help text are being generated.
* This is necessary to accessibly associate the label with that element.
*
* The recommended way is to use the `useBaseControlProps` hook, which takes care of generating a unique `id` for you.
* Otherwise, if you choose to pass an explicit `id` to this prop, you are responsible for ensuring the uniqueness of the `id`.
*/
id?: string;
/**
* Additional description for the control.
*
* It is preferable to use plain text for `help`, as it can be accessibly associated with the control using `aria-describedby`.
* When the `help` contains links, or otherwise non-plain text content, it will be associated with the control using `aria-details`.
*/
help?: ReactNode;
/**
* If this property is added, a label will be generated using label property as the content.
*/
label?: ReactNode;
/**
* If true, the label will only be visible to screen readers.
*
* @default false
*/
hideLabelFromVision?: boolean;
className?: string;
/**
* The content to be displayed within the `BaseControl`.
*/
children: ReactNode;
};
export type BaseControlVisualLabelProps = {
children: ReactNode;
};

View File

@@ -0,0 +1,47 @@
/**
* WordPress dependencies
*/
import { link, linkOff } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import Button from '../../button';
import Tooltip from '../../tooltip';
import { View } from '../../view';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import { useBorderBoxControlLinkedButton } from './hook';
import type { LinkedButtonProps } from '../types';
const BorderBoxControlLinkedButton = (
props: WordPressComponentProps< LinkedButtonProps, 'button' >,
forwardedRef: React.ForwardedRef< any >
) => {
const { className, isLinked, ...buttonProps } =
useBorderBoxControlLinkedButton( props );
const label = isLinked ? __( 'Unlink sides' ) : __( 'Link sides' );
return (
<Tooltip text={ label }>
<View className={ className }>
<Button
{ ...buttonProps }
size="small"
icon={ isLinked ? link : linkOff }
iconSize={ 24 }
aria-label={ label }
ref={ forwardedRef }
/>
</View>
</Tooltip>
);
};
const ConnectedBorderBoxControlLinkedButton = contextConnect(
BorderBoxControlLinkedButton,
'BorderBoxControlLinkedButton'
);
export default ConnectedBorderBoxControlLinkedButton;

View File

@@ -0,0 +1,32 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
import type { LinkedButtonProps } from '../types';
export function useBorderBoxControlLinkedButton(
props: WordPressComponentProps< LinkedButtonProps, 'button' >
) {
const {
className,
size = 'default',
...otherProps
} = useContextSystem( props, 'BorderBoxControlLinkedButton' );
// Generate class names.
const cx = useCx();
const classes = useMemo( () => {
return cx( styles.borderBoxControlLinkedButton( size ), className );
}, [ className, cx, size ] );
return { ...otherProps, className: classes };
}

View File

@@ -0,0 +1 @@
export { default } from './component';

View File

@@ -0,0 +1,120 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { useMemo, useState } from '@wordpress/element';
import { useMergeRefs } from '@wordpress/compose';
/**
* Internal dependencies
*/
import BorderBoxControlVisualizer from '../border-box-control-visualizer';
import { BorderControl } from '../../border-control';
import { Grid } from '../../grid';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import { useBorderBoxControlSplitControls } from './hook';
import type { BorderControlProps } from '../../border-control/types';
import type { SplitControlsProps } from '../types';
const BorderBoxControlSplitControls = (
props: WordPressComponentProps< SplitControlsProps, 'div' >,
forwardedRef: React.ForwardedRef< any >
) => {
const {
centeredClassName,
colors,
disableCustomColors,
enableAlpha,
enableStyle,
onChange,
popoverPlacement,
popoverOffset,
rightAlignedClassName,
size = 'default',
value,
__experimentalIsRenderedInSidebar,
...otherProps
} = useBorderBoxControlSplitControls( props );
// Use internal state instead of a ref to make sure that the component
// re-renders when the popover's anchor updates.
const [ popoverAnchor, setPopoverAnchor ] = useState< Element | null >(
null
);
// Memoize popoverProps to avoid returning a new object every time.
const popoverProps: BorderControlProps[ '__unstablePopoverProps' ] =
useMemo(
() =>
popoverPlacement
? {
placement: popoverPlacement,
offset: popoverOffset,
anchor: popoverAnchor,
shift: true,
}
: undefined,
[ popoverPlacement, popoverOffset, popoverAnchor ]
);
const sharedBorderControlProps = {
colors,
disableCustomColors,
enableAlpha,
enableStyle,
isCompact: true,
__experimentalIsRenderedInSidebar,
size,
};
const mergedRef = useMergeRefs( [ setPopoverAnchor, forwardedRef ] );
return (
<Grid { ...otherProps } ref={ mergedRef } gap={ 4 }>
<BorderBoxControlVisualizer value={ value } size={ size } />
<BorderControl
className={ centeredClassName }
hideLabelFromVision={ true }
label={ __( 'Top border' ) }
onChange={ ( newBorder ) => onChange( newBorder, 'top' ) }
__unstablePopoverProps={ popoverProps }
value={ value?.top }
{ ...sharedBorderControlProps }
/>
<BorderControl
hideLabelFromVision={ true }
label={ __( 'Left border' ) }
onChange={ ( newBorder ) => onChange( newBorder, 'left' ) }
__unstablePopoverProps={ popoverProps }
value={ value?.left }
{ ...sharedBorderControlProps }
/>
<BorderControl
className={ rightAlignedClassName }
hideLabelFromVision={ true }
label={ __( 'Right border' ) }
onChange={ ( newBorder ) => onChange( newBorder, 'right' ) }
__unstablePopoverProps={ popoverProps }
value={ value?.right }
{ ...sharedBorderControlProps }
/>
<BorderControl
className={ centeredClassName }
hideLabelFromVision={ true }
label={ __( 'Bottom border' ) }
onChange={ ( newBorder ) => onChange( newBorder, 'bottom' ) }
__unstablePopoverProps={ popoverProps }
value={ value?.bottom }
{ ...sharedBorderControlProps }
/>
</Grid>
);
};
const ConnectedBorderBoxControlSplitControls = contextConnect(
BorderBoxControlSplitControls,
'BorderBoxControlSplitControls'
);
export default ConnectedBorderBoxControlSplitControls;

View File

@@ -0,0 +1,54 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils/';
import type { SplitControlsProps } from '../types';
export function useBorderBoxControlSplitControls(
props: WordPressComponentProps< SplitControlsProps, 'div' >
) {
const {
className,
colors = [],
enableAlpha = false,
enableStyle = true,
size = 'default',
__experimentalIsRenderedInSidebar = false,
...otherProps
} = useContextSystem( props, 'BorderBoxControlSplitControls' );
// Generate class names.
const cx = useCx();
const classes = useMemo( () => {
return cx( styles.borderBoxControlSplitControls( size ), className );
}, [ cx, className, size ] );
const centeredClassName = useMemo( () => {
return cx( styles.centeredBorderControl, className );
}, [ cx, className ] );
const rightAlignedClassName = useMemo( () => {
return cx( styles.rightBorderControl(), className );
}, [ cx, className ] );
return {
...otherProps,
centeredClassName,
className: classes,
colors,
enableAlpha,
enableStyle,
rightAlignedClassName,
size,
__experimentalIsRenderedInSidebar,
};
}

View File

@@ -0,0 +1 @@
export { default } from './component';

View File

@@ -0,0 +1,29 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { View } from '../../view';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import { useBorderBoxControlVisualizer } from './hook';
import type { VisualizerProps } from '../types';
const BorderBoxControlVisualizer = (
props: WordPressComponentProps< VisualizerProps, 'div' >,
forwardedRef: React.ForwardedRef< any >
) => {
const { value, ...otherProps } = useBorderBoxControlVisualizer( props );
return <View { ...otherProps } ref={ forwardedRef } />;
};
const ConnectedBorderBoxControlVisualizer = contextConnect(
BorderBoxControlVisualizer,
'BorderBoxControlVisualizer'
);
export default ConnectedBorderBoxControlVisualizer;

View File

@@ -0,0 +1,36 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils';
import type { VisualizerProps } from '../types';
export function useBorderBoxControlVisualizer(
props: WordPressComponentProps< VisualizerProps, 'div' >
) {
const {
className,
value,
size = 'default',
...otherProps
} = useContextSystem( props, 'BorderBoxControlVisualizer' );
// Generate class names.
const cx = useCx();
const classes = useMemo( () => {
return cx(
styles.borderBoxControlVisualizer( value, size ),
className
);
}, [ cx, className, value, size ] );
return { ...otherProps, className: classes, value };
}

View File

@@ -0,0 +1 @@
export { default } from './component';

View File

@@ -0,0 +1,171 @@
# BorderBoxControl
<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>
<br />
This component provides users with the ability to configure a single "flat"
border or separate borders per side.
## Development guidelines
The `BorderBoxControl` effectively has two view states. The first, a "linked"
view, allows configuration of a flat border via a single `BorderControl`.
The second, a "split" view, contains a `BorderControl` for each side
as well as a visualizer for the currently selected borders. Each view also
contains a button to toggle between the two.
When switching from the "split" view to "linked", if the individual side
borders are not consistent, the "linked" view will display any border properties
selections that are consistent while showing a mixed state for those that
aren't. For example, if all borders had the same color and style but different
widths, then the border dropdown in the "linked" view's `BorderControl` would
show that consistent color and style but the "linked" view's width input would
show "Mixed" placeholder text.
## Usage
```jsx
import { useState } from 'react';
import { __experimentalBorderBoxControl as BorderBoxControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const colors = [
{ name: 'Blue 20', color: '#72aee6' },
// ...
];
const MyBorderBoxControl = () => {
const defaultBorder = {
color: '#72aee6',
style: 'dashed',
width: '1px',
};
const [ borders, setBorders ] = useState( {
top: defaultBorder,
right: defaultBorder,
bottom: defaultBorder,
left: defaultBorder,
} );
const onChange = ( newBorders ) => setBorders( newBorders );
return (
<BorderBoxControl
colors={ colors }
label={ __( 'Borders' ) }
onChange={ onChange }
value={ borders }
/>
);
};
```
If you're using this component outside the editor, you can
[ensure `Tooltip` positioning](/packages/components/README.md#popovers-and-tooltips)
for the `BorderBoxControl`'s color and style options, by rendering your
`BorderBoxControl` with a `Popover.Slot` further up the element tree and within
a `SlotFillProvider` overall.
## Props
### `colors`: `( PaletteObject | ColorObject )[]`
An array of color definitions. This may also be a multi-dimensional array where
colors are organized by multiple origins.
Each color may be an object containing a `name` and `color` value.
- Required: No
- Default: `[]`
### `disableCustomColors`: `boolean`
This toggles the ability to choose custom colors.
- Required: No
### `enableAlpha`: `boolean`
This controls whether the alpha channel will be offered when selecting
custom colors.
- Required: No
- Default: `false`
### `enableStyle`: `boolean`
This controls whether to support border style selections.
- Required: No
- Default: `true`
### `hideLabelFromVision`: `boolean`
Provides control over whether the label will only be visible to screen readers.
- Required: No
### `label`: `string`
If provided, a label will be generated using this as the content.
_Whether it is visible only to screen readers is controlled via
`hideLabelFromVision`._
- Required: No
### `onChange`: `( value?: Object ) => void`
A callback function invoked when any border value is changed. The value received
may be a "flat" border object, one that has properties defining individual side
borders, or `undefined`.
_Note: The will be `undefined` if a user clears all borders._
- Required: Yes
### `popoverPlacement`: `string`
The position of the color popovers relative to the control wrapper.
By default, popovers are displayed relative to the button that initiated the popover. By supplying a popover placement, you force the popover to display in a specific location.
The available base placements are 'top', 'right', 'bottom', 'left'. Each of these base placements has an alignment in the form -start and -end. For example, 'right-start', or 'bottom-end'. These allow you to align the tooltip to the edges of the button, rather than centering it.
- Required: No
### `popoverOffset`: `number`
The space between the popover and the control wrapper.
- Required: No
### `size`: `string`
Size of the control.
- Required: No
- Default: `default`
- Allowed values: `default`, `__unstable-large`
### `value`: `Object`
An object representing the current border configuration.
This may be a "flat" border where the object has `color`, `style`, and `width`
properties or a "split" border which defines the previous properties but for
each side; `top`, `right`, `bottom`, and `left`.
Examples:
```js
const flatBorder = { color: '#72aee6', style: 'solid', width: '1px' };
const splitBorders = {
top: { color: '#72aee6', style: 'solid', width: '1px' },
right: { color: '#e65054', style: 'dashed', width: '2px' },
bottom: { color: '#68de7c', style: 'solid', width: '1px' },
left: { color: '#f2d675', style: 'dotted', width: '1em' },
};
```
- Required: No

View File

@@ -0,0 +1,203 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { useMemo, useState } from '@wordpress/element';
import { useMergeRefs } from '@wordpress/compose';
/**
* Internal dependencies
*/
import BorderBoxControlLinkedButton from '../border-box-control-linked-button';
import BorderBoxControlSplitControls from '../border-box-control-split-controls';
import { BorderControl } from '../../border-control';
import { StyledLabel } from '../../base-control/styles/base-control-styles';
import { View } from '../../view';
import { VisuallyHidden } from '../../visually-hidden';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import { useBorderBoxControl } from './hook';
import type { BorderBoxControlProps } from '../types';
import type {
LabelProps,
BorderControlProps,
} from '../../border-control/types';
const BorderLabel = ( props: LabelProps ) => {
const { label, hideLabelFromVision } = props;
if ( ! label ) {
return null;
}
return hideLabelFromVision ? (
<VisuallyHidden as="label">{ label }</VisuallyHidden>
) : (
<StyledLabel>{ label }</StyledLabel>
);
};
const UnconnectedBorderBoxControl = (
props: WordPressComponentProps< BorderBoxControlProps, 'div', false >,
forwardedRef: React.ForwardedRef< any >
) => {
const {
className,
colors,
disableCustomColors,
disableUnits,
enableAlpha,
enableStyle,
hasMixedBorders,
hideLabelFromVision,
isLinked,
label,
linkedControlClassName,
linkedValue,
onLinkedChange,
onSplitChange,
popoverPlacement,
popoverOffset,
size,
splitValue,
toggleLinked,
wrapperClassName,
__experimentalIsRenderedInSidebar,
...otherProps
} = useBorderBoxControl( props );
// Use internal state instead of a ref to make sure that the component
// re-renders when the popover's anchor updates.
const [ popoverAnchor, setPopoverAnchor ] = useState< Element | null >(
null
);
// Memoize popoverProps to avoid returning a new object every time.
const popoverProps: BorderControlProps[ '__unstablePopoverProps' ] =
useMemo(
() =>
popoverPlacement
? {
placement: popoverPlacement,
offset: popoverOffset,
anchor: popoverAnchor,
shift: true,
}
: undefined,
[ popoverPlacement, popoverOffset, popoverAnchor ]
);
const mergedRef = useMergeRefs( [ setPopoverAnchor, forwardedRef ] );
return (
<View className={ className } { ...otherProps } ref={ mergedRef }>
<BorderLabel
label={ label }
hideLabelFromVision={ hideLabelFromVision }
/>
<View className={ wrapperClassName }>
{ isLinked ? (
<BorderControl
className={ linkedControlClassName }
colors={ colors }
disableUnits={ disableUnits }
disableCustomColors={ disableCustomColors }
enableAlpha={ enableAlpha }
enableStyle={ enableStyle }
onChange={ onLinkedChange }
placeholder={
hasMixedBorders ? __( 'Mixed' ) : undefined
}
__unstablePopoverProps={ popoverProps }
shouldSanitizeBorder={ false } // This component will handle that.
value={ linkedValue }
withSlider={ true }
width={
size === '__unstable-large' ? '116px' : '110px'
}
__experimentalIsRenderedInSidebar={
__experimentalIsRenderedInSidebar
}
size={ size }
/>
) : (
<BorderBoxControlSplitControls
colors={ colors }
disableCustomColors={ disableCustomColors }
enableAlpha={ enableAlpha }
enableStyle={ enableStyle }
onChange={ onSplitChange }
popoverPlacement={ popoverPlacement }
popoverOffset={ popoverOffset }
value={ splitValue }
__experimentalIsRenderedInSidebar={
__experimentalIsRenderedInSidebar
}
size={ size }
/>
) }
<BorderBoxControlLinkedButton
onClick={ toggleLinked }
isLinked={ isLinked }
size={ size }
/>
</View>
</View>
);
};
/**
* The `BorderBoxControl` effectively has two view states. The first, a "linked"
* view, allows configuration of a flat border via a single `BorderControl`.
* The second, a "split" view, contains a `BorderControl` for each side
* as well as a visualizer for the currently selected borders. Each view also
* contains a button to toggle between the two.
*
* When switching from the "split" view to "linked", if the individual side
* borders are not consistent, the "linked" view will display any border
* properties selections that are consistent while showing a mixed state for
* those that aren't. For example, if all borders had the same color and style
* but different widths, then the border dropdown in the "linked" view's
* `BorderControl` would show that consistent color and style but the "linked"
* view's width input would show "Mixed" placeholder text.
*
* ```jsx
* import { __experimentalBorderBoxControl as BorderBoxControl } from '@wordpress/components';
* import { __ } from '@wordpress/i18n';
*
* const colors = [
* { name: 'Blue 20', color: '#72aee6' },
* // ...
* ];
*
* const MyBorderBoxControl = () => {
* const defaultBorder = {
* color: '#72aee6',
* style: 'dashed',
* width: '1px',
* };
* const [ borders, setBorders ] = useState( {
* top: defaultBorder,
* right: defaultBorder,
* bottom: defaultBorder,
* left: defaultBorder,
* } );
* const onChange = ( newBorders ) => setBorders( newBorders );
*
* return (
* <BorderBoxControl
* colors={ colors }
* label={ __( 'Borders' ) }
* onChange={ onChange }
* value={ borders }
* />
* );
* };
* ```
*/
export const BorderBoxControl = contextConnect(
UnconnectedBorderBoxControl,
'BorderBoxControl'
);
export default BorderBoxControl;

View File

@@ -0,0 +1,145 @@
/**
* WordPress dependencies
*/
import { useMemo, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import {
getBorderDiff,
getCommonBorder,
getSplitBorders,
hasMixedBorders,
hasSplitBorders,
isCompleteBorder,
isEmptyBorder,
} from '../utils';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
import type { Border } from '../../border-control/types';
import type { Borders, BorderSide, BorderBoxControlProps } from '../types';
export function useBorderBoxControl(
props: WordPressComponentProps< BorderBoxControlProps, 'div' >
) {
const {
className,
colors = [],
onChange,
enableAlpha = false,
enableStyle = true,
size = 'default',
value,
__experimentalIsRenderedInSidebar = false,
__next40pxDefaultSize,
...otherProps
} = useContextSystem( props, 'BorderBoxControl' );
const computedSize =
size === 'default' && __next40pxDefaultSize ? '__unstable-large' : size;
const mixedBorders = hasMixedBorders( value );
const splitBorders = hasSplitBorders( value );
const linkedValue = splitBorders
? getCommonBorder( value as Borders | undefined )
: ( value as Border );
const splitValue = splitBorders
? ( value as Borders )
: getSplitBorders( value as Border | undefined );
// If no numeric width value is set, the unit select will be disabled.
const hasWidthValue = ! isNaN( parseFloat( `${ linkedValue?.width }` ) );
const [ isLinked, setIsLinked ] = useState( ! mixedBorders );
const toggleLinked = () => setIsLinked( ! isLinked );
const onLinkedChange = ( newBorder?: Border ) => {
if ( ! newBorder ) {
return onChange( undefined );
}
// If we have all props defined on the new border apply it.
if ( ! mixedBorders || isCompleteBorder( newBorder ) ) {
return onChange(
isEmptyBorder( newBorder ) ? undefined : newBorder
);
}
// If we had mixed borders we might have had some shared border props
// that we need to maintain. For example; we could have mixed borders
// with all the same color but different widths. Then from the linked
// control we change the color. We should keep the separate widths.
const changes = getBorderDiff(
linkedValue as Border,
newBorder as Border
);
const updatedBorders = {
top: { ...( value as Borders )?.top, ...changes },
right: { ...( value as Borders )?.right, ...changes },
bottom: { ...( value as Borders )?.bottom, ...changes },
left: { ...( value as Borders )?.left, ...changes },
};
if ( hasMixedBorders( updatedBorders ) ) {
return onChange( updatedBorders );
}
const filteredResult = isEmptyBorder( updatedBorders.top )
? undefined
: updatedBorders.top;
onChange( filteredResult );
};
const onSplitChange = (
newBorder: Border | undefined,
side: BorderSide
) => {
const updatedBorders = { ...splitValue, [ side ]: newBorder };
if ( hasMixedBorders( updatedBorders ) ) {
onChange( updatedBorders );
} else {
onChange( newBorder );
}
};
const cx = useCx();
const classes = useMemo( () => {
return cx( styles.borderBoxControl, className );
}, [ cx, className ] );
const linkedControlClassName = useMemo( () => {
return cx( styles.linkedBorderControl() );
}, [ cx ] );
const wrapperClassName = useMemo( () => {
return cx( styles.wrapper );
}, [ cx ] );
return {
...otherProps,
className: classes,
colors,
disableUnits: mixedBorders && ! hasWidthValue,
enableAlpha,
enableStyle,
hasMixedBorders: mixedBorders,
isLinked,
linkedControlClassName,
onLinkedChange,
onSplitChange,
toggleLinked,
linkedValue,
size: computedSize,
splitValue,
wrapperClassName,
__experimentalIsRenderedInSidebar,
};
}

View File

@@ -0,0 +1,2 @@
export { default as BorderBoxControl } from './component';
export { useBorderBoxControl } from './hook';

View File

@@ -0,0 +1,3 @@
export { default as BorderBoxControl } from './border-box-control/component';
export { useBorderBoxControl } from './border-box-control/hook';
export { hasSplitBorders, isEmptyBorder, isDefinedBorder } from './utils';

View File

@@ -0,0 +1,86 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
import type { ComponentProps } from 'react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import Button from '../../button';
import { BorderBoxControl } from '../';
const meta: Meta< typeof BorderBoxControl > = {
title: 'Components (Experimental)/BorderBoxControl',
component: BorderBoxControl,
argTypes: {
onChange: { action: 'onChange' },
value: { control: { type: null } },
},
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
// Available border colors.
const colors = [
{ name: 'Blue 20', color: '#72aee6' },
{ name: 'Blue 40', color: '#3582c4' },
{ name: 'Red 40', color: '#e65054' },
{ name: 'Red 70', color: '#8a2424' },
{ name: 'Yellow 10', color: '#f2d675' },
{ name: 'Yellow 40', color: '#bd8600' },
];
const Template: StoryFn< typeof BorderBoxControl > = ( props ) => {
const { onChange, ...otherProps } = props;
const [ borders, setBorders ] = useState< ( typeof props )[ 'value' ] >();
const onChangeMerged: ComponentProps<
typeof BorderBoxControl
>[ 'onChange' ] = ( newBorders ) => {
setBorders( newBorders );
onChange( newBorders );
};
return (
<>
<BorderBoxControl
{ ...otherProps }
onChange={ onChangeMerged }
value={ borders }
/>
<hr
style={ {
marginTop: '100px',
borderColor: '#ddd',
borderStyle: 'solid',
borderBottom: 'none',
} }
/>
<p style={ { color: '#aaa', fontSize: '0.9em' } }>
The BorderBoxControl is intended to be used within a component
that will provide reset controls. The button below is only for
convenience.
</p>
<Button
variant="primary"
onClick={ () => onChangeMerged( undefined ) }
>
Reset
</Button>
</>
);
};
export const Default = Template.bind( {} );
Default.args = {
colors,
label: 'Borders',
};

View File

@@ -0,0 +1,87 @@
/**
* External dependencies
*/
import { css } from '@emotion/react';
/**
* Internal dependencies
*/
import { COLORS, CONFIG, rtl } from '../utils';
import type { Border } from '../border-control/types';
import type { Borders } from './types';
export const borderBoxControl = css``;
export const linkedBorderControl = () => css`
flex: 1;
${ rtl( { marginRight: '24px' } )() }
`;
export const wrapper = css`
position: relative;
`;
export const borderBoxControlLinkedButton = (
size?: 'default' | '__unstable-large'
) => {
return css`
position: absolute;
top: ${ size === '__unstable-large' ? '8px' : '3px' };
${ rtl( { right: 0 } )() }
line-height: 0;
`;
};
const borderBoxStyleWithFallback = ( border?: Border ) => {
const {
color = COLORS.gray[ 200 ],
style = 'solid',
width = CONFIG.borderWidth,
} = border || {};
const clampedWidth =
width !== CONFIG.borderWidth ? `clamp(1px, ${ width }, 10px)` : width;
const hasVisibleBorder = ( !! width && width !== '0' ) || !! color;
const borderStyle = hasVisibleBorder ? style || 'solid' : style;
return `${ color } ${ borderStyle } ${ clampedWidth }`;
};
export const borderBoxControlVisualizer = (
borders?: Borders,
size?: 'default' | '__unstable-large'
) => {
return css`
position: absolute;
top: ${ size === '__unstable-large' ? '20px' : '15px' };
right: ${ size === '__unstable-large' ? '39px' : '29px' };
bottom: ${ size === '__unstable-large' ? '20px' : '15px' };
left: ${ size === '__unstable-large' ? '39px' : '29px' };
border-top: ${ borderBoxStyleWithFallback( borders?.top ) };
border-bottom: ${ borderBoxStyleWithFallback( borders?.bottom ) };
${ rtl( {
borderLeft: borderBoxStyleWithFallback( borders?.left ),
} )() }
${ rtl( {
borderRight: borderBoxStyleWithFallback( borders?.right ),
} )() }
`;
};
export const borderBoxControlSplitControls = (
size?: 'default' | '__unstable-large'
) => css`
position: relative;
flex: 1;
width: ${ size === '__unstable-large' ? undefined : '80%' };
`;
export const centeredBorderControl = css`
grid-column: span 2;
margin: 0 auto;
`;
export const rightBorderControl = () => css`
${ rtl( { marginLeft: 'auto' } )() }
`;

View File

@@ -0,0 +1,502 @@
/**
* External dependencies
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import { BorderBoxControl } from '..';
const colors = [
{ name: 'Gray', color: '#f6f7f7' },
{ name: 'Blue', color: '#72aee6' },
{ name: 'Red', color: '#e65054' },
{ name: 'Green', color: '#00a32a' },
{ name: 'Yellow', color: '#bd8600' },
];
const defaultBorder = {
color: '#72aee6',
style: 'solid',
width: '1px',
};
const defaultBorders = {
top: defaultBorder,
right: defaultBorder,
bottom: defaultBorder,
left: defaultBorder,
};
const mixedBorders = {
top: { color: '#f6f7f7', style: 'solid', width: '1px' },
right: { color: '#e65054', style: 'dashed', width: undefined },
bottom: { color: undefined, style: 'dotted', width: '2rem' },
left: { color: '#bd8600', style: undefined, width: '0.75em' },
};
const props = {
colors,
label: 'Border Box',
onChange: jest.fn().mockImplementation( ( newValue ) => {
props.value = newValue;
} ),
value: undefined,
};
const toggleLabelRegex = /Border color( and style)* picker/;
const colorPickerRegex = /Border color picker/;
describe( 'BorderBoxControl', () => {
describe( 'Linked view rendering', () => {
it( 'should render correctly when no value provided', () => {
render( <BorderBoxControl { ...props } /> );
const label = screen.getByText( props.label );
const colorButton = screen.getByLabelText( toggleLabelRegex );
const widthInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} );
const unitSelect = screen.getByRole( 'combobox', {
name: 'Select unit',
} );
const slider = screen.getByRole( 'slider', {
name: 'Border width',
} );
const linkedButton = screen.getByLabelText( 'Unlink sides' );
expect( label ).toBeInTheDocument();
expect( colorButton ).toBeInTheDocument();
expect( widthInput ).toBeInTheDocument();
expect( widthInput ).not.toHaveAttribute( 'placeholder' );
expect( unitSelect ).toBeInTheDocument();
expect( slider ).toBeInTheDocument();
expect( linkedButton ).toBeInTheDocument();
} );
it( 'should hide label', () => {
render( <BorderBoxControl { ...props } hideLabelFromVision /> );
const label = screen.getByText( props.label );
// As visually hidden labels are still included in the document
// and do not have `display: none` styling, we can't rely on
// `.toBeInTheDocument()` or `.toBeVisible()` assertions.
expect( label ).toHaveAttribute(
'data-wp-component',
'VisuallyHidden'
);
} );
it( 'should show correct width value when flat border value provided', () => {
render( <BorderBoxControl { ...props } value={ defaultBorder } /> );
const widthInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} ) as HTMLInputElement;
expect( widthInput.value ).toBe( '1' );
} );
it( 'should show correct width value when consistent split borders provided', () => {
render(
<BorderBoxControl { ...props } value={ defaultBorders } />
);
const widthInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} ) as HTMLInputElement;
expect( widthInput.value ).toBe( '1' );
} );
it( 'should render placeholder and omit unit select when border values are mixed', async () => {
const user = userEvent.setup();
render( <BorderBoxControl { ...props } value={ mixedBorders } /> );
// There are 4 inputs when in unlinked mode (top/right/bottom/left)
expect(
screen.getAllByRole( 'spinbutton', {
name: 'Border width',
} )
).toHaveLength( 4 );
// First render of control with mixed values should show split view.
await user.click(
screen.getByRole( 'button', { name: 'Link sides' } )
);
// In linked mode, there is only one input
await waitFor( () =>
expect(
screen.getByRole( 'spinbutton', {
name: 'Border width',
} )
).toBeVisible()
);
const widthInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} );
const unitSelect = screen.queryByRole( 'combobox', {
name: 'Select unit',
} );
expect( widthInput ).toHaveAttribute( 'placeholder', 'Mixed' );
expect( unitSelect ).not.toBeInTheDocument();
} );
it( 'should render shared border width and unit select when switching to linked view', async () => {
const user = userEvent.setup();
// Render control with mixed border values but consistent widths.
render(
<BorderBoxControl
{ ...props }
value={ {
top: { color: 'red', width: '5px', style: 'solid' },
right: { color: 'blue', width: '5px', style: 'dashed' },
bottom: {
color: 'green',
width: '5px',
style: 'solid',
},
left: {
color: 'yellow',
width: '5px',
style: 'dotted',
},
} }
/>
);
// First render of control with mixed values should show split view.
await user.click(
screen.getByRole( 'button', { name: 'Link sides' } )
);
const linkedInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} ) as HTMLInputElement;
const unitSelect = screen.getByRole( 'combobox', {
name: 'Select unit',
} );
expect( linkedInput.value ).toBe( '5' );
expect( unitSelect ).toBeInTheDocument();
} );
it( 'should omit style options when requested', async () => {
const user = userEvent.setup();
render( <BorderBoxControl { ...props } enableStyle={ false } /> );
const colorButton = screen.getByLabelText( colorPickerRegex );
await user.click( colorButton );
// Wait for the custom color picker in the dropdown to appear
await waitFor( () =>
expect(
screen.getByRole( 'button', {
name: 'Custom color picker.',
} )
).toBeVisible()
);
// Make sure that none of the border style buttons (and the section
// title) are rendered to screen.
expect( screen.queryByText( 'Style' ) ).not.toBeInTheDocument();
expect(
screen.queryByRole( 'button', {
name: /(Solid)|(Dashed)|(Dotted)/,
} )
).not.toBeInTheDocument();
} );
} );
describe( 'Split view rendering', () => {
it( 'should render split view by default when mixed values provided', () => {
render( <BorderBoxControl { ...props } value={ mixedBorders } /> );
const colorButtons = screen.getAllByLabelText( toggleLabelRegex );
const widthInputs = screen.getAllByRole( 'spinbutton', {
name: 'Border width',
} );
const unitSelects = screen.getAllByRole( 'combobox', {
name: 'Select unit',
} );
const sliders = screen.queryAllByRole( 'slider', {
name: 'Border width',
} );
const linkedButton = screen.getByLabelText( 'Link sides' );
expect( colorButtons.length ).toBe( 4 );
expect( widthInputs.length ).toBe( 4 );
expect( unitSelects.length ).toBe( 4 );
expect( sliders.length ).toBe( 0 );
expect( linkedButton ).toBeInTheDocument();
} );
it( 'should render correct width values in appropriate inputs', () => {
render( <BorderBoxControl { ...props } value={ mixedBorders } /> );
const widthInputs = screen.getAllByRole( 'spinbutton', {
name: 'Border width',
} ) as HTMLInputElement[];
expect( widthInputs[ 0 ].value ).toBe( '1' ); // Top.
expect( widthInputs[ 1 ].value ).toBe( '0.75' ); // Left.
expect( widthInputs[ 2 ].value ).toBe( '' ); // Right.
expect( widthInputs[ 3 ].value ).toBe( '2' ); // Bottom.
} );
it( 'should render split view correctly when starting with flat border', async () => {
const user = userEvent.setup();
render(
<BorderBoxControl { ...props } value={ defaultBorders } />
);
await user.click(
screen.getByRole( 'button', { name: 'Unlink sides' } )
);
const widthInputs = screen.getAllByRole( 'spinbutton', {
name: 'Border width',
} ) as HTMLInputElement[];
expect( widthInputs[ 0 ].value ).toBe( '1' ); // Top.
expect( widthInputs[ 1 ].value ).toBe( '1' ); // Left.
expect( widthInputs[ 2 ].value ).toBe( '1' ); // Right.
expect( widthInputs[ 3 ].value ).toBe( '1' ); // Bottom.
} );
// We're expecting to have 4 color buttons by default.
const colorButtonIndexes = [ ...Array( 4 ).keys() ];
it.each( colorButtonIndexes )(
'should omit style options when color button %s is pressed',
async ( colorButtonIndex ) => {
const user = userEvent.setup();
render(
<BorderBoxControl { ...props } enableStyle={ false } />
);
await user.click(
screen.getByRole( 'button', { name: 'Unlink sides' } )
);
const colorButtons =
screen.getAllByLabelText( colorPickerRegex );
await user.click( colorButtons[ colorButtonIndex ] );
// Make sure that none of the border style buttons (and the section
// title) are rendered to screen.
expect( screen.queryByText( 'Style' ) ).not.toBeInTheDocument();
expect(
screen.queryByRole( 'button', {
name: /(Solid)|(Dashed)|(Dotted)/,
} )
).not.toBeInTheDocument();
}
);
} );
describe( 'onChange handling', () => {
beforeEach( () => {
jest.clearAllMocks();
props.value = undefined;
} );
describe( 'Linked value change handling', () => {
it( 'should set undefined when new border is empty', async () => {
const user = userEvent.setup();
render(
<BorderBoxControl { ...props } value={ { width: '1px' } } />
);
await user.clear(
screen.getByRole( 'spinbutton', { name: 'Border width' } )
);
expect( props.onChange ).toHaveBeenCalledWith( undefined );
} );
it( 'should update with complete flat border', async () => {
const user = userEvent.setup();
render(
<BorderBoxControl { ...props } value={ defaultBorder } />
);
const widthInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} );
await user.clear( widthInput );
await user.type( widthInput, '3' );
expect( props.onChange ).toHaveBeenCalledWith( {
...defaultBorder,
width: '3px',
} );
} );
it( 'should maintain mixed values if not explicitly set via linked control', async () => {
const user = userEvent.setup();
render(
<BorderBoxControl
{ ...props }
value={ {
top: { color: '#72aee6' },
right: { color: '#f6f7f7', style: 'dashed' },
bottom: { color: '#e65054', style: 'dotted' },
left: { color: undefined },
} }
/>
);
await user.click(
screen.getByRole( 'button', { name: 'Link sides' } )
);
const widthInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} );
await user.clear( widthInput );
await user.type( widthInput, '4' );
expect( props.onChange ).toHaveBeenCalledWith( {
top: { color: '#72aee6', width: '4px' },
right: { color: '#f6f7f7', style: 'dashed', width: '4px' },
bottom: { color: '#e65054', style: 'dotted', width: '4px' },
left: { color: undefined, width: '4px' },
} );
} );
it( 'should update with consistent split borders', async () => {
const user = userEvent.setup();
render(
<BorderBoxControl { ...props } value={ defaultBorders } />
);
const widthInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} );
await user.clear( widthInput );
await user.type( widthInput, '10' );
expect( props.onChange ).toHaveBeenCalledWith( {
...defaultBorder,
width: '10px',
} );
} );
it( 'should set undefined borders when change results in empty borders', async () => {
const user = userEvent.setup();
render(
<BorderBoxControl
{ ...props }
value={ {
top: { width: '1px' },
right: { width: '1px' },
bottom: { width: '1px' },
left: { width: '1px' },
} }
/>
);
await user.clear(
screen.getByRole( 'spinbutton', { name: 'Border width' } )
);
expect( props.onChange ).toHaveBeenCalledWith( undefined );
} );
it( 'should set flat border when change results in consistent split borders', async () => {
const user = userEvent.setup();
render(
<BorderBoxControl
{ ...props }
value={ {
top: { ...defaultBorder, width: '1px' },
right: { ...defaultBorder, width: '2px' },
bottom: { ...defaultBorder, width: '3px' },
left: { ...defaultBorder, width: '4px' },
} }
/>
);
await user.click(
screen.getByRole( 'button', { name: 'Link sides' } )
);
const widthInput = screen.getByRole( 'spinbutton', {
name: 'Border width',
} );
await user.clear( widthInput );
await user.type( widthInput, '10' );
expect( props.onChange ).toHaveBeenCalledWith( {
...defaultBorder,
width: '10px',
} );
} );
} );
describe( 'Split value change handling', () => {
it( 'should set split borders when the updated borders are mixed', async () => {
const user = userEvent.setup();
const borders = {
top: { ...defaultBorder, width: '1px' },
right: { ...defaultBorder, width: '2px' },
bottom: { ...defaultBorder, width: '3px' },
left: { ...defaultBorder, width: '4px' },
};
render( <BorderBoxControl { ...props } value={ borders } /> );
const widthInput = screen.getAllByRole( 'spinbutton', {
name: 'Border width',
} )[ 0 ];
await user.clear( widthInput );
await user.type( widthInput, '5' );
expect( props.onChange ).toHaveBeenCalledWith( {
...borders,
top: { ...defaultBorder, width: '5px' },
} );
} );
it( 'should set flat border when updated borders are consistent', async () => {
const user = userEvent.setup();
const borders = {
top: { ...defaultBorder, width: '4px' },
right: { ...defaultBorder, width: '1px' },
bottom: { ...defaultBorder, width: '1px' },
left: { ...defaultBorder, width: '1px' },
};
render( <BorderBoxControl { ...props } value={ borders } /> );
const widthInput = screen.getAllByRole( 'spinbutton', {
name: 'Border width',
} )[ 0 ];
await user.clear( widthInput );
await user.type( widthInput, '1' );
expect( props.onChange ).toHaveBeenCalledWith( defaultBorder );
} );
} );
} );
} );

View File

@@ -0,0 +1,373 @@
/**
* Internal dependencies
*/
import {
getBorderDiff,
getCommonBorder,
getShorthandBorderStyle,
getSplitBorders,
hasMixedBorders,
hasSplitBorders,
isCompleteBorder,
isDefinedBorder,
isEmptyBorder,
} from '../utils';
const completeBorder = { color: '#000', style: 'solid', width: '1px' };
const partialBorder = { color: undefined, style: undefined, width: '2px' };
const partialWithExtraProp = { color: '#fff', unrelated: true };
const nonBorder = { unrelatedProperty: true };
const splitBorders = {
top: completeBorder,
right: completeBorder,
bottom: completeBorder,
left: completeBorder,
};
const undefinedSplitBorders = {
top: undefined,
right: undefined,
bottom: undefined,
left: undefined,
};
const mixedBorders = {
top: completeBorder,
right: completeBorder,
bottom: completeBorder,
left: { color: '#fff', style: 'dashed', width: '10px' },
};
const mixedBordersWithUndefined = {
top: undefined,
right: undefined,
bottom: completeBorder,
left: partialBorder,
};
describe( 'BorderBoxControl Utils', () => {
describe( 'isEmptyBorder', () => {
it( 'should determine a undefined, null, and {} to be empty', () => {
expect( isEmptyBorder( undefined ) ).toBe( true );
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
expect( isEmptyBorder( null ) ).toBe( true );
expect( isEmptyBorder( {} ) ).toBe( true );
} );
it( 'should determine object missing all border props to be empty', () => {
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
expect( isEmptyBorder( nonBorder ) ).toBe( true );
} );
it( 'should determine that a border object with all properties is not empty', () => {
expect( isEmptyBorder( completeBorder ) ).toBe( false );
} );
it( 'should determine object with at least one border property as non-empty', () => {
expect( isEmptyBorder( partialWithExtraProp ) ).toBe( false );
} );
} );
describe( 'isDefinedBorder', () => {
it( 'should determine undefined is not a defined border', () => {
expect( isDefinedBorder( undefined ) ).toBe( false );
} );
it( 'should determine an empty object to be an undefined border', () => {
expect( isDefinedBorder( {} ) ).toBe( false );
} );
it( 'should determine an border object with undefined properties to be an undefined border', () => {
const emptyBorder = {
color: undefined,
style: undefined,
width: undefined,
};
expect( isDefinedBorder( emptyBorder ) ).toBe( false );
} );
it( 'should class an object with at least one side border as defined', () => {
expect( isDefinedBorder( mixedBordersWithUndefined ) ).toBe( true );
} );
it( 'should determine complete split borders object is defined border', () => {
expect( isDefinedBorder( splitBorders ) ).toBe( true );
} );
it( 'should determine border is not defined when all sides are empty', () => {
const mixedUndefinedBorders = {
top: undefined,
right: undefined,
bottom: {},
left: {
color: undefined,
style: undefined,
width: undefined,
},
};
expect( isDefinedBorder( undefinedSplitBorders ) ).toBe( false );
expect( isDefinedBorder( mixedUndefinedBorders ) ).toBe( false );
} );
} );
describe( 'isCompleteBorder', () => {
it( 'should determine a undefined, null, and {} to be incomplete', () => {
expect( isCompleteBorder( undefined ) ).toBe( false );
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
expect( isCompleteBorder( null ) ).toBe( false );
expect( isCompleteBorder( {} ) ).toBe( false );
} );
it( 'should determine objects missing border props to be incomplete', () => {
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
expect( isCompleteBorder( nonBorder ) ).toBe( false );
expect( isCompleteBorder( partialBorder ) ).toBe( false );
expect( isCompleteBorder( partialWithExtraProp ) ).toBe( false );
} );
it( 'should determine that a border object with all properties is complete', () => {
expect( isCompleteBorder( completeBorder ) ).toBe( true );
} );
} );
describe( 'hasSplitBorders', () => {
it( 'should determine empty or undefined borders as not being split', () => {
expect( hasSplitBorders( undefined ) ).toBe( false );
expect( hasSplitBorders( {} ) ).toBe( false );
} );
it( 'should determine flat border object as not being split', () => {
expect( hasSplitBorders( completeBorder ) ).toBe( false );
} );
it( 'should determine object with at least one side property as split', () => {
expect( hasSplitBorders( splitBorders ) ).toBe( true );
expect( hasSplitBorders( { top: completeBorder } ) ).toBe( true );
} );
it( 'should determine object with undefined sides but containing properties as split', () => {
expect( hasSplitBorders( undefinedSplitBorders ) ).toBe( true );
} );
} );
describe( 'hasMixedBorders', () => {
it( 'should determine undefined, non-border or empty object as not being mixed', () => {
expect( hasMixedBorders( undefined ) ).toBe( false );
expect( hasMixedBorders( {} ) ).toBe( false );
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
expect( hasMixedBorders( nonBorder ) ).toBe( false );
} );
it( 'should determine flat border object as not being mixed', () => {
expect( hasMixedBorders( completeBorder ) ).toBe( false );
} );
it( 'should determine split border object with some undefined side borders as mixed', () => {
expect( hasMixedBorders( mixedBordersWithUndefined ) ).toBe( true );
} );
it( 'should determine split border object with different side borders as mixed', () => {
expect( hasMixedBorders( mixedBorders ) ).toBe( true );
} );
} );
describe( 'getSplitBorders', () => {
it( 'should return undefined when no border provided', () => {
expect( getSplitBorders( undefined ) ).toEqual( undefined );
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
expect( getSplitBorders( null ) ).toEqual( undefined );
} );
it( 'should return undefined when supplied border is empty', () => {
expect( getSplitBorders( {} ) ).toEqual( undefined );
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
expect( getSplitBorders( nonBorder ) ).toEqual( undefined );
} );
it( 'should return object with all sides populated when given valid border', () => {
expect( getSplitBorders( completeBorder ) ).toEqual( {
top: completeBorder,
right: completeBorder,
bottom: completeBorder,
left: completeBorder,
} );
} );
} );
describe( 'getBorderDiff', () => {
it( 'should return empty object when there are no differences', () => {
const diff = getBorderDiff( completeBorder, completeBorder );
expect( diff ).toEqual( {} );
} );
it( 'should only return differences for border related properties', () => {
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
const diff = getBorderDiff( nonBorder, { caffeine: 'coffee' } );
expect( diff ).toEqual( {} );
} );
it( 'should return object with only border properties that have changed', () => {
const diff = getBorderDiff( completeBorder, {
...completeBorder,
color: '#21759b',
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
caffeine: 'cola',
} );
expect( diff ).toEqual( { color: '#21759b' } );
} );
} );
describe( 'getCommonBorder', () => {
it( 'should return undefined when no borders supplied', () => {
expect( getCommonBorder( undefined ) ).toEqual( undefined );
} );
it( 'should return border object with undefined properties when undefined borders given', () => {
const undefinedBorder = {
color: undefined,
style: undefined,
width: undefined,
};
expect( getCommonBorder( {} ) ).toEqual( undefinedBorder );
expect( getCommonBorder( undefinedSplitBorders ) ).toEqual(
undefinedBorder
);
} );
it( 'should return flat border object when split borders are the same', () => {
expect( getCommonBorder( splitBorders ) ).toEqual( completeBorder );
} );
it( 'should only set properties where every side border shares the same value', () => {
const sideBorders = {
top: { color: '#fff', style: 'solid', width: '1px' },
right: { color: '#000', style: 'solid', width: '1px' },
bottom: { color: '#000', style: 'solid', width: '1px' },
left: { color: '#000', style: undefined, width: '1px' },
};
const commonBorder = {
color: undefined,
style: undefined,
width: '1px',
};
expect( getCommonBorder( sideBorders ) ).toEqual( commonBorder );
} );
it( 'should return most common unit selection if border widths are mixed', () => {
const sideBorders = {
top: { color: '#fff', style: 'solid', width: '10px' },
right: { color: '#000', style: 'solid', width: '1rem' },
bottom: { color: '#000', style: 'solid', width: '2em' },
left: { color: '#000', style: undefined, width: '2em' },
};
const commonBorder = {
color: undefined,
style: undefined,
width: 'em',
};
expect( getCommonBorder( sideBorders ) ).toEqual( commonBorder );
} );
it( 'should return first unit when multiple units are equal most common', () => {
const sideBorders = {
top: { color: '#fff', style: 'solid', width: '1rem' },
right: { color: '#000', style: 'solid', width: '0.75em' },
bottom: { color: '#000', style: 'solid', width: '1vw' },
left: { color: '#000', style: undefined, width: '2vh' },
};
const commonBorder = {
color: undefined,
style: undefined,
width: 'rem',
};
expect( getCommonBorder( sideBorders ) ).toEqual( commonBorder );
} );
it( 'should ignore undefined values in determining most common unit', () => {
const sideBorders = {
top: { color: '#fff', style: 'solid', width: undefined },
right: { color: '#000', style: 'solid', width: '5vw' },
bottom: { color: '#000', style: 'solid', width: undefined },
left: { color: '#000', style: undefined, width: '2vh' },
};
const commonBorder = {
color: undefined,
style: undefined,
width: 'vw',
};
expect( getCommonBorder( sideBorders ) ).toEqual( commonBorder );
} );
} );
describe( 'getShorthandBorderStyle', () => {
it( 'should return undefined when no border provided', () => {
expect( getShorthandBorderStyle( undefined ) ).toEqual( undefined );
expect( getShorthandBorderStyle( {} ) ).toEqual( undefined );
// Checking for extra resilience, even if not a valid type.
// @ts-expect-error
expect( getShorthandBorderStyle( nonBorder ) ).toEqual( undefined );
} );
it( 'should generate correct shorthand style from valid border', () => {
const style = getShorthandBorderStyle( completeBorder );
expect( style ).toEqual( '1px solid #000' );
} );
it( 'should generate correct style from partial border', () => {
const style = getShorthandBorderStyle( {
style: 'dashed',
width: '2px',
} );
expect( style ).toEqual( '2px dashed' );
} );
it( 'should default borders with either color or width to solid style', () => {
const widthOnlyStyle = getShorthandBorderStyle( { width: '5px' } );
const colorOnlyStyle = getShorthandBorderStyle( { color: '#000' } );
expect( widthOnlyStyle ).toEqual( '5px solid' );
expect( colorOnlyStyle ).toEqual( 'solid #000' );
} );
it( 'should not default border style to solid for zero width border', () => {
const zeroWidthStyle = getShorthandBorderStyle( { width: '0' } );
expect( zeroWidthStyle ).toEqual( '0' );
} );
it( 'should return undefined when no border or fallback supplied', () => {
expect( getShorthandBorderStyle() ).toBe( undefined );
} );
it( 'should return fallback border when border is undefined', () => {
const result = getShorthandBorderStyle( undefined, completeBorder );
expect( result ).toEqual( completeBorder );
} );
it( 'should return fallback border when empty border supplied', () => {
const result = getShorthandBorderStyle( {}, completeBorder );
expect( result ).toEqual( completeBorder );
} );
it( 'should use fallback border properties if missing from border', () => {
const result = getShorthandBorderStyle(
{ width: '1em' },
{ width: '5px', style: 'dashed', color: '#72aee6' }
);
expect( result ).toEqual( `1em dashed #72aee6` );
} );
} );
} );

View File

@@ -0,0 +1,101 @@
/**
* Internal dependencies
*/
import type {
Border,
ColorProps,
LabelProps,
BorderControlProps,
} from '../border-control/types';
import type { PopoverProps } from '../popover/types';
export type Borders = {
top?: Border;
right?: Border;
bottom?: Border;
left?: Border;
};
export type AnyBorder = Border | Borders | undefined;
export type BorderProp = keyof Border;
export type BorderSide = keyof Borders;
export type BorderBoxControlProps = ColorProps &
LabelProps &
Pick< BorderControlProps, 'enableStyle' | 'size' > & {
/**
* A callback function invoked when any border value is changed. The value
* received may be a "flat" border object, one that has properties defining
* individual side borders, or `undefined`.
*/
onChange: ( value: AnyBorder ) => void;
/**
* The position of the color popovers compared to the control wrapper.
*/
popoverPlacement?: PopoverProps[ 'placement' ];
/**
* The space between the popover and the control wrapper.
*/
popoverOffset?: PopoverProps[ 'offset' ];
/**
* An object representing the current border configuration.
*
* This may be a "flat" border where the object has `color`, `style`, and
* `width` properties or a "split" border which defines the previous
* properties but for each side; `top`, `right`, `bottom`, and `left`.
*/
value: AnyBorder;
/**
* Start opting into the larger default height that will become the default size in a future version.
*
* @default false
*/
__next40pxDefaultSize?: boolean;
};
export type LinkedButtonProps = Pick< BorderBoxControlProps, 'size' > & {
/**
* This prop allows the `LinkedButton` to reflect whether the parent
* `BorderBoxControl` is currently displaying "linked" or "unlinked"
* border controls.
*/
isLinked: boolean;
/**
* A callback invoked when this `LinkedButton` is clicked. It is used to
* toggle display between linked or split border controls within the parent
* `BorderBoxControl`.
*/
onClick: () => void;
};
export type VisualizerProps = Pick< BorderBoxControlProps, 'size' > & {
/**
* An object representing the current border configuration. It contains
* properties for each side, with each side an object reflecting the border
* color, style, and width.
*/
value?: Borders;
};
export type SplitControlsProps = ColorProps &
Pick< BorderBoxControlProps, 'enableStyle' | 'size' > & {
/**
* A callback that is invoked whenever an individual side's border has
* changed.
*/
onChange: ( value: Border | undefined, side: BorderSide ) => void;
/**
* The position of the color popovers compared to the control wrapper.
*/
popoverPlacement?: PopoverProps[ 'placement' ];
/**
* The space between the popover and the control wrapper.
*/
popoverOffset?: PopoverProps[ 'offset' ];
/**
* An object representing the current border configuration. It contains
* properties for each side, with each side an object reflecting the border
* color, style, and width.
*/
value?: Borders;
};

View File

@@ -0,0 +1,197 @@
/**
* External dependencies
*/
import type { CSSProperties } from 'react';
/**
* Internal dependencies
*/
import { parseCSSUnitValue } from '../utils/unit-values';
import type { Border } from '../border-control/types';
import type { AnyBorder, Borders, BorderProp, BorderSide } from './types';
const sides: BorderSide[] = [ 'top', 'right', 'bottom', 'left' ];
const borderProps: BorderProp[] = [ 'color', 'style', 'width' ];
export const isEmptyBorder = ( border?: Border ) => {
if ( ! border ) {
return true;
}
return ! borderProps.some( ( prop ) => border[ prop ] !== undefined );
};
export const isDefinedBorder = ( border: AnyBorder ) => {
// No border, no worries :)
if ( ! border ) {
return false;
}
// If we have individual borders per side within the border object we
// need to check whether any of those side borders have been set.
if ( hasSplitBorders( border ) ) {
const allSidesEmpty = sides.every( ( side ) =>
isEmptyBorder( ( border as Borders )[ side ] )
);
return ! allSidesEmpty;
}
// If we have a top-level border only, check if that is empty. e.g.
// { color: undefined, style: undefined, width: undefined }
// Border radius can still be set within the border object as it is
// handled separately.
return ! isEmptyBorder( border as Border );
};
export const isCompleteBorder = ( border?: Border ) => {
if ( ! border ) {
return false;
}
return borderProps.every( ( prop ) => border[ prop ] !== undefined );
};
export const hasSplitBorders = ( border: AnyBorder = {} ) => {
return Object.keys( border ).some(
( side ) => sides.indexOf( side as BorderSide ) !== -1
);
};
export const hasMixedBorders = ( borders: AnyBorder ) => {
if ( ! hasSplitBorders( borders ) ) {
return false;
}
const shorthandBorders = sides.map( ( side: BorderSide ) =>
getShorthandBorderStyle( ( borders as Borders )?.[ side ] )
);
return ! shorthandBorders.every(
( border ) => border === shorthandBorders[ 0 ]
);
};
export const getSplitBorders = ( border?: Border ) => {
if ( ! border || isEmptyBorder( border ) ) {
return undefined;
}
return {
top: border,
right: border,
bottom: border,
left: border,
};
};
export const getBorderDiff = ( original: Border, updated: Border ) => {
const diff: Border = {};
if ( original.color !== updated.color ) {
diff.color = updated.color;
}
if ( original.style !== updated.style ) {
diff.style = updated.style;
}
if ( original.width !== updated.width ) {
diff.width = updated.width;
}
return diff;
};
export const getCommonBorder = ( borders?: Borders ) => {
if ( ! borders ) {
return undefined;
}
const colors: ( CSSProperties[ 'borderColor' ] | undefined )[] = [];
const styles: ( CSSProperties[ 'borderStyle' ] | undefined )[] = [];
const widths: ( CSSProperties[ 'borderWidth' ] | undefined )[] = [];
sides.forEach( ( side ) => {
colors.push( borders[ side ]?.color );
styles.push( borders[ side ]?.style );
widths.push( borders[ side ]?.width );
} );
const allColorsMatch = colors.every( ( value ) => value === colors[ 0 ] );
const allStylesMatch = styles.every( ( value ) => value === styles[ 0 ] );
const allWidthsMatch = widths.every( ( value ) => value === widths[ 0 ] );
return {
color: allColorsMatch ? colors[ 0 ] : undefined,
style: allStylesMatch ? styles[ 0 ] : undefined,
width: allWidthsMatch ? widths[ 0 ] : getMostCommonUnit( widths ),
};
};
export const getShorthandBorderStyle = (
border?: Border,
fallbackBorder?: Border
) => {
if ( isEmptyBorder( border ) ) {
return fallbackBorder;
}
const {
color: fallbackColor,
style: fallbackStyle,
width: fallbackWidth,
} = fallbackBorder || {};
const {
color = fallbackColor,
style = fallbackStyle,
width = fallbackWidth,
} = border as Border;
const hasVisibleBorder = ( !! width && width !== '0' ) || !! color;
const borderStyle = hasVisibleBorder ? style || 'solid' : style;
return [ width, borderStyle, color ].filter( Boolean ).join( ' ' );
};
export const getMostCommonUnit = (
values: Array< string | number | undefined >
): string | undefined => {
// Collect all the CSS units.
const units = values.map( ( value ) =>
value === undefined ? undefined : parseCSSUnitValue( `${ value }` )[ 1 ]
);
// Return the most common unit out of only the defined CSS units.
const filteredUnits = units.filter( ( value ) => value !== undefined );
return mode( filteredUnits as string[] );
};
/**
* Finds the mode value out of the array passed favouring the first value
* as a tiebreaker.
*
* @param values Values to determine the mode from.
*
* @return The mode value.
*/
function mode( values: Array< string > ): string | undefined {
if ( values.length === 0 ) {
return undefined;
}
const map: { [ index: string ]: number } = {};
let maxCount = 0;
let currentMode;
values.forEach( ( value ) => {
map[ value ] = map[ value ] === undefined ? 1 : map[ value ] + 1;
if ( map[ value ] > maxCount ) {
currentMode = value;
maxCount = map[ value ];
}
} );
return currentMode;
}

View File

@@ -0,0 +1,266 @@
/**
* External dependencies
*/
import type { CSSProperties } from 'react';
/**
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { closeSmall } from '@wordpress/icons';
/**
* Internal dependencies
*/
import BorderControlStylePicker from '../border-control-style-picker';
import Button from '../../button';
import ColorIndicator from '../../color-indicator';
import ColorPalette from '../../color-palette';
import Dropdown from '../../dropdown';
import { HStack } from '../../h-stack';
import { VStack } from '../../v-stack';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import { useBorderControlDropdown } from './hook';
import { StyledLabel } from '../../base-control/styles/base-control-styles';
import DropdownContentWrapper from '../../dropdown/dropdown-content-wrapper';
import type { ColorObject } from '../../color-palette/types';
import { isMultiplePaletteArray } from '../../color-palette/utils';
import type { DropdownProps as DropdownComponentProps } from '../../dropdown/types';
import type { ColorProps, DropdownProps } from '../types';
const getAriaLabelColorValue = ( colorValue: string ) => {
// Leave hex values as-is. Remove the `var()` wrapper from CSS vars.
return colorValue.replace( /^var\((.+)\)$/, '$1' );
};
const getColorObject = (
colorValue: CSSProperties[ 'borderColor' ],
colors: ColorProps[ 'colors' ] | undefined
) => {
if ( ! colorValue || ! colors ) {
return;
}
if ( isMultiplePaletteArray( colors ) ) {
// Multiple origins
let matchedColor;
colors.some( ( origin ) =>
origin.colors.some( ( color ) => {
if ( color.color === colorValue ) {
matchedColor = color;
return true;
}
return false;
} )
);
return matchedColor;
}
// Single origin
return colors.find( ( color ) => color.color === colorValue );
};
const getToggleAriaLabel = (
colorValue: CSSProperties[ 'borderColor' ],
colorObject: ColorObject | undefined,
style: CSSProperties[ 'borderStyle' ],
isStyleEnabled: boolean
) => {
if ( isStyleEnabled ) {
if ( colorObject ) {
const ariaLabelValue = getAriaLabelColorValue( colorObject.color );
return style
? sprintf(
// translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g.: "#f00:". %3$s: The current border style selection e.g. "solid".
'Border color and style picker. The currently selected color is called "%1$s" and has a value of "%2$s". The currently selected style is "%3$s".',
colorObject.name,
ariaLabelValue,
style
)
: sprintf(
// translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g.: "#f00:".
'Border color and style picker. The currently selected color is called "%1$s" and has a value of "%2$s".',
colorObject.name,
ariaLabelValue
);
}
if ( colorValue ) {
const ariaLabelValue = getAriaLabelColorValue( colorValue );
return style
? sprintf(
// translators: %1$s: The color's hex code e.g.: "#f00:". %2$s: The current border style selection e.g. "solid".
'Border color and style picker. The currently selected color has a value of "%1$s". The currently selected style is "%2$s".',
ariaLabelValue,
style
)
: sprintf(
// translators: %1$s: The color's hex code e.g: "#f00".
'Border color and style picker. The currently selected color has a value of "%1$s".',
ariaLabelValue
);
}
return __( 'Border color and style picker.' );
}
if ( colorObject ) {
return sprintf(
// translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g: "#f00".
'Border color picker. The currently selected color is called "%1$s" and has a value of "%2$s".',
colorObject.name,
getAriaLabelColorValue( colorObject.color )
);
}
if ( colorValue ) {
return sprintf(
// translators: %1$s: The color's hex code e.g: "#f00".
'Border color picker. The currently selected color has a value of "%1$s".',
getAriaLabelColorValue( colorValue )
);
}
return __( 'Border color picker.' );
};
const BorderControlDropdown = (
props: WordPressComponentProps< DropdownProps, 'div' >,
forwardedRef: React.ForwardedRef< any >
) => {
const {
__experimentalIsRenderedInSidebar,
border,
colors,
disableCustomColors,
enableAlpha,
enableStyle,
indicatorClassName,
indicatorWrapperClassName,
isStyleSettable,
onReset,
onColorChange,
onStyleChange,
popoverContentClassName,
popoverControlsClassName,
resetButtonClassName,
showDropdownHeader,
size,
__unstablePopoverProps,
...otherProps
} = useBorderControlDropdown( props );
const { color, style } = border || {};
const colorObject = getColorObject( color, colors );
const toggleAriaLabel = getToggleAriaLabel(
color,
colorObject,
style,
enableStyle
);
const showResetButton = color || ( style && style !== 'none' );
const dropdownPosition = __experimentalIsRenderedInSidebar
? 'bottom left'
: undefined;
const renderToggle: DropdownComponentProps[ 'renderToggle' ] = ( {
onToggle,
} ) => (
<Button
onClick={ onToggle }
variant="tertiary"
aria-label={ toggleAriaLabel }
tooltipPosition={ dropdownPosition }
label={ __( 'Border color and style picker' ) }
showTooltip={ true }
__next40pxDefaultSize={ size === '__unstable-large' ? true : false }
>
<span className={ indicatorWrapperClassName }>
<ColorIndicator
className={ indicatorClassName }
colorValue={ color }
/>
</span>
</Button>
);
const renderContent: DropdownComponentProps[ 'renderContent' ] = ( {
onClose,
} ) => (
<>
<DropdownContentWrapper paddingSize="medium">
<VStack className={ popoverControlsClassName } spacing={ 6 }>
{ showDropdownHeader ? (
<HStack>
<StyledLabel>{ __( 'Border color' ) }</StyledLabel>
<Button
size="small"
label={ __( 'Close border color' ) }
icon={ closeSmall }
onClick={ onClose }
/>
</HStack>
) : undefined }
<ColorPalette
className={ popoverContentClassName }
value={ color }
onChange={ onColorChange }
{ ...{ colors, disableCustomColors } }
__experimentalIsRenderedInSidebar={
__experimentalIsRenderedInSidebar
}
clearable={ false }
enableAlpha={ enableAlpha }
/>
{ enableStyle && isStyleSettable && (
<BorderControlStylePicker
label={ __( 'Style' ) }
value={ style }
onChange={ onStyleChange }
/>
) }
</VStack>
</DropdownContentWrapper>
{ showResetButton && (
<DropdownContentWrapper paddingSize="none">
<Button
className={ resetButtonClassName }
variant="tertiary"
onClick={ () => {
onReset();
onClose();
} }
>
{ __( 'Reset' ) }
</Button>
</DropdownContentWrapper>
) }
</>
);
return (
<Dropdown
renderToggle={ renderToggle }
renderContent={ renderContent }
popoverProps={ {
...__unstablePopoverProps,
} }
{ ...otherProps }
ref={ forwardedRef }
/>
);
};
const ConnectedBorderControlDropdown = contextConnect(
BorderControlDropdown,
'BorderControlDropdown'
);
export default ConnectedBorderControlDropdown;

View File

@@ -0,0 +1,101 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import { parseQuantityAndUnitFromRawValue } from '../../unit-control/utils';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
import type { DropdownProps } from '../types';
export function useBorderControlDropdown(
props: WordPressComponentProps< DropdownProps, 'div' >
) {
const {
border,
className,
colors = [],
enableAlpha = false,
enableStyle = true,
onChange,
previousStyleSelection,
size = 'default',
__experimentalIsRenderedInSidebar = false,
...otherProps
} = useContextSystem( props, 'BorderControlDropdown' );
const [ widthValue ] = parseQuantityAndUnitFromRawValue( border?.width );
const hasZeroWidth = widthValue === 0;
const onColorChange = ( color?: string ) => {
const style =
border?.style === 'none' ? previousStyleSelection : border?.style;
const width = hasZeroWidth && !! color ? '1px' : border?.width;
onChange( { color, style, width } );
};
const onStyleChange = ( style?: string ) => {
const width = hasZeroWidth && !! style ? '1px' : border?.width;
onChange( { ...border, style, width } );
};
const onReset = () => {
onChange( {
...border,
color: undefined,
style: undefined,
} );
};
// Generate class names.
const cx = useCx();
const classes = useMemo( () => {
return cx( styles.borderControlDropdown, className );
}, [ className, cx ] );
const indicatorClassName = useMemo( () => {
return cx( styles.borderColorIndicator );
}, [ cx ] );
const indicatorWrapperClassName = useMemo( () => {
return cx( styles.colorIndicatorWrapper( border, size ) );
}, [ border, cx, size ] );
const popoverControlsClassName = useMemo( () => {
return cx( styles.borderControlPopoverControls );
}, [ cx ] );
const popoverContentClassName = useMemo( () => {
return cx( styles.borderControlPopoverContent );
}, [ cx ] );
const resetButtonClassName = useMemo( () => {
return cx( styles.resetButton );
}, [ cx ] );
return {
...otherProps,
border,
className: classes,
colors,
enableAlpha,
enableStyle,
indicatorClassName,
indicatorWrapperClassName,
onColorChange,
onStyleChange,
onReset,
popoverContentClassName,
popoverControlsClassName,
resetButtonClassName,
size,
__experimentalIsRenderedInSidebar,
};
}

View File

@@ -0,0 +1 @@
export { default } from './component';

View File

@@ -0,0 +1,55 @@
/**
* WordPress dependencies
*/
import { lineDashed, lineDotted, lineSolid } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { contextConnect } from '../../context';
import type { StylePickerProps } from '../types';
import {
ToggleGroupControl,
ToggleGroupControlOptionIcon,
} from '../../toggle-group-control';
const BORDER_STYLES = [
{ label: __( 'Solid' ), icon: lineSolid, value: 'solid' },
{ label: __( 'Dashed' ), icon: lineDashed, value: 'dashed' },
{ label: __( 'Dotted' ), icon: lineDotted, value: 'dotted' },
];
function UnconnectedBorderControlStylePicker(
{ onChange, ...restProps }: StylePickerProps,
forwardedRef: React.ForwardedRef< any >
) {
return (
<ToggleGroupControl
__nextHasNoMarginBottom
__next40pxDefaultSize
ref={ forwardedRef }
isDeselectable
onChange={ ( value ) => {
onChange?.( value as string | undefined );
} }
{ ...restProps }
>
{ BORDER_STYLES.map( ( borderStyle ) => (
<ToggleGroupControlOptionIcon
key={ borderStyle.value }
value={ borderStyle.value }
icon={ borderStyle.icon }
label={ borderStyle.label }
/>
) ) }
</ToggleGroupControl>
);
}
const BorderControlStylePicker = contextConnect(
UnconnectedBorderControlStylePicker,
'BorderControlStylePicker'
);
export default BorderControlStylePicker;

View File

@@ -0,0 +1 @@
export { default } from './component';

View File

@@ -0,0 +1,175 @@
# BorderControl
<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>
<br />
This component provides control over a border's color, style, and width.
## Development guidelines
The `BorderControl` brings together internal sub-components which allow users to
set the various properties of a border. The first sub-component, a
`BorderDropdown` contains options representing border color and style. The
border width is controlled via a `UnitControl` and an optional `RangeControl`.
Border radius is not covered by this control as it may be desired separate to
color, style, and width. For example, the border radius may be absorbed under
a "shape" abstraction.
## Usage
```jsx
import { useState } from 'react';
import { __experimentalBorderControl as BorderControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const colors = [
{ name: 'Blue 20', color: '#72aee6' },
// ...
];
const MyBorderControl = () => {
const [ border, setBorder ] = useState();
return (
<BorderControl
colors={ colors }
label={ __( 'Border' ) }
onChange={ setBorder }
value={ border }
/>
);
};
```
If you're using this component outside the editor, you can
[ensure `Tooltip` positioning](/packages/components/README.md#popovers-and-tooltips)
for the `BorderControl`'s color and style options, by rendering your
`BorderControl` with a `Popover.Slot` further up the element tree and within a
`SlotFillProvider` overall.
## Props
### `colors`: `( PaletteObject | ColorObject )[]`
An array of color definitions. This may also be a multi-dimensional array where
colors are organized by multiple origins.
Each color may be an object containing a `name` and `color` value.
- Required: No
- Default: `[]`
### `disableCustomColors`: `boolean`
This toggles the ability to choose custom colors.
- Required: No
### `disableUnits`: `boolean`
This controls whether unit selection should be disabled.
- Required: No
### `enableAlpha`: `boolean`
This controls whether the alpha channel will be offered when selecting
custom colors.
- Required: No
- Default: `false`
### `enableStyle`: `boolean`
This controls whether to support border style selection.
- Required: No
- Default: `true`
### `hideLabelFromVision`: `boolean`
Provides control over whether the label will only be visible to screen readers.
- Required: No
### `isCompact`: `boolean`
This flags the `BorderControl` to render with a more compact appearance. It
restricts the width of the control and prevents it from expanding to take up
additional space.
- Required: No
### `label`: `string`
If provided, a label will be generated using this as the content.
_Whether it is visible only to screen readers is controlled via
`hideLabelFromVision`._
- Required: No
### `onChange`: `( value?: Object ) => void`
A callback function invoked when the border value is changed via an interaction
that selects or clears, border color, style, or width.
_Note: the value may be `undefined` if a user clears all border properties._
- Required: Yes
### `shouldSanitizeBorder`: `boolean`
If opted into, sanitizing the border means that if no width or color have been
selected, the border style is also cleared and `undefined` is returned as the
new border value.
- Required: No
- Default: true
### `showDropdownHeader`: `boolean`
Whether or not to render a header for the border color and style picker
dropdown. The header includes a label for the color picker and a close button.
- Required: No
### `size`: `string`
Size of the control.
- Required: No
- Default: `default`
- Allowed values: `default`, `__unstable-large`
### `value`: `Object`
An object representing a border or `undefined`. Used to set the current border
configuration for this component.
Example:
```js
{
color: '#72aee6',
style: 'solid',
width: '2px,
}
```
- Required: No
### `width`: `CSSProperties[ 'width' ]`
Controls the visual width of the `BorderControl`. It has no effect if the
`isCompact` prop is set to `true`.
- Required: No
### `withSlider`: `boolean`
Flags whether this `BorderControl` should also render a `RangeControl` for
additional control over a border's width.
- Required: No

View File

@@ -0,0 +1,165 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import BorderControlDropdown from '../border-control-dropdown';
import UnitControl from '../../unit-control';
import RangeControl from '../../range-control';
import { HStack } from '../../h-stack';
import { StyledLabel } from '../../base-control/styles/base-control-styles';
import { View } from '../../view';
import { VisuallyHidden } from '../../visually-hidden';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import { useBorderControl } from './hook';
import type { BorderControlProps, LabelProps } from '../types';
const BorderLabel = ( props: LabelProps ) => {
const { label, hideLabelFromVision } = props;
if ( ! label ) {
return null;
}
return hideLabelFromVision ? (
<VisuallyHidden as="legend">{ label }</VisuallyHidden>
) : (
<StyledLabel as="legend">{ label }</StyledLabel>
);
};
const UnconnectedBorderControl = (
props: WordPressComponentProps< BorderControlProps, 'div', false >,
forwardedRef: React.ForwardedRef< any >
) => {
const {
__next40pxDefaultSize = false,
colors,
disableCustomColors,
disableUnits,
enableAlpha,
enableStyle,
hideLabelFromVision,
innerWrapperClassName,
inputWidth,
isStyleSettable,
label,
onBorderChange,
onSliderChange,
onWidthChange,
placeholder,
__unstablePopoverProps,
previousStyleSelection,
showDropdownHeader,
size,
sliderClassName,
value: border,
widthUnit,
widthValue,
withSlider,
__experimentalIsRenderedInSidebar,
...otherProps
} = useBorderControl( props );
return (
<View as="fieldset" { ...otherProps } ref={ forwardedRef }>
<BorderLabel
label={ label }
hideLabelFromVision={ hideLabelFromVision }
/>
<HStack spacing={ 4 } className={ innerWrapperClassName }>
<UnitControl
prefix={
<BorderControlDropdown
border={ border }
colors={ colors }
__unstablePopoverProps={ __unstablePopoverProps }
disableCustomColors={ disableCustomColors }
enableAlpha={ enableAlpha }
enableStyle={ enableStyle }
isStyleSettable={ isStyleSettable }
onChange={ onBorderChange }
previousStyleSelection={ previousStyleSelection }
showDropdownHeader={ showDropdownHeader }
__experimentalIsRenderedInSidebar={
__experimentalIsRenderedInSidebar
}
size={ size }
/>
}
label={ __( 'Border width' ) }
hideLabelFromVision
min={ 0 }
onChange={ onWidthChange }
value={ border?.width || '' }
placeholder={ placeholder }
disableUnits={ disableUnits }
__unstableInputWidth={ inputWidth }
size={ size }
/>
{ withSlider && (
<RangeControl
__nextHasNoMarginBottom
label={ __( 'Border width' ) }
hideLabelFromVision
className={ sliderClassName }
initialPosition={ 0 }
max={ 100 }
min={ 0 }
onChange={ onSliderChange }
step={ [ 'px', '%' ].includes( widthUnit ) ? 1 : 0.1 }
value={ widthValue || undefined }
withInputField={ false }
__next40pxDefaultSize={ __next40pxDefaultSize }
/>
) }
</HStack>
</View>
);
};
/**
* The `BorderControl` brings together internal sub-components which allow users to
* set the various properties of a border. The first sub-component, a
* `BorderDropdown` contains options representing border color and style. The
* border width is controlled via a `UnitControl` and an optional `RangeControl`.
*
* Border radius is not covered by this control as it may be desired separate to
* color, style, and width. For example, the border radius may be absorbed under
* a "shape" abstraction.
*
* ```jsx
* import { __experimentalBorderControl as BorderControl } from '@wordpress/components';
* import { __ } from '@wordpress/i18n';
*
* const colors = [
* { name: 'Blue 20', color: '#72aee6' },
* // ...
* ];
*
* const MyBorderControl = () => {
* const [ border, setBorder ] = useState();
* const onChange = ( newBorder ) => setBorder( newBorder );
*
* return (
* <BorderControl
* colors={ colors }
* label={ __( 'Border' ) }
* onChange={ onChange }
* value={ border }
* />
* );
* };
* ```
*/
export const BorderControl = contextConnect(
UnconnectedBorderControl,
'BorderControl'
);
export default BorderControl;

View File

@@ -0,0 +1,167 @@
/**
* WordPress dependencies
*/
import { useCallback, useMemo, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import { parseQuantityAndUnitFromRawValue } from '../../unit-control/utils';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
import type { Border, BorderControlProps } from '../types';
// If either width or color are defined, the border is considered valid
// and a border style can be set as well.
const isValidBorder = ( border?: Border ) => {
const hasWidth = border?.width !== undefined && border.width !== '';
const hasColor = border?.color !== undefined;
return hasWidth || hasColor;
};
export function useBorderControl(
props: WordPressComponentProps< BorderControlProps, 'div' >
) {
const {
className,
colors = [],
isCompact,
onChange,
enableAlpha = true,
enableStyle = true,
shouldSanitizeBorder = true,
size = 'default',
value: border,
width,
__experimentalIsRenderedInSidebar = false,
__next40pxDefaultSize,
...otherProps
} = useContextSystem( props, 'BorderControl' );
const computedSize =
size === 'default' && __next40pxDefaultSize ? '__unstable-large' : size;
const [ widthValue, originalWidthUnit ] = parseQuantityAndUnitFromRawValue(
border?.width
);
const widthUnit = originalWidthUnit || 'px';
const hadPreviousZeroWidth = widthValue === 0;
const [ colorSelection, setColorSelection ] = useState< string >();
const [ styleSelection, setStyleSelection ] = useState< string >();
const isStyleSettable = shouldSanitizeBorder
? isValidBorder( border )
: true;
const onBorderChange = useCallback(
( newBorder?: Border ) => {
if ( shouldSanitizeBorder && ! isValidBorder( newBorder ) ) {
onChange( undefined );
return;
}
onChange( newBorder );
},
[ onChange, shouldSanitizeBorder ]
);
const onWidthChange = useCallback(
( newWidth?: string ) => {
const newWidthValue = newWidth === '' ? undefined : newWidth;
const [ parsedValue ] =
parseQuantityAndUnitFromRawValue( newWidth );
const hasZeroWidth = parsedValue === 0;
const updatedBorder = { ...border, width: newWidthValue };
// Setting the border width explicitly to zero will also set the
// border style to `none` and clear the border color.
if ( hasZeroWidth && ! hadPreviousZeroWidth ) {
// Before clearing the color and style selections, keep track of
// the current selections so they can be restored when the width
// changes to a non-zero value.
setColorSelection( border?.color );
setStyleSelection( border?.style );
// Clear the color and style border properties.
updatedBorder.color = undefined;
updatedBorder.style = 'none';
}
// Selection has changed from zero border width to non-zero width.
if ( ! hasZeroWidth && hadPreviousZeroWidth ) {
// Restore previous border color and style selections if width
// is now not zero.
if ( updatedBorder.color === undefined ) {
updatedBorder.color = colorSelection;
}
if ( updatedBorder.style === 'none' ) {
updatedBorder.style = styleSelection;
}
}
onBorderChange( updatedBorder );
},
[
border,
hadPreviousZeroWidth,
colorSelection,
styleSelection,
onBorderChange,
]
);
const onSliderChange = useCallback(
( value?: number ) => {
onWidthChange( `${ value }${ widthUnit }` );
},
[ onWidthChange, widthUnit ]
);
// Generate class names.
const cx = useCx();
const classes = useMemo( () => {
return cx( styles.borderControl, className );
}, [ className, cx ] );
let wrapperWidth = width;
if ( isCompact ) {
// Widths below represent the minimum usable width for compact controls.
// Taller controls contain greater internal padding, thus greater width.
wrapperWidth = size === '__unstable-large' ? '116px' : '90px';
}
const innerWrapperClassName = useMemo( () => {
const widthStyle = !! wrapperWidth && styles.wrapperWidth;
const heightStyle = styles.wrapperHeight( computedSize );
return cx( styles.innerWrapper(), widthStyle, heightStyle );
}, [ wrapperWidth, cx, computedSize ] );
const sliderClassName = useMemo( () => {
return cx( styles.borderSlider() );
}, [ cx ] );
return {
...otherProps,
className: classes,
colors,
enableAlpha,
enableStyle,
innerWrapperClassName,
inputWidth: wrapperWidth,
isStyleSettable,
onBorderChange,
onSliderChange,
onWidthChange,
previousStyleSelection: styleSelection,
sliderClassName,
value: border,
widthUnit,
widthValue,
size: computedSize,
__experimentalIsRenderedInSidebar,
__next40pxDefaultSize,
};
}

View File

@@ -0,0 +1,2 @@
export { default as BorderControl } from './component';
export { useBorderControl } from './hook';

View File

@@ -0,0 +1,2 @@
export { default as BorderControl } from './border-control/component';
export { useBorderControl } from './border-control/hook';

View File

@@ -0,0 +1,144 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
import type { ComponentProps } from 'react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { BorderControl } from '..';
import type { Border } from '../types';
const meta: Meta< typeof BorderControl > = {
title: 'Components (Experimental)/BorderControl',
component: BorderControl,
argTypes: {
onChange: {
action: 'onChange',
},
width: { control: { type: 'text' } },
value: { control: { type: null } },
},
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
// Available border colors.
const colors = [
{ name: 'Blue 20', color: '#72aee6' },
{ name: 'Blue 40', color: '#3582c4' },
{ name: 'Red 40', color: '#e65054' },
{ name: 'Red 70', color: '#8a2424' },
{ name: 'Yellow 10', color: '#f2d675' },
{ name: 'Yellow 40', color: '#bd8600' },
];
// Multiple origin colors.
const multipleOriginColors = [
{
name: 'Default',
colors: [
{ name: 'Gray 20', color: '#a7aaad' },
{ name: 'Gray 70', color: '#3c434a' },
],
},
{
name: 'Theme',
colors: [
{ name: 'Blue 20', color: '#72aee6' },
{ name: 'Blue 40', color: '#3582c4' },
{ name: 'Blue 70', color: '#0a4b78' },
],
},
{
name: 'User',
colors: [
{ name: 'Green', color: '#00a32a' },
{ name: 'Yellow', color: '#f2d675' },
],
},
];
const Template: StoryFn< typeof BorderControl > = ( {
onChange,
...props
} ) => {
const [ border, setBorder ] = useState< Border >();
const onChangeMerged: ComponentProps<
typeof BorderControl
>[ 'onChange' ] = ( newBorder ) => {
setBorder( newBorder );
onChange( newBorder );
};
return (
<BorderControl
onChange={ onChangeMerged }
value={ border }
{ ...props }
/>
);
};
export const Default = Template.bind( {} );
Default.args = {
colors,
label: 'Border',
};
/**
* Render a slider beside the control.
*/
export const WithSlider = Template.bind( {} );
WithSlider.args = {
...Default.args,
withSlider: true,
};
/**
* When rendering with a slider, the `width` prop is useful to customize the width of the number input.
*/
export const WithSliderCustomWidth = Template.bind( {} );
WithSliderCustomWidth.args = {
...Default.args,
withSlider: true,
width: '150px',
};
WithSliderCustomWidth.storyName = 'With Slider (Custom Width)';
/**
* Restrict the width of the control and prevent it from expanding to take up additional space.
* When `true`, the `width` prop will be ignored.
*/
export const IsCompact = Template.bind( {} );
IsCompact.args = {
...Default.args,
isCompact: true,
};
/**
* The `colors` object can contain multiple origins.
*/
export const WithMultipleOrigins = Template.bind( {} );
WithMultipleOrigins.args = {
...Default.args,
colors: multipleOriginColors,
};
/**
* Allow the alpha channel to be edited on each color.
*/
export const WithAlphaEnabled = Template.bind( {} );
WithAlphaEnabled.args = {
...Default.args,
enableAlpha: true,
};

View File

@@ -0,0 +1,171 @@
/**
* External dependencies
*/
import { css } from '@emotion/react';
/**
* Internal dependencies
*/
import { COLORS, CONFIG, boxSizingReset, rtl } from '../utils';
import { space } from '../utils/space';
import { StyledLabel } from '../base-control/styles/base-control-styles';
import {
ValueInput as UnitControlWrapper,
UnitSelect,
} from '../unit-control/styles/unit-control-styles';
import type { Border } from './types';
const labelStyles = css`
font-weight: 500;
`;
const focusBoxShadow = css`
box-shadow: inset ${ CONFIG.controlBoxShadowFocus };
`;
export const borderControl = css`
border: 0;
padding: 0;
margin: 0;
${ boxSizingReset }
`;
export const innerWrapper = () => css`
${ UnitControlWrapper } {
flex: 1 1 40%;
}
&& ${ UnitSelect } {
/* Prevent unit select forcing min height larger than its UnitControl */
min-height: 0;
}
`;
/*
* This style is only applied to the UnitControl wrapper when the border width
* field should be a set width. Omitting this allows the UnitControl &
* RangeControl to share the available width in a 40/60 split respectively.
*/
export const wrapperWidth = css`
${ UnitControlWrapper } {
/* Force the UnitControl's set width. */
flex: 0 0 auto;
}
`;
export const wrapperHeight = ( size?: 'default' | '__unstable-large' ) => {
return css`
height: ${ size === '__unstable-large' ? '40px' : '30px' };
`;
};
export const borderControlDropdown = css`
background: #fff;
&& > button {
aspect-ratio: 1;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
${ rtl(
{ borderRadius: `2px 0 0 2px` },
{ borderRadius: `0 2px 2px 0` }
)() }
border: ${ CONFIG.borderWidth } solid ${ COLORS.ui.border };
&:focus,
&:hover:not( :disabled ) {
${ focusBoxShadow }
border-color: ${ COLORS.ui.borderFocus };
z-index: 1;
position: relative;
}
}
`;
export const colorIndicatorBorder = ( border?: Border ) => {
const { color, style } = border || {};
const fallbackColor =
!! style && style !== 'none' ? COLORS.gray[ 300 ] : undefined;
return css`
border-style: ${ style === 'none' ? 'solid' : style };
border-color: ${ color || fallbackColor };
`;
};
export const colorIndicatorWrapper = (
border?: Border,
size?: 'default' | '__unstable-large'
) => {
const { style } = border || {};
return css`
border-radius: 9999px;
border: 2px solid transparent;
${ style ? colorIndicatorBorder( border ) : undefined }
width: ${ size === '__unstable-large' ? '24px' : '22px' };
height: ${ size === '__unstable-large' ? '24px' : '22px' };
padding: ${ size === '__unstable-large' ? '2px' : '1px' };
/*
* ColorIndicator
*
* The transparent colors used here ensure visibility of the indicator
* over the active state of the border control dropdown's toggle button.
*/
& > span {
height: ${ space( 4 ) };
width: ${ space( 4 ) };
background: linear-gradient(
-45deg,
transparent 48%,
rgb( 0 0 0 / 20% ) 48%,
rgb( 0 0 0 / 20% ) 52%,
transparent 52%
);
}
`;
};
// Must equal $color-palette-circle-size from:
// @wordpress/components/src/circular-option-picker/style.scss
const swatchSize = 28;
const swatchGap = 12;
export const borderControlPopoverControls = css`
width: ${ swatchSize * 6 + swatchGap * 5 }px;
> div:first-of-type > ${ StyledLabel } {
margin-bottom: 0;
${ labelStyles }
}
&& ${ StyledLabel } + button:not( .has-text ) {
min-width: 24px;
padding: 0;
}
`;
export const borderControlPopoverContent = css``;
export const borderColorIndicator = css``;
export const resetButton = css`
justify-content: center;
width: 100%;
/* Override button component styling */
&& {
border-top: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 400 ] };
border-top-left-radius: 0;
border-top-right-radius: 0;
height: 40px;
}
`;
export const borderSlider = () => css`
flex: 1 1 60%;
${ rtl( { marginRight: space( 3 ) } )() }
`;

View File

@@ -0,0 +1,501 @@
/**
* External dependencies
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import { BorderControl } from '../';
const colors = [
{ name: 'Gray', color: '#f6f7f7' },
{ name: 'Blue', color: '#72aee6' },
{ name: 'Red', color: '#e65054' },
{ name: 'Green', color: '#00a32a' },
{ name: 'Yellow', color: '#bd8600' },
];
const defaultBorder = {
color: '#72aee6',
style: 'solid',
width: '1px',
};
function createProps( customProps ) {
const props = {
colors,
label: 'Border',
onChange: jest.fn().mockImplementation( ( newValue ) => {
props.value = newValue;
} ),
value: defaultBorder,
...customProps,
};
return props;
}
const toggleLabelRegex = /Border color( and style)* picker/;
const openPopover = async ( user ) => {
const toggleButton = screen.getByLabelText( toggleLabelRegex );
await user.click( toggleButton );
// Wait for color picker popover to fully appear
const pickerButton = screen.getByRole( 'button', {
name: /^Custom color picker/,
} );
await waitFor( () => expect( pickerButton ).toBePositionedPopover() );
};
const getButton = ( name ) => {
return screen.getByRole( 'button', { name } );
};
const getColorOption = ( color ) => {
return screen.getByRole( 'option', { name: `Color: ${ color }` } );
};
const queryButton = ( name ) => {
return screen.queryByRole( 'button', { name } );
};
const getSliderInput = () => {
return screen.getByRole( 'slider', { name: 'Border width' } );
};
const getWidthInput = () => {
return screen.getByRole( 'spinbutton', { name: 'Border width' } );
};
describe( 'BorderControl', () => {
describe( 'basic rendering', () => {
it( 'should render standard border control', () => {
const props = createProps();
render( <BorderControl { ...props } /> );
const label = screen.getByText( props.label );
const colorButton = screen.getByLabelText( toggleLabelRegex );
const widthInput = getWidthInput();
const unitSelect = screen.getByRole( 'combobox', {
name: 'Select unit',
} );
const slider = screen.queryByRole( 'slider', {
name: 'Border width',
} );
expect( label ).toBeInTheDocument();
expect( colorButton ).toBeInTheDocument();
expect( widthInput ).toBeInTheDocument();
expect( unitSelect ).toBeInTheDocument();
expect( slider ).not.toBeInTheDocument();
} );
it( 'should hide label', () => {
const props = createProps( { hideLabelFromVision: true } );
render( <BorderControl { ...props } /> );
const label = screen.getByText( props.label );
// As visually hidden labels are still included in the document
// and do not have `display: none` styling, we can't rely on
// `.toBeInTheDocument()` or `.toBeVisible()` assertions.
expect( label ).toHaveAttribute(
'data-wp-component',
'VisuallyHidden'
);
} );
it( 'should render with slider', () => {
const props = createProps( { withSlider: true } );
render( <BorderControl { ...props } /> );
const slider = getSliderInput();
expect( slider ).toBeInTheDocument();
} );
it( 'should render placeholder in UnitControl', () => {
const props = createProps( { placeholder: 'Mixed' } );
render( <BorderControl { ...props } /> );
const widthInput = getWidthInput();
expect( widthInput ).toHaveAttribute( 'placeholder', 'Mixed' );
} );
it( 'should render color and style popover', async () => {
const user = userEvent.setup();
const props = createProps();
render( <BorderControl { ...props } /> );
await openPopover( user );
const customColorPicker = getButton( /Custom color picker/ );
const colorSwatchButtons = screen.getAllByRole( 'option', {
name: /^Color:/,
} );
const styleLabel = screen.getByText( 'Style' );
const solidButton = getButton( 'Solid' );
const dashedButton = getButton( 'Dashed' );
const dottedButton = getButton( 'Dotted' );
const resetButton = getButton( 'Reset' );
expect( customColorPicker ).toBeInTheDocument();
expect( colorSwatchButtons.length ).toEqual( colors.length );
expect( styleLabel ).toBeInTheDocument();
expect( solidButton ).toBeInTheDocument();
expect( dashedButton ).toBeInTheDocument();
expect( dottedButton ).toBeInTheDocument();
expect( resetButton ).toBeInTheDocument();
} );
it( 'should render color and style popover header', async () => {
const user = userEvent.setup();
const props = createProps( { showDropdownHeader: true } );
render( <BorderControl { ...props } /> );
await openPopover( user );
const headerLabel = screen.getByText( 'Border color' );
const closeButton = getButton( 'Close border color' );
expect( headerLabel ).toBeInTheDocument();
expect( closeButton ).toBeInTheDocument();
} );
it( 'should not render style options when opted out of', async () => {
const user = userEvent.setup();
const props = createProps( { enableStyle: false } );
render( <BorderControl { ...props } /> );
await openPopover( user );
const styleLabel = screen.queryByText( 'Style' );
const solidButton = queryButton( 'Solid' );
const dashedButton = queryButton( 'Dashed' );
const dottedButton = queryButton( 'Dotted' );
expect( styleLabel ).not.toBeInTheDocument();
expect( solidButton ).not.toBeInTheDocument();
expect( dashedButton ).not.toBeInTheDocument();
expect( dottedButton ).not.toBeInTheDocument();
} );
} );
describe( 'color and style picker aria labels', () => {
describe( 'with style selection enabled', () => {
it( 'should include both color and style in label', () => {
const props = createProps( { value: undefined } );
render( <BorderControl { ...props } /> );
expect(
screen.getByLabelText( 'Border color and style picker.' )
).toBeInTheDocument();
} );
it( 'should correctly describe named color selection', () => {
const props = createProps( { value: { color: '#72aee6' } } );
render( <BorderControl { ...props } /> );
expect(
screen.getByLabelText(
'Border color and style picker. The currently selected color is called "Blue" and has a value of "#72aee6".'
)
).toBeInTheDocument();
} );
it( 'should correctly describe custom color selection', () => {
const props = createProps( { value: { color: '#4b1d80' } } );
render( <BorderControl { ...props } /> );
expect(
screen.getByLabelText(
'Border color and style picker. The currently selected color has a value of "#4b1d80".'
)
).toBeInTheDocument();
} );
it( 'should correctly describe named color and style selections', () => {
const props = createProps( {
value: { color: '#72aee6', style: 'dotted' },
} );
render( <BorderControl { ...props } /> );
expect(
screen.getByLabelText(
'Border color and style picker. The currently selected color is called "Blue" and has a value of "#72aee6". The currently selected style is "dotted".'
)
).toBeInTheDocument();
} );
it( 'should correctly describe custom color and style selections', () => {
const props = createProps( {
value: { color: '#4b1d80', style: 'dashed' },
} );
render( <BorderControl { ...props } /> );
expect(
screen.getByLabelText(
'Border color and style picker. The currently selected color has a value of "#4b1d80". The currently selected style is "dashed".'
)
).toBeInTheDocument();
} );
} );
describe( 'with style selection disabled', () => {
it( 'should only include color in the label', () => {
const props = createProps( {
value: undefined,
enableStyle: false,
} );
render( <BorderControl { ...props } /> );
expect(
screen.getByLabelText( 'Border color picker.' )
).toBeInTheDocument();
} );
it( 'should correctly describe named color selection', () => {
const props = createProps( {
value: { color: '#72aee6' },
enableStyle: false,
} );
render( <BorderControl { ...props } /> );
expect(
screen.getByLabelText(
'Border color picker. The currently selected color is called "Blue" and has a value of "#72aee6".'
)
).toBeInTheDocument();
} );
it( 'should correctly describe custom color selection', () => {
const props = createProps( {
value: { color: '#4b1d80' },
enableStyle: false,
} );
render( <BorderControl { ...props } /> );
expect(
screen.getByLabelText(
'Border color picker. The currently selected color has a value of "#4b1d80".'
)
).toBeInTheDocument();
} );
} );
} );
describe( 'onChange handling', () => {
it( 'should update width with slider value', () => {
const props = createProps( { withSlider: true } );
const { rerender } = render( <BorderControl { ...props } /> );
const slider = getSliderInput();
// As per [1], it is not currently possible to reasonably
// replicate this interaction using `userEvent`, so leaving
// `fireEvent` in place to cover it.
// [1]: https://github.com/testing-library/user-event/issues/871
fireEvent.change( slider, { target: { value: '5' } } );
expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
...defaultBorder,
width: '5px',
} );
rerender( <BorderControl { ...props } /> );
const widthInput = getWidthInput();
expect( widthInput.value ).toEqual( '5' );
} );
it( 'should update color selection', async () => {
const user = userEvent.setup();
const props = createProps();
render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getColorOption( 'Green' ) );
expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
...defaultBorder,
color: '#00a32a',
} );
} );
it( 'should clear color selection when toggling swatch off', async () => {
const user = userEvent.setup();
const props = createProps();
render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getColorOption( 'Blue' ) );
expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
...defaultBorder,
color: undefined,
} );
} );
it( 'should update style selection', async () => {
const user = userEvent.setup();
const props = createProps();
render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getButton( 'Dashed' ) );
expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
...defaultBorder,
style: 'dashed',
} );
} );
it( 'should take no action when color and style popover is closed', async () => {
const user = userEvent.setup();
const props = createProps( { showDropdownHeader: true } );
render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getButton( 'Close border color' ) );
expect( props.onChange ).not.toHaveBeenCalled();
} );
it( 'should reset color and style only when popover reset button clicked', async () => {
const user = userEvent.setup();
const props = createProps();
render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getButton( 'Reset' ) );
expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
color: undefined,
style: undefined,
width: defaultBorder.width,
} );
} );
it( 'should sanitize border when width and color are undefined', async () => {
const user = userEvent.setup();
const props = createProps();
const { rerender } = render( <BorderControl { ...props } /> );
await user.clear( getWidthInput() );
rerender( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getColorOption( 'Blue' ) );
expect( props.onChange ).toHaveBeenCalledWith( undefined );
} );
it( 'should not sanitize border when requested', async () => {
const user = userEvent.setup();
const props = createProps( {
shouldSanitizeBorder: false,
} );
const { rerender } = render( <BorderControl { ...props } /> );
await user.clear( getWidthInput() );
rerender( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getColorOption( 'Blue' ) );
expect( props.onChange ).toHaveBeenNthCalledWith( 2, {
color: undefined,
style: defaultBorder.style,
width: undefined,
} );
} );
it( 'should clear color and set style to `none` when setting zero width', async () => {
const user = userEvent.setup();
const props = createProps();
render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getColorOption( 'Green' ) );
await user.click( getButton( 'Dotted' ) );
await user.type( getWidthInput(), '0', {
initialSelectionStart: 0,
initialSelectionEnd: 1,
} );
expect( props.onChange ).toHaveBeenNthCalledWith( 3, {
color: undefined,
style: 'none',
width: '0px',
} );
} );
it( 'should reselect color and style selections when changing to non-zero width', async () => {
const user = userEvent.setup();
const props = createProps();
const { rerender } = render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getColorOption( 'Green' ) );
rerender( <BorderControl { ...props } /> );
await user.click( getButton( 'Dotted' ) );
rerender( <BorderControl { ...props } /> );
const widthInput = getWidthInput();
await user.type( widthInput, '0', {
initialSelectionStart: 0,
initialSelectionEnd: 1,
} );
await user.type( widthInput, '5', {
initialSelectionStart: 0,
initialSelectionEnd: 1,
} );
expect( props.onChange ).toHaveBeenNthCalledWith( 4, {
color: '#00a32a',
style: 'dotted',
width: '5px',
} );
} );
it( 'should set a non-zero width when applying color to zero width border', async () => {
const user = userEvent.setup();
const props = createProps( { value: undefined } );
const { rerender } = render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getColorOption( 'Yellow' ) );
expect( props.onChange ).toHaveBeenCalledWith( {
color: '#bd8600',
style: undefined,
width: undefined,
} );
await user.type( getWidthInput(), '0' );
rerender( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getColorOption( 'Green' ) );
expect( props.onChange ).toHaveBeenCalledWith( {
color: '#00a32a',
style: undefined,
width: '1px',
} );
} );
it( 'should set a non-zero width when applying style to zero width border', async () => {
const user = userEvent.setup();
const props = createProps( {
value: undefined,
shouldSanitizeBorder: false,
} );
const { rerender } = render( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getButton( 'Dashed' ) );
expect( props.onChange ).toHaveBeenCalledWith( {
color: undefined,
style: 'dashed',
width: undefined,
} );
await user.type( getWidthInput(), '0' );
rerender( <BorderControl { ...props } /> );
await openPopover( user );
await user.click( getButton( 'Dotted' ) );
expect( props.onChange ).toHaveBeenCalledWith( {
color: undefined,
style: 'dotted',
width: '1px',
} );
} );
} );
} );

View File

@@ -0,0 +1,159 @@
/**
* External dependencies
*/
import type { CSSProperties } from 'react';
/**
* Internal dependencies
*/
import type { ColorPaletteProps } from '../color-palette/types';
import type { PopoverProps } from '../popover/types';
import type { ToggleGroupControlProps } from '../toggle-group-control/types';
export type Border = {
color?: CSSProperties[ 'borderColor' ];
style?: CSSProperties[ 'borderStyle' ];
width?: CSSProperties[ 'borderWidth' ];
};
export type ColorProps = Pick<
ColorPaletteProps,
'colors' | 'enableAlpha' | '__experimentalIsRenderedInSidebar'
> & {
/**
* This toggles the ability to choose custom colors.
*/
disableCustomColors?: boolean;
};
export type LabelProps = {
/**
* Provides control over whether the label will only be visible to
* screen readers.
*/
hideLabelFromVision?: boolean;
/**
* If provided, a label will be generated using this as the content.
*/
label?: string;
};
export type BorderControlProps = ColorProps &
LabelProps & {
/**
* This controls whether unit selection should be disabled.
*/
disableUnits?: boolean;
/**
* This controls whether to support border style selection.
*
* @default true
*/
enableStyle?: boolean;
/**
* This flags the `BorderControl` to render with a more compact
* appearance. It restricts the width of the control and prevents it
* from expanding to take up additional space.
*/
isCompact?: boolean;
/**
* A callback function invoked when the border value is changed via an
* interaction that selects or clears, border color, style, or width.
*/
onChange: ( value?: Border ) => void;
/**
* An internal prop used to control the visibility of the dropdown.
*/
__unstablePopoverProps?: Omit< PopoverProps, 'children' >;
/**
* If opted into, sanitizing the border means that if no width or color
* have been selected, the border style is also cleared and `undefined`
* is returned as the new border value.
*
* @default true
*/
shouldSanitizeBorder?: boolean;
/**
* Whether or not to show the header for the border color and style
* picker dropdown. The header includes a label for the color picker
* and a close button.
*/
showDropdownHeader?: boolean;
/**
* Size of the control.
*
* @default 'default'
*/
size?: 'default' | '__unstable-large';
/**
* An object representing a border or `undefined`. Used to set the
* current border configuration for this component.
*/
value?: Border;
/**
* Controls the visual width of the `BorderControl`. It has no effect if
* the `isCompact` prop is set to `true`.
*/
width?: CSSProperties[ 'width' ];
/**
* Flags whether this `BorderControl` should also render a
* `RangeControl` for additional control over a border's width.
*/
withSlider?: boolean;
/**
* Start opting into the larger default height that will become the default size in a future version.
*
* @default false
*/
__next40pxDefaultSize?: boolean;
};
export type DropdownProps = ColorProps &
Pick< BorderControlProps, 'enableStyle' | 'size' > & {
/**
* An object representing a border or `undefined`. This component will
* extract the border color and style selections from this object to use as
* values for its popover controls.
*/
border?: Border;
/**
* Whether a border style can be set, based on the border sanitization settings.
*/
isStyleSettable: boolean;
/**
* An internal prop used to control the visibility of the dropdown.
*/
__unstablePopoverProps?: Omit< PopoverProps, 'children' >;
/**
* A callback invoked when the border color or style selections change.
*/
onChange: ( newBorder?: Border ) => void;
/**
* Any previous style selection made by the user. This can be used to
* reapply that previous selection when, for example, a zero border width is
* to a non-zero value.
*/
previousStyleSelection?: string;
/**
* Whether or not to render a header for the border color and style picker
* dropdown. The header includes a label for the color picker and a
* close button.
*/
showDropdownHeader?: boolean;
};
export type StylePickerProps = Omit<
ToggleGroupControlProps,
'value' | 'onChange' | 'children'
> & {
/**
* A callback function invoked when a border style is selected or cleared.
*/
onChange: ( style?: string ) => void;
/**
* The currently selected border style if there is one. Styles available via
* this control are `solid`, `dashed` & `dotted`, however the possibility
* to store other valid CSS values is maintained e.g. `none`, `inherit` etc.
*/
value?: string;
};

View File

@@ -0,0 +1,102 @@
# BoxControl
<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>
BoxControl components let users set values for Top, Right, Bottom, and Left. This can be used as an input control for values like `padding` or `margin`.
## Usage
```jsx
import { useState } from 'react';
import { __experimentalBoxControl as BoxControl } from '@wordpress/components';
const Example = () => {
const [ values, setValues ] = useState( {
top: '50px',
left: '10%',
right: '10%',
bottom: '50px',
} );
return (
<BoxControl
values={ values }
onChange={ ( nextValues ) => setValues( nextValues ) }
/>
);
};
```
## Props
### `allowReset`: `boolean`
If this property is true, a button to reset the box control is rendered.
- Required: No
- Default: `true`
### `splitOnAxis`: `boolean`
If this property is true, when the box control is unlinked, vertical and horizontal controls can be used instead of updating individual sides.
- Required: No
- Default: `false`
### `inputProps`: `object`
Props for the internal [UnitControl](../unit-control) components.
- Required: No
- Default: `{ min: 0 }`
### `label`: `string`
Heading label for the control.
- Required: No
- Default: `__( 'Box Control' )`
### `onChange`: `(next: BoxControlValue) => void`
A callback function when an input value changes.
- Required: Yes
### `resetValues`: `object`
The `top`, `right`, `bottom`, and `left` box dimension values to use when the control is reset.
- Required: No
- Default: `{ top: undefined, right: undefined, bottom: undefined, left: undefined }`
### `sides`: `string[]`
Collection of sides to allow control of. If omitted or empty, all sides will be available. Allowed values are "top", "right", "bottom", "left", "vertical", and "horizontal".
- Required: No
### `units`: `WPUnitControlUnit[]`
Collection of available units which are compatible with [UnitControl](../unit-control).
- Required: No
### `values`: `object`
The `top`, `right`, `bottom`, and `left` box dimension values.
- Required: No
### `onMouseOver`: `function`
A handler for onMouseOver events.
- Required: No
### `onMouseOut`: `function`
A handler for onMouseOut events.
- Required: No

View File

@@ -0,0 +1,109 @@
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
import type { UnitControlProps } from '../unit-control/types';
import {
FlexedRangeControl,
StyledUnitControl,
} from './styles/box-control-styles';
import { HStack } from '../h-stack';
import type { BoxControlInputControlProps } from './types';
import { parseQuantityAndUnitFromRawValue } from '../unit-control';
import {
LABELS,
applyValueToSides,
getAllValue,
isValuesMixed,
isValuesDefined,
CUSTOM_VALUE_SETTINGS,
} from './utils';
const noop = () => {};
export default function AllInputControl( {
__next40pxDefaultSize,
onChange = noop,
onFocus = noop,
values,
sides,
selectedUnits,
setSelectedUnits,
...props
}: BoxControlInputControlProps ) {
const inputId = useInstanceId( AllInputControl, 'box-control-input-all' );
const allValue = getAllValue( values, selectedUnits, sides );
const hasValues = isValuesDefined( values );
const isMixed = hasValues && isValuesMixed( values, selectedUnits, sides );
const allPlaceholder = isMixed ? LABELS.mixed : undefined;
const [ parsedQuantity, parsedUnit ] =
parseQuantityAndUnitFromRawValue( allValue );
const handleOnFocus: React.FocusEventHandler< HTMLInputElement > = (
event
) => {
onFocus( event, { side: 'all' } );
};
const onValueChange = ( next?: string ) => {
const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) );
const nextValue = isNumeric ? next : undefined;
const nextValues = applyValueToSides( values, nextValue, sides );
onChange( nextValues );
};
const sliderOnChange = ( next?: number ) => {
onValueChange(
next !== undefined ? [ next, parsedUnit ].join( '' ) : undefined
);
};
// Set selected unit so it can be used as fallback by unlinked controls
// when individual sides do not have a value containing a unit.
const handleOnUnitChange: UnitControlProps[ 'onUnitChange' ] = ( unit ) => {
const newUnits = applyValueToSides( selectedUnits, unit, sides );
setSelectedUnits( newUnits );
};
return (
<HStack>
<StyledUnitControl
{ ...props }
__next40pxDefaultSize={ __next40pxDefaultSize }
className="component-box-control__unit-control"
disableUnits={ isMixed }
id={ inputId }
isPressEnterToChange
value={ allValue }
onChange={ onValueChange }
onUnitChange={ handleOnUnitChange }
onFocus={ handleOnFocus }
placeholder={ allPlaceholder }
label={ LABELS.all }
hideLabelFromVision
/>
<FlexedRangeControl
__nextHasNoMarginBottom
__next40pxDefaultSize={ __next40pxDefaultSize }
aria-controls={ inputId }
label={ LABELS.all }
hideLabelFromVision
onChange={ sliderOnChange }
min={ 0 }
max={ CUSTOM_VALUE_SETTINGS[ parsedUnit ?? 'px' ]?.max ?? 10 }
step={
CUSTOM_VALUE_SETTINGS[ parsedUnit ?? 'px' ]?.step ?? 0.1
}
value={ parsedQuantity ?? 0 }
withInputField={ false }
/>
</HStack>
);
}

View File

@@ -0,0 +1,163 @@
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils';
import Tooltip from '../tooltip';
import { CUSTOM_VALUE_SETTINGS, LABELS } from './utils';
import {
FlexedBoxControlIcon,
FlexedRangeControl,
InputWrapper,
StyledUnitControl,
} from './styles/box-control-styles';
import type { BoxControlInputControlProps } from './types';
const groupedSides = [ 'vertical', 'horizontal' ] as const;
type GroupedSide = ( typeof groupedSides )[ number ];
export default function AxialInputControls( {
__next40pxDefaultSize,
onChange,
onFocus,
values,
selectedUnits,
setSelectedUnits,
sides,
...props
}: BoxControlInputControlProps ) {
const generatedId = useInstanceId(
AxialInputControls,
`box-control-input`
);
const createHandleOnFocus =
( side: GroupedSide ) =>
( event: React.FocusEvent< HTMLInputElement > ) => {
if ( ! onFocus ) {
return;
}
onFocus( event, { side } );
};
const handleOnValueChange = ( side: GroupedSide, next?: string ) => {
if ( ! onChange ) {
return;
}
const nextValues = { ...values };
const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) );
const nextValue = isNumeric ? next : undefined;
if ( side === 'vertical' ) {
nextValues.top = nextValue;
nextValues.bottom = nextValue;
}
if ( side === 'horizontal' ) {
nextValues.left = nextValue;
nextValues.right = nextValue;
}
onChange( nextValues );
};
const createHandleOnUnitChange =
( side: GroupedSide ) => ( next?: string ) => {
const newUnits = { ...selectedUnits };
if ( side === 'vertical' ) {
newUnits.top = next;
newUnits.bottom = next;
}
if ( side === 'horizontal' ) {
newUnits.left = next;
newUnits.right = next;
}
setSelectedUnits( newUnits );
};
// Filter sides if custom configuration provided, maintaining default order.
const filteredSides = sides?.length
? groupedSides.filter( ( side ) => sides.includes( side ) )
: groupedSides;
return (
<>
{ filteredSides.map( ( side ) => {
const [ parsedQuantity, parsedUnit ] =
parseQuantityAndUnitFromRawValue(
side === 'vertical' ? values.top : values.left
);
const selectedUnit =
side === 'vertical'
? selectedUnits.top
: selectedUnits.left;
const inputId = [ generatedId, side ].join( '-' );
return (
<InputWrapper key={ side }>
<FlexedBoxControlIcon side={ side } sides={ sides } />
<Tooltip placement="top-end" text={ LABELS[ side ] }>
<StyledUnitControl
{ ...props }
__next40pxDefaultSize={ __next40pxDefaultSize }
className="component-box-control__unit-control"
id={ inputId }
isPressEnterToChange
value={ [
parsedQuantity,
selectedUnit ?? parsedUnit,
].join( '' ) }
onChange={ ( newValue ) =>
handleOnValueChange( side, newValue )
}
onUnitChange={ createHandleOnUnitChange(
side
) }
onFocus={ createHandleOnFocus( side ) }
label={ LABELS[ side ] }
hideLabelFromVision
key={ side }
/>
</Tooltip>
<FlexedRangeControl
__nextHasNoMarginBottom
__next40pxDefaultSize={ __next40pxDefaultSize }
aria-controls={ inputId }
label={ LABELS[ side ] }
hideLabelFromVision
onChange={ ( newValue ) =>
handleOnValueChange(
side,
newValue !== undefined
? [
newValue,
selectedUnit ?? parsedUnit,
].join( '' )
: undefined
)
}
min={ 0 }
max={
CUSTOM_VALUE_SETTINGS[ selectedUnit ?? 'px' ]
?.max ?? 10
}
step={
CUSTOM_VALUE_SETTINGS[ selectedUnit ?? 'px' ]
?.step ?? 0.1
}
value={ parsedQuantity ?? 0 }
withInputField={ false }
/>
</InputWrapper>
);
} ) }
</>
);
}

View File

@@ -0,0 +1,55 @@
/**
* Internal dependencies
*/
import type { WordPressComponentProps } from '../context';
import {
Root,
Viewbox,
TopStroke,
RightStroke,
BottomStroke,
LeftStroke,
} from './styles/box-control-icon-styles';
import type { BoxControlIconProps, BoxControlProps } from './types';
const BASE_ICON_SIZE = 24;
export default function BoxControlIcon( {
size = 24,
side = 'all',
sides,
...props
}: WordPressComponentProps< BoxControlIconProps, 'span' > ) {
const isSideDisabled = (
value: NonNullable< BoxControlProps[ 'sides' ] >[ number ]
) => sides?.length && ! sides.includes( value );
const hasSide = (
value: NonNullable< BoxControlProps[ 'sides' ] >[ number ]
) => {
if ( isSideDisabled( value ) ) {
return false;
}
return side === 'all' || side === value;
};
const top = hasSide( 'top' ) || hasSide( 'vertical' );
const right = hasSide( 'right' ) || hasSide( 'horizontal' );
const bottom = hasSide( 'bottom' ) || hasSide( 'vertical' );
const left = hasSide( 'left' ) || hasSide( 'horizontal' );
// Simulates SVG Icon scaling.
const scale = size / BASE_ICON_SIZE;
return (
<Root style={ { transform: `scale(${ scale })` } } { ...props }>
<Viewbox>
<TopStroke isFocused={ top } />
<RightStroke isFocused={ right } />
<BottomStroke isFocused={ bottom } />
<LeftStroke isFocused={ left } />
</Viewbox>
</Root>
);
}

View File

@@ -0,0 +1,206 @@
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
import { useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { BaseControl } from '../base-control';
import AllInputControl from './all-input-control';
import InputControls from './input-controls';
import AxialInputControls from './axial-input-controls';
import LinkedButton from './linked-button';
import { Grid } from '../grid';
import {
FlexedBoxControlIcon,
InputWrapper,
ResetButton,
LinkedButtonWrapper,
} from './styles/box-control-styles';
import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils';
import {
DEFAULT_VALUES,
getInitialSide,
isValuesMixed,
isValuesDefined,
} from './utils';
import { useControlledState } from '../utils/hooks';
import type {
BoxControlIconProps,
BoxControlProps,
BoxControlValue,
} from './types';
const defaultInputProps = {
min: 0,
};
const noop = () => {};
function useUniqueId( idProp?: string ) {
const instanceId = useInstanceId( BoxControl, 'inspector-box-control' );
return idProp || instanceId;
}
/**
* BoxControl components let users set values for Top, Right, Bottom, and Left.
* This can be used as an input control for values like `padding` or `margin`.
*
* ```jsx
* import { __experimentalBoxControl as BoxControl } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* const Example = () => {
* const [ values, setValues ] = useState( {
* top: '50px',
* left: '10%',
* right: '10%',
* bottom: '50px',
* } );
*
* return (
* <BoxControl
* values={ values }
* onChange={ ( nextValues ) => setValues( nextValues ) }
* />
* );
* };
* ```
*/
function BoxControl( {
__next40pxDefaultSize = false,
id: idProp,
inputProps = defaultInputProps,
onChange = noop,
label = __( 'Box Control' ),
values: valuesProp,
units,
sides,
splitOnAxis = false,
allowReset = true,
resetValues = DEFAULT_VALUES,
onMouseOver,
onMouseOut,
}: BoxControlProps ) {
const [ values, setValues ] = useControlledState( valuesProp, {
fallback: DEFAULT_VALUES,
} );
const inputValues = values || DEFAULT_VALUES;
const hasInitialValue = isValuesDefined( valuesProp );
const hasOneSide = sides?.length === 1;
const [ isDirty, setIsDirty ] = useState( hasInitialValue );
const [ isLinked, setIsLinked ] = useState(
! hasInitialValue || ! isValuesMixed( inputValues ) || hasOneSide
);
const [ side, setSide ] = useState< BoxControlIconProps[ 'side' ] >(
getInitialSide( isLinked, splitOnAxis )
);
// Tracking selected units via internal state allows filtering of CSS unit
// only values from being saved while maintaining preexisting unit selection
// behaviour. Filtering CSS only values prevents invalid style values.
const [ selectedUnits, setSelectedUnits ] = useState< BoxControlValue >( {
top: parseQuantityAndUnitFromRawValue( valuesProp?.top )[ 1 ],
right: parseQuantityAndUnitFromRawValue( valuesProp?.right )[ 1 ],
bottom: parseQuantityAndUnitFromRawValue( valuesProp?.bottom )[ 1 ],
left: parseQuantityAndUnitFromRawValue( valuesProp?.left )[ 1 ],
} );
const id = useUniqueId( idProp );
const headingId = `${ id }-heading`;
const toggleLinked = () => {
setIsLinked( ! isLinked );
setSide( getInitialSide( ! isLinked, splitOnAxis ) );
};
const handleOnFocus = (
_event: React.FocusEvent< HTMLInputElement >,
{ side: nextSide }: { side: typeof side }
) => {
setSide( nextSide );
};
const handleOnChange = ( nextValues: BoxControlValue ) => {
onChange( nextValues );
setValues( nextValues );
setIsDirty( true );
};
const handleOnReset = () => {
onChange( resetValues );
setValues( resetValues );
setSelectedUnits( resetValues );
setIsDirty( false );
};
const inputControlProps = {
...inputProps,
onChange: handleOnChange,
onFocus: handleOnFocus,
isLinked,
units,
selectedUnits,
setSelectedUnits,
sides,
values: inputValues,
onMouseOver,
onMouseOut,
__next40pxDefaultSize,
};
return (
<Grid
id={ id }
columns={ 3 }
templateColumns="1fr min-content min-content"
role="group"
aria-labelledby={ headingId }
>
<BaseControl.VisualLabel id={ headingId }>
{ label }
</BaseControl.VisualLabel>
{ isLinked && (
<InputWrapper>
<FlexedBoxControlIcon side={ side } sides={ sides } />
<AllInputControl { ...inputControlProps } />
</InputWrapper>
) }
{ ! hasOneSide && (
<LinkedButtonWrapper>
<LinkedButton
onClick={ toggleLinked }
isLinked={ isLinked }
/>
</LinkedButtonWrapper>
) }
{ ! isLinked && splitOnAxis && (
<AxialInputControls { ...inputControlProps } />
) }
{ ! isLinked && ! splitOnAxis && (
<InputControls { ...inputControlProps } />
) }
{ allowReset && (
<ResetButton
className="component-box-control__reset-button"
variant="secondary"
size="small"
onClick={ handleOnReset }
disabled={ ! isDirty }
>
{ __( 'Reset' ) }
</ResetButton>
) }
</Grid>
);
}
export { applyValueToSides } from './utils';
export default BoxControl;

View File

@@ -0,0 +1,165 @@
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
import Tooltip from '../tooltip';
import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils';
import { ALL_SIDES, CUSTOM_VALUE_SETTINGS, LABELS } from './utils';
import {
FlexedBoxControlIcon,
FlexedRangeControl,
InputWrapper,
StyledUnitControl,
} from './styles/box-control-styles';
import type { BoxControlInputControlProps, BoxControlValue } from './types';
const noop = () => {};
export default function BoxInputControls( {
__next40pxDefaultSize,
onChange = noop,
onFocus = noop,
values,
selectedUnits,
setSelectedUnits,
sides,
...props
}: BoxControlInputControlProps ) {
const generatedId = useInstanceId( BoxInputControls, 'box-control-input' );
const createHandleOnFocus =
( side: keyof BoxControlValue ) =>
( event: React.FocusEvent< HTMLInputElement > ) => {
onFocus( event, { side } );
};
const handleOnChange = ( nextValues: BoxControlValue ) => {
onChange( nextValues );
};
const handleOnValueChange = (
side: keyof BoxControlValue,
next?: string,
extra?: { event: React.SyntheticEvent< Element, Event > }
) => {
const nextValues = { ...values };
const isNumeric = next !== undefined && ! isNaN( parseFloat( next ) );
const nextValue = isNumeric ? next : undefined;
nextValues[ side ] = nextValue;
/**
* Supports changing pair sides. For example, holding the ALT key
* when changing the TOP will also update BOTTOM.
*/
// @ts-expect-error - TODO: event.altKey is only present when the change event was
// triggered by a keyboard event. Should this feature be implemented differently so
// it also works with drag events?
if ( extra?.event.altKey ) {
switch ( side ) {
case 'top':
nextValues.bottom = nextValue;
break;
case 'bottom':
nextValues.top = nextValue;
break;
case 'left':
nextValues.right = nextValue;
break;
case 'right':
nextValues.left = nextValue;
break;
}
}
handleOnChange( nextValues );
};
const createHandleOnUnitChange =
( side: keyof BoxControlValue ) => ( next?: string ) => {
const newUnits = { ...selectedUnits };
newUnits[ side ] = next;
setSelectedUnits( newUnits );
};
// Filter sides if custom configuration provided, maintaining default order.
const filteredSides = sides?.length
? ALL_SIDES.filter( ( side ) => sides.includes( side ) )
: ALL_SIDES;
return (
<>
{ filteredSides.map( ( side ) => {
const [ parsedQuantity, parsedUnit ] =
parseQuantityAndUnitFromRawValue( values[ side ] );
const computedUnit = values[ side ]
? parsedUnit
: selectedUnits[ side ];
const inputId = [ generatedId, side ].join( '-' );
return (
<InputWrapper key={ `box-control-${ side }` } expanded>
<FlexedBoxControlIcon side={ side } sides={ sides } />
<Tooltip placement="top-end" text={ LABELS[ side ] }>
<StyledUnitControl
{ ...props }
__next40pxDefaultSize={ __next40pxDefaultSize }
className="component-box-control__unit-control"
id={ inputId }
isPressEnterToChange
value={ [ parsedQuantity, computedUnit ].join(
''
) }
onChange={ ( nextValue, extra ) =>
handleOnValueChange(
side,
nextValue,
extra
)
}
onUnitChange={ createHandleOnUnitChange(
side
) }
onFocus={ createHandleOnFocus( side ) }
label={ LABELS[ side ] }
hideLabelFromVision
/>
</Tooltip>
<FlexedRangeControl
__nextHasNoMarginBottom
__next40pxDefaultSize={ __next40pxDefaultSize }
aria-controls={ inputId }
label={ LABELS[ side ] }
hideLabelFromVision
onChange={ ( newValue ) => {
handleOnValueChange(
side,
newValue !== undefined
? [ newValue, computedUnit ].join( '' )
: undefined
);
} }
min={ 0 }
max={
CUSTOM_VALUE_SETTINGS[ computedUnit ?? 'px' ]
?.max ?? 10
}
step={
CUSTOM_VALUE_SETTINGS[ computedUnit ?? 'px' ]
?.step ?? 0.1
}
value={ parsedQuantity ?? 0 }
withInputField={ false }
/>
</InputWrapper>
);
} ) }
</>
);
}

View File

@@ -0,0 +1,31 @@
/**
* WordPress dependencies
*/
import { link, linkOff } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import Button from '../button';
import Tooltip from '../tooltip';
export default function LinkedButton( {
isLinked,
...props
}: { isLinked?: boolean } & React.ComponentProps< typeof Button > ) {
const label = isLinked ? __( 'Unlink sides' ) : __( 'Link sides' );
return (
<Tooltip text={ label }>
<Button
{ ...props }
className="component-box-control__linked-button"
size="small"
icon={ isLinked ? link : linkOff }
iconSize={ 24 }
aria-label={ label }
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,82 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import BoxControl from '../';
const meta: Meta< typeof BoxControl > = {
title: 'Components (Experimental)/BoxControl',
component: BoxControl,
argTypes: {
values: { control: { type: null } },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const TemplateUncontrolled: StoryFn< typeof BoxControl > = ( props ) => {
return <BoxControl { ...props } />;
};
const TemplateControlled: StoryFn< typeof BoxControl > = ( props ) => {
const [ values, setValues ] = useState< ( typeof props )[ 'values' ] >();
return (
<BoxControl
values={ values }
{ ...props }
onChange={ ( nextValue ) => {
setValues( nextValue );
props.onChange?.( nextValue );
} }
/>
);
};
export const Default = TemplateUncontrolled.bind( {} );
Default.args = {
label: 'Label',
};
export const Controlled = TemplateControlled.bind( {} );
Controlled.args = {
...Default.args,
};
export const ArbitrarySides = TemplateControlled.bind( {} );
ArbitrarySides.args = {
...Default.args,
sides: [ 'top', 'bottom' ],
};
export const SingleSide = TemplateControlled.bind( {} );
SingleSide.args = {
...Default.args,
sides: [ 'bottom' ],
};
export const AxialControls = TemplateControlled.bind( {} );
AxialControls.args = {
...Default.args,
splitOnAxis: true,
};
export const AxialControlsWithSingleSide = TemplateControlled.bind( {} );
AxialControlsWithSingleSide.args = {
...Default.args,
sides: [ 'horizontal' ],
splitOnAxis: true,
};

View File

@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { css } from '@emotion/react';
import styled from '@emotion/styled';
export const Root = styled.span`
box-sizing: border-box;
display: block;
width: 24px;
height: 24px;
position: relative;
padding: 4px;
`;
export const Viewbox = styled.span`
box-sizing: border-box;
display: block;
position: relative;
width: 100%;
height: 100%;
`;
const strokeFocus = ( { isFocused }: { isFocused: boolean } ) => {
return css( {
backgroundColor: 'currentColor',
opacity: isFocused ? 1 : 0.3,
} );
};
const Stroke = styled.span`
box-sizing: border-box;
display: block;
pointer-events: none;
position: absolute;
${ strokeFocus };
`;
const VerticalStroke = styled( Stroke )`
bottom: 3px;
top: 3px;
width: 2px;
`;
const HorizontalStroke = styled( Stroke )`
height: 2px;
left: 3px;
right: 3px;
`;
export const TopStroke = styled( HorizontalStroke )`
top: 0;
`;
export const RightStroke = styled( VerticalStroke )`
right: 0;
`;
export const BottomStroke = styled( HorizontalStroke )`
bottom: 0;
`;
export const LeftStroke = styled( VerticalStroke )`
left: 0;
`;

View File

@@ -0,0 +1,40 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
/**
* Internal dependencies
*/
import BoxControlIcon from '../icon';
import Button from '../../button';
import { HStack } from '../../h-stack';
import RangeControl from '../../range-control';
import UnitControl from '../../unit-control';
import { space } from '../../utils/space';
export const StyledUnitControl = styled( UnitControl )`
max-width: 90px;
`;
export const InputWrapper = styled( HStack )`
grid-column: 1 / span 3;
`;
export const ResetButton = styled( Button )`
grid-area: 1 / 2;
justify-self: end;
`;
export const LinkedButtonWrapper = styled.div`
grid-area: 1 / 3;
justify-self: end;
`;
export const FlexedBoxControlIcon = styled( BoxControlIcon )`
flex: 0 0 auto;
`;
export const FlexedRangeControl = styled( RangeControl )`
width: 100%;
margin-inline-end: ${ space( 2 ) };
`;

View File

@@ -0,0 +1,441 @@
/**
* External dependencies
*/
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import BoxControl from '..';
import type { BoxControlProps, BoxControlValue } from '../types';
const Example = ( extraProps: Omit< BoxControlProps, 'onChange' > ) => {
const [ state, setState ] = useState< BoxControlValue >();
return (
<BoxControl
values={ state }
onChange={ ( next ) => setState( next ) }
{ ...extraProps }
/>
);
};
describe( 'BoxControl', () => {
describe( 'Basic rendering', () => {
it( 'should render a box control input', () => {
render( <BoxControl onChange={ () => {} } /> );
expect(
screen.getByRole( 'group', { name: 'Box Control' } )
).toBeVisible();
expect(
screen.getByRole( 'textbox', { name: 'All sides' } )
).toBeVisible();
} );
it( 'should update values when interacting with input', async () => {
const user = userEvent.setup();
render( <BoxControl onChange={ () => {} } /> );
const input = screen.getByRole( 'textbox', { name: 'All sides' } );
await user.type( input, '100' );
await user.keyboard( '{Enter}' );
expect( input ).toHaveValue( '100' );
} );
it( 'should update input values when interacting with slider', () => {
render( <BoxControl onChange={ () => {} } /> );
const slider = screen.getByRole( 'slider' );
fireEvent.change( slider, { target: { value: 50 } } );
expect( slider ).toHaveValue( '50' );
expect(
screen.getByRole( 'textbox', { name: 'All sides' } )
).toHaveValue( '50' );
} );
it( 'should update slider values when interacting with input', async () => {
const user = userEvent.setup();
render( <BoxControl onChange={ () => {} } /> );
const input = screen.getByRole( 'textbox', {
name: 'All sides',
} );
await user.type( input, '50' );
await user.keyboard( '{Enter}' );
expect( input ).toHaveValue( '50' );
expect( screen.getByRole( 'slider' ) ).toHaveValue( '50' );
} );
} );
describe( 'Reset', () => {
it( 'should reset values when clicking Reset', async () => {
const user = userEvent.setup();
render( <BoxControl onChange={ () => {} } /> );
const input = screen.getByRole( 'textbox', {
name: 'All sides',
} );
await user.type( input, '100' );
await user.keyboard( '{Enter}' );
expect( input ).toHaveValue( '100' );
await user.click( screen.getByRole( 'button', { name: 'Reset' } ) );
expect( input ).toHaveValue( '' );
} );
it( 'should reset values when clicking Reset, if controlled', async () => {
const user = userEvent.setup();
render( <Example /> );
const input = screen.getByRole( 'textbox', {
name: 'All sides',
} );
await user.type( input, '100' );
await user.keyboard( '{Enter}' );
expect( input ).toHaveValue( '100' );
await user.click( screen.getByRole( 'button', { name: 'Reset' } ) );
expect( input ).toHaveValue( '' );
} );
it( 'should reset values when clicking Reset, if controlled <-> uncontrolled state changes', async () => {
const user = userEvent.setup();
render( <Example /> );
const input = screen.getByRole( 'textbox', {
name: 'All sides',
} );
await user.type( input, '100' );
await user.keyboard( '{Enter}' );
expect( input ).toHaveValue( '100' );
await user.click( screen.getByRole( 'button', { name: 'Reset' } ) );
expect( input ).toHaveValue( '' );
} );
it( 'should persist cleared value when focus changes', async () => {
const user = userEvent.setup();
const spyChange = jest.fn();
render( <BoxControl onChange={ ( v ) => spyChange( v ) } /> );
const input = screen.getByRole( 'textbox', {
name: 'All sides',
} );
await user.type( input, '100' );
await user.keyboard( '{Enter}' );
expect( input ).toHaveValue( '100' );
await user.clear( input );
expect( input ).toHaveValue( '' );
// Clicking document.body to trigger a blur event on the input.
await user.click( document.body );
expect( input ).toHaveValue( '' );
expect( spyChange ).toHaveBeenLastCalledWith( {
top: undefined,
right: undefined,
bottom: undefined,
left: undefined,
} );
} );
} );
describe( 'Unlinked sides', () => {
it( 'should update a single side value when unlinked', async () => {
const user = userEvent.setup();
render( <Example /> );
await user.click(
screen.getByRole( 'button', { name: 'Unlink sides' } )
);
await user.type(
screen.getByRole( 'textbox', { name: 'Top side' } ),
'100'
);
expect(
screen.getByRole( 'textbox', { name: 'Top side' } )
).toHaveValue( '100' );
expect(
screen.getByRole( 'textbox', { name: 'Right side' } )
).not.toHaveValue();
expect(
screen.getByRole( 'textbox', { name: 'Bottom side' } )
).not.toHaveValue();
expect(
screen.getByRole( 'textbox', { name: 'Left side' } )
).not.toHaveValue();
} );
it( 'should update a single side value when using slider unlinked', async () => {
const user = userEvent.setup();
render( <Example /> );
await user.click(
screen.getByRole( 'button', { name: 'Unlink sides' } )
);
const slider = screen.getByRole( 'slider', { name: 'Right side' } );
fireEvent.change( slider, { target: { value: 50 } } );
expect( slider ).toHaveValue( '50' );
expect(
screen.getByRole( 'textbox', { name: 'Top side' } )
).not.toHaveValue();
expect(
screen.getByRole( 'textbox', { name: 'Right side' } )
).toHaveValue( '50' );
expect(
screen.getByRole( 'textbox', { name: 'Bottom side' } )
).not.toHaveValue();
expect(
screen.getByRole( 'textbox', { name: 'Left side' } )
).not.toHaveValue();
} );
it( 'should update a whole axis when value is changed when unlinked', async () => {
const user = userEvent.setup();
render( <Example splitOnAxis /> );
await user.click(
screen.getByRole( 'button', { name: 'Unlink sides' } )
);
await user.type(
screen.getByRole( 'textbox', {
name: 'Top and bottom sides',
} ),
'100'
);
expect(
screen.getByRole( 'textbox', { name: 'Top and bottom sides' } )
).toHaveValue( '100' );
expect(
screen.getByRole( 'textbox', { name: 'Left and right sides' } )
).not.toHaveValue();
} );
it( 'should update a whole axis using a slider when value is changed when unlinked', async () => {
const user = userEvent.setup();
render( <Example splitOnAxis /> );
await user.click(
screen.getByRole( 'button', { name: 'Unlink sides' } )
);
const slider = screen.getByRole( 'slider', {
name: 'Left and right sides',
} );
fireEvent.change( slider, { target: { value: 50 } } );
expect( slider ).toHaveValue( '50' );
expect(
screen.getByRole( 'textbox', { name: 'Top and bottom sides' } )
).not.toHaveValue();
expect(
screen.getByRole( 'textbox', { name: 'Left and right sides' } )
).toHaveValue( '50' );
} );
it( 'should show "Mixed" label when sides have different values but are linked', async () => {
const user = userEvent.setup();
render( <Example /> );
const unlinkButton = screen.getByRole( 'button', {
name: 'Unlink sides',
} );
await user.click( unlinkButton );
await user.type(
screen.getByRole( 'textbox', {
name: 'Right side',
} ),
'13'
);
expect(
screen.getByRole( 'textbox', { name: 'Right side' } )
).toHaveValue( '13' );
await user.click( unlinkButton );
expect( screen.getByPlaceholderText( 'Mixed' ) ).toHaveValue( '' );
} );
} );
describe( 'Unit selections', () => {
it( 'should update unlinked controls unit selection based on all input control', async () => {
const user = userEvent.setup();
// Render control.
render( <BoxControl onChange={ () => {} } /> );
// Make unit selection on all input control.
await user.selectOptions(
screen.getByRole( 'combobox', {
name: 'Select unit',
} ),
[ 'em' ]
);
// Unlink the controls.
await user.click(
screen.getByRole( 'button', { name: 'Unlink sides' } )
);
const controls = screen.getAllByRole( 'combobox', {
name: 'Select unit',
} );
// Confirm we have exactly 4 controls.
expect( controls ).toHaveLength( 4 );
// Confirm that each individual control has the selected unit
controls.forEach( ( control ) => {
expect( control ).toHaveValue( 'em' );
} );
} );
it( 'should use individual side attribute unit when available', async () => {
const user = userEvent.setup();
// Render control.
const { rerender } = render( <BoxControl onChange={ () => {} } /> );
// Make unit selection on all input control.
await user.selectOptions(
screen.getByRole( 'combobox', {
name: 'Select unit',
} ),
[ 'vw' ]
);
// Unlink the controls.
await user.click(
screen.getByRole( 'button', { name: 'Unlink sides' } )
);
const controls = screen.getAllByRole( 'combobox', {
name: 'Select unit',
} );
// Confirm we have exactly 4 controls.
expect( controls ).toHaveLength( 4 );
// Confirm that each individual control has the selected unit
controls.forEach( ( control ) => {
expect( control ).toHaveValue( 'vw' );
} );
// Rerender with individual side value & confirm unit is selected.
rerender(
<BoxControl values={ { top: '2.5em' } } onChange={ () => {} } />
);
const rerenderedControls = screen.getAllByRole( 'combobox', {
name: 'Select unit',
} );
// Confirm we have exactly 4 controls.
expect( rerenderedControls ).toHaveLength( 4 );
// Confirm that each individual control has the right selected unit
rerenderedControls.forEach( ( control, index ) => {
const expected = index === 0 ? 'em' : 'vw';
expect( control ).toHaveValue( expected );
} );
} );
} );
describe( 'onChange updates', () => {
it( 'should call onChange when values contain more than just CSS units', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render( <BoxControl onChange={ onChangeSpy } /> );
const valueInput = screen.getByRole( 'textbox', {
name: 'All sides',
} );
const unitSelect = screen.getByRole( 'combobox', {
name: 'Select unit',
} );
// Typing the first letter of a unit blurs the input and focuses the combobox.
await user.type( valueInput, '7r' );
expect( unitSelect ).toHaveFocus();
// The correct expected behavior would be for the values to have "rem"
// as their unit, but the test environment doesn't seem to change
// values on `select` elements when using the keyboard.
expect( onChangeSpy ).toHaveBeenLastCalledWith( {
top: '7px',
right: '7px',
bottom: '7px',
left: '7px',
} );
} );
it( 'should not pass invalid CSS unit only values to onChange', async () => {
const user = userEvent.setup();
const setState = jest.fn();
render( <BoxControl onChange={ setState } /> );
await user.selectOptions(
screen.getByRole( 'combobox', {
name: 'Select unit',
} ),
'rem'
);
expect( setState ).toHaveBeenCalledWith( {
top: undefined,
right: undefined,
bottom: undefined,
left: undefined,
} );
} );
} );
} );

View File

@@ -0,0 +1,110 @@
/**
* Internal dependencies
*/
import type { UnitControlProps } from '../unit-control/types';
import type { LABELS } from './utils';
export type BoxControlValue = {
top?: string;
right?: string;
bottom?: string;
left?: string;
};
export type CustomValueUnits = {
[ key: string ]: { max: number; step: number };
};
type UnitControlPassthroughProps = Omit<
UnitControlProps,
'label' | 'onChange' | 'onFocus' | 'onMouseOver' | 'onMouseOut' | 'units'
>;
export type BoxControlProps = Pick<
UnitControlProps,
'onMouseOver' | 'onMouseOut' | 'units'
> & {
/**
* If this property is true, a button to reset the box control is rendered.
*
* @default true
*/
allowReset?: boolean;
/**
* The id to use as a base for the unique HTML id attribute of the control.
*/
id?: string;
/**
* Props for the internal `UnitControl` components.
*
* @default `{ min: 0 }`
*/
inputProps?: UnitControlPassthroughProps;
/**
* Heading label for the control.
*
* @default `__( 'Box Control' )`
*/
label?: string;
/**
* A callback function when an input value changes.
*/
onChange: ( next: BoxControlValue ) => void;
/**
* The `top`, `right`, `bottom`, and `left` box dimension values to use when the control is reset.
*
* @default `{ top: undefined, right: undefined, bottom: undefined, left: undefined }`
*/
resetValues?: BoxControlValue;
/**
* Collection of sides to allow control of. If omitted or empty, all sides will be available.
*/
sides?: readonly ( keyof BoxControlValue | 'horizontal' | 'vertical' )[];
/**
* If this property is true, when the box control is unlinked, vertical and horizontal controls
* can be used instead of updating individual sides.
*
* @default false
*/
splitOnAxis?: boolean;
/**
* The current values of the control, expressed as an object of `top`, `right`, `bottom`, and `left` values.
*/
values?: BoxControlValue;
/**
* Start opting into the larger default height that will become the default size in a future version.
*
* @default false
*/
__next40pxDefaultSize?: boolean;
};
export type BoxControlInputControlProps = UnitControlPassthroughProps & {
onChange?: ( nextValues: BoxControlValue ) => void;
onFocus?: (
_event: React.FocusEvent< HTMLInputElement >,
{ side }: { side: keyof typeof LABELS }
) => void;
onHoverOff?: (
sides: Partial< Record< keyof BoxControlValue, boolean > >
) => void;
onHoverOn?: (
sides: Partial< Record< keyof BoxControlValue, boolean > >
) => void;
selectedUnits: BoxControlValue;
setSelectedUnits: React.Dispatch< React.SetStateAction< BoxControlValue > >;
sides: BoxControlProps[ 'sides' ];
values: BoxControlValue;
};
export type BoxControlIconProps = {
/**
* @default 24
*/
size?: number;
/**
* @default 'all'
*/
side?: keyof typeof LABELS;
sides?: BoxControlProps[ 'sides' ];
};

View File

@@ -0,0 +1,272 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { parseQuantityAndUnitFromRawValue } from '../unit-control/utils';
import type {
BoxControlProps,
BoxControlValue,
CustomValueUnits,
} from './types';
export const CUSTOM_VALUE_SETTINGS: CustomValueUnits = {
px: { max: 300, step: 1 },
'%': { max: 100, step: 1 },
vw: { max: 100, step: 1 },
vh: { max: 100, step: 1 },
em: { max: 10, step: 0.1 },
rm: { max: 10, step: 0.1 },
svw: { max: 100, step: 1 },
lvw: { max: 100, step: 1 },
dvw: { max: 100, step: 1 },
svh: { max: 100, step: 1 },
lvh: { max: 100, step: 1 },
dvh: { max: 100, step: 1 },
vi: { max: 100, step: 1 },
svi: { max: 100, step: 1 },
lvi: { max: 100, step: 1 },
dvi: { max: 100, step: 1 },
vb: { max: 100, step: 1 },
svb: { max: 100, step: 1 },
lvb: { max: 100, step: 1 },
dvb: { max: 100, step: 1 },
vmin: { max: 100, step: 1 },
svmin: { max: 100, step: 1 },
lvmin: { max: 100, step: 1 },
dvmin: { max: 100, step: 1 },
vmax: { max: 100, step: 1 },
svmax: { max: 100, step: 1 },
lvmax: { max: 100, step: 1 },
dvmax: { max: 100, step: 1 },
};
export const LABELS = {
all: __( 'All sides' ),
top: __( 'Top side' ),
bottom: __( 'Bottom side' ),
left: __( 'Left side' ),
right: __( 'Right side' ),
mixed: __( 'Mixed' ),
vertical: __( 'Top and bottom sides' ),
horizontal: __( 'Left and right sides' ),
};
export const DEFAULT_VALUES = {
top: undefined,
right: undefined,
bottom: undefined,
left: undefined,
};
export const ALL_SIDES = [ 'top', 'right', 'bottom', 'left' ] as const;
/**
* Gets an items with the most occurrence within an array
* https://stackoverflow.com/a/20762713
*
* @param arr Array of items to check.
* @return The item with the most occurrences.
*/
function mode< T >( arr: T[] ) {
return arr
.sort(
( a, b ) =>
arr.filter( ( v ) => v === a ).length -
arr.filter( ( v ) => v === b ).length
)
.pop();
}
/**
* Gets the 'all' input value and unit from values data.
*
* @param values Box values.
* @param selectedUnits Box units.
* @param availableSides Available box sides to evaluate.
*
* @return A value + unit for the 'all' input.
*/
export function getAllValue(
values: BoxControlValue = {},
selectedUnits?: BoxControlValue,
availableSides: BoxControlProps[ 'sides' ] = ALL_SIDES
) {
const sides = normalizeSides( availableSides );
const parsedQuantitiesAndUnits = sides.map( ( side ) =>
parseQuantityAndUnitFromRawValue( values[ side ] )
);
const allParsedQuantities = parsedQuantitiesAndUnits.map(
( value ) => value[ 0 ] ?? ''
);
const allParsedUnits = parsedQuantitiesAndUnits.map(
( value ) => value[ 1 ]
);
const commonQuantity = allParsedQuantities.every(
( v ) => v === allParsedQuantities[ 0 ]
)
? allParsedQuantities[ 0 ]
: '';
/**
* The typeof === 'number' check is important. On reset actions, the incoming value
* may be null or an empty string.
*
* Also, the value may also be zero (0), which is considered a valid unit value.
*
* typeof === 'number' is more specific for these cases, rather than relying on a
* simple truthy check.
*/
let commonUnit;
if ( typeof commonQuantity === 'number' ) {
commonUnit = mode( allParsedUnits );
} else {
// Set meaningful unit selection if no commonQuantity and user has previously
// selected units without assigning values while controls were unlinked.
commonUnit =
getAllUnitFallback( selectedUnits ) ?? mode( allParsedUnits );
}
return [ commonQuantity, commonUnit ].join( '' );
}
/**
* Determine the most common unit selection to use as a fallback option.
*
* @param selectedUnits Current unit selections for individual sides.
* @return Most common unit selection.
*/
export function getAllUnitFallback( selectedUnits?: BoxControlValue ) {
if ( ! selectedUnits || typeof selectedUnits !== 'object' ) {
return undefined;
}
const filteredUnits = Object.values( selectedUnits ).filter( Boolean );
return mode( filteredUnits );
}
/**
* Checks to determine if values are mixed.
*
* @param values Box values.
* @param selectedUnits Box units.
* @param sides Available box sides to evaluate.
*
* @return Whether values are mixed.
*/
export function isValuesMixed(
values: BoxControlValue = {},
selectedUnits?: BoxControlValue,
sides: BoxControlProps[ 'sides' ] = ALL_SIDES
) {
const allValue = getAllValue( values, selectedUnits, sides );
const isMixed = isNaN( parseFloat( allValue ) );
return isMixed;
}
/**
* Checks to determine if values are defined.
*
* @param values Box values.
*
* @return Whether values are mixed.
*/
export function isValuesDefined( values?: BoxControlValue ) {
return (
values !== undefined &&
Object.values( values ).filter(
// Switching units when input is empty causes values only
// containing units. This gives false positive on mixed values
// unless filtered.
( value ) => !! value && /\d/.test( value )
).length > 0
);
}
/**
* Get initial selected side, factoring in whether the sides are linked,
* and whether the vertical / horizontal directions are grouped via splitOnAxis.
*
* @param isLinked Whether the box control's fields are linked.
* @param splitOnAxis Whether splitting by horizontal or vertical axis.
* @return The initial side.
*/
export function getInitialSide( isLinked: boolean, splitOnAxis: boolean ) {
let initialSide: keyof typeof LABELS = 'all';
if ( ! isLinked ) {
initialSide = splitOnAxis ? 'vertical' : 'top';
}
return initialSide;
}
/**
* Normalizes provided sides configuration to an array containing only top,
* right, bottom and left. This essentially just maps `horizontal` or `vertical`
* to their appropriate sides to facilitate correctly determining value for
* all input control.
*
* @param sides Available sides for box control.
* @return Normalized sides configuration.
*/
export function normalizeSides( sides: BoxControlProps[ 'sides' ] ) {
const filteredSides: ( keyof BoxControlValue )[] = [];
if ( ! sides?.length ) {
return ALL_SIDES;
}
if ( sides.includes( 'vertical' ) ) {
filteredSides.push( ...( [ 'top', 'bottom' ] as const ) );
} else if ( sides.includes( 'horizontal' ) ) {
filteredSides.push( ...( [ 'left', 'right' ] as const ) );
} else {
const newSides = ALL_SIDES.filter( ( side ) => sides.includes( side ) );
filteredSides.push( ...newSides );
}
return filteredSides;
}
/**
* Applies a value to an object representing top, right, bottom and left sides
* while taking into account any custom side configuration.
*
* @param currentValues The current values for each side.
* @param newValue The value to apply to the sides object.
* @param sides Array defining valid sides.
*
* @return Object containing the updated values for each side.
*/
export function applyValueToSides(
currentValues: BoxControlValue,
newValue?: string,
sides?: BoxControlProps[ 'sides' ]
): BoxControlValue {
const newValues = { ...currentValues };
if ( sides?.length ) {
sides.forEach( ( side ) => {
if ( side === 'vertical' ) {
newValues.top = newValue;
newValues.bottom = newValue;
} else if ( side === 'horizontal' ) {
newValues.left = newValue;
newValues.right = newValue;
} else {
newValues[ side ] = newValue;
}
} );
} else {
ALL_SIDES.forEach( ( side ) => ( newValues[ side ] = newValue ) );
}
return newValues;
}

View File

@@ -0,0 +1,56 @@
# ButtonGroup
ButtonGroup can be used to group any related buttons together. To emphasize related buttons, a group should share a common container.
![ButtonGroup component](https://wordpress.org/gutenberg/files/2018/12/s_96EC471FE9C9D91A996770229947AAB54A03351BDE98F444FD3C1BF0CED365EA_1541792995815_ButtonGroup.png)
## Design guidelines
### Usage
#### Selected action
![ButtonGroup selection](https://wordpress.org/gutenberg/files/2018/12/s_96EC471FE9C9D91A996770229947AAB54A03351BDE98F444FD3C1BF0CED365EA_1544127594329_ButtonGroup-Do.png)
**Do**
Only one option in a button group can be selected and active at a time. Selecting one option deselects any other.
### Best practices
Button groups should:
- **Be clearly and accurately labeled.**
- **Clearly communicate that clicking or tapping will trigger an action.**
- **Use established colors appropriately.** For example, only use red buttons for actions that are difficult or impossible to undo.
- **Have consistent locations in the interface.**
### States
![ButtonGroup component](https://wordpress.org/gutenberg/files/2018/12/s_96EC471FE9C9D91A996770229947AAB54A03351BDE98F444FD3C1BF0CED365EA_1541792995815_ButtonGroup.png)
**Active and available button groups**
A button groups state makes it clear which button is active. Hover and focus states express the available selection options for buttons in a button group.
**Disabled button groups**
Button groups that cannot be selected can either be given a disabled state, or be hidden.
## Development guidelines
### Usage
```jsx
import { Button, ButtonGroup } from '@wordpress/components';
const MyButtonGroup = () => (
<ButtonGroup>
<Button variant="primary">Button 1</Button>
<Button variant="primary">Button 2</Button>
</ButtonGroup>
);
```
## Related components
- For individual buttons, use a `Button` component.

View File

@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import type { ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import type { ButtonGroupProps } from './types';
import type { WordPressComponentProps } from '../context';
function UnforwardedButtonGroup(
props: WordPressComponentProps< ButtonGroupProps, 'div', false >,
ref: ForwardedRef< HTMLDivElement >
) {
const { className, ...restProps } = props;
const classes = classnames( 'components-button-group', className );
return (
<div ref={ ref } role="group" className={ classes } { ...restProps } />
);
}
/**
* ButtonGroup can be used to group any related buttons together. To emphasize
* related buttons, a group should share a common container.
*
* ```jsx
* import { Button, ButtonGroup } from '@wordpress/components';
*
* const MyButtonGroup = () => (
* <ButtonGroup>
* <Button variant="primary">Button 1</Button>
* <Button variant="primary">Button 2</Button>
* </ButtonGroup>
* );
* ```
*/
export const ButtonGroup = forwardRef( UnforwardedButtonGroup );
export default ButtonGroup;

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* Internal dependencies
*/
import ButtonGroup from '..';
import Button from '../../button';
const meta: Meta< typeof ButtonGroup > = {
title: 'Components/ButtonGroup',
component: ButtonGroup,
argTypes: {
children: { control: { type: null } },
},
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const Template: StoryFn< typeof ButtonGroup > = ( args ) => {
const style = { margin: '0 4px' };
return (
<ButtonGroup { ...args }>
<Button variant="primary" style={ style }>
Button 1
</Button>
<Button variant="primary" style={ style }>
Button 2
</Button>
</ButtonGroup>
);
};
export const Default: StoryFn< typeof ButtonGroup > = Template.bind( {} );

View File

@@ -0,0 +1,35 @@
.components-button-group {
display: inline-block;
.components-button {
border-radius: 0;
display: inline-flex;
color: $gray-900;
box-shadow: inset 0 0 0 $border-width $gray-900;
& + .components-button {
margin-left: -1px;
}
&:first-child {
border-radius: $radius-block-ui 0 0 $radius-block-ui;
}
&:last-child {
border-radius: 0 $radius-block-ui $radius-block-ui 0;
}
// The focused button should be elevated so the focus ring isn't cropped,
// as should the active button, because it has a different border color.
&:focus,
&.is-primary {
position: relative;
z-index: z-index(".components-button {:focus or .is-primary}");
}
// The active button should look pressed.
&.is-primary {
box-shadow: inset 0 0 0 $border-width $gray-900;
}
}
}

View File

@@ -0,0 +1,11 @@
/**
* External dependencies
*/
import type { ReactNode } from 'react';
export type ButtonGroupProps = {
/**
* The children elements.
*/
children: ReactNode;
};

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

@@ -0,0 +1,284 @@
# Button
Buttons let users take actions and make choices with a single click or tap.
![Button components](https://make.wordpress.org/design/files/2019/03/button.png)
## Design guidelines
### Usage
Buttons tell users what actions they can take and give them a way to interact with the interface. Youll find them throughout a UI, particularly in places like:
- Modals
- Forms
- Toolbars
### Best practices
Buttons should:
- **Be clearly and accurately labeled.**
- **Clearly communicate that clicking or tapping will trigger an action.**
- **Use established colors appropriately.** For example, only use red buttons for actions that are difficult or impossible to undo.
- **Prioritize the most important actions.** This helps users focus. Too many calls to action on one screen can be confusing, making users unsure what to do next.
- **Have consistent locations in the interface.**
### Content guidelines
Buttons should be clear and predictable—users should be able to anticipate what will happen when they click a button. Never deceive a user by mislabeling a button.
Buttons text should lead with a strong verb that encourages action, and add a noun that clarifies what will actually change. The only exceptions are common actions like Save, Close, Cancel, or OK. Otherwise, use the {verb}+{noun} format to ensure that your button gives the user enough information.
Button text should also be quickly scannable — avoid unnecessary words and articles like the, an, or a.
### Types
#### Link button
Link buttons have low emphasis. They dont stand out much on the page, so theyre used for less-important actions. Whats less important can vary based on context, but its usually a supplementary action to the main action we want someone to take. Link buttons are also useful when you dont want to distract from the content.
![Link button](https://make.wordpress.org/design/files/2019/03/link-button.png)
#### Default button
Default buttons have medium emphasis. The button appearance helps differentiate them from the page background, so theyre useful when you want more emphasis than a link button offers.
![Default button](https://make.wordpress.org/design/files/2019/03/default-button.png)
#### Primary button
Primary buttons have high emphasis. Their color fill and shadow means they pop off the background.
Since a high-emphasis button commands the most attention, a layout should contain a single primary button. This makes it clear that other buttons have less importance and helps users understand when an action requires their attention.
![Primary button](https://make.wordpress.org/design/files/2019/03/primary-button.png)
#### Text label
All button types use text labels to describe the action that happens when a user taps a button. If theres no text label, there needs to be a [label](#label) added and an icon to signify what the button does.
![](https://make.wordpress.org/design/files/2019/03/do-link-button.png)
**Do**
Use color to distinguish link button labels from other text.
![](https://make.wordpress.org/design/files/2019/03/dont-wrap-button-text.png)
**Dont**
Dont wrap button text. For maximum legibility, keep text labels on a single line.
### Hierarchy
![A layout with a single prominent button](https://make.wordpress.org/design/files/2019/03/button.png)
A layout should contain a single prominently-located button. If multiple buttons are required, a single high-emphasis button can be joined by medium- and low-emphasis buttons mapped to less-important actions. When using multiple buttons, make sure the available state of one button doesnt look like the disabled state of another.
![A diagram showing high emphasis at the top, medium emphasis in the middle, and low emphasis at the bottom](https://make.wordpress.org/design/files/2019/03/button-hierarchy.png)
A buttons level of emphasis helps determine its appearance, typography, and placement.
#### Placement
Use button types to express different emphasis levels for all the actions a user can perform.
![A link, default, and primary button](https://make.wordpress.org/design/files/2019/03/button-layout.png)
This screen layout uses:
1. A primary button for high emphasis.
2. A default button for medium emphasis.
3. A link button for low emphasis.
Placement best practices:
- **Do**: When using multiple buttons in a row, show users which action is more important by placing it next to a button with a lower emphasis (e.g. a primary button next to a default button, or a default button next to a link button).
- **Dont**: Dont place two primary buttons next to one another — they compete for focus. Only use one primary button per view.
- **Dont**: Dont place a button below another button if there is space to place them side by side.
- **Caution**: Avoid using too many buttons on a single page. When designing pages in the app or website, think about the most important actions for users to take. Too many calls to action can cause confusion and make users unsure what to do next — we always want users to feel confident and capable.
## Development guidelines
### Usage
Renders a button with default style.
```jsx
import { Button } from '@wordpress/components';
const MyButton = () => <Button variant="secondary">Click me!</Button>;
```
### Props
The presence of a `href` prop determines whether an `anchor` element is rendered instead of a `button`.
Props not included in this set will be applied to the `a` or `button` element.
#### `children`: `ReactNode`
The button's children.
- Required: No
#### `className`: `string`
An optional additional class name to apply to the rendered button.
- Required: No
#### `describedBy`: `string`
An accessible description for the button.
- Required: No
#### `disabled`: `boolean`
Whether the button is disabled. If `true`, this will force a `button` element to be rendered, even when an `href` is given.
- Required: No
#### `href`: `string`
If provided, renders `a` instead of `button`.
- Required: No
#### `icon`: `IconProps< unknown >[ 'icon' ]`
If provided, renders an [Icon](/packages/components/src/icon/README.md) component inside the button.
- Required: No
#### `iconPosition`: `'left' | 'right'`
If provided with `icon`, sets the position of icon relative to the `text`. Available options are `left|right`.
- Required: No
- Default: `left`
#### `iconSize`: `IconProps< unknown >[ 'size' ]`
If provided with `icon`, sets the icon size. Please refer to the [Icon](/packages/components/src/icon/README.md) component for more details regarding the default value of its `size` prop.
- Required: No
#### `isBusy`: `boolean`
Indicates activity while a action is being performed.
- Required: No
#### `isDestructive`: `boolean`
Renders a red text-based button style to indicate destructive behavior.
- Required: No
#### `isLink`: `boolean`
Deprecated: Renders a button with an anchor style.
Use `variant` prop with `link` value instead.
- Required: No
- Default: `false`
#### `isPressed`: `boolean`
Renders a pressed button style.
If the native `aria-pressed` attribute is also set, it will take precedence.
- Required: No
#### `isPrimary`: `boolean`
Deprecated: Renders a primary button style.
Use `variant` prop with `primary` value instead.
- Required: No
- Default: `false`
#### `isSecondary`: `boolean`
Deprecated: Renders a default button style.
Use `variant` prop with `secondary` value instead.
- Required: No
- Default: `false`
#### `isSmall`: `boolean`
Decreases the size of the button.
Deprecated in favor of the `size` prop. If both props are defined, the `size` prop will take precedence.
- Required: No
#### `isTertiary`: `boolean`
Deprecated: Renders a text-based button style.
Use `variant` prop with `tertiary` value instead.
- Required: No
- Default: `false`
#### `label`: `string`
Sets the `aria-label` of the component, if none is provided. Sets the Tooltip content if `showTooltip` is provided.
- Required: No
#### `shortcut`: `string | { display: string; ariaLabel: string; }`
If provided with `showTooltip`, appends the Shortcut label to the tooltip content. If an object is provided, it should contain `display` and `ariaLabel` keys.
- Required: No
#### `showTooltip`: `boolean`
If provided, renders a [Tooltip](/packages/components/src/tooltip/README.md) component for the button.
- Required: No
#### `size`: `'default'` | `'compact'` | `'small'`
The size of the button.
- `'default'`: For normal text-label buttons, unless it is a toggle button.
- `'compact'`: For toggle buttons, icon buttons, and buttons when used in context of either.
- `'small'`: For icon buttons associated with more advanced or auxiliary features.
If the deprecated `isSmall` prop is also defined, this prop will take precedence.
- Required: No
- Default: `'default'`
#### `target`: `string`
If provided with `href`, sets the `target` attribute to the `a`.
- Required: No
#### `text`: `string`
If provided, displays the given text inside the button. If the button contains children elements, the text is displayed before them.
- Required: No
#### `tooltipPosition`: `PopoverProps[ 'position' ]`
If provided with`showTooltip`, sets the position of the tooltip. Please refer to the [Tooltip](/packages/components/src/tooltip/README.md) component for more details regarding the defaults.
- Required: No
#### `variant`: `'primary' | 'secondary' | 'tertiary' | 'link'`
Specifies the button's style. The accepted values are `'primary'` (the primary button styles), `'secondary'` (the default button styles), `'tertiary'` (the text-based button styles), and `'link'` (the link button styles).
- Required: No
## Related components
- To group buttons together, use the [ButtonGroup](/packages/components/src/button-group/README.md) component.

View File

@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import type { ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import deprecated from '@wordpress/deprecated';
import { forwardRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import Button from '.';
import type { DeprecatedIconButtonProps } from './types';
function UnforwardedIconButton(
{
label,
labelPosition,
size,
tooltip,
...props
}: React.ComponentPropsWithoutRef< typeof Button > &
DeprecatedIconButtonProps,
ref: ForwardedRef< any >
) {
deprecated( 'wp.components.IconButton', {
since: '5.4',
alternative: 'wp.components.Button',
version: '6.2',
} );
return (
<Button
{ ...props }
ref={ ref }
tooltipPosition={ labelPosition }
iconSize={ size }
showTooltip={ tooltip !== undefined ? !! tooltip : undefined }
label={ tooltip || label }
/>
);
}
export default forwardRef( UnforwardedIconButton );

View File

@@ -0,0 +1,236 @@
/**
* External dependencies
*/
import {
StyleSheet,
TouchableOpacity,
Text,
View,
Platform,
} from 'react-native';
import { LongPressGestureHandler, State } from 'react-native-gesture-handler';
/**
* WordPress dependencies
*/
import { Children, cloneElement, useCallback } from '@wordpress/element';
import {
usePreferredColorScheme,
usePreferredColorSchemeStyle,
} from '@wordpress/compose';
/**
* Internal dependencies
*/
import Tooltip from '../tooltip';
import Icon from '../icon';
import style from './style.scss';
const isAndroid = Platform.OS === 'android';
const marginBottom = isAndroid ? -0.5 : 0;
const marginLeft = -3;
const styles = StyleSheet.create( {
container: {
flex: 1,
padding: 3,
justifyContent: 'center',
alignItems: 'center',
},
buttonInactive: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
fixedRatio: {
aspectRatio: 1,
},
buttonActive: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 6,
},
subscriptInactive: {
color: '#7b9ab1', // $toolbar-button.
fontWeight: 'bold',
fontSize: 13,
alignSelf: 'flex-end',
marginLeft,
marginBottom,
},
subscriptInactiveDark: {
color: '#a7aaad', // $gray_20.
},
subscriptActive: {
color: 'white',
fontWeight: 'bold',
fontSize: 13,
alignSelf: 'flex-end',
marginLeft,
marginBottom,
},
} );
export function Button( props ) {
const {
children,
onClick,
onLongPress,
disabled,
hint,
fixedRatio = true,
isPressed,
'aria-disabled': ariaDisabled,
'data-subscript': subscript,
testID,
icon,
iconSize,
showTooltip,
label,
shortcut,
tooltipPosition,
isActiveStyle,
customContainerStyles,
hitSlop,
} = props;
const preferredColorScheme = usePreferredColorScheme();
const isDisabled = ariaDisabled || disabled;
const containerStyle = [
styles.container,
customContainerStyles && { ...customContainerStyles },
];
const buttonActiveColorStyles = usePreferredColorSchemeStyle(
style[ 'components-button-light--active' ],
style[ 'components-button-dark--active' ]
);
const buttonViewStyle = {
opacity: isDisabled ? 0.3 : 1,
...( fixedRatio && styles.fixedRatio ),
...( isPressed ? styles.buttonActive : styles.buttonInactive ),
...( isPressed ? buttonActiveColorStyles : {} ),
...( isPressed &&
isActiveStyle?.borderRadius && {
borderRadius: isActiveStyle.borderRadius,
} ),
...( isActiveStyle?.backgroundColor && {
backgroundColor: isActiveStyle.backgroundColor,
} ),
};
const states = [];
if ( isPressed ) {
states.push( 'selected' );
}
if ( isDisabled ) {
states.push( 'disabled' );
}
const subscriptInactive = usePreferredColorSchemeStyle(
styles.subscriptInactive,
styles.subscriptInactiveDark
);
const newChildren = Children.map( children, ( child ) => {
return child
? cloneElement( child, {
colorScheme: preferredColorScheme,
isPressed,
} )
: child;
} );
// Should show the tooltip if...
const shouldShowTooltip =
! isDisabled &&
// An explicit tooltip is passed or...
( ( showTooltip && label ) ||
// There's a shortcut or...
shortcut ||
// There's a label and...
( !! label &&
// The children are empty and...
( ! children ||
( Array.isArray( children ) && ! children.length ) ) &&
// The tooltip is not explicitly disabled.
false !== showTooltip ) );
const newIcon = icon
? cloneElement( <Icon icon={ icon } size={ iconSize } />, {
isPressed,
} )
: null;
const longPressHandler = useCallback(
( { nativeEvent } ) => {
if ( nativeEvent.state === State.ACTIVE && onLongPress ) {
onLongPress();
}
},
[ onLongPress ]
);
const element = (
<TouchableOpacity
activeOpacity={ 0.7 }
accessible={ true }
accessibilityLabel={ label }
accessibilityStates={ states }
accessibilityRole={ 'button' }
accessibilityHint={ hint }
onPress={ onClick }
style={ containerStyle }
disabled={ isDisabled }
testID={ testID }
hitSlop={ hitSlop }
>
<LongPressGestureHandler
minDurationMs={ 500 }
maxDist={ 150 }
onHandlerStateChange={ longPressHandler }
>
<View style={ buttonViewStyle }>
<View style={ { flexDirection: 'row' } }>
{ newIcon }
{ newChildren }
{ subscript && (
<Text
style={
isPressed
? styles.subscriptActive
: subscriptInactive
}
>
{ subscript }
</Text>
) }
</View>
</View>
</LongPressGestureHandler>
</TouchableOpacity>
);
if ( ! shouldShowTooltip ) {
return element;
}
return (
<Tooltip
text={ label }
shortcut={ shortcut }
position={ tooltipPosition }
visible={ showTooltip === true }
>
{ element }
</Tooltip>
);
}
export default Button;

299
node_modules/@wordpress/components/src/button/index.tsx generated vendored Normal file
View File

@@ -0,0 +1,299 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import type {
ComponentPropsWithoutRef,
ForwardedRef,
HTMLAttributes,
MouseEvent,
ReactElement,
} from 'react';
/**
* WordPress dependencies
*/
import deprecated from '@wordpress/deprecated';
import { forwardRef } from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
import Tooltip from '../tooltip';
import Icon from '../icon';
import { VisuallyHidden } from '../visually-hidden';
import type { ButtonProps, DeprecatedButtonProps } from './types';
import { positionToPlacement } from '../popover/utils';
const disabledEventsOnDisabledButton = [ 'onMouseDown', 'onClick' ] as const;
function useDeprecatedProps( {
isDefault,
isPrimary,
isSecondary,
isTertiary,
isLink,
isPressed,
isSmall,
size,
variant,
...otherProps
}: ButtonProps & DeprecatedButtonProps ): ButtonProps {
let computedSize = size;
let computedVariant = variant;
const newProps: { 'aria-pressed'?: boolean } = {
// @TODO Mark `isPressed` as deprecated
'aria-pressed': isPressed,
};
if ( isSmall ) {
computedSize ??= 'small';
}
if ( isPrimary ) {
computedVariant ??= 'primary';
}
if ( isTertiary ) {
computedVariant ??= 'tertiary';
}
if ( isSecondary ) {
computedVariant ??= 'secondary';
}
if ( isDefault ) {
deprecated( 'Button isDefault prop', {
since: '5.4',
alternative: 'variant="secondary"',
version: '6.2',
} );
computedVariant ??= 'secondary';
}
if ( isLink ) {
computedVariant ??= 'link';
}
return {
...newProps,
...otherProps,
size: computedSize,
variant: computedVariant,
};
}
export function UnforwardedButton(
props: ButtonProps,
ref: ForwardedRef< any >
) {
const {
__next40pxDefaultSize,
isBusy,
isDestructive,
className,
disabled,
icon,
iconPosition = 'left',
iconSize,
showTooltip,
tooltipPosition,
shortcut,
label,
children,
size = 'default',
text,
variant,
__experimentalIsFocusable: isFocusable,
describedBy,
...buttonOrAnchorProps
} = useDeprecatedProps( props );
const {
href,
target,
'aria-checked': ariaChecked,
'aria-pressed': ariaPressed,
'aria-selected': ariaSelected,
...additionalProps
} = 'href' in buttonOrAnchorProps
? buttonOrAnchorProps
: { href: undefined, target: undefined, ...buttonOrAnchorProps };
const instanceId = useInstanceId(
Button,
'components-button__description'
);
const hasChildren =
( 'string' === typeof children && !! children ) ||
( Array.isArray( children ) &&
children?.[ 0 ] &&
children[ 0 ] !== null &&
// Tooltip should not considered as a child
children?.[ 0 ]?.props?.className !== 'components-tooltip' );
const truthyAriaPressedValues: ( typeof ariaPressed )[] = [
true,
'true',
'mixed',
];
const classes = classnames( 'components-button', className, {
'is-next-40px-default-size': __next40pxDefaultSize,
'is-secondary': variant === 'secondary',
'is-primary': variant === 'primary',
'is-small': size === 'small',
'is-compact': size === 'compact',
'is-tertiary': variant === 'tertiary',
'is-pressed': truthyAriaPressedValues.includes( ariaPressed ),
'is-pressed-mixed': ariaPressed === 'mixed',
'is-busy': isBusy,
'is-link': variant === 'link',
'is-destructive': isDestructive,
'has-text': !! icon && ( hasChildren || text ),
'has-icon': !! icon,
} );
const trulyDisabled = disabled && ! isFocusable;
const Tag = href !== undefined && ! trulyDisabled ? 'a' : 'button';
const buttonProps: ComponentPropsWithoutRef< 'button' > =
Tag === 'button'
? {
type: 'button',
disabled: trulyDisabled,
'aria-checked': ariaChecked,
'aria-pressed': ariaPressed,
'aria-selected': ariaSelected,
}
: {};
const anchorProps: ComponentPropsWithoutRef< 'a' > =
Tag === 'a' ? { href, target } : {};
if ( disabled && isFocusable ) {
// In this case, the button will be disabled, but still focusable and
// perceivable by screen reader users.
buttonProps[ 'aria-disabled' ] = true;
anchorProps[ 'aria-disabled' ] = true;
for ( const disabledEvent of disabledEventsOnDisabledButton ) {
additionalProps[ disabledEvent ] = ( event: MouseEvent ) => {
if ( event ) {
event.stopPropagation();
event.preventDefault();
}
};
}
}
// Should show the tooltip if...
const shouldShowTooltip =
! trulyDisabled &&
// An explicit tooltip is passed or...
( ( showTooltip && !! label ) ||
// There's a shortcut or...
!! shortcut ||
// There's a label and...
( !! label &&
// The children are empty and...
! ( children as string | ReactElement[] )?.length &&
// The tooltip is not explicitly disabled.
false !== showTooltip ) );
const descriptionId = describedBy ? instanceId : undefined;
const describedById =
additionalProps[ 'aria-describedby' ] || descriptionId;
const commonProps = {
className: classes,
'aria-label': additionalProps[ 'aria-label' ] || label,
'aria-describedby': describedById,
ref,
};
const elementChildren = (
<>
{ icon && iconPosition === 'left' && (
<Icon icon={ icon } size={ iconSize } />
) }
{ text && <>{ text }</> }
{ icon && iconPosition === 'right' && (
<Icon icon={ icon } size={ iconSize } />
) }
{ children }
</>
);
const element =
Tag === 'a' ? (
<a
{ ...anchorProps }
{ ...( additionalProps as HTMLAttributes< HTMLAnchorElement > ) }
{ ...commonProps }
>
{ elementChildren }
</a>
) : (
<button
{ ...buttonProps }
{ ...( additionalProps as HTMLAttributes< HTMLButtonElement > ) }
{ ...commonProps }
>
{ elementChildren }
</button>
);
// In order to avoid some React reconciliation issues, we are always rendering
// the `Tooltip` component even when `shouldShowTooltip` is `false`.
// In order to make sure that the tooltip doesn't show when it shouldn't,
// we don't pass the props to the `Tooltip` component.
const tooltipProps = shouldShowTooltip
? {
text:
( children as string | ReactElement[] )?.length &&
describedBy
? describedBy
: label,
shortcut,
placement:
tooltipPosition &&
// Convert legacy `position` values to be used with the new `placement` prop
positionToPlacement( tooltipPosition ),
}
: {};
return (
<>
<Tooltip { ...tooltipProps }>{ element }</Tooltip>
{ describedBy && (
<VisuallyHidden>
<span id={ descriptionId }>{ describedBy }</span>
</VisuallyHidden>
) }
</>
);
}
/**
* Lets users take actions and make choices with a single click or tap.
*
* ```jsx
* import { Button } from '@wordpress/components';
* const Mybutton = () => (
* <Button
* variant="primary"
* onClick={ handleClick }
* >
* Click here
* </Button>
* );
* ```
*/
export const Button = forwardRef( UnforwardedButton );
export default Button;

View File

@@ -0,0 +1,80 @@
/**
* External dependencies
*/
import type { StoryFn, Meta } from '@storybook/react';
/**
* WordPress dependencies
*/
import { wordpress } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { Button } from '../..';
import type { ButtonAsButtonProps } from '../../types';
const meta: Meta< typeof Button > = {
component: Button,
title: 'Components/Button',
};
export default meta;
export const VariantStates: StoryFn< typeof Button > = (
props: ButtonAsButtonProps
) => {
const variants: ( typeof props.variant )[] = [
undefined,
'primary',
'secondary',
'tertiary',
'link',
];
return (
<div style={ { display: 'flex', flexDirection: 'column', gap: 24 } }>
{ variants.map( ( variant ) => (
<div
style={ { display: 'flex', gap: 8 } }
key={ variant ?? 'undefined' }
>
<Button { ...props } variant={ variant } />
<Button { ...props } variant={ variant } disabled />
<Button { ...props } variant={ variant } isBusy />
<Button { ...props } variant={ variant } isDestructive />
<Button { ...props } variant={ variant } isPressed />
</div>
) ) }
</div>
);
};
VariantStates.args = {
children: 'Code is poetry',
};
export const Icon = VariantStates.bind( {} );
Icon.args = {
icon: wordpress,
};
export const Dashicons: StoryFn< typeof Button > = ( props ) => {
return (
<div style={ { display: 'flex', gap: 8 } }>
<Button { ...props } />
<Button { ...props }>Children</Button>
<Button { ...props } iconPosition="right">
Children (icon right)
</Button>
<Button { ...props } text="Text" />
<Button
{ ...props }
text="Text (icon right)"
iconPosition="right"
/>
</div>
);
};
Dashicons.args = {
icon: 'editor-help',
variant: 'primary',
};

View File

@@ -0,0 +1,115 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
import type { ReactNode } from 'react';
/**
* WordPress dependencies
*/
import {
formatBold,
formatItalic,
link,
more,
wordpress,
} from '@wordpress/icons';
/**
* Internal dependencies
*/
import './style.css';
import Button from '..';
const meta: Meta< typeof Button > = {
title: 'Components/Button',
component: Button,
argTypes: {
// Overrides a limitation of the docgen interpreting our TS types for this as required.
'aria-pressed': {
control: { type: 'select' },
description:
'Indicates the current "pressed" state, implying it is a toggle button. Implicitly set by `isPressed`, but takes precedence if both are provided.',
options: [ undefined, 'true', 'false', 'mixed' ],
table: {
type: {
summary: 'boolean | "true" | "false" | "mixed"',
},
},
},
href: { type: { name: 'string', required: false } },
icon: {
control: { type: 'select' },
options: [ 'wordpress', 'link', 'more' ],
mapping: {
wordpress,
link,
more,
},
},
},
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const Template: StoryFn< typeof Button > = ( props ) => {
return <Button { ...props }></Button>;
};
export const Default = Template.bind( {} );
Default.args = {
children: 'Code is poetry',
};
export const Primary = Template.bind( {} );
Primary.args = {
...Default.args,
variant: 'primary',
};
export const Secondary = Template.bind( {} );
Secondary.args = {
...Default.args,
variant: 'secondary',
};
export const Tertiary = Template.bind( {} );
Tertiary.args = {
...Default.args,
variant: 'tertiary',
};
export const Link = Template.bind( {} );
Link.args = {
...Default.args,
variant: 'link',
};
export const IsDestructive = Template.bind( {} );
IsDestructive.args = {
...Default.args,
isDestructive: true,
};
export const Icon = Template.bind( {} );
Icon.args = {
label: 'Code is poetry',
icon: 'wordpress',
};
export const GroupedIcons = () => {
const GroupContainer = ( { children }: { children: ReactNode } ) => (
<div style={ { display: 'inline-flex' } }>{ children }</div>
);
return (
<GroupContainer>
<Button icon={ formatBold } label="Bold" />
<Button icon={ formatItalic } label="Italic" />
<Button icon={ link } label="Link" />
</GroupContainer>
);
};

Some files were not shown because too many files have changed in this diff Show More