@wordpress/block-editor
Version:
734 lines (619 loc) • 19.9 kB
JavaScript
/**
* Comprehensive test suite for BorderRadiusControl component
*
* This test file provides extensive coverage of the BorderRadiusControl component's
* user-facing functionality, including:
*
* - Basic rendering with different value types (string/object, defined/undefined)
* - Linked vs unlinked mode behavior and toggling
* - Value changes in both linked and unlinked modes
* - Preset functionality (range controls, custom toggles, preset recognition)
* - Edge cases (undefined values, complex CSS values, invalid inputs)
* - Accessibility features (proper ARIA labels, focus management)
* - Integration with Global Styles preset system
*
* Total coverage: 30 passing tests across 9 test categories.
*
* The tests ensure that when the BorderRadiusControl is refactored to use
* PresetInputControl, the existing user experience and behavior is preserved.
*/
/**
* External dependencies
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
/**
* Internal dependencies
*/
import BorderRadiusControl from '../index';
describe( 'BorderRadiusControl', () => {
const mockOnChange = jest.fn();
const mockPresets = {
default: [
{ name: 'None', slug: '0', size: 0 },
{ name: 'Small', slug: 'sm', size: '4px' },
{ name: 'Medium', slug: 'md', size: '8px' },
{ name: 'Large', slug: 'lg', size: '16px' },
],
theme: [
{ name: 'Theme Small', slug: 'theme-sm', size: '2px' },
{ name: 'Theme Large', slug: 'theme-lg', size: '12px' },
],
custom: [ { name: 'Custom XL', slug: 'custom-xl', size: '20px' } ],
};
beforeEach( () => {
mockOnChange.mockClear();
} );
describe( 'Basic Rendering', () => {
it( 'renders with default props', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
/>
);
expect( screen.getByText( 'Radius' ) ).toBeInTheDocument();
expect(
screen.getByLabelText( 'Unlink radii' )
).toBeInTheDocument(); // Linked button should be shown by default
} );
it( 'renders with string value (shorthand)', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values="8px"
presets={ mockPresets }
/>
);
// Should be in linked mode - when presets are available, it shows preset control
// "8px" matches the "Medium" preset, so slider should be at that position
const slider = screen.getByRole( 'slider' );
expect( slider ).toHaveAttribute( 'aria-valuetext', 'Medium' );
} );
it( 'renders with object values (longhand)', () => {
const values = {
topLeft: '4px',
topRight: '8px',
bottomLeft: '12px',
bottomRight: '16px',
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ values }
presets={ mockPresets }
/>
);
// Should be in unlinked mode due to mixed values
expect( screen.getByLabelText( 'Link radii' ) ).toBeInTheDocument();
} );
it( 'renders with uniform object values in linked mode', () => {
const values = {
topLeft: '8px',
topRight: '8px',
bottomLeft: '8px',
bottomRight: '8px',
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ values }
presets={ mockPresets }
/>
);
// Should be in linked mode since all values are the same
expect(
screen.getByLabelText( 'Unlink radii' )
).toBeInTheDocument();
} );
} );
describe( 'Linked/Unlinked Toggle', () => {
it( 'toggles between linked and unlinked modes', async () => {
const user = userEvent.setup();
render(
<BorderRadiusControl
onChange={ mockOnChange }
values="8px"
presets={ mockPresets }
/>
);
const linkButton = screen.getByLabelText( 'Unlink radii' );
// Click to unlink
await user.click( linkButton );
expect( screen.getByLabelText( 'Link radii' ) ).toBeInTheDocument();
// Should now show individual corner controls (sliders in this case with presets)
expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 4 );
// Click to link again
const newLinkButton = screen.getByLabelText( 'Link radii' );
await user.click( newLinkButton );
expect(
screen.getByLabelText( 'Unlink radii' )
).toBeInTheDocument();
} );
it( 'starts in unlinked mode when values are mixed', () => {
const mixedValues = {
topLeft: '4px',
topRight: '8px',
bottomLeft: '4px',
bottomRight: '8px',
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ mixedValues }
presets={ mockPresets }
/>
);
const linkButton = screen.getByLabelText( 'Link radii' );
expect( linkButton ).toBeInTheDocument();
} );
it( 'starts in linked mode when no values are defined', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ mockPresets }
/>
);
const linkButton = screen.getByLabelText( 'Unlink radii' );
expect( linkButton ).toBeInTheDocument();
} );
} );
describe( 'Value Changes - Linked Mode', () => {
it( 'applies value to all corners when in linked mode', async () => {
const user = userEvent.setup();
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ mockPresets }
/>
);
// Need to switch to custom mode first to get spinbutton input
const customToggle = screen.getByLabelText( 'Set custom value' );
await user.click( customToggle );
const input = screen.getByRole( 'spinbutton' );
await user.clear( input );
await user.type( input, '12' );
expect( mockOnChange ).toHaveBeenCalledWith( {
topLeft: '12px',
topRight: '12px',
bottomLeft: '12px',
bottomRight: '12px',
} );
} );
it( 'handles unit changes in linked mode', async () => {
const user = userEvent.setup();
render(
<BorderRadiusControl
onChange={ mockOnChange }
values="8px"
presets={ mockPresets }
/>
);
// Need to switch to custom mode first to get unit controls
const customToggle = screen.getByLabelText( 'Set custom value' );
await user.click( customToggle );
// Find and change the unit dropdown
const unitSelect = screen.getByLabelText( 'Select unit' );
await user.selectOptions( unitSelect, 'em' );
// The unit change should be reflected in the selected units state
expect( screen.getByDisplayValue( 'em' ) ).toBeInTheDocument();
} );
it( 'filters out CSS-unit-only values', async () => {
const user = userEvent.setup();
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ mockPresets }
/>
);
// Need to switch to custom mode first to get spinbutton input
const customToggle = screen.getByLabelText( 'Set custom value' );
await user.click( customToggle );
const input = screen.getByRole( 'spinbutton' );
await user.clear( input );
await user.type( input, 'px' ); // Just unit, no number
// Should not call onChange with invalid CSS-unit-only value
expect( mockOnChange ).not.toHaveBeenCalledWith(
expect.objectContaining( {
topLeft: 'px',
} )
);
// Input should reject unit-only values
expect( input ).toHaveValue( null );
} );
} );
describe( 'Value Changes - Unlinked Mode', () => {
it( 'changes individual corner values when unlinked', async () => {
const user = userEvent.setup();
const initialValues = {
topLeft: '4px',
topRight: '4px',
bottomLeft: '4px',
bottomRight: '4px',
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ initialValues }
presets={ mockPresets }
/>
);
// Unlink first
const linkButton = screen.getByLabelText( 'Unlink radii' );
await user.click( linkButton );
// Need to switch to custom mode first to get spinbutton inputs
// Find and click the custom toggle for the top-left corner specifically
const customToggles =
screen.getAllByLabelText( 'Set custom value' );
await user.click( customToggles[ 0 ] ); // Click first corner's custom toggle
// Find the top-left input specifically by its role and aria-label
const topLeftInput = screen.getByRole( 'spinbutton', {
name: 'Top left',
} );
// Ensure we have a spinbutton input for top-left corner
expect( topLeftInput ).toHaveAttribute( 'type', 'number' );
await user.clear( topLeftInput );
await user.type( topLeftInput, '8' );
expect( mockOnChange ).toHaveBeenCalledWith( {
topLeft: '8px',
topRight: '4px',
bottomLeft: '4px',
bottomRight: '4px',
} );
} );
it( 'handles different units for different corners', () => {
const mixedValues = {
topLeft: '4px',
topRight: '1em',
bottomLeft: '2rem',
bottomRight: '8px',
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ mixedValues }
presets={ mockPresets }
/>
);
// Should start unlinked due to mixed values - shows preset sliders by default
const sliders = screen.getAllByRole( 'slider' );
expect( sliders ).toHaveLength( 4 );
// This test is complex due to mixed units and preset interactions
// Just verify that the component renders properly with mixed values
expect( sliders ).toHaveLength( 4 );
// Each corner should have its own control
expect(
screen.getByRole( 'slider', { name: 'Top left' } )
).toBeInTheDocument();
expect(
screen.getByRole( 'slider', { name: 'Top right' } )
).toBeInTheDocument();
expect(
screen.getByRole( 'slider', { name: 'Bottom left' } )
).toBeInTheDocument();
expect(
screen.getByRole( 'slider', { name: 'Bottom right' } )
).toBeInTheDocument();
} );
} );
describe( 'Preset Functionality', () => {
it( 'uses range control when presets are available and count is small', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ mockPresets }
/>
);
// Should show range control for presets
expect( screen.getByRole( 'slider' ) ).toBeInTheDocument();
} );
it( 'applies preset values via range control', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ mockPresets }
/>
);
const slider = screen.getByRole( 'slider' );
// Move slider to select "Small" preset (index 1)
fireEvent.change( slider, { target: { value: '1' } } );
expect( mockOnChange ).toHaveBeenCalledWith( {
topLeft: 'var:preset|border-radius|custom-xl',
topRight: 'var:preset|border-radius|custom-xl',
bottomLeft: 'var:preset|border-radius|custom-xl',
bottomRight: 'var:preset|border-radius|custom-xl',
} );
} );
it( 'shows custom value toggle when presets are available', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ mockPresets }
/>
);
// Should show the settings/custom toggle button
const customToggle = screen.getByLabelText( 'Set custom value' );
expect( customToggle ).toBeInTheDocument();
} );
it( 'toggles between preset and custom modes', async () => {
const user = userEvent.setup();
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ mockPresets }
/>
);
const customToggle = screen.getByLabelText( 'Set custom value' );
// Initially should show preset controls (range slider)
expect( screen.getByRole( 'slider' ) ).toBeInTheDocument();
expect(
screen.queryByRole( 'spinbutton' )
).not.toBeInTheDocument();
// Click to switch to custom mode
await user.click( customToggle );
// Should now show custom input controls
expect( screen.getByRole( 'spinbutton' ) ).toBeInTheDocument();
expect( customToggle ).toHaveAttribute( 'aria-pressed', 'true' );
} );
it( 'recognizes preset values and shows them correctly', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values="var:preset|border-radius|sm"
presets={ mockPresets }
/>
);
// Should show the preset range control at the correct position
const slider = screen.getByRole( 'slider' );
// The presets are ordered: None, Custom XL, Theme Small, Theme Large, Small, Medium, Large
// So 'sm' should be at index 5
expect( slider ).toHaveValue( '5' );
} );
} );
describe( 'Large Preset Sets', () => {
const largePresetSet = {
default: Array.from( { length: 15 }, ( _, i ) => ( {
name: `Size ${ i }`,
slug: `size-${ i }`,
size: `${ i * 2 }px`,
} ) ),
};
it( 'uses select dropdown for large preset sets', async () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ largePresetSet }
/>
);
// Should use CustomSelectControl instead of range for large sets
await waitFor( () => {
expect(
screen.queryByRole( 'slider' )
).not.toBeInTheDocument();
} );
// Should show combobox with default selection
await waitFor( () => {
const combobox = screen.getByRole( 'combobox' );
expect( combobox ).toBeInTheDocument();
} );
await waitFor( () => {
const combobox = screen.getByRole( 'combobox' );
expect( combobox ).toHaveTextContent( 'Default' );
} );
} );
it( 'can interact with select dropdown options', async () => {
const user = userEvent.setup();
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ largePresetSet }
/>
);
// Click on the combobox to open dropdown
const combobox = screen.getByRole( 'combobox' );
await user.click( combobox );
// Should show preset options in the dropdown
await waitFor( () => {
expect( screen.getByText( 'Size 1' ) ).toBeInTheDocument();
} );
} );
} );
describe( 'Edge Cases and Error Handling', () => {
it( 'handles undefined values gracefully', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
/>
);
expect( screen.getByText( 'Radius' ) ).toBeInTheDocument();
// Should not crash and should show linked mode
expect(
screen.getByLabelText( 'Unlink radii' )
).toBeInTheDocument();
} );
it( 'handles empty object values', () => {
render(
<BorderRadiusControl onChange={ mockOnChange } values={ {} } />
);
expect( screen.getByText( 'Radius' ) ).toBeInTheDocument();
expect(
screen.getByLabelText( 'Unlink radii' )
).toBeInTheDocument();
} );
it( 'handles partial object values', () => {
const partialValues = {
topLeft: '4px',
bottomRight: '8px',
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ partialValues }
/>
);
// Should start in unlinked mode due to missing values
expect( screen.getByLabelText( 'Link radii' ) ).toBeInTheDocument();
} );
it( 'handles complex CSS values', () => {
const complexValue = 'clamp(4px, 2vw, 16px)';
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ complexValue }
/>
);
// Complex values should be handled - verify the control renders properly
expect( screen.getByText( 'Radius' ) ).toBeInTheDocument();
// Complex CSS values cannot be parsed into the number input,
// but the component should still render without crashing
const input = screen.getByRole( 'spinbutton', {
name: 'Border radius',
} );
expect( input ).toBeInTheDocument();
// The component should handle the complex value gracefully
// (actual behavior may vary - this ensures no crash)
expect( input ).toHaveValue( null );
} );
it( 'handles zero values correctly', () => {
render(
<BorderRadiusControl
onChange={ mockOnChange }
values="0"
presets={ mockPresets }
/>
);
// Zero should be recognized as a valid preset
const slider = screen.getByRole( 'slider' );
expect( slider ).toHaveValue( '0' ); // Index of '0' preset
} );
it( 'handles invalid numeric values', async () => {
const user = userEvent.setup();
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
/>
);
// Without presets, shows input controls directly
const input = screen.getByRole( 'spinbutton' );
await user.clear( input );
await user.type( input, 'invalid' );
// Invalid input should not trigger onChange with invalid value
expect( mockOnChange ).not.toHaveBeenCalledWith(
expect.objectContaining( {
topLeft: 'invalid',
} )
);
// Input should still be present and functional
expect( input ).toBeInTheDocument();
expect( input ).toHaveValue( null );
} );
} );
describe( 'Accessibility', () => {
it( 'has proper ARIA labels for corner inputs', () => {
const mixedValues = {
topLeft: '4px',
topRight: '8px',
bottomLeft: '12px',
bottomRight: '16px',
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ mixedValues }
/>
);
// Check that individual corner controls have proper labels (sliders with presets)
expect(
screen.getByRole( 'slider', { name: 'Top left' } )
).toBeInTheDocument();
expect(
screen.getByRole( 'slider', { name: 'Top right' } )
).toBeInTheDocument();
expect(
screen.getByRole( 'slider', { name: 'Bottom left' } )
).toBeInTheDocument();
expect(
screen.getByRole( 'slider', { name: 'Bottom right' } )
).toBeInTheDocument();
} );
it( 'has proper ARIA label for linked input', () => {
render(
<BorderRadiusControl onChange={ mockOnChange } values="8px" />
);
// Without presets, shows both input and range controls
const borderRadiusInputs =
screen.getAllByLabelText( 'Border radius' );
expect( borderRadiusInputs ).toHaveLength( 2 ); // Input and range slider
} );
it( 'maintains focus management when toggling modes', async () => {
const user = userEvent.setup();
render(
<BorderRadiusControl onChange={ mockOnChange } values="8px" />
);
const linkButton = screen.getByRole( 'button' );
await user.click( linkButton );
// After unlinking, should show 4 individual controls (sliders with presets)
const sliders = screen.getAllByRole( 'slider' );
expect( sliders ).toHaveLength( 4 );
} );
} );
describe( 'Integration with Global Styles', () => {
it( 'works with theme-provided presets', () => {
const themePresets = {
theme: [
{ name: 'Theme None', slug: 'none', size: 0 },
{ name: 'Theme Small', slug: 'small', size: '0.25rem' },
{ name: 'Theme Medium', slug: 'medium', size: '0.5rem' },
{ name: 'Theme Large', slug: 'large', size: '1rem' },
],
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ themePresets }
/>
);
// Should include the default "None" option plus theme presets
const slider = screen.getByRole( 'slider' );
expect( slider ).toHaveAttribute( 'max', '4' ); // 0-4 range for 5 total options
} );
it( 'prioritizes custom over theme over default presets', () => {
const mixedPresets = {
default: [
{ name: 'Default Small', slug: 'def-sm', size: '2px' },
],
theme: [
{ name: 'Theme Medium', slug: 'theme-md', size: '4px' },
],
custom: [
{ name: 'Custom Large', slug: 'custom-lg', size: '8px' },
],
};
render(
<BorderRadiusControl
onChange={ mockOnChange }
values={ undefined }
presets={ mixedPresets }
/>
);
// All presets should be available
const slider = screen.getByRole( 'slider' );
expect( slider ).toHaveAttribute( 'max', '3' ); // 0-3 range (None + 3 presets)
} );
} );
} );