diff --git a/docs/manifest.json b/docs/manifest.json
index cba23e20f0a07a..cb10d3c6e80a36 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": "BorderControl",
+ "slug": "border-control",
+ "markdown_source": "../packages/components/src/border-control/border-control/README.md",
+ "parent": "components"
+ },
{
"title": "BoxControl",
"slug": "box-control",
diff --git a/packages/components/src/border-control/border-control-dropdown/component.tsx b/packages/components/src/border-control/border-control-dropdown/component.tsx
new file mode 100644
index 00000000000000..e7bad227f72edd
--- /dev/null
+++ b/packages/components/src/border-control/border-control-dropdown/component.tsx
@@ -0,0 +1,252 @@
+/**
+ * External dependencies
+ */
+import type { CSSProperties } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { closeSmall } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import BorderControlStylePicker from '../border-control-style-picker';
+import Button from '../../button';
+import ColorIndicator from '../../color-indicator';
+import ColorPalette from '../../color-palette';
+import Dropdown from '../../dropdown';
+import { HStack } from '../../h-stack';
+import { VStack } from '../../v-stack';
+import { contextConnect, WordPressComponentProps } from '../../ui/context';
+import { useBorderControlDropdown } from './hook';
+import { StyledLabel } from '../../base-control/styles/base-control-styles';
+
+import type {
+ Color,
+ ColorOrigin,
+ Colors,
+ DropdownProps,
+ PopoverProps,
+} from '../types';
+
+const noop = () => undefined;
+const getColorObject = (
+ colorValue: CSSProperties[ 'borderColor' ],
+ colors: Colors | undefined,
+ hasMultipleColorOrigins: boolean
+) => {
+ if ( ! colorValue || ! colors ) {
+ return;
+ }
+
+ if ( hasMultipleColorOrigins ) {
+ let matchedColor;
+
+ ( colors as ColorOrigin[] ).some( ( origin ) =>
+ origin.colors.some( ( color ) => {
+ if ( color.color === colorValue ) {
+ matchedColor = color;
+ return true;
+ }
+
+ return false;
+ } )
+ );
+
+ return matchedColor;
+ }
+
+ return ( colors as Color[] ).find(
+ ( color ) => color.color === colorValue
+ );
+};
+
+const getToggleAriaLabel = (
+ colorValue: CSSProperties[ 'borderColor' ],
+ colorObject: Color | undefined,
+ style: CSSProperties[ 'borderStyle' ],
+ isStyleEnabled: boolean
+) => {
+ if ( isStyleEnabled ) {
+ if ( colorObject ) {
+ return style
+ ? sprintf(
+ // translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g.: "#f00:". %3$s: The current border style selection e.g. "solid".
+ 'Border color and style picker. The currently selected color is called "%1$s" and has a value of "%2$s". The currently selected style is "%3$s".',
+ colorObject.name,
+ colorObject.color,
+ style
+ )
+ : sprintf(
+ // translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g.: "#f00:".
+ 'Border color and style picker. The currently selected color is called "%1$s" and has a value of "%2$s".',
+ colorObject.name,
+ colorObject.color
+ );
+ }
+
+ if ( colorValue ) {
+ return style
+ ? sprintf(
+ // translators: %1$s: The color's hex code e.g.: "#f00:". %2$s: The current border style selection e.g. "solid".
+ 'Border color and style picker. The currently selected color has a value of "%1$s". The currently selected style is "%2$s".',
+ colorValue,
+ style
+ )
+ : sprintf(
+ // translators: %1$s: The color's hex code e.g.: "#f00:".
+ 'Border color and style picker. The currently selected color has a value of "%1$s".',
+ colorValue
+ );
+ }
+
+ return __( 'Border color and style picker.' );
+ }
+
+ if ( colorObject ) {
+ return sprintf(
+ // translators: %1$s: The name of the color e.g. "vivid red". %2$s: The color's hex code e.g.: "#f00:".
+ 'Border color picker. The currently selected color is called "%1$s" and has a value of "%2$s".',
+ colorObject.name,
+ colorObject.color
+ );
+ }
+
+ if ( colorValue ) {
+ return sprintf(
+ // translators: %1$s: The color's hex code e.g.: "#f00:".
+ 'Border color picker. The currently selected color has a value of "%1$s".',
+ colorValue
+ );
+ }
+
+ return __( 'Border color picker.' );
+};
+
+const BorderControlDropdown = (
+ props: WordPressComponentProps< DropdownProps, 'div' >,
+ forwardedRef: React.ForwardedRef< any >
+) => {
+ const {
+ __experimentalHasMultipleOrigins,
+ __experimentalIsRenderedInSidebar,
+ border,
+ colors,
+ disableCustomColors,
+ enableAlpha,
+ indicatorClassName,
+ indicatorWrapperClassName,
+ onReset,
+ onColorChange,
+ onStyleChange,
+ popoverClassName,
+ popoverContentClassName,
+ popoverControlsClassName,
+ resetButtonClassName,
+ showDropdownHeader,
+ enableStyle = true,
+ ...otherProps
+ } = useBorderControlDropdown( props );
+
+ const { color, style } = border || {};
+ const colorObject = getColorObject(
+ color,
+ colors,
+ !! __experimentalHasMultipleOrigins
+ );
+
+ const toggleAriaLabel = getToggleAriaLabel(
+ color,
+ colorObject,
+ style,
+ enableStyle
+ );
+
+ const dropdownPosition = __experimentalIsRenderedInSidebar
+ ? 'bottom left'
+ : undefined;
+
+ const renderToggle = ( { onToggle = noop } ) => (
+
+ );
+
+ const renderContent = ( { onClose }: PopoverProps ) => (
+ <>
+
+ { showDropdownHeader ? (
+
+ { __( 'Border color' ) }
+
+
+ ) : undefined }
+
+ { enableStyle && (
+
+ ) }
+
+
+ >
+ );
+
+ return (
+
+ );
+};
+
+const ConnectedBorderControlDropdown = contextConnect(
+ BorderControlDropdown,
+ 'BorderControlDropdown'
+);
+
+export default ConnectedBorderControlDropdown;
diff --git a/packages/components/src/border-control/border-control-dropdown/hook.ts b/packages/components/src/border-control/border-control-dropdown/hook.ts
new file mode 100644
index 00000000000000..4bdbdc7ded5c41
--- /dev/null
+++ b/packages/components/src/border-control/border-control-dropdown/hook.ts
@@ -0,0 +1,97 @@
+/**
+ * WordPress dependencies
+ */
+import { useMemo } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import * as styles from '../styles';
+import { parseQuantityAndUnitFromRawValue } from '../../unit-control/utils';
+import { useContextSystem, WordPressComponentProps } from '../../ui/context';
+import { useCx } from '../../utils/hooks/use-cx';
+
+import type { DropdownProps } from '../types';
+
+export function useBorderControlDropdown(
+ props: WordPressComponentProps< DropdownProps, 'div' >
+) {
+ const {
+ border,
+ className,
+ colors,
+ onChange,
+ previousStyleSelection,
+ ...otherProps
+ } = useContextSystem( props, 'BorderControlDropdown' );
+
+ const [ widthValue ] = parseQuantityAndUnitFromRawValue( border?.width );
+ const hasZeroWidth = widthValue === 0;
+
+ const onColorChange = ( color?: string ) => {
+ const style =
+ border?.style === 'none' ? previousStyleSelection : border?.style;
+ const width = hasZeroWidth && !! color ? '1px' : border?.width;
+
+ onChange( { color, style, width } );
+ };
+
+ const onStyleChange = ( style?: string ) => {
+ const width = hasZeroWidth && !! style ? '1px' : border?.width;
+ onChange( { ...border, style, width } );
+ };
+
+ const onReset = () => {
+ onChange( {
+ ...border,
+ color: undefined,
+ style: undefined,
+ } );
+ };
+
+ // Generate class names.
+ const cx = useCx();
+ const classes = useMemo( () => {
+ return cx( styles.borderControlDropdown(), className );
+ }, [ className, cx ] );
+
+ const indicatorClassName = useMemo( () => {
+ return cx( styles.borderColorIndicator );
+ }, [ cx ] );
+
+ const indicatorWrapperClassName = useMemo( () => {
+ return cx( styles.colorIndicatorWrapper( border ) );
+ }, [ border, cx ] );
+
+ const popoverClassName = useMemo( () => {
+ return cx( styles.borderControlPopover );
+ }, [ cx ] );
+
+ const popoverControlsClassName = useMemo( () => {
+ return cx( styles.borderControlPopoverControls );
+ }, [ cx ] );
+
+ const popoverContentClassName = useMemo( () => {
+ return cx( styles.borderControlPopoverContent );
+ }, [ cx ] );
+
+ const resetButtonClassName = useMemo( () => {
+ return cx( styles.resetButton );
+ }, [ cx ] );
+
+ return {
+ ...otherProps,
+ border,
+ className: classes,
+ colors,
+ indicatorClassName,
+ indicatorWrapperClassName,
+ onColorChange,
+ onStyleChange,
+ onReset,
+ popoverClassName,
+ popoverContentClassName,
+ popoverControlsClassName,
+ resetButtonClassName,
+ };
+}
diff --git a/packages/components/src/border-control/border-control-dropdown/index.ts b/packages/components/src/border-control/border-control-dropdown/index.ts
new file mode 100644
index 00000000000000..b404d7fd44a81a
--- /dev/null
+++ b/packages/components/src/border-control/border-control-dropdown/index.ts
@@ -0,0 +1 @@
+export { default } from './component';
diff --git a/packages/components/src/border-control/border-control-style-picker/component.tsx b/packages/components/src/border-control/border-control-style-picker/component.tsx
new file mode 100644
index 00000000000000..c61cec058be1af
--- /dev/null
+++ b/packages/components/src/border-control/border-control-style-picker/component.tsx
@@ -0,0 +1,89 @@
+/**
+ * WordPress dependencies
+ */
+import { lineDashed, lineDotted, lineSolid } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import Button from '../../button';
+import { StyledLabel } from '../../base-control/styles/base-control-styles';
+import { View } from '../../view';
+import { Flex } from '../../flex';
+import { VisuallyHidden } from '../../visually-hidden';
+import { contextConnect, WordPressComponentProps } from '../../ui/context';
+import { useBorderControlStylePicker } from './hook';
+
+import type { LabelProps, StylePickerProps } from '../types';
+
+const BORDER_STYLES = [
+ { label: __( 'Solid' ), icon: lineSolid, value: 'solid' },
+ { label: __( 'Dashed' ), icon: lineDashed, value: 'dashed' },
+ { label: __( 'Dotted' ), icon: lineDotted, value: 'dotted' },
+];
+
+const Label = ( props: LabelProps ) => {
+ const { label, hideLabelFromVision } = props;
+
+ if ( ! label ) {
+ return null;
+ }
+
+ return hideLabelFromVision ? (
+ { label }
+ ) : (
+ { label }
+ );
+};
+
+const BorderControlStylePicker = (
+ props: WordPressComponentProps< StylePickerProps, 'div' >,
+ forwardedRef: React.ForwardedRef< any >
+) => {
+ const {
+ buttonClassName,
+ hideLabelFromVision,
+ label,
+ onChange,
+ value,
+ ...otherProps
+ } = useBorderControlStylePicker( props );
+
+ return (
+
+
+
+ { BORDER_STYLES.map( ( borderStyle ) => (
+
+
+ );
+};
+
+const ConnectedBorderControlStylePicker = contextConnect(
+ BorderControlStylePicker,
+ 'BorderControlStylePicker'
+);
+
+export default ConnectedBorderControlStylePicker;
diff --git a/packages/components/src/border-control/border-control-style-picker/hook.ts b/packages/components/src/border-control/border-control-style-picker/hook.ts
new file mode 100644
index 00000000000000..b8d1c27b9a7276
--- /dev/null
+++ b/packages/components/src/border-control/border-control-style-picker/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 { StylePickerProps } from '../types';
+
+export function useBorderControlStylePicker(
+ props: WordPressComponentProps< StylePickerProps, 'div' >
+) {
+ const { className, ...otherProps } = useContextSystem(
+ props,
+ 'BorderControlStylePicker'
+ );
+
+ // Generate class names.
+ const cx = useCx();
+ const classes = useMemo( () => {
+ return cx( styles.borderControlStylePicker, className );
+ }, [ className, cx ] );
+
+ const buttonClassName = useMemo( () => {
+ return cx( styles.borderStyleButton );
+ }, [ cx ] );
+
+ return { ...otherProps, className: classes, buttonClassName };
+}
diff --git a/packages/components/src/border-control/border-control-style-picker/index.ts b/packages/components/src/border-control/border-control-style-picker/index.ts
new file mode 100644
index 00000000000000..b404d7fd44a81a
--- /dev/null
+++ b/packages/components/src/border-control/border-control-style-picker/index.ts
@@ -0,0 +1 @@
+export { default } from './component';
diff --git a/packages/components/src/border-control/border-control/README.md b/packages/components/src/border-control/border-control/README.md
new file mode 100644
index 00000000000000..8c5991c68a0091
--- /dev/null
+++ b/packages/components/src/border-control/border-control/README.md
@@ -0,0 +1,168 @@
+# BorderControl
+
+
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
+
+
+This component provides control over a border's color, style, and width.
+
+## Development guidelines
+
+The `BorderControl` brings together internal sub-components which allow users to
+set the various properties of a border. The first sub-component, a
+`BorderDropdown` contains options representing border color and style. The
+border width is controlled via a `UnitControl` and an optional `RangeControl`.
+
+Border radius is not covered by this control as it may be desired separate to
+color, style, and width. For example, the border radius may be absorbed under
+a "shape" abstraction.
+
+## Usage
+
+```jsx
+import { __experimentalBorderControl as BorderControl } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+const colors = [
+ { name: 'Blue 20', color: '#72aee6' },
+ // ...
+];
+
+const MyBorderControl = () => {
+ const [ border, setBorder ] = useState();
+ const onChange = ( newBorder ) => setBorder( newBorder );
+
+ return (
+
+ );
+};
+```
+
+## 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 include border style options within the
+`BorderDropdown` sub-component.
+
+- Required: No
+- Default: `true`
+
+### `hideLabelFromVision`: `boolean`
+
+Provides control over whether the label will only be visible to screen readers.
+
+- Required: No
+
+### `isCompact`: `boolean`
+
+This flags the `BorderControl` to render with a more compact appearance. It
+restricts the width of the control and prevents it from expanding to take up
+additional space.
+
+- Required: No
+
+### `label`: `string`
+
+If provided, a label will be generated using this as the content.
+
+_Whether it is visible only to screen readers is controlled via
+`hideLabelFromVision`._
+
+- Required: No
+
+### `onChange`: `( value?: Object ) => void`
+
+A callback function invoked when the border value is changed via an interaction
+that selects or clears, border color, style, or width.
+
+_Note: the value may be `undefined` if a user clears all border properties._
+
+- Required: Yes
+
+### `shouldSanitizeBorder`: `boolean`
+
+If opted into, sanitizing the border means that if no width or color have been
+selected, the border style is also cleared and `undefined` is returned as the
+new border value.
+
+- Required: No
+- Default: true
+
+### `showDropdownHeader`: `boolean`
+
+Whether or not to render a header for the border color and style picker
+dropdown. The header includes a label for the color picker and a close button.
+
+- Required: No
+
+### `value`: `Object`
+
+An object representing a border or `undefined`. Used to set the current border
+configuration for this component.
+
+Example:
+```js
+ {
+ color: '#72aee6',
+ style: 'solid',
+ width: '2px,
+}
+```
+
+- Required: No
+
+### `width`: `CSSProperties[ 'width' ]`
+
+Controls the visual width of the `BorderControl`. It has no effect if the
+`isCompact` prop is set to `true`.
+
+- Required: No
+
+### `withSlider`: `boolean`
+
+Flags whether this `BorderControl` should also render a `RangeControl` for
+additional control over a border's width.
+
+- Required: No
+
+### `__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-control/border-control/component.tsx b/packages/components/src/border-control/border-control/component.tsx
new file mode 100644
index 00000000000000..d4025f2c3c92f7
--- /dev/null
+++ b/packages/components/src/border-control/border-control/component.tsx
@@ -0,0 +1,110 @@
+/**
+ * Internal dependencies
+ */
+import BorderControlDropdown from '../border-control-dropdown';
+import UnitControl from '../../unit-control';
+import RangeControl from '../../range-control';
+import { HStack } from '../../h-stack';
+import { StyledLabel } from '../../base-control/styles/base-control-styles';
+import { View } from '../../view';
+import { VisuallyHidden } from '../../visually-hidden';
+import { contextConnect, WordPressComponentProps } from '../../ui/context';
+import { useBorderControl } from './hook';
+
+import type { BorderControlProps, LabelProps } from '../types';
+
+const BorderLabel = ( props: LabelProps ) => {
+ const { label, hideLabelFromVision } = props;
+
+ if ( ! label ) {
+ return null;
+ }
+
+ return hideLabelFromVision ? (
+ { label }
+ ) : (
+ { label }
+ );
+};
+
+const BorderControl = (
+ props: WordPressComponentProps< BorderControlProps, 'div' >,
+ forwardedRef: React.ForwardedRef< any >
+) => {
+ const {
+ colors,
+ disableCustomColors,
+ enableAlpha,
+ enableStyle = true,
+ hideLabelFromVision,
+ innerWrapperClassName,
+ label,
+ onBorderChange,
+ onSliderChange,
+ onWidthChange,
+ placeholder,
+ previousStyleSelection,
+ showDropdownHeader,
+ sliderClassName,
+ value: border,
+ widthControlClassName,
+ widthUnit,
+ widthValue,
+ withSlider,
+ __experimentalHasMultipleOrigins,
+ __experimentalIsRenderedInSidebar,
+ ...otherProps
+ } = useBorderControl( props );
+
+ return (
+
+
+
+
+
+
+
+ { withSlider && (
+
+ ) }
+
+
+ );
+};
+
+const ConnectedBorderControl = contextConnect( BorderControl, 'BorderControl' );
+
+export default ConnectedBorderControl;
diff --git a/packages/components/src/border-control/border-control/hook.ts b/packages/components/src/border-control/border-control/hook.ts
new file mode 100644
index 00000000000000..10a83e8fa437a1
--- /dev/null
+++ b/packages/components/src/border-control/border-control/hook.ts
@@ -0,0 +1,145 @@
+/**
+ * WordPress dependencies
+ */
+import { useCallback, useMemo, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import * as styles from '../styles';
+import { parseQuantityAndUnitFromRawValue } from '../../unit-control/utils';
+import { useContextSystem, WordPressComponentProps } from '../../ui/context';
+import { useCx } from '../../utils/hooks/use-cx';
+
+import type { Border, BorderControlProps } from '../types';
+
+const sanitizeBorder = ( border?: Border ) => {
+ const hasNoWidth = border?.width === undefined || border.width === '';
+ const hasNoColor = border?.color === undefined;
+
+ // If width and color are undefined, unset any style selection as well.
+ if ( hasNoWidth && hasNoColor ) {
+ return undefined;
+ }
+
+ return border;
+};
+
+export function useBorderControl(
+ props: WordPressComponentProps< BorderControlProps, 'div' >
+) {
+ const {
+ className,
+ isCompact,
+ onChange,
+ shouldSanitizeBorder = true,
+ value: border,
+ width,
+ ...otherProps
+ } = useContextSystem( props, 'BorderControl' );
+
+ const [ widthValue, originalWidthUnit ] = parseQuantityAndUnitFromRawValue(
+ border?.width
+ );
+ const widthUnit = originalWidthUnit || 'px';
+ const hadPreviousZeroWidth = widthValue === 0;
+
+ const [ colorSelection, setColorSelection ] = useState< string >();
+ const [ styleSelection, setStyleSelection ] = useState< string >();
+
+ const onBorderChange = useCallback(
+ ( newBorder?: Border ) => {
+ if ( shouldSanitizeBorder ) {
+ return onChange( sanitizeBorder( newBorder ) );
+ }
+
+ onChange( newBorder );
+ },
+ [ onChange, shouldSanitizeBorder, sanitizeBorder ]
+ );
+
+ const onWidthChange = useCallback(
+ ( newWidth?: string ) => {
+ const newWidthValue = newWidth === '' ? undefined : newWidth;
+ const [ parsedValue ] = parseQuantityAndUnitFromRawValue(
+ newWidth
+ );
+ const hasZeroWidth = parsedValue === 0;
+
+ const updatedBorder = { ...border, width: newWidthValue };
+
+ // Setting the border width explicitly to zero will also set the
+ // border style to `none` and clear the border color.
+ if ( hasZeroWidth && ! hadPreviousZeroWidth ) {
+ // Before clearing the color and style selections, keep track of
+ // the current selections so they can be restored when the width
+ // changes to a non-zero value.
+ setColorSelection( border?.color );
+ setStyleSelection( border?.style );
+
+ // Clear the color and style border properties.
+ updatedBorder.color = undefined;
+ updatedBorder.style = 'none';
+ }
+
+ // Selection has changed from zero border width to non-zero width.
+ if ( ! hasZeroWidth && hadPreviousZeroWidth ) {
+ // Restore previous border color and style selections if width
+ // is now not zero.
+ if ( updatedBorder.color === undefined ) {
+ updatedBorder.color = colorSelection;
+ }
+ if ( updatedBorder.style === 'none' ) {
+ updatedBorder.style = styleSelection;
+ }
+ }
+
+ onBorderChange( updatedBorder );
+ },
+ [ border, hadPreviousZeroWidth, onBorderChange ]
+ );
+
+ const onSliderChange = useCallback(
+ ( value: string ) => {
+ onWidthChange( `${ value }${ widthUnit }` );
+ },
+ [ onWidthChange, widthUnit ]
+ );
+
+ // Generate class names.
+ const cx = useCx();
+ const classes = useMemo( () => {
+ return cx( styles.borderControl, className );
+ }, [ className, cx ] );
+
+ const innerWrapperClassName = useMemo( () => {
+ const wrapperWidth = isCompact ? '90px' : width;
+ const widthStyle =
+ !! wrapperWidth && styles.wrapperWidth( wrapperWidth );
+
+ return cx( styles.innerWrapper(), widthStyle );
+ }, [ isCompact, width, cx ] );
+
+ const widthControlClassName = useMemo( () => {
+ return cx( styles.borderWidthControl() );
+ }, [ cx ] );
+
+ const sliderClassName = useMemo( () => {
+ return cx( styles.borderSlider() );
+ }, [ cx ] );
+
+ return {
+ ...otherProps,
+ className: classes,
+ innerWrapperClassName,
+ onBorderChange,
+ onSliderChange,
+ onWidthChange,
+ previousStyleSelection: styleSelection,
+ sliderClassName,
+ value: border,
+ widthControlClassName,
+ widthUnit,
+ widthValue,
+ };
+}
diff --git a/packages/components/src/border-control/border-control/index.ts b/packages/components/src/border-control/border-control/index.ts
new file mode 100644
index 00000000000000..7aa372d0c656b8
--- /dev/null
+++ b/packages/components/src/border-control/border-control/index.ts
@@ -0,0 +1,2 @@
+export { default as BorderControl } from './component';
+export { useBorderControl } from './hook';
diff --git a/packages/components/src/border-control/index.ts b/packages/components/src/border-control/index.ts
new file mode 100644
index 00000000000000..a5eeb763a7c9c1
--- /dev/null
+++ b/packages/components/src/border-control/index.ts
@@ -0,0 +1,2 @@
+export { default as BorderControl } from './border-control/component';
+export { useBorderControl } from './border-control/hook';
diff --git a/packages/components/src/border-control/stories/index.js b/packages/components/src/border-control/stories/index.js
new file mode 100644
index 00000000000000..3b14f7c3d8e212
--- /dev/null
+++ b/packages/components/src/border-control/stories/index.js
@@ -0,0 +1,118 @@
+/**
+ * External dependencies
+ */
+import styled from '@emotion/styled';
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { BorderControl } from '../';
+import { Provider as SlotFillProvider } from '../../slot-fill';
+import Popover from '../../popover';
+
+export default {
+ title: 'Components (Experimental)/BorderControl',
+ component: BorderControl,
+};
+
+// 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' },
+];
+
+// Multiple origin colors.
+const multipleOriginColors = [
+ {
+ name: 'Default',
+ 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: 'Theme',
+ colors: [
+ { 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: 'User',
+ colors: [
+ { 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' },
+ ],
+ },
+];
+
+const _default = ( props ) => {
+ const [ border, setBorder ] = useState();
+ const onChange = ( newBorder ) => setBorder( newBorder );
+ const { __experimentalHasMultipleOrigins } = props;
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const Default = _default.bind( {} );
+Default.args = {
+ disableCustomColors: false,
+ enableAlpha: true,
+ enableStyle: true,
+ isCompact: true,
+ shouldSanitizeBorder: true,
+ showDropdownHeader: false,
+ width: '110px',
+ withSlider: true,
+ __experimentalIsRenderedInSidebar: false,
+ __experimentalHasMultipleOrigins: false,
+};
+
+const WrapperView = styled.div`
+ max-width: 280px;
+`;
diff --git a/packages/components/src/border-control/styles.ts b/packages/components/src/border-control/styles.ts
new file mode 100644
index 00000000000000..9c2bb0a2522894
--- /dev/null
+++ b/packages/components/src/border-control/styles.ts
@@ -0,0 +1,189 @@
+/**
+ * External dependencies
+ */
+import { css } from '@emotion/react';
+import type { CSSProperties } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { COLORS, CONFIG, rtl } from '../utils';
+import { space } from '../ui/utils/space';
+import {
+ StyledField,
+ StyledLabel,
+} from '../base-control/styles/base-control-styles';
+import { BackdropUI } from '../input-control/styles/input-control-styles';
+import { Root as UnitControlWrapper } from '../unit-control/styles/unit-control-styles';
+
+import type { Border } from './types';
+
+const labelStyles = css`
+ font-weight: 500;
+`;
+
+export const borderControl = css`
+ position: relative;
+`;
+
+export const innerWrapper = () => css`
+ border: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] };
+ border-radius: 2px;
+ flex: 1 0 40%;
+
+ /*
+ * Needs more thought. Aim is to prevent the border for BorderBoxControl
+ * showing through the control. Likely needs to take into account
+ * light/dark themes etc.
+ */
+ background: #fff;
+
+ /*
+ * Forces the width control to fill available space given UnitControl
+ * passes its className directly through to the input.
+ */
+ ${ UnitControlWrapper } {
+ flex: 1;
+ ${ rtl( { marginLeft: 0 } )() }
+ }
+`;
+
+export const wrapperWidth = ( width: CSSProperties[ 'width' ] ) => {
+ return css`
+ width: ${ width };
+ flex: 0 0 auto;
+ `;
+};
+
+export const borderControlDropdown = () => css`
+ background: #fff;
+ ${ rtl(
+ {
+ borderRadius: `1px 0 0 1px`,
+ borderRight: `${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] }`,
+ },
+ {
+ borderRadius: `0 1px 1px 0`,
+ borderLeft: `${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] }`,
+ }
+ )() }
+
+ && > button {
+ padding: ${ space( 1 ) };
+ border-radius: inherit;
+ }
+`;
+
+export const colorIndicatorBorder = ( border?: Border ) => {
+ const { color, style } = border || {};
+
+ const fallbackColor =
+ !! style && style !== 'none' ? COLORS.gray[ 300 ] : undefined;
+
+ return css`
+ border-style: ${ style === 'none' ? 'solid' : style };
+ border-color: ${ color || fallbackColor };
+ `;
+};
+
+export const colorIndicatorWrapper = ( border?: Border ) => {
+ const { style } = border || {};
+
+ return css`
+ border-radius: 9999px;
+ border: 2px solid transparent;
+ ${ style ? colorIndicatorBorder( border ) : undefined }
+ width: 28px;
+ height: 28px;
+ padding: 2px;
+
+ /*
+ * ColorIndicator
+ *
+ * The transparent colors used here ensure visibility of the indicator
+ * over the active state of the border control dropdown's toggle button.
+ */
+ & > span {
+ background: linear-gradient(
+ -45deg,
+ transparent 48%,
+ rgb( 0 0 0 / 20% ) 48%,
+ rgb( 0 0 0 / 20% ) 52%,
+ transparent 52%
+ );
+ }
+ `;
+};
+
+export const borderControlPopover = css`
+ /* Remove padding from content, this will be re-added via inner elements*/
+ && > div > div {
+ padding: 0;
+ }
+`;
+
+export const borderControlPopoverControls = css`
+ padding: ${ space( 4 ) };
+
+ > div:first-of-type > ${ StyledLabel } {
+ margin-bottom: 0;
+ ${ labelStyles }
+ }
+
+ && ${ StyledLabel } + button:not( .has-text ) {
+ min-width: 24px;
+ padding: 0;
+ }
+`;
+
+export const borderControlPopoverContent = css``;
+export const borderColorIndicator = css``;
+
+export const resetButton = css`
+ justify-content: center;
+ width: 100%;
+
+ /* Override button component styling */
+ && {
+ border-top: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] };
+ height: 46px;
+ }
+`;
+
+export const borderWidthControl = () => css`
+ /* Target the InputControl's backdrop */
+ &&& ${ BackdropUI } {
+ border: none;
+ }
+
+ /* Specificity required to overcome UnitControl padding */
+ /* See packages/components/src/unit-control/styles/unit-control-styles.ts */
+ &&& input {
+ ${ rtl( { paddingRight: 0 } )() }
+ }
+`;
+
+export const borderControlStylePicker = css`
+ ${ StyledLabel } {
+ ${ labelStyles }
+ }
+`;
+
+export const borderStyleButton = css`
+ &&&&& {
+ min-width: 30px;
+ width: 30px;
+ height: 30px;
+ padding: 3px;
+ }
+`;
+
+export const borderSlider = () => css`
+ flex: 1 1 60%;
+ ${ rtl( { marginRight: space( 3 ) } )() }
+
+ ${ StyledField } {
+ margin-bottom: 0;
+ font-size: 0;
+ }
+`;
diff --git a/packages/components/src/border-control/test/index.js b/packages/components/src/border-control/test/index.js
new file mode 100644
index 00000000000000..b0960f2e96a7da
--- /dev/null
+++ b/packages/components/src/border-control/test/index.js
@@ -0,0 +1,436 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { BorderControl } from '../';
+
+const colors = [
+ { name: 'Gray', color: '#f6f7f7' },
+ { name: 'Blue', color: '#72aee6' },
+ { name: 'Red', color: '#e65054' },
+ { name: 'Green', color: '#00a32a' },
+ { name: 'Yellow', color: '#bd8600' },
+];
+
+const defaultBorder = {
+ color: '#72aee6',
+ style: 'solid',
+ width: '1px',
+};
+
+const props = {
+ colors,
+ label: 'Border',
+ onChange: jest.fn().mockImplementation( ( newValue ) => {
+ props.value = newValue;
+ } ),
+ value: defaultBorder,
+};
+
+const toggleLabelRegex = /Border color( and style)* picker/;
+
+const renderBorderControl = ( customProps ) => {
+ return render( );
+};
+
+const rerenderBorderControl = ( rerender, customProps ) => {
+ return rerender( );
+};
+
+const openPopover = () => {
+ const toggleButton = screen.getByLabelText( toggleLabelRegex );
+ fireEvent.click( toggleButton );
+};
+
+const getButton = ( name ) => {
+ return screen.getByRole( 'button', { name } );
+};
+
+const queryButton = ( name ) => {
+ return screen.queryByRole( 'button', { name } );
+};
+
+const clickButton = ( name ) => {
+ fireEvent.click( getButton( name ) );
+};
+
+const setWidthInput = ( value ) => {
+ const widthInput = screen.getByRole( 'spinbutton' );
+ widthInput.focus();
+ fireEvent.change( widthInput, { target: { value } } );
+};
+
+const clearWidthInput = () => setWidthInput( '' );
+
+describe( 'BorderControl', () => {
+ describe( 'basic rendering', () => {
+ it( 'should render standard border control', () => {
+ renderBorderControl();
+
+ const label = screen.getByText( props.label );
+ const colorButton = screen.getByLabelText( toggleLabelRegex );
+ const widthInput = screen.getByRole( 'spinbutton' );
+ const unitSelect = screen.getByRole( 'combobox' );
+ const slider = screen.queryByRole( 'slider' );
+
+ expect( label ).toBeInTheDocument();
+ expect( colorButton ).toBeInTheDocument();
+ expect( widthInput ).toBeInTheDocument();
+ expect( unitSelect ).toBeInTheDocument();
+ expect( slider ).not.toBeInTheDocument();
+ } );
+
+ it( 'should hide label', () => {
+ renderBorderControl( { 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 render with slider', () => {
+ renderBorderControl( { withSlider: true } );
+
+ const slider = screen.getByRole( 'slider' );
+ expect( slider ).toBeInTheDocument();
+ } );
+
+ it( 'should render placeholder in UnitControl', () => {
+ renderBorderControl( { placeholder: 'Mixed' } );
+ const widthInput = screen.getByRole( 'spinbutton' );
+
+ expect( widthInput ).toHaveAttribute( 'placeholder', 'Mixed' );
+ } );
+
+ it( 'should render color and style popover', () => {
+ renderBorderControl();
+ openPopover();
+
+ const customColorPicker = getButton( /Custom color picker/ );
+ const colorSwatchButtons = screen.getAllByRole( 'button', {
+ name: /^Color:/,
+ } );
+ const styleLabel = screen.getByText( 'Style' );
+ const solidButton = getButton( 'Solid' );
+ const dashedButton = getButton( 'Dashed' );
+ const dottedButton = getButton( 'Dotted' );
+ const resetButton = getButton( 'Reset to default' );
+
+ expect( customColorPicker ).toBeInTheDocument();
+ expect( colorSwatchButtons.length ).toEqual( colors.length );
+ expect( styleLabel ).toBeInTheDocument();
+ expect( solidButton ).toBeInTheDocument();
+ expect( dashedButton ).toBeInTheDocument();
+ expect( dottedButton ).toBeInTheDocument();
+ expect( resetButton ).toBeInTheDocument();
+ } );
+
+ it( 'should render color and style popover header', () => {
+ renderBorderControl( { showDropdownHeader: true } );
+ openPopover();
+
+ const headerLabel = screen.getByText( 'Border color' );
+ const closeButton = getButton( 'Close border color' );
+
+ expect( headerLabel ).toBeInTheDocument();
+ expect( closeButton ).toBeInTheDocument();
+ } );
+
+ it( 'should not render style options when opted out of', () => {
+ renderBorderControl( { enableStyle: false } );
+ openPopover();
+
+ const styleLabel = screen.queryByText( 'Style' );
+ const solidButton = queryButton( 'Solid' );
+ const dashedButton = queryButton( 'Dashed' );
+ const dottedButton = queryButton( 'Dotted' );
+
+ expect( styleLabel ).not.toBeInTheDocument();
+ expect( solidButton ).not.toBeInTheDocument();
+ expect( dashedButton ).not.toBeInTheDocument();
+ expect( dottedButton ).not.toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'color and style picker aria labels', () => {
+ describe( 'with style selection enabled', () => {
+ it( 'should include both color and style in label', () => {
+ renderBorderControl( { value: undefined } );
+
+ expect(
+ screen.getByLabelText( 'Border color and style picker.' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should correctly describe named color selection', () => {
+ renderBorderControl( { value: { color: '#72aee6' } } );
+
+ expect(
+ screen.getByLabelText(
+ 'Border color and style picker. The currently selected color is called "Blue" and has a value of "#72aee6".'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should correctly describe custom color selection', () => {
+ renderBorderControl( { value: { color: '#4b1d80' } } );
+
+ expect(
+ screen.getByLabelText(
+ 'Border color and style picker. The currently selected color has a value of "#4b1d80".'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should correctly describe named color and style selections', () => {
+ renderBorderControl( {
+ value: { color: '#72aee6', style: 'dotted' },
+ } );
+
+ expect(
+ screen.getByLabelText(
+ 'Border color and style picker. The currently selected color is called "Blue" and has a value of "#72aee6". The currently selected style is "dotted".'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should correctly describe custom color and style selections', () => {
+ renderBorderControl( {
+ value: { color: '#4b1d80', style: 'dashed' },
+ } );
+
+ expect(
+ screen.getByLabelText(
+ 'Border color and style picker. The currently selected color has a value of "#4b1d80". The currently selected style is "dashed".'
+ )
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'with style selection disabled', () => {
+ it( 'should only include color in the label', () => {
+ renderBorderControl( { value: undefined, enableStyle: false } );
+
+ expect(
+ screen.getByLabelText( 'Border color picker.' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should correctly describe named color selection', () => {
+ renderBorderControl( {
+ value: { color: '#72aee6' },
+ enableStyle: false,
+ } );
+
+ expect(
+ screen.getByLabelText(
+ 'Border color picker. The currently selected color is called "Blue" and has a value of "#72aee6".'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should correctly describe custom color selection', () => {
+ renderBorderControl( {
+ value: { color: '#4b1d80' },
+ enableStyle: false,
+ } );
+
+ expect(
+ screen.getByLabelText(
+ 'Border color picker. The currently selected color has a value of "#4b1d80".'
+ )
+ ).toBeInTheDocument();
+ } );
+ } );
+ } );
+
+ describe( 'onChange handling', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ props.value = defaultBorder;
+ } );
+
+ it( 'should update width with slider value', () => {
+ const { rerender } = renderBorderControl( { withSlider: true } );
+
+ const slider = screen.getByRole( 'slider' );
+ fireEvent.change( slider, { target: { value: '5' } } );
+
+ expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
+ ...defaultBorder,
+ width: '5px',
+ } );
+
+ rerenderBorderControl( rerender, { withSlider: true } );
+ const widthInput = screen.getByRole( 'spinbutton' );
+
+ expect( widthInput.value ).toEqual( '5' );
+ } );
+
+ it( 'should update color selection', () => {
+ renderBorderControl();
+ openPopover();
+ clickButton( 'Color: Green' );
+
+ expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
+ ...defaultBorder,
+ color: '#00a32a',
+ } );
+ } );
+
+ it( 'should clear color selection when toggling swatch off', () => {
+ renderBorderControl();
+ openPopover();
+ clickButton( 'Color: Blue' );
+
+ expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
+ ...defaultBorder,
+ color: undefined,
+ } );
+ } );
+
+ it( 'should update style selection', () => {
+ renderBorderControl();
+ openPopover();
+ clickButton( 'Dashed' );
+
+ expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
+ ...defaultBorder,
+ style: 'dashed',
+ } );
+ } );
+
+ it( 'should take no action when color and style popover is closed', () => {
+ renderBorderControl( { showDropdownHeader: true } );
+ openPopover();
+ clickButton( 'Close border color' );
+
+ expect( props.onChange ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should reset color and style only when popover reset button clicked', () => {
+ renderBorderControl();
+ openPopover();
+ clickButton( 'Reset to default' );
+
+ expect( props.onChange ).toHaveBeenNthCalledWith( 1, {
+ color: undefined,
+ style: undefined,
+ width: defaultBorder.width,
+ } );
+ } );
+
+ it( 'should sanitize border when width and color are undefined', () => {
+ const { rerender } = renderBorderControl();
+ clearWidthInput();
+ rerenderBorderControl( rerender );
+ openPopover();
+ clickButton( 'Color: Blue' );
+
+ expect( props.onChange ).toHaveBeenCalledWith( undefined );
+ } );
+
+ it( 'should not sanitize border when requested', () => {
+ const { rerender } = renderBorderControl( {
+ shouldSanitizeBorder: false,
+ } );
+ clearWidthInput();
+ rerenderBorderControl( rerender, { shouldSanitizeBorder: false } );
+ openPopover();
+ clickButton( 'Color: Blue' );
+
+ expect( props.onChange ).toHaveBeenNthCalledWith( 2, {
+ color: undefined,
+ style: defaultBorder.style,
+ width: undefined,
+ } );
+ } );
+
+ it( 'should clear color and set style to `none` when setting zero width', () => {
+ renderBorderControl();
+ openPopover();
+ clickButton( 'Color: Green' );
+ clickButton( 'Dotted' );
+ setWidthInput( '0' );
+
+ expect( props.onChange ).toHaveBeenNthCalledWith( 3, {
+ color: undefined,
+ style: 'none',
+ width: '0px',
+ } );
+ } );
+
+ it( 'should reselect color and style selections when changing to non-zero width', () => {
+ const { rerender } = renderBorderControl();
+ openPopover();
+ clickButton( 'Color: Green' );
+ rerenderBorderControl( rerender );
+ clickButton( 'Dotted' );
+ rerenderBorderControl( rerender );
+ setWidthInput( '0' );
+ setWidthInput( '5' );
+
+ expect( props.onChange ).toHaveBeenNthCalledWith( 4, {
+ color: '#00a32a',
+ style: 'dotted',
+ width: '5px',
+ } );
+ } );
+
+ it( 'should set a non-zero width when applying color to zero width border', () => {
+ const { rerender } = renderBorderControl( { value: undefined } );
+ openPopover();
+ clickButton( 'Color: Yellow' );
+
+ expect( props.onChange ).toHaveBeenCalledWith( {
+ color: '#bd8600',
+ style: undefined,
+ width: undefined,
+ } );
+
+ setWidthInput( '0' );
+ rerenderBorderControl( rerender );
+ clickButton( 'Color: Green' );
+
+ expect( props.onChange ).toHaveBeenCalledWith( {
+ color: '#00a32a',
+ style: undefined,
+ width: '1px',
+ } );
+ } );
+
+ it( 'should set a non-zero width when applying style to zero width border', () => {
+ const { rerender } = renderBorderControl( {
+ value: undefined,
+ shouldSanitizeBorder: false,
+ } );
+ openPopover();
+ clickButton( 'Dashed' );
+
+ expect( props.onChange ).toHaveBeenCalledWith( {
+ color: undefined,
+ style: 'dashed',
+ width: undefined,
+ } );
+
+ setWidthInput( '0' );
+ rerenderBorderControl( rerender, { shouldSanitizeBorder: false } );
+ clickButton( 'Dotted' );
+
+ expect( props.onChange ).toHaveBeenCalledWith( {
+ color: undefined,
+ style: 'dotted',
+ width: '1px',
+ } );
+ } );
+ } );
+} );
diff --git a/packages/components/src/border-control/types.ts b/packages/components/src/border-control/types.ts
new file mode 100644
index 00000000000000..0873599ee09a27
--- /dev/null
+++ b/packages/components/src/border-control/types.ts
@@ -0,0 +1,163 @@
+/**
+ * External dependencies
+ */
+import type { CSSProperties } from 'react';
+
+export type Border = {
+ color?: CSSProperties[ 'borderColor' ];
+ style?: CSSProperties[ 'borderStyle' ];
+ width?: CSSProperties[ 'borderWidth' ];
+};
+
+export type Color = {
+ name: string;
+ color: CSSProperties[ 'color' ];
+};
+
+export type ColorOrigin = {
+ name: string;
+ colors: Color[];
+};
+
+export type Colors = ColorOrigin[] | Color[];
+
+export type ColorProps = {
+ /**
+ * An array of color definitions. This may also be a multi-dimensional array
+ * where colors are organized by multiple origins.
+ */
+ colors?: Colors;
+ /**
+ * This toggles the ability to choose custom colors.
+ */
+ disableCustomColors?: boolean;
+ /**
+ * This controls whether the alpha channel will be offered when selecting
+ * custom colors.
+ */
+ enableAlpha?: 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.
+ */
+ __experimentalHasMultipleOrigins?: boolean;
+ /**
+ * This is passed on to the color related sub-components so they may render
+ * more effectively when used within a sidebar.
+ */
+ __experimentalIsRenderedInSidebar?: boolean;
+};
+
+export type LabelProps = {
+ /**
+ * Provides control over whether the label will only be visible to
+ * screen readers.
+ */
+ hideLabelFromVision?: boolean;
+ /**
+ * If provided, a label will be generated using this as the content.
+ */
+ label?: string;
+};
+
+export type BorderControlProps = ColorProps &
+ LabelProps & {
+ /**
+ * This controls whether to include border style options within the
+ * `BorderDropdown` sub-component.
+ *
+ * @default true
+ */
+ enableStyle?: boolean;
+ /**
+ * This flags the `BorderControl` to render with a more compact
+ * appearance. It restricts the width of the control and prevents it
+ * from expanding to take up additional space.
+ */
+ isCompact?: boolean;
+ /**
+ * A callback function invoked when the border value is changed via an
+ * interaction that selects or clears, border color, style, or width.
+ */
+ onChange: ( value?: Border ) => void;
+ /**
+ * If opted into, sanitizing the border means that if no width or color
+ * have been selected, the border style is also cleared and `undefined`
+ * is returned as the new border value.
+ *
+ * @default true
+ */
+ shouldSanitizeBorder?: boolean;
+ /**
+ * Whether or not to show the header for the border color and style
+ * picker dropdown. The header includes a label for the color picker
+ * and a close button.
+ */
+ showDropdownHeader?: boolean;
+ /**
+ * An object representing a border or `undefined`. Used to set the
+ * current border configuration for this component.
+ */
+ value?: Border;
+ /**
+ * Controls the visual width of the `BorderControl`. It has no effect if
+ * the `isCompact` prop is set to `true`.
+ */
+ width?: CSSProperties[ 'width' ];
+ /**
+ * Flags whether this `BorderControl` should also render a
+ * `RangeControl` for additional control over a border's width.
+ */
+ withSlider?: boolean;
+ };
+
+export type DropdownProps = ColorProps & {
+ /**
+ * An object representing a border or `undefined`. This component will
+ * extract the border color and style selections from this object to use as
+ * values for its popover controls.
+ */
+ border?: Border;
+ /**
+ * This controls whether to render border style options.
+ *
+ * @default true
+ */
+ enableStyle?: boolean;
+ /**
+ * A callback invoked when the border color or style selections change.
+ */
+ onChange: ( newBorder?: Border ) => void;
+ /**
+ * Any previous style selection made by the user. This can be used to
+ * reapply that previous selection when, for example, a zero border width is
+ * to a non-zero value.
+ */
+ previousStyleSelection?: string;
+ /**
+ * Whether or not to render a header for the border color and style picker
+ * dropdown. The header includes a label for the color picker and a
+ * close button.
+ */
+ showDropdownHeader?: boolean;
+};
+
+export type StylePickerProps = LabelProps & {
+ /**
+ * A callback function invoked when a border style is selected or cleared.
+ */
+ onChange: ( style?: string ) => void;
+ /**
+ * The currently selected border style if there is one. Styles available via
+ * this control are `solid`, `dashed` & `dotted`, however the possibility
+ * to store other valid CSS values is maintained e.g. `none`, `inherit` etc.
+ */
+ value?: string;
+};
+
+export type PopoverProps = {
+ /**
+ * Callback function to invoke when closing the border dropdown's popover.
+ */
+ onClose: () => void;
+};
diff --git a/packages/components/src/index.js b/packages/components/src/index.js
index 6331cd9b7d5d56..16a41cf6143568 100644
--- a/packages/components/src/index.js
+++ b/packages/components/src/index.js
@@ -23,6 +23,7 @@ export {
useAutocompleteProps as __unstableUseAutocompleteProps,
} from './autocomplete';
export { default as BaseControl } from './base-control';
+export { BorderControl as __experimentalBorderControl } from './border-control';
export { default as __experimentalBoxControl } from './box-control';
export { default as Button } from './button';
export { default as ButtonGroup } from './button-group';
diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json
index bb38249afa6248..0fc06f1977183c 100644
--- a/packages/components/tsconfig.json
+++ b/packages/components/tsconfig.json
@@ -26,9 +26,11 @@
"src/animate/**/*",
"src/base-control/**/*",
"src/base-field/**/*",
+ "src/border-control/**/*",
"src/button/**/*",
"src/card/**/*",
"src/circular-option-picker/**/*",
+ "src/color-indicator/**/*",
"src/color-palette/**/*",
"src/color-picker/**/*",
"src/confirm-dialog/**/*",