From ac742b3a1ae0ae7049c7dfc52e8d8ad597036982 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Thu, 17 Feb 2022 11:52:14 +1000 Subject: [PATCH 01/12] Add BorderBoxControl component This provides a component through which you can configure separate borders for individual sides. --- docs/manifest.json | 6 + .../component.tsx | 49 +++ .../border-box-control-linked-button/hook.ts | 30 ++ .../border-box-control-linked-button/index.ts | 1 + .../component.tsx | 77 ++++ .../border-box-control-split-controls/hook.ts | 34 ++ .../index.ts | 1 + .../component.tsx | 35 ++ .../border-box-control-visualizer/hook.ts | 30 ++ .../border-box-control-visualizer/index.ts | 1 + .../border-box-control/README.md | 152 ++++++++ .../border-box-control/component.tsx | 120 ++++++ .../border-box-control/hook.ts | 119 ++++++ .../border-box-control/index.ts | 2 + .../src/border-box-control/index.ts | 3 + .../src/border-box-control/stories/index.js | 101 +++++ .../src/border-box-control/styles.ts | 44 +++ .../src/border-box-control/test/index.js | 355 ++++++++++++++++++ .../src/border-box-control/test/utils.js | 317 ++++++++++++++++ .../src/border-box-control/types.ts | 80 ++++ .../src/border-box-control/utils.ts | 158 ++++++++ packages/components/src/index.js | 6 + packages/components/tsconfig.json | 1 + 23 files changed, 1722 insertions(+) create mode 100644 packages/components/src/border-box-control/border-box-control-linked-button/component.tsx create mode 100644 packages/components/src/border-box-control/border-box-control-linked-button/hook.ts create mode 100644 packages/components/src/border-box-control/border-box-control-linked-button/index.ts create mode 100644 packages/components/src/border-box-control/border-box-control-split-controls/component.tsx create mode 100644 packages/components/src/border-box-control/border-box-control-split-controls/hook.ts create mode 100644 packages/components/src/border-box-control/border-box-control-split-controls/index.ts create mode 100644 packages/components/src/border-box-control/border-box-control-visualizer/component.tsx create mode 100644 packages/components/src/border-box-control/border-box-control-visualizer/hook.ts create mode 100644 packages/components/src/border-box-control/border-box-control-visualizer/index.ts create mode 100644 packages/components/src/border-box-control/border-box-control/README.md create mode 100644 packages/components/src/border-box-control/border-box-control/component.tsx create mode 100644 packages/components/src/border-box-control/border-box-control/hook.ts create mode 100644 packages/components/src/border-box-control/border-box-control/index.ts create mode 100644 packages/components/src/border-box-control/index.ts create mode 100644 packages/components/src/border-box-control/stories/index.js create mode 100644 packages/components/src/border-box-control/styles.ts create mode 100644 packages/components/src/border-box-control/test/index.js create mode 100644 packages/components/src/border-box-control/test/utils.js create mode 100644 packages/components/src/border-box-control/types.ts create mode 100644 packages/components/src/border-box-control/utils.ts diff --git a/docs/manifest.json b/docs/manifest.json index 528200605e94dc..9a45a8f7317cd2 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -611,6 +611,12 @@ "markdown_source": "../packages/components/src/base-field/README.md", "parent": "components" }, + { + "title": "BorderBoxControl", + "slug": "border-box-control", + "markdown_source": "../packages/components/src/border-box-control/border-box-control/README.md", + "parent": "components" + }, { "title": "BorderControl", "slug": "border-control", diff --git a/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx b/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx new file mode 100644 index 00000000000000..a2b4e73e436f7c --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { link, linkOff } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import Tooltip from '../../tooltip'; +import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import { useBorderBoxControlLinkedButton } from './hook'; + +import type { LinkedButtonProps } from '../types'; + +const BorderBoxControlLinkedButton = ( + props: WordPressComponentProps< LinkedButtonProps, 'div' >, + forwardedRef: React.Ref< any > +) => { + const { + className, + isLinked, + ...buttonProps + } = useBorderBoxControlLinkedButton( props ); + const label = isLinked ? __( 'Unlink sides' ) : __( 'Link sides' ); + + return ( + +
+
+
+ ); +}; + +const ConnectedBorderBoxControlLinkedButton = contextConnect( + BorderBoxControlLinkedButton, + 'BorderBoxControlLinkedButton' +); +export default ConnectedBorderBoxControlLinkedButton; diff --git a/packages/components/src/border-box-control/border-box-control-linked-button/hook.ts b/packages/components/src/border-box-control/border-box-control-linked-button/hook.ts new file mode 100644 index 00000000000000..38e4e49bdfe996 --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-linked-button/hook.ts @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as styles from '../styles'; +import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import { useCx } from '../../utils/hooks/use-cx'; + +import type { LinkedButtonProps } from '../types'; + +export function useBorderBoxControlLinkedButton( + props: WordPressComponentProps< LinkedButtonProps, 'div' > +) { + const { className, ...otherProps } = useContextSystem( + props, + 'BorderBoxControlLinkedButton' + ); + + // Generate class names. + const cx = useCx(); + const classes = useMemo( () => { + return cx( styles.BorderBoxControlLinkedButton, className ); + }, [ className ] ); + + return { ...otherProps, className: classes }; +} diff --git a/packages/components/src/border-box-control/border-box-control-linked-button/index.ts b/packages/components/src/border-box-control/border-box-control-linked-button/index.ts new file mode 100644 index 00000000000000..b404d7fd44a81a --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-linked-button/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx new file mode 100644 index 00000000000000..d4a7c8d5753cda --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import BorderBoxControlVisualizer from '../border-box-control-visualizer'; +import { BorderControl } from '../../border-control'; +import { View } from '../../view'; +import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import { useBorderBoxControlSplitControls } from './hook'; + +import type { SplitControlsProps } from '../types'; + +const BorderBoxControlSplitControls = ( + props: WordPressComponentProps< SplitControlsProps, 'div' >, + forwardedRef: React.Ref< any > +) => { + const { + centeredClassName, + colors, + disableCustomColors, + enableAlpha, + enableStyle, + onChange, + value, + __experimentalHasMultipleOrigins, + __experimentalIsRenderedInSidebar, + ...otherProps + } = useBorderBoxControlSplitControls( props ); + + const sharedBorderControlProps = { + colors, + disableCustomColors, + enableAlpha, + enableStyle, + isCompact: true, + __experimentalHasMultipleOrigins, + __experimentalIsRenderedInSidebar, + }; + + return ( + + + onChange( newBorder, 'top' ) } + value={ value?.top } + { ...sharedBorderControlProps } + /> + onChange( newBorder, 'left' ) } + value={ value?.left } + { ...sharedBorderControlProps } + /> + onChange( newBorder, 'right' ) } + value={ value?.right } + { ...sharedBorderControlProps } + /> + onChange( newBorder, 'bottom' ) } + value={ value?.bottom } + { ...sharedBorderControlProps } + /> + + ); +}; + +const ConnectedBorderBoxControlSplitControls = contextConnect( + BorderBoxControlSplitControls, + 'BorderBoxControlSplitControls' +); +export default ConnectedBorderBoxControlSplitControls; diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts b/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts new file mode 100644 index 00000000000000..f8d4d148850dca --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as styles from '../styles'; +import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import { useCx } from '../../utils/hooks/use-cx'; + +import type { SplitControlsProps } from '../types'; + +export function useBorderBoxControlSplitControls( + props: WordPressComponentProps< SplitControlsProps, 'div' > +) { + const { className, ...otherProps } = useContextSystem( + props, + 'BorderBoxControlSplitControls' + ); + + // Generate class names. + const cx = useCx(); + const classes = useMemo( () => { + return cx( styles.BorderBoxControlSplitControls, className ); + }, [ className ] ); + + const centeredClassName = useMemo( () => { + return cx( styles.CenteredBorderControl, className ); + }, [] ); + + return { ...otherProps, centeredClassName, className: classes }; +} diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/index.ts b/packages/components/src/border-box-control/border-box-control-split-controls/index.ts new file mode 100644 index 00000000000000..b404d7fd44a81a --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-split-controls/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx new file mode 100644 index 00000000000000..1ac0f3d97aa55a --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { View } from '../../view'; +import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import { getClampedWidthBorderStyle } from '../utils'; +import { useBorderBoxControlVisualizer } from './hook'; + +import type { VisualizerProps } from '../types'; + +const BorderBoxControlVisualizer = ( + props: WordPressComponentProps< VisualizerProps, 'div' >, + forwardedRef: React.Ref< any > +) => { + const { value, ...otherProps } = useBorderBoxControlVisualizer( props ); + const styles = { + borderTop: getClampedWidthBorderStyle( value?.top ), + borderRight: getClampedWidthBorderStyle( value?.right ), + borderBottom: getClampedWidthBorderStyle( value?.bottom ), + borderLeft: getClampedWidthBorderStyle( value?.left ), + }; + + return ; +}; + +const ConnectedBorderBoxControlVisualizer = contextConnect( + BorderBoxControlVisualizer, + 'BorderBoxControlVisualizer' +); +export default ConnectedBorderBoxControlVisualizer; diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts b/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts new file mode 100644 index 00000000000000..7299ddc980ef2f --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as styles from '../styles'; +import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import { useCx } from '../../utils/hooks/use-cx'; + +import type { VisualizerProps } from '../types'; + +export function useBorderBoxControlVisualizer( + props: WordPressComponentProps< VisualizerProps, 'div' > +) { + const { className, ...otherProps } = useContextSystem( + props, + 'BorderBoxControlVisualizer' + ); + + // Generate class names. + const cx = useCx(); + const classes = useMemo( () => { + return cx( styles.BorderBoxControlVisualizer, className ); + }, [ className ] ); + + return { ...otherProps, className: classes }; +} diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/index.ts b/packages/components/src/border-box-control/border-box-control-visualizer/index.ts new file mode 100644 index 00000000000000..b404d7fd44a81a --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control-visualizer/index.ts @@ -0,0 +1 @@ +export { default } from './component'; diff --git a/packages/components/src/border-box-control/border-box-control/README.md b/packages/components/src/border-box-control/border-box-control/README.md new file mode 100644 index 00000000000000..0706d0e20f63d0 --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control/README.md @@ -0,0 +1,152 @@ +# BorderBoxControl + +
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+
+ +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 { __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 ( + + ); +}; +``` + +## Props + +### `colors`: `Array` + +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 + +### `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 + +### `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 + +### `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 + +### `__experimentalHasMultipleOrigins`: `boolean` + +This is passed on to the color related sub-components which need to be made +aware of whether the colors prop contains multiple origins. + +- Required: No + +### `__experimentalIsRenderedInSidebar`: `boolean` + +This is passed on to the color related sub-components so they may render more +effectively when used within a sidebar. + +- Required: No diff --git a/packages/components/src/border-box-control/border-box-control/component.tsx b/packages/components/src/border-box-control/border-box-control/component.tsx new file mode 100644 index 00000000000000..39e6d68525c608 --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control/component.tsx @@ -0,0 +1,120 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import BorderBoxControlLinkedButton from '../border-box-control-linked-button'; +import BorderBoxControlSplitControls from '../border-box-control-split-controls'; +import { BorderControl } from '../../border-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 { contextConnect, WordPressComponentProps } from '../../ui/context'; +import { useBorderBoxControl } from './hook'; + +import type { BorderBoxControlProps } from '../types'; +import type { LabelProps } from '../../border-control/types'; + +const BorderLabel = ( props: LabelProps ) => { + const { label, hideLabelFromVision } = props; + + if ( ! label ) { + return null; + } + + return hideLabelFromVision ? ( + { label } + ) : ( + { label } + ); +}; + +const BorderBoxControl = ( + props: WordPressComponentProps< BorderBoxControlProps, 'div' >, + forwardedRef: React.Ref< any > +) => { + const { + className, + colors, + disableCustomColors, + enableAlpha, + enableStyle, + hasMixedBorders, + hideLabelFromVision, + isLinked, + label, + linkedControlClassName, + linkedValue, + onLinkedChange, + onSplitChange, + splitValue, + toggleLinked, + __experimentalHasMultipleOrigins, + __experimentalIsRenderedInSidebar, + ...otherProps + } = useBorderBoxControl( props ); + + return ( + + + + { isLinked ? ( + + ) : ( + + ) } + + + + ); +}; + +const ConnectedBorderBoxControl = contextConnect( + BorderBoxControl, + 'BorderBoxControl' +); + +export default ConnectedBorderBoxControl; diff --git a/packages/components/src/border-box-control/border-box-control/hook.ts b/packages/components/src/border-box-control/border-box-control/hook.ts new file mode 100644 index 00000000000000..f0071f0e87d29e --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control/hook.ts @@ -0,0 +1,119 @@ +/** + * 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 { useContextSystem, WordPressComponentProps } from '../../ui/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, onChange, value, ...otherProps } = useContextSystem( + props, + 'BorderBoxControl' + ); + + 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 ); + + 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 ); + }, [ className ] ); + + const linkedControlClassName = useMemo( () => { + return cx( styles.LinkedBorderControl ); + }, [] ); + + return { + ...otherProps, + className: classes, + hasMixedBorders: mixedBorders, + isLinked, + linkedControlClassName, + onLinkedChange, + onSplitChange, + toggleLinked, + linkedValue, + splitValue, + }; +} diff --git a/packages/components/src/border-box-control/border-box-control/index.ts b/packages/components/src/border-box-control/border-box-control/index.ts new file mode 100644 index 00000000000000..daaf27db19b825 --- /dev/null +++ b/packages/components/src/border-box-control/border-box-control/index.ts @@ -0,0 +1,2 @@ +export { default as BorderBoxControl } from './component'; +export { useBorderBoxControl } from './hook'; diff --git a/packages/components/src/border-box-control/index.ts b/packages/components/src/border-box-control/index.ts new file mode 100644 index 00000000000000..97f56a171ac728 --- /dev/null +++ b/packages/components/src/border-box-control/index.ts @@ -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'; diff --git a/packages/components/src/border-box-control/stories/index.js b/packages/components/src/border-box-control/stories/index.js new file mode 100644 index 00000000000000..315c94270e2005 --- /dev/null +++ b/packages/components/src/border-box-control/stories/index.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import styled from '@emotion/styled'; + +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import { BorderBoxControl } from '../'; + +// Available border colors. +const colors = [ + { name: 'Gray 0', color: '#f6f7f7' }, + { name: 'Gray 5', color: '#dcdcde' }, + { name: 'Gray 20', color: '#a7aaad' }, + { name: 'Gray 70', color: '#3c434a' }, + { name: 'Gray 100', color: '#101517' }, + { name: 'Blue 20', color: '#72aee6' }, + { name: 'Blue 40', color: '#3582c4' }, + { name: 'Blue 70', color: '#0a4b78' }, + { name: 'Red 40', color: '#e65054' }, + { name: 'Red 70', color: '#8a2424' }, + { name: 'Green 10', color: '#68de7c' }, + { name: 'Green 40', color: '#00a32a' }, + { name: 'Green 60', color: '#007017' }, + { name: 'Yellow 10', color: '#f2d675' }, + { name: 'Yellow 40', color: '#bd8600' }, +]; + +export default { + title: 'Components (Experimental)/BorderBoxControl', + component: BorderBoxControl, + parameters: { + knobs: { disable: false }, + }, +}; + +const _default = ( props ) => { + const { defaultBorder } = props; + const [ borders, setBorders ] = useState( defaultBorder ); + + useEffect( () => setBorders( defaultBorder ), [ defaultBorder ] ); + + return ( + <> + + setBorders( newBorders ) } + value={ borders } + { ...props } + /> + + + + The BorderBoxControl is intended to be used within a component + that will provide reset controls. The button below is only for + convenience. + + + + ); +}; + +export const Default = _default.bind( {} ); +Default.args = { + disableCustomColors: false, + enableAlpha: true, + enableStyle: true, + defaultBorder: { + color: '#72aee6', + style: 'dashed', + width: '1px', + }, +}; + +const WrapperView = styled.div` + max-width: 280px; + padding: 16px; +`; + +const Separator = styled.hr` + margin-top: 100px; + border-color: #ddd; + border-style: solid; + border-bottom: none; +`; + +const HelpText = styled.p` + color: #aaa; + font-size: 0.9em; +`; diff --git a/packages/components/src/border-box-control/styles.ts b/packages/components/src/border-box-control/styles.ts new file mode 100644 index 00000000000000..21e25ec6a52b6c --- /dev/null +++ b/packages/components/src/border-box-control/styles.ts @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { css } from '@emotion/react'; + +/** + * Internal dependencies + */ +import { COLORS, CONFIG } from '../utils'; +import { space } from '../ui/utils/space'; + +export const BorderBoxControl = css``; + +export const LinkedBorderControl = css` + flex: 1; +`; + +export const BorderBoxControlLinkedButton = css` + flex: 0; + flex-basis: 36px; + margin-top: 7px; +`; + +export const BorderBoxControlVisualizer = css` + border: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] }; + position: absolute; + top: 20px; + right: 30px; + bottom: 20px; + left: 30px; +`; + +export const BorderBoxControlSplitControls = css` + display: grid; + position: relative; + gap: ${ space( 4 ) }; + flex: 1; + margin-right: ${ space( 3 ) }; +`; + +export const CenteredBorderControl = css` + grid-column: span 2; + margin: 0 auto; +`; diff --git a/packages/components/src/border-box-control/test/index.js b/packages/components/src/border-box-control/test/index.js new file mode 100644 index 00000000000000..b9fce10c7d6de4 --- /dev/null +++ b/packages/components/src/border-box-control/test/index.js @@ -0,0 +1,355 @@ +/** + * External dependencies + */ +import { fireEvent, render, screen } from '@testing-library/react'; + +/** + * 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 renderBorderBoxControl = ( customProps ) => { + return render( ); +}; + +const clickButton = ( name ) => { + fireEvent.click( screen.getByRole( 'button', { name } ) ); +}; + +const queryButton = ( name ) => { + return screen.queryByRole( 'button', { name } ); +}; + +const updateLinkedWidthInput = ( value ) => { + const widthInput = screen.getByRole( 'spinbutton' ); + widthInput.focus(); + fireEvent.change( widthInput, { target: { value } } ); +}; + +const updateSplitWidthInput = ( value, index = 0 ) => { + const splitInputs = screen.getAllByRole( 'spinbutton' ); + splitInputs[ index ].focus(); + fireEvent.change( splitInputs[ index ], { target: { value } } ); +}; + +describe( 'BorderBoxControl', () => { + describe( 'Linked view rendering', () => { + it( 'should render correctly when no value provided', () => { + renderBorderBoxControl(); + + const label = screen.getByText( props.label ); + const colorButton = screen.getByLabelText( 'Open border options' ); + const widthInput = screen.getByRole( 'spinbutton' ); + const unitSelect = screen.getByRole( 'combobox' ); + const slider = screen.getByRole( 'slider' ); + 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', () => { + renderBorderBoxControl( { hideLabelFromVision: true } ); + 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', () => { + renderBorderBoxControl( { value: defaultBorder } ); + const widthInput = screen.getByRole( 'spinbutton' ); + + expect( widthInput.value ).toBe( '1' ); + } ); + + it( 'should show correct width value when consistent split borders provided', () => { + renderBorderBoxControl( { value: defaultBorders } ); + const widthInput = screen.getByRole( 'spinbutton' ); + + expect( widthInput.value ).toBe( '1' ); + } ); + + it( 'should render placeholder when border values are mixed', () => { + renderBorderBoxControl( { value: mixedBorders } ); + + // First render of control with mixed values should show split view. + clickButton( 'Link sides' ); + + const widthInput = screen.getByRole( 'spinbutton' ); + expect( widthInput ).toHaveAttribute( 'placeholder', 'Mixed' ); + } ); + + it( 'should render shared border width when switching to linked view', async () => { + // Render control with mixed border values but consistent widths. + renderBorderBoxControl( { + 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. + clickButton( 'Link sides' ); + const linkedInput = screen.getByRole( 'spinbutton' ); + + expect( linkedInput.value ).toBe( '5' ); + } ); + + it( 'should omit style options when requested', () => { + renderBorderBoxControl( { enableStyle: false } ); + + const colorButton = screen.getByLabelText( 'Open border options' ); + fireEvent.click( colorButton ); + + 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( 'Split view rendering', () => { + it( 'should render split view by default when mixed values provided', () => { + renderBorderBoxControl( { value: mixedBorders } ); + + const colorButtons = screen.getAllByLabelText( + 'Open border options' + ); + const widthInputs = screen.getAllByRole( 'spinbutton' ); + const unitSelects = screen.getAllByRole( 'combobox' ); + const sliders = screen.queryAllByRole( 'slider' ); + 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', () => { + renderBorderBoxControl( { value: mixedBorders } ); + + const widthInputs = screen.getAllByRole( 'spinbutton' ); + + 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', () => { + renderBorderBoxControl( { value: defaultBorders } ); + clickButton( 'Unlink sides' ); + + const widthInputs = screen.getAllByRole( 'spinbutton' ); + 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. + } ); + + it( 'should omit style options when requested', () => { + renderBorderBoxControl( { enableStyle: false } ); + clickButton( 'Unlink sides' ); + + const colorButtons = screen.getAllByLabelText( + 'Open border options' + ); + + colorButtons.forEach( ( button ) => { + fireEvent.click( button ); + + 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(); + + fireEvent.click( button ); + } ); + } ); + } ); + + describe( 'onChange handling', () => { + beforeEach( () => { + jest.clearAllMocks(); + props.value = undefined; + } ); + + describe( 'Linked value change handling', () => { + it( 'should set undefined when new border is empty', () => { + renderBorderBoxControl( { value: { width: '1px' } } ); + updateLinkedWidthInput( '' ); + + expect( props.onChange ).toHaveBeenCalledWith( undefined ); + } ); + + it( 'should update with complete flat border', () => { + renderBorderBoxControl( { value: defaultBorder } ); + updateLinkedWidthInput( '3' ); + + expect( props.onChange ).toHaveBeenCalledWith( { + ...defaultBorder, + width: '3px', + } ); + } ); + + it( 'should maintain mixed values if not explicitly set via linked control', () => { + renderBorderBoxControl( { + value: { + top: { color: '#72aee6' }, + right: { color: '#f6f7f7', style: 'dashed' }, + bottom: { color: '#e65054', style: 'dotted' }, + left: { color: undefined }, + }, + } ); + + clickButton( 'Link sides' ); + updateLinkedWidthInput( '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', () => { + renderBorderBoxControl( { value: defaultBorders } ); + updateLinkedWidthInput( '10' ); + + expect( props.onChange ).toHaveBeenCalledWith( { + ...defaultBorder, + width: '10px', + } ); + } ); + + it( 'should set undefined borders when change results in empty borders', () => { + renderBorderBoxControl( { + value: { + top: { width: '1px' }, + right: { width: '1px' }, + bottom: { width: '1px' }, + left: { width: '1px' }, + }, + } ); + updateLinkedWidthInput( '' ); + + expect( props.onChange ).toHaveBeenCalledWith( undefined ); + } ); + + it( 'should set flat border when change results in consistent split borders', () => { + renderBorderBoxControl( { + value: { + top: { ...defaultBorder, width: '1px' }, + right: { ...defaultBorder, width: '2px' }, + bottom: { ...defaultBorder, width: '3px' }, + left: { ...defaultBorder, width: '4px' }, + }, + } ); + + clickButton( 'Link sides' ); + updateLinkedWidthInput( '10' ); + + expect( props.onChange ).toHaveBeenCalledWith( { + ...defaultBorder, + width: '10px', + } ); + } ); + } ); + + describe( 'Split value change handling', () => { + it( 'should set split borders when the updated borders are mixed', () => { + const borders = { + top: { ...defaultBorder, width: '1px' }, + right: { ...defaultBorder, width: '2px' }, + bottom: { ...defaultBorder, width: '3px' }, + left: { ...defaultBorder, width: '4px' }, + }; + + renderBorderBoxControl( { value: borders } ); + updateSplitWidthInput( '5' ); + + expect( props.onChange ).toHaveBeenCalledWith( { + ...borders, + top: { ...defaultBorder, width: '5px' }, + } ); + } ); + + it( 'should set flat border when updated borders are consistent', () => { + const borders = { + top: { ...defaultBorder, width: '4px' }, + right: { ...defaultBorder, width: '1px' }, + bottom: { ...defaultBorder, width: '1px' }, + left: { ...defaultBorder, width: '1px' }, + }; + + renderBorderBoxControl( { value: borders } ); + updateSplitWidthInput( '1' ); + + expect( props.onChange ).toHaveBeenCalledWith( defaultBorder ); + } ); + } ); + } ); +} ); diff --git a/packages/components/src/border-box-control/test/utils.js b/packages/components/src/border-box-control/test/utils.js new file mode 100644 index 00000000000000..c715cfe5ffc9c4 --- /dev/null +++ b/packages/components/src/border-box-control/test/utils.js @@ -0,0 +1,317 @@ +/** + * Internal dependencies + */ +import { + getBorderDiff, + getClampedWidthBorderStyle, + 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 ); + expect( isEmptyBorder( null ) ).toBe( true ); + expect( isEmptyBorder( {} ) ).toBe( true ); + } ); + + it( 'should determine object missing all border props to be empty', () => { + 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 ); + expect( isCompleteBorder( null ) ).toBe( false ); + expect( isCompleteBorder( {} ) ).toBe( false ); + } ); + + it( 'should determine objects missing border props to be incomplete', () => { + 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 ); + 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 ); + expect( getSplitBorders( null ) ).toEqual( undefined ); + } ); + + it( 'should return undefined when supplied border is empty', () => { + expect( getSplitBorders( {} ) ).toEqual( undefined ); + 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', () => { + 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', + 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 ); + } ); + } ); + + describe( 'getShorthandBorderStyle', () => { + it( 'should return undefined when no border provided', () => { + expect( getShorthandBorderStyle( undefined ) ).toEqual( undefined ); + expect( getShorthandBorderStyle( {} ) ).toEqual( undefined ); + 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' ); + } ); + } ); + + describe( 'getClampedWidthBorderStyle', () => { + it( 'should return undefined when no border provided', () => { + const style = getClampedWidthBorderStyle( undefined ); + expect( style ).toEqual( undefined ); + } ); + + it( 'should wrap defined width within clamp() in generated style', () => { + const style = getClampedWidthBorderStyle( + completeBorder, + '2px', + '3em' + ); + expect( style ).toEqual( + `clamp(2px, ${ completeBorder.width }, 3em) solid #000` + ); + + const zeroWidthStyle = getClampedWidthBorderStyle( + { color: '#fff', style: 'solid', width: 0 }, + '2px', + '3em' + ); + expect( zeroWidthStyle ).toEqual( `clamp(2px, 0, 3em) solid #fff` ); + } ); + + it( 'should not add clamp() when border width is undefined', () => { + const style = getClampedWidthBorderStyle( { + color: '#fff', + style: 'dashed', + width: undefined, + } ); + expect( style ).toEqual( `dashed #fff` ); + } ); + } ); +} ); diff --git a/packages/components/src/border-box-control/types.ts b/packages/components/src/border-box-control/types.ts new file mode 100644 index 00000000000000..c07f197c3f65f6 --- /dev/null +++ b/packages/components/src/border-box-control/types.ts @@ -0,0 +1,80 @@ +/** + * Internal dependencies + */ +import type { Border, ColorProps, LabelProps } from '../border-control/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 & { + /** + * This controls whether to support border style selections. + */ + enableStyle?: boolean; + /** + * 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; + /** + * 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; + }; + +export type LinkedButtonProps = { + /** + * 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 = { + /** + * 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 & { + /** + * This controls whether to include border style options within the + * individual `BorderControl` components. + */ + enableStyle?: boolean; + /** + * A callback that is invoked whenever an individual side's border has + * changed. + */ + onChange: ( value: Border | undefined, side: BorderSide ) => void; + /** + * 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; +}; diff --git a/packages/components/src/border-box-control/utils.ts b/packages/components/src/border-box-control/utils.ts new file mode 100644 index 00000000000000..8c0803cc364f71 --- /dev/null +++ b/packages/components/src/border-box-control/utils.ts @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import type { CSSProperties } from 'react'; + +/** + * Internal dependencies + */ +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 ] : undefined, + }; +}; + +export const getShorthandBorderStyle = ( border?: Border ) => { + if ( isEmptyBorder( border ) ) { + return undefined; + } + + const { color, style, width } = border as Border; + const hasVisibleBorder = ( !! width && width !== '0' ) || !! color; + const borderStyle = hasVisibleBorder ? style || 'solid' : style; + + return [ width, borderStyle, color ].filter( Boolean ).join( ' ' ); +}; + +export const getClampedWidthBorderStyle = ( + border: Border | undefined, + min = '1px', + max = '10px' +) => { + if ( ! border ) { + return undefined; + } + + return getShorthandBorderStyle( { + ...border, + width: + border.width !== undefined + ? `clamp(${ min }, ${ border.width }, ${ max })` + : undefined, + } ); +}; diff --git a/packages/components/src/index.js b/packages/components/src/index.js index 16a41cf6143568..b3a820f05a38d7 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -23,6 +23,12 @@ export { useAutocompleteProps as __unstableUseAutocompleteProps, } from './autocomplete'; export { default as BaseControl } from './base-control'; +export { + BorderBoxControl as __experimentalBorderBoxControl, + hasSplitBorders as __experimentalHasSplitBorders, + isDefinedBorder as __experimentalIsDefinedBorder, + isEmptyBorder as __experimentalIsEmptyBorder, +} from './border-box-control'; export { BorderControl as __experimentalBorderControl } from './border-control'; export { default as __experimentalBoxControl } from './box-control'; export { default as Button } from './button'; diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index 0fc06f1977183c..53f2fde0352449 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -26,6 +26,7 @@ "src/animate/**/*", "src/base-control/**/*", "src/base-field/**/*", + "src/border-box-control/**/*", "src/border-control/**/*", "src/button/**/*", "src/card/**/*", From d318d89737060960adbca252a7d4bb12231dcc43 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Fri, 18 Mar 2022 16:39:36 +1000 Subject: [PATCH 02/12] Add RTL styles for split border controls --- .../border-box-control-split-controls/hook.ts | 4 ++-- packages/components/src/border-box-control/styles.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts b/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts index f8d4d148850dca..d7e018d7580ea0 100644 --- a/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts +++ b/packages/components/src/border-box-control/border-box-control-split-controls/hook.ts @@ -8,7 +8,7 @@ import { useMemo } from '@wordpress/element'; */ import * as styles from '../styles'; import { useContextSystem, WordPressComponentProps } from '../../ui/context'; -import { useCx } from '../../utils/hooks/use-cx'; +import { useCx, rtl } from '../../utils/'; import type { SplitControlsProps } from '../types'; @@ -24,7 +24,7 @@ export function useBorderBoxControlSplitControls( const cx = useCx(); const classes = useMemo( () => { return cx( styles.BorderBoxControlSplitControls, className ); - }, [ className ] ); + }, [ className, rtl.watch() ] ); const centeredClassName = useMemo( () => { return cx( styles.CenteredBorderControl, className ); diff --git a/packages/components/src/border-box-control/styles.ts b/packages/components/src/border-box-control/styles.ts index 21e25ec6a52b6c..8a42117fa74c5e 100644 --- a/packages/components/src/border-box-control/styles.ts +++ b/packages/components/src/border-box-control/styles.ts @@ -6,7 +6,7 @@ import { css } from '@emotion/react'; /** * Internal dependencies */ -import { COLORS, CONFIG } from '../utils'; +import { COLORS, CONFIG, rtl } from '../utils'; import { space } from '../ui/utils/space'; export const BorderBoxControl = css``; @@ -35,7 +35,7 @@ export const BorderBoxControlSplitControls = css` position: relative; gap: ${ space( 4 ) }; flex: 1; - margin-right: ${ space( 3 ) }; + ${ rtl( { marginRight: space( 3 ) }, { marginLeft: space( 3 ) } )() } `; export const CenteredBorderControl = css` From 21757a4cfa4e346d5b6222e9c9c7efacb367d473 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Fri, 18 Mar 2022 18:13:48 +1000 Subject: [PATCH 03/12] Use ForwardRef for typing instead of Ref --- .../border-box-control-linked-button/component.tsx | 2 +- .../border-box-control-split-controls/component.tsx | 2 +- .../border-box-control-visualizer/component.tsx | 2 +- .../src/border-box-control/border-box-control/component.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx b/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx index a2b4e73e436f7c..396989d56aa7f0 100644 --- a/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx @@ -16,7 +16,7 @@ import type { LinkedButtonProps } from '../types'; const BorderBoxControlLinkedButton = ( props: WordPressComponentProps< LinkedButtonProps, 'div' >, - forwardedRef: React.Ref< any > + forwardedRef: React.ForwardedRef< any > ) => { const { className, diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx index d4a7c8d5753cda..66f9817bf76acb 100644 --- a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx @@ -16,7 +16,7 @@ import type { SplitControlsProps } from '../types'; const BorderBoxControlSplitControls = ( props: WordPressComponentProps< SplitControlsProps, 'div' >, - forwardedRef: React.Ref< any > + forwardedRef: React.ForwardedRef< any > ) => { const { centeredClassName, diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx index 1ac0f3d97aa55a..d392b6d4d7ef95 100644 --- a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx @@ -15,7 +15,7 @@ import type { VisualizerProps } from '../types'; const BorderBoxControlVisualizer = ( props: WordPressComponentProps< VisualizerProps, 'div' >, - forwardedRef: React.Ref< any > + forwardedRef: React.ForwardedRef< any > ) => { const { value, ...otherProps } = useBorderBoxControlVisualizer( props ); const styles = { diff --git a/packages/components/src/border-box-control/border-box-control/component.tsx b/packages/components/src/border-box-control/border-box-control/component.tsx index 39e6d68525c608..05018cf80fe76c 100644 --- a/packages/components/src/border-box-control/border-box-control/component.tsx +++ b/packages/components/src/border-box-control/border-box-control/component.tsx @@ -35,7 +35,7 @@ const BorderLabel = ( props: LabelProps ) => { const BorderBoxControl = ( props: WordPressComponentProps< BorderBoxControlProps, 'div' >, - forwardedRef: React.Ref< any > + forwardedRef: React.ForwardedRef< any > ) => { const { className, From 9062684b950c3570e48b915d2cd992bb26600896 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 21 Mar 2022 17:40:44 +1000 Subject: [PATCH 04/12] Move inline visualizer styles to dynamic class --- .../component.tsx | 11 ++----- .../border-box-control-visualizer/hook.ts | 8 ++--- .../src/border-box-control/styles.ts | 32 ++++++++++++++----- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx index d392b6d4d7ef95..1f4bdf0a7f52c2 100644 --- a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx @@ -8,7 +8,6 @@ import { __ } from '@wordpress/i18n'; */ import { View } from '../../view'; import { contextConnect, WordPressComponentProps } from '../../ui/context'; -import { getClampedWidthBorderStyle } from '../utils'; import { useBorderBoxControlVisualizer } from './hook'; import type { VisualizerProps } from '../types'; @@ -17,15 +16,9 @@ const BorderBoxControlVisualizer = ( props: WordPressComponentProps< VisualizerProps, 'div' >, forwardedRef: React.ForwardedRef< any > ) => { - const { value, ...otherProps } = useBorderBoxControlVisualizer( props ); - const styles = { - borderTop: getClampedWidthBorderStyle( value?.top ), - borderRight: getClampedWidthBorderStyle( value?.right ), - borderBottom: getClampedWidthBorderStyle( value?.bottom ), - borderLeft: getClampedWidthBorderStyle( value?.left ), - }; + const visualizerProps = useBorderBoxControlVisualizer( props ); - return ; + return ; }; const ConnectedBorderBoxControlVisualizer = contextConnect( diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts b/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts index 7299ddc980ef2f..6ec92b2f518058 100644 --- a/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts +++ b/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts @@ -15,7 +15,7 @@ import type { VisualizerProps } from '../types'; export function useBorderBoxControlVisualizer( props: WordPressComponentProps< VisualizerProps, 'div' > ) { - const { className, ...otherProps } = useContextSystem( + const { className, value, ...otherProps } = useContextSystem( props, 'BorderBoxControlVisualizer' ); @@ -23,8 +23,8 @@ export function useBorderBoxControlVisualizer( // Generate class names. const cx = useCx(); const classes = useMemo( () => { - return cx( styles.BorderBoxControlVisualizer, className ); - }, [ className ] ); + return cx( styles.BorderBoxControlVisualizer( value ), className ); + }, [ className, value ] ); - return { ...otherProps, className: classes }; + return { ...otherProps, className: classes, value }; } diff --git a/packages/components/src/border-box-control/styles.ts b/packages/components/src/border-box-control/styles.ts index 8a42117fa74c5e..26e1151b566a9e 100644 --- a/packages/components/src/border-box-control/styles.ts +++ b/packages/components/src/border-box-control/styles.ts @@ -8,6 +8,10 @@ import { css } from '@emotion/react'; */ import { COLORS, CONFIG, rtl } from '../utils'; import { space } from '../ui/utils/space'; +import { getClampedWidthBorderStyle } from './utils'; + +import type { Border } from '../border-control/types'; +import type { Borders } from './types'; export const BorderBoxControl = css``; @@ -21,14 +25,26 @@ export const BorderBoxControlLinkedButton = css` margin-top: 7px; `; -export const BorderBoxControlVisualizer = css` - border: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] }; - position: absolute; - top: 20px; - right: 30px; - bottom: 20px; - left: 30px; -`; +export const BorderBoxStyleWithFallback = ( border?: Border ) => { + return ( + getClampedWidthBorderStyle( border ) || + `${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] }` + ); +}; + +export const BorderBoxControlVisualizer = ( borders?: Borders ) => { + return css` + border-top: ${ BorderBoxStyleWithFallback( borders?.top ) }; + border-right: ${ BorderBoxStyleWithFallback( borders?.right ) }; + border-bottom: ${ BorderBoxStyleWithFallback( borders?.bottom ) }; + border-left: ${ BorderBoxStyleWithFallback( borders?.left ) }; + position: absolute; + top: 20px; + right: 30px; + bottom: 20px; + left: 30px; + `; +}; export const BorderBoxControlSplitControls = css` display: grid; From d3254227c41ce93070bdcc9d8b08e1bad9b8ceda Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 21 Mar 2022 17:51:02 +1000 Subject: [PATCH 05/12] Add RTL styles for BorderBoxControlVisualizer --- .../border-box-control-visualizer/hook.ts | 4 ++-- packages/components/src/border-box-control/styles.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts b/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts index 6ec92b2f518058..cebca985db3254 100644 --- a/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts +++ b/packages/components/src/border-box-control/border-box-control-visualizer/hook.ts @@ -8,7 +8,7 @@ import { useMemo } from '@wordpress/element'; */ import * as styles from '../styles'; import { useContextSystem, WordPressComponentProps } from '../../ui/context'; -import { useCx } from '../../utils/hooks/use-cx'; +import { useCx, rtl } from '../../utils'; import type { VisualizerProps } from '../types'; @@ -24,7 +24,7 @@ export function useBorderBoxControlVisualizer( const cx = useCx(); const classes = useMemo( () => { return cx( styles.BorderBoxControlVisualizer( value ), className ); - }, [ className, value ] ); + }, [ className, value, rtl.watch() ] ); return { ...otherProps, className: classes, value }; } diff --git a/packages/components/src/border-box-control/styles.ts b/packages/components/src/border-box-control/styles.ts index 26e1151b566a9e..bd37fb16c1059d 100644 --- a/packages/components/src/border-box-control/styles.ts +++ b/packages/components/src/border-box-control/styles.ts @@ -34,15 +34,19 @@ export const BorderBoxStyleWithFallback = ( border?: Border ) => { export const BorderBoxControlVisualizer = ( borders?: Borders ) => { return css` - border-top: ${ BorderBoxStyleWithFallback( borders?.top ) }; - border-right: ${ BorderBoxStyleWithFallback( borders?.right ) }; - border-bottom: ${ BorderBoxStyleWithFallback( borders?.bottom ) }; - border-left: ${ BorderBoxStyleWithFallback( borders?.left ) }; position: absolute; top: 20px; right: 30px; bottom: 20px; left: 30px; + border-top: ${ BorderBoxStyleWithFallback( borders?.top ) }; + border-bottom: ${ BorderBoxStyleWithFallback( borders?.bottom ) }; + ${ rtl( { + borderLeft: BorderBoxStyleWithFallback( borders?.left ), + } )() } + ${ rtl( { + borderRight: BorderBoxStyleWithFallback( borders?.right ), + } )() } `; }; From ad3f0e286f69c7b2639e3bb6690df9c26433b91a Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 21 Mar 2022 17:56:38 +1000 Subject: [PATCH 06/12] Avoid using hardcoded DOM element --- .../border-box-control-linked-button/component.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx b/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx index 396989d56aa7f0..21e9d5e3e90fe3 100644 --- a/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-linked-button/component.tsx @@ -9,6 +9,7 @@ import { __ } from '@wordpress/i18n'; */ import Button from '../../button'; import Tooltip from '../../tooltip'; +import { View } from '../../view'; import { contextConnect, WordPressComponentProps } from '../../ui/context'; import { useBorderBoxControlLinkedButton } from './hook'; @@ -27,7 +28,7 @@ const BorderBoxControlLinkedButton = ( return ( -
+
+
); }; From 1ac2425c1b0ebf57aa4a6106b877e9046cb22ffa Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 21 Mar 2022 18:02:23 +1000 Subject: [PATCH 07/12] Use Grid for split controls layout --- .../border-box-control-split-controls/component.tsx | 6 +++--- packages/components/src/border-box-control/styles.ts | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx index 66f9817bf76acb..4b5d3bbafebc56 100644 --- a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx @@ -8,7 +8,7 @@ import { __ } from '@wordpress/i18n'; */ import BorderBoxControlVisualizer from '../border-box-control-visualizer'; import { BorderControl } from '../../border-control'; -import { View } from '../../view'; +import { Grid } from '../../grid'; import { contextConnect, WordPressComponentProps } from '../../ui/context'; import { useBorderBoxControlSplitControls } from './hook'; @@ -42,7 +42,7 @@ const BorderBoxControlSplitControls = ( }; return ( - + - + ); }; diff --git a/packages/components/src/border-box-control/styles.ts b/packages/components/src/border-box-control/styles.ts index bd37fb16c1059d..141b799b6abe04 100644 --- a/packages/components/src/border-box-control/styles.ts +++ b/packages/components/src/border-box-control/styles.ts @@ -51,9 +51,7 @@ export const BorderBoxControlVisualizer = ( borders?: Borders ) => { }; export const BorderBoxControlSplitControls = css` - display: grid; position: relative; - gap: ${ space( 4 ) }; flex: 1; ${ rtl( { marginRight: space( 3 ) }, { marginLeft: space( 3 ) } )() } `; From 821d7801c7e24f62dc22f1a803a2b6b7e1e7a844 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 21 Mar 2022 18:10:20 +1000 Subject: [PATCH 08/12] Prevent application of value prop on visualizer element --- .../border-box-control-visualizer/component.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx index 1f4bdf0a7f52c2..c0abb92f3803ba 100644 --- a/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-visualizer/component.tsx @@ -16,9 +16,9 @@ const BorderBoxControlVisualizer = ( props: WordPressComponentProps< VisualizerProps, 'div' >, forwardedRef: React.ForwardedRef< any > ) => { - const visualizerProps = useBorderBoxControlVisualizer( props ); + const { value, ...otherProps } = useBorderBoxControlVisualizer( props ); - return ; + return ; }; const ConnectedBorderBoxControlVisualizer = contextConnect( From 263f3a1bc4fbd8954f3d6aa08ea68d16faffe5f5 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 21 Mar 2022 20:44:21 +1000 Subject: [PATCH 09/12] Refactor clamped and shorthand border style generation - Moves the `getClampedWidthBorderStyle` functionality into the style.ts file - Updates `getShorthandBorderStyle` to accept optional fallback border styles to work around possible override of inherited values. --- .../src/border-box-control/styles.ts | 19 +++++--- .../src/border-box-control/test/utils.js | 44 +++++++------------ .../src/border-box-control/utils.ts | 35 ++++++--------- 3 files changed, 43 insertions(+), 55 deletions(-) diff --git a/packages/components/src/border-box-control/styles.ts b/packages/components/src/border-box-control/styles.ts index 141b799b6abe04..bbc571f255c3e7 100644 --- a/packages/components/src/border-box-control/styles.ts +++ b/packages/components/src/border-box-control/styles.ts @@ -8,7 +8,6 @@ import { css } from '@emotion/react'; */ import { COLORS, CONFIG, rtl } from '../utils'; import { space } from '../ui/utils/space'; -import { getClampedWidthBorderStyle } from './utils'; import type { Border } from '../border-control/types'; import type { Borders } from './types'; @@ -25,11 +24,19 @@ export const BorderBoxControlLinkedButton = css` margin-top: 7px; `; -export const BorderBoxStyleWithFallback = ( border?: Border ) => { - return ( - getClampedWidthBorderStyle( border ) || - `${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] }` - ); +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 ) => { diff --git a/packages/components/src/border-box-control/test/utils.js b/packages/components/src/border-box-control/test/utils.js index c715cfe5ffc9c4..f5c65ed6532831 100644 --- a/packages/components/src/border-box-control/test/utils.js +++ b/packages/components/src/border-box-control/test/utils.js @@ -3,7 +3,6 @@ */ import { getBorderDiff, - getClampedWidthBorderStyle, getCommonBorder, getShorthandBorderStyle, getSplitBorders, @@ -279,39 +278,28 @@ describe( 'BorderBoxControl Utils', () => { const zeroWidthStyle = getShorthandBorderStyle( { width: '0' } ); expect( zeroWidthStyle ).toEqual( '0' ); } ); - } ); - describe( 'getClampedWidthBorderStyle', () => { - it( 'should return undefined when no border provided', () => { - const style = getClampedWidthBorderStyle( undefined ); - expect( style ).toEqual( undefined ); + it( 'should return undefined when no border or fallback supplied', () => { + expect( getShorthandBorderStyle() ).toBe( undefined ); } ); - it( 'should wrap defined width within clamp() in generated style', () => { - const style = getClampedWidthBorderStyle( - completeBorder, - '2px', - '3em' - ); - expect( style ).toEqual( - `clamp(2px, ${ completeBorder.width }, 3em) solid #000` - ); + it( 'should return fallback border when border is undefined', () => { + const result = getShorthandBorderStyle( undefined, completeBorder ); + expect( result ).toEqual( completeBorder ); + } ); - const zeroWidthStyle = getClampedWidthBorderStyle( - { color: '#fff', style: 'solid', width: 0 }, - '2px', - '3em' - ); - expect( zeroWidthStyle ).toEqual( `clamp(2px, 0, 3em) solid #fff` ); + it( 'should return fallback border when empty border supplied', () => { + const result = getShorthandBorderStyle( {}, completeBorder ); + expect( result ).toEqual( completeBorder ); } ); - it( 'should not add clamp() when border width is undefined', () => { - const style = getClampedWidthBorderStyle( { - color: '#fff', - style: 'dashed', - width: undefined, - } ); - expect( style ).toEqual( `dashed #fff` ); + 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` ); } ); } ); } ); diff --git a/packages/components/src/border-box-control/utils.ts b/packages/components/src/border-box-control/utils.ts index 8c0803cc364f71..01f52751372bcd 100644 --- a/packages/components/src/border-box-control/utils.ts +++ b/packages/components/src/border-box-control/utils.ts @@ -127,32 +127,25 @@ export const getCommonBorder = ( borders?: Borders ) => { }; }; -export const getShorthandBorderStyle = ( border?: Border ) => { +export const getShorthandBorderStyle = ( + border?: Border, + fallbackBorder?: Border +) => { if ( isEmptyBorder( border ) ) { - return undefined; + return fallbackBorder; } - const { color, style, width } = border as Border; + 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 getClampedWidthBorderStyle = ( - border: Border | undefined, - min = '1px', - max = '10px' -) => { - if ( ! border ) { - return undefined; - } - - return getShorthandBorderStyle( { - ...border, - width: - border.width !== undefined - ? `clamp(${ min }, ${ border.width }, ${ max })` - : undefined, - } ); -}; From 804ec2ad1a556345d43ce0acf385af092b64cad7 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 21 Mar 2022 21:16:09 +1000 Subject: [PATCH 10/12] Update BorderBoxControl tests to match latest aria labels --- .../src/border-box-control/test/index.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/components/src/border-box-control/test/index.js b/packages/components/src/border-box-control/test/index.js index b9fce10c7d6de4..f2c152f71ea870 100644 --- a/packages/components/src/border-box-control/test/index.js +++ b/packages/components/src/border-box-control/test/index.js @@ -45,6 +45,9 @@ const props = { value: undefined, }; +const toggleLabelRegex = /Border color( and style)* picker/; +const colorPickerRegex = /Border color picker/; + const renderBorderBoxControl = ( customProps ) => { return render( ); }; @@ -75,7 +78,7 @@ describe( 'BorderBoxControl', () => { renderBorderBoxControl(); const label = screen.getByText( props.label ); - const colorButton = screen.getByLabelText( 'Open border options' ); + const colorButton = screen.getByLabelText( toggleLabelRegex ); const widthInput = screen.getByRole( 'spinbutton' ); const unitSelect = screen.getByRole( 'combobox' ); const slider = screen.getByRole( 'slider' ); @@ -148,7 +151,7 @@ describe( 'BorderBoxControl', () => { it( 'should omit style options when requested', () => { renderBorderBoxControl( { enableStyle: false } ); - const colorButton = screen.getByLabelText( 'Open border options' ); + const colorButton = screen.getByLabelText( colorPickerRegex ); fireEvent.click( colorButton ); const styleLabel = screen.queryByText( 'Style' ); @@ -167,9 +170,7 @@ describe( 'BorderBoxControl', () => { it( 'should render split view by default when mixed values provided', () => { renderBorderBoxControl( { value: mixedBorders } ); - const colorButtons = screen.getAllByLabelText( - 'Open border options' - ); + const colorButtons = screen.getAllByLabelText( toggleLabelRegex ); const widthInputs = screen.getAllByRole( 'spinbutton' ); const unitSelects = screen.getAllByRole( 'combobox' ); const sliders = screen.queryAllByRole( 'slider' ); @@ -208,9 +209,7 @@ describe( 'BorderBoxControl', () => { renderBorderBoxControl( { enableStyle: false } ); clickButton( 'Unlink sides' ); - const colorButtons = screen.getAllByLabelText( - 'Open border options' - ); + const colorButtons = screen.getAllByLabelText( colorPickerRegex ); colorButtons.forEach( ( button ) => { fireEvent.click( button ); From ca889dfa108e5d4916ffb87a2d99a3ab2bee3a58 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Tue, 22 Mar 2022 17:43:46 +1000 Subject: [PATCH 11/12] Add visually hidden labels for split border controls --- .../border-box-control-split-controls/component.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx index 4b5d3bbafebc56..41e2b2b3c33463 100644 --- a/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx +++ b/packages/components/src/border-box-control/border-box-control-split-controls/component.tsx @@ -46,22 +46,30 @@ const BorderBoxControlSplitControls = ( onChange( newBorder, 'top' ) } value={ value?.top } { ...sharedBorderControlProps } /> onChange( newBorder, 'left' ) } value={ value?.left } { ...sharedBorderControlProps } /> onChange( newBorder, 'right' ) } value={ value?.right } { ...sharedBorderControlProps } /> onChange( newBorder, 'bottom' ) } value={ value?.bottom } { ...sharedBorderControlProps } From 1aeb812167417902ddd91b0767f37dc18c83a9b5 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Thu, 24 Mar 2022 11:09:03 +1000 Subject: [PATCH 12/12] Add SlotFill provider as wrapper to storybook example --- .../components/src/border-box-control/stories/index.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/components/src/border-box-control/stories/index.js b/packages/components/src/border-box-control/stories/index.js index 315c94270e2005..0aaadfbcf1af59 100644 --- a/packages/components/src/border-box-control/stories/index.js +++ b/packages/components/src/border-box-control/stories/index.js @@ -12,7 +12,9 @@ import { useEffect, useState } from '@wordpress/element'; * Internal dependencies */ import Button from '../../button'; +import Popover from '../../popover'; import { BorderBoxControl } from '../'; +import { Provider as SlotFillProvider } from '../../slot-fill'; // Available border colors. const colors = [ @@ -48,7 +50,7 @@ const _default = ( props ) => { useEffect( () => setBorders( defaultBorder ), [ defaultBorder ] ); return ( - <> + { - + + ); };