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 (
- <>
+
{
- >
+
+
);
};