8889841ctest/validated-text-input.tsx000064400000021571150537730440012355 0ustar00/** * External dependencies */ import { act, render, screen } from '@testing-library/react'; import { VALIDATION_STORE_KEY } from '@woocommerce/block-data'; import { dispatch, select } from '@wordpress/data'; import userEvent from '@testing-library/user-event'; import { useState } from '@wordpress/element'; import * as wpData from '@wordpress/data'; /** * Internal dependencies */ import ValidatedTextInput from '../validated-text-input'; jest.mock( '@wordpress/data', () => ( { __esModule: true, ...jest.requireActual( '@wordpress/data' ), useDispatch: jest.fn().mockImplementation( ( args ) => { return jest.requireActual( '@wordpress/data' ).useDispatch( args ); } ), } ) ); describe( 'ValidatedTextInput', () => { it( 'Removes related validation error on change', async () => { render( void 0 } value={ 'Test' } id={ 'test-input' } label={ 'Test Input' } /> ); await act( () => dispatch( VALIDATION_STORE_KEY ).setValidationErrors( { 'test-input': { message: 'Error message', hidden: false, }, } ) ); await expect( select( VALIDATION_STORE_KEY ).getValidationError( 'test-input' ) ).not.toBe( undefined ); const textInputElement = await screen.getByLabelText( 'Test Input' ); await userEvent.type( textInputElement, 'New value' ); await expect( select( VALIDATION_STORE_KEY ).getValidationError( 'test-input' ) ).toBe( undefined ); } ); it( 'Hides related validation error on change when id is not specified', async () => { render( void 0 } value={ 'Test' } label={ 'Test Input' } /> ); await act( () => dispatch( VALIDATION_STORE_KEY ).setValidationErrors( { 'textinput-1': { message: 'Error message', hidden: false, }, } ) ); await expect( select( VALIDATION_STORE_KEY ).getValidationError( 'textinput-1' ) ).not.toBe( undefined ); const textInputElement = await screen.getByLabelText( 'Test Input' ); await userEvent.type( textInputElement, 'New value' ); await expect( select( VALIDATION_STORE_KEY ).getValidationError( 'textinput-1' ) ).toBe( undefined ); } ); it( 'Displays a passed error message', async () => { render( void 0 } value={ 'Test' } label={ 'Test Input' } errorMessage={ 'Custom error message' } /> ); await act( () => dispatch( VALIDATION_STORE_KEY ).setValidationErrors( { 'textinput-2': { message: 'Error message in data store', hidden: false, }, } ) ); const customErrorMessageElement = await screen.getByText( 'Custom error message' ); expect( screen.queryByText( 'Error message in data store' ) ).not.toBeInTheDocument(); await expect( customErrorMessageElement ).toBeInTheDocument(); } ); it( 'Displays an error message from the data store', async () => { render( void 0 } value={ 'Test' } label={ 'Test Input' } /> ); await act( () => dispatch( VALIDATION_STORE_KEY ).setValidationErrors( { 'textinput-3': { message: 'Error message 3', hidden: false, }, } ) ); const errorMessageElement = await screen.getByText( 'Error message 3' ); await expect( errorMessageElement ).toBeInTheDocument(); } ); it( 'Runs custom validation on the input', async () => { const TestComponent = () => { const [ inputValue, setInputValue ] = useState( 'Test' ); return ( setInputValue( value ) } value={ inputValue } label={ 'Test Input' } customValidation={ ( inputObject ) => { return inputObject.value === 'Valid Value'; } } /> ); }; render( ); const textInputElement = await screen.getByLabelText( 'Test Input' ); await userEvent.type( textInputElement, 'Invalid Value' ); await expect( select( VALIDATION_STORE_KEY ).getValidationError( 'test-input' ) ).not.toBe( undefined ); await userEvent.type( textInputElement, '{selectall}{del}Valid Value' ); await expect( textInputElement.value ).toBe( 'Valid Value' ); await expect( select( VALIDATION_STORE_KEY ).getValidationError( 'test-input' ) ).toBe( undefined ); } ); it( 'Shows a custom error message for an invalid required input', async () => { const TestComponent = () => { const [ inputValue, setInputValue ] = useState( '' ); return ( setInputValue( value ) } value={ inputValue } label={ 'Test Input' } required={ true } /> ); }; render( ); const textInputElement = await screen.getByLabelText( 'Test Input' ); await userEvent.type( textInputElement, 'test' ); await userEvent.type( textInputElement, '{selectall}{del}' ); await textInputElement.blur(); await expect( screen.queryByText( 'Please enter a valid test input' ) ).not.toBeNull(); } ); describe( 'correctly validates on mount', () => { it( 'validates when focusOnMount is true and validateOnMount is not set', async () => { const setValidationErrors = jest.fn(); wpData.useDispatch.mockImplementation( ( storeName: string ) => { if ( storeName === VALIDATION_STORE_KEY ) { return { ...jest .requireActual( '@wordpress/data' ) .useDispatch( storeName ), setValidationErrors, }; } return jest .requireActual( '@wordpress/data' ) .useDispatch( storeName ); } ); const TestComponent = () => { const [ inputValue, setInputValue ] = useState( '' ); return ( setInputValue( value ) } value={ inputValue } label={ 'Test Input' } required={ true } focusOnMount={ true } /> ); }; await render( ); const textInputElement = await screen.getByLabelText( 'Test Input' ); await expect( textInputElement ).toHaveFocus(); await expect( setValidationErrors ).toHaveBeenCalledWith( { 'test-input': { message: 'Please enter a valid test input', hidden: true, }, } ); } ); it( 'validates when focusOnMount is false, regardless of validateOnMount value', async () => { const setValidationErrors = jest.fn(); wpData.useDispatch.mockImplementation( ( storeName: string ) => { if ( storeName === VALIDATION_STORE_KEY ) { return { ...jest .requireActual( '@wordpress/data' ) .useDispatch( storeName ), setValidationErrors, }; } return jest .requireActual( '@wordpress/data' ) .useDispatch( storeName ); } ); const TestComponent = ( { validateOnMount = false } ) => { const [ inputValue, setInputValue ] = useState( '' ); return ( setInputValue( value ) } value={ inputValue } label={ 'Test Input' } required={ true } focusOnMount={ true } validateOnMount={ validateOnMount } /> ); }; const { rerender } = await render( ); const textInputElement = await screen.getByLabelText( 'Test Input' ); await expect( textInputElement ).toHaveFocus(); await expect( setValidationErrors ).not.toHaveBeenCalled(); await rerender( ); await expect( textInputElement ).toHaveFocus(); await expect( setValidationErrors ).not.toHaveBeenCalled(); } ); it( 'does not validate when validateOnMount is false and focusOnMount is true', async () => { const setValidationErrors = jest.fn(); wpData.useDispatch.mockImplementation( ( storeName: string ) => { if ( storeName === VALIDATION_STORE_KEY ) { return { ...jest .requireActual( '@wordpress/data' ) .useDispatch( storeName ), setValidationErrors, }; } return jest .requireActual( '@wordpress/data' ) .useDispatch( storeName ); } ); const TestComponent = () => { const [ inputValue, setInputValue ] = useState( '' ); return ( setInputValue( value ) } value={ inputValue } label={ 'Test Input' } required={ true } focusOnMount={ true } validateOnMount={ false } /> ); }; await render( ); const textInputElement = await screen.getByLabelText( 'Test Input' ); await expect( textInputElement ).toHaveFocus(); await expect( setValidationErrors ).not.toHaveBeenCalled(); } ); } ); } ); text-input.tsx000064400000004544150537730440007444 0ustar00/** * External dependencies */ import classnames from 'classnames'; import { forwardRef, useState } from '@wordpress/element'; import { decodeEntities } from '@wordpress/html-entities'; import type { InputHTMLAttributes } from 'react'; /** * Internal dependencies */ import Label from '../label'; import './style.scss'; export interface TextInputProps extends Omit< InputHTMLAttributes< HTMLInputElement >, 'onChange' | 'onBlur' > { id: string; ariaLabel?: string; label?: string | undefined; ariaDescribedBy?: string | undefined; screenReaderLabel?: string | undefined; help?: string; feedback?: JSX.Element | null; autoComplete?: string | undefined; onChange: ( newValue: string ) => void; onBlur?: ( newValue: string ) => void; } const TextInput = forwardRef< HTMLInputElement, TextInputProps >( ( { className, id, type = 'text', ariaLabel, ariaDescribedBy, label, screenReaderLabel, disabled, help, autoCapitalize = 'off', autoComplete = 'off', value = '', onChange, required = false, onBlur = () => { /* Do nothing */ }, feedback, ...rest }, ref ) => { const [ isActive, setIsActive ] = useState( false ); return (
{ onChange( event.target.value ); } } onFocus={ () => setIsActive( true ) } onBlur={ ( event ) => { onBlur( event.target.value ); setIsActive( false ); } } aria-label={ ariaLabel || label } disabled={ disabled } aria-describedby={ !! help && ! ariaDescribedBy ? id + '__help' : ariaDescribedBy } required={ required } { ...rest } />
); } ); export default TextInput; validated-text-input.tsx000064400000014627150537730440011402 0ustar00/** * External dependencies */ import { useEffect, useState, useCallback, forwardRef, useImperativeHandle, useRef, } from '@wordpress/element'; import classnames from 'classnames'; import { isObject } from '@woocommerce/types'; import { useDispatch, useSelect } from '@wordpress/data'; import { VALIDATION_STORE_KEY } from '@woocommerce/block-data'; import { usePrevious } from '@woocommerce/base-hooks'; import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ import TextInput from './text-input'; import './style.scss'; import { ValidationInputError } from '../validation-input-error'; import { getValidityMessageForInput } from '../../checkout/utils'; import { ValidatedTextInputProps } from './types'; export type ValidatedTextInputHandle = { revalidate: () => void; }; /** * A text based input which validates the input value. */ const ValidatedTextInput = forwardRef< ValidatedTextInputHandle, ValidatedTextInputProps >( ( { className, id, type = 'text', ariaDescribedBy, errorId, focusOnMount = false, onChange, showError = true, errorMessage: passedErrorMessage = '', value = '', customValidation = () => true, customFormatter = ( newValue: string ) => newValue, label, validateOnMount = true, instanceId: preferredInstanceId = '', ...rest }, forwardedRef ): JSX.Element => { // True on mount. const [ isPristine, setIsPristine ] = useState( true ); // Track incoming value. const previousValue = usePrevious( value ); // Ref for the input element. const inputRef = useRef< HTMLInputElement >( null ); const instanceId = useInstanceId( ValidatedTextInput, '', preferredInstanceId ); const textInputId = typeof id !== 'undefined' ? id : 'textinput-' + instanceId; const errorIdString = errorId !== undefined ? errorId : textInputId; const { setValidationErrors, hideValidationError, clearValidationError, } = useDispatch( VALIDATION_STORE_KEY ); // Ref for validation callback. const customValidationRef = useRef( customValidation ); // Update ref when validation callback changes. useEffect( () => { customValidationRef.current = customValidation; }, [ customValidation ] ); const { validationError, validationErrorId } = useSelect( ( select ) => { const store = select( VALIDATION_STORE_KEY ); return { validationError: store.getValidationError( errorIdString ), validationErrorId: store.getValidationErrorId( errorIdString ), }; } ); const validateInput = useCallback( ( errorsHidden = true ) => { const inputObject = inputRef.current || null; if ( inputObject === null ) { return; } // Trim white space before validation. inputObject.value = inputObject.value.trim(); inputObject.setCustomValidity( '' ); if ( inputObject.checkValidity() && customValidationRef.current( inputObject ) ) { clearValidationError( errorIdString ); return; } setValidationErrors( { [ errorIdString ]: { message: label ? getValidityMessageForInput( label, inputObject ) : inputObject.validationMessage, hidden: errorsHidden, }, } ); }, [ clearValidationError, errorIdString, setValidationErrors, label ] ); // Allows parent to trigger revalidation. useImperativeHandle( forwardedRef, function () { return { revalidate() { validateInput( ! value ); }, }; }, [ validateInput, value ] ); /** * Handle browser autofill / changes via data store. * * Trigger validation on incoming state change if the current element is not in focus. This is because autofilled * elements do not trigger the blur() event, and so values can be validated in the background if the state changes * elsewhere. * * Errors are immediately visible. */ useEffect( () => { if ( value !== previousValue && ( value || previousValue ) && inputRef && inputRef.current !== null && inputRef.current?.ownerDocument?.activeElement !== inputRef.current ) { const formattedValue = customFormatter( inputRef.current.value ); if ( formattedValue !== value ) { onChange( formattedValue ); } else { validateInput( true ); } } }, [ validateInput, customFormatter, value, previousValue, onChange ] ); /** * Validation on mount. * * If the input is in pristine state on mount, focus the element (if focusOnMount is enabled), and validate in the * background. * * Errors are hidden until blur. */ useEffect( () => { if ( ! isPristine ) { return; } setIsPristine( false ); if ( focusOnMount ) { inputRef.current?.focus(); } // if validateOnMount is false, only validate input if focusOnMount is also false if ( validateOnMount || ! focusOnMount ) { validateInput( true ); } }, [ validateOnMount, focusOnMount, isPristine, setIsPristine, validateInput, ] ); // Remove validation errors when unmounted. useEffect( () => { return () => { clearValidationError( errorIdString ); }; }, [ clearValidationError, errorIdString ] ); if ( passedErrorMessage !== '' && isObject( validationError ) ) { validationError.message = passedErrorMessage; } const hasError = validationError?.message && ! validationError?.hidden; const describedBy = showError && hasError && validationErrorId ? validationErrorId : ariaDescribedBy; return ( ) : null } ref={ inputRef } onChange={ ( newValue ) => { // Hide errors while typing. hideValidationError( errorIdString ); // Validate the input value. validateInput( true ); // Push the changes up to the parent component. const formattedValue = customFormatter( newValue ); if ( formattedValue !== value ) { onChange( formattedValue ); } } } onBlur={ () => validateInput( false ) } ariaDescribedBy={ describedBy } value={ value } title="" // This prevents the same error being shown on hover. label={ label } { ...rest } /> ); } ); export default ValidatedTextInput; types.ts000064400000003072150537730440006272 0ustar00/** * External dependencies */ import type { InputHTMLAttributes } from 'react'; export interface ValidatedTextInputProps extends Omit< InputHTMLAttributes< HTMLInputElement >, 'onChange' | 'onBlur' > { // id to use for the input. If not provided, an id will be generated. id?: string; // Unique instance ID. id will be used instead if provided. instanceId?: string | undefined; // Type of input, defaults to text. type?: string; // Class name to add to the input. className?: string | undefined; // aria-describedby attribute to add to the input. ariaDescribedBy?: string | undefined; // id to use for the error message. If not provided, an id will be generated. errorId?: string; // if true, the input will be focused on mount. focusOnMount?: boolean; // Callback to run on change which is passed the updated value. onChange: ( newValue: string ) => void; // Optional label for the field. label?: string | undefined; // Field value. value: string; // If true, validation errors will be shown. showError?: boolean; // Error message to display alongside the field regardless of validation. errorMessage?: string | undefined; // Custom validation function that is run on change. Use setCustomValidity to set an error message. customValidation?: | ( ( inputObject: HTMLInputElement ) => boolean ) | undefined; // Custom formatted to format values as they are typed. customFormatter?: ( value: string ) => string; // Whether validation should run when focused - only has an effect when focusOnMount is also true. validateOnMount?: boolean | undefined; } stories/validated-text-input.stories.tsx000064400000012056150537730440014553 0ustar00/** * External dependencies */ import type { StoryFn, Meta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { useArgs } from '@storybook/client-api'; /** * Internal dependencies */ import ValidatedTextInput from '../validated-text-input'; import '../style.scss'; import '../../validation-input-error/style.scss'; import { ValidatedTextInputProps } from '../types'; export default { title: 'External Components/ValidatedTextInput', component: ValidatedTextInput, parameters: { actions: { handles: [ 'blur', 'change' ], }, }, argTypes: { ariaDescribedBy: { type: 'string', control: 'text', description: 'The aria-describedby attribute to set on the input element', table: { type: { summary: 'string', }, }, }, ariaLabel: { type: 'string', control: 'text', description: 'The aria-label attribute to set on the input element', table: { type: { summary: 'string', }, }, }, autoComplete: { type: 'string', control: 'text', description: 'The [autocomplete property](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) to pass to the underlying HTMl input.', table: { type: { summary: 'string', }, }, }, className: { control: 'text', table: { type: { summary: 'string', }, }, }, customFormatter: { control: 'function', table: { type: { summary: 'function', }, }, }, customValidation: { control: 'function', table: { type: { summary: 'function', }, }, description: 'A custom validation function that is run on change. Use `setCustomValidity` to set an error message.', }, errorMessage: { type: 'string', control: 'text', description: 'If supplied, this error will be used rather than the `message` from the validation data store.', }, focusOnMount: { type: 'boolean', table: { type: { summary: 'boolean', }, }, description: 'If true, the input will be focused on mount.', }, help: { type: 'string', description: 'Help text to show alongside the input.', }, id: { type: 'string', control: 'text', description: 'ID for the element.', table: { type: { summary: 'string', }, }, }, instanceId: { type: 'string', control: 'text', description: 'Unique instance ID. id will be used instead if provided.', table: { type: { summary: 'string', }, }, }, label: { type: 'string', control: 'text', description: 'Label for the input.', table: { type: { summary: 'string', }, }, }, onBlur: { type: 'function', table: { type: { summary: 'function', }, }, description: 'Function to run when the input is blurred.', }, onChange: { type: 'function', table: { type: { summary: 'function', }, }, description: "Function to run when the input's value changes.", }, screenReaderLabel: { type: 'string', table: { type: { summary: 'string', }, }, description: 'The label to be read by screen readers, if this prop is undefined, the `label` prop will be used instead', }, showError: { type: 'boolean', table: { type: { summary: 'boolean', }, }, description: 'If true, validation errors will be shown.', }, title: { type: 'string', control: 'text', description: 'Title of the input.', table: { type: { summary: 'string', }, }, }, type: { type: 'string', control: 'text', description: 'The type attribute to set on the input element.', table: { type: { summary: 'string', }, }, }, validateOnMount: { type: 'boolean', control: 'boolean', description: 'If the field should perform validation when mounted, as opposed to waiting for a change or blur event.', table: { type: { summary: 'boolean', }, }, }, value: { type: 'string', control: 'text', description: 'The value of the element.', table: { type: { summary: 'string', }, }, }, }, } as Meta< ValidatedTextInputProps >; const Template: StoryFn< ValidatedTextInputProps > = ( args ) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [ _, updateArgs ] = useArgs(); const onChange = ( value: string ) => { action( 'change' )( value || '' ); updateArgs( { value } ); }; return ; }; export const Default = Template.bind( {} ); Default.args = { id: 'unique-id', label: 'Enter your value', value: '', }; export const WithError = Template.bind( {} ); WithError.args = { id: 'unique-id', showError: true, errorMessage: 'This is an error message', label: 'Enter your value', value: 'Lorem ipsum', }; export const WithCustomFormatter = Template.bind( {} ); WithCustomFormatter.args = { id: 'unique-id', label: 'Enter your value', value: 'The custom formatter will turn this lowercase string to uppercase.', customFormatter: ( value: string ) => { return value.toUpperCase(); }, }; stories/text-input.stories.tsx000064400000006775150537730440012633 0ustar00/** * External dependencies */ import type { StoryFn, Meta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { useArgs } from '@storybook/client-api'; /** * Internal dependencies */ import TextInput, { type TextInputProps } from '../text-input'; import '../style.scss'; import '../../validation-input-error/style.scss'; export default { title: 'External Components/TextInput', component: TextInput, parameters: { actions: { handles: [ 'blur', 'change' ], }, }, argTypes: { ariaDescribedBy: { type: 'string', control: 'text', description: 'The aria-describedby attribute to set on the input element', table: { type: { summary: 'string', }, }, }, ariaLabel: { type: 'string', control: 'text', description: 'The aria-label attribute to set on the input element', table: { type: { summary: 'string', }, }, }, autoComplete: { type: 'string', control: 'text', description: 'The [autocomplete property](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) to pass to the underlying HTMl input.', table: { type: { summary: 'string', }, }, }, className: { control: 'text', table: { type: { summary: 'string', }, }, }, feedback: { control: 'null', description: 'Element shown after the input. Used by `ValidatedTextInput` to show the error message.', table: { type: { summary: 'JSX.Element', }, }, }, id: { type: 'string', control: 'text', description: 'ID for the element.', table: { type: { summary: 'string', }, }, }, label: { type: 'string', control: 'text', description: 'Label for the input.', table: { type: { summary: 'string', }, }, }, onBlur: { type: 'function', table: { type: { summary: 'function', }, }, description: 'Function to run when the input is blurred.', }, onChange: { type: 'function', table: { type: { summary: 'function', }, }, description: "Function to run when the input's value changes.", }, screenReaderLabel: { type: 'string', table: { type: { summary: 'string', }, }, description: 'The label to be read by screen readers, if this prop is undefined, the `label` prop will be used instead', }, title: { type: 'string', control: 'text', description: 'Title of the input.', table: { type: { summary: 'string', }, }, }, type: { type: 'string', control: 'text', description: 'The type attribute to set on the input element.', table: { type: { summary: 'string', }, }, }, value: { type: 'string', control: 'text', description: 'The value of the element.', table: { type: { summary: 'string', }, }, }, }, } as Meta< TextInputProps >; const Template: StoryFn< TextInputProps > = ( args ) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [ _, updateArgs ] = useArgs(); const onChange = ( value: string ) => { action( 'change' )( value || '' ); updateArgs( { value } ); }; return ; }; export const Default = Template.bind( {} ); Default.args = { id: 'unique-id', label: 'Enter your value', value: 'Lorem ipsum', feedback: (

This is a feedback element, usually it would be used as an error message.

), }; style.scss000064400000005301150537730440006610 0ustar00.wc-block-components-form .wc-block-components-text-input, .wc-block-components-text-input { position: relative; margin-top: $gap; white-space: nowrap; label { @include reset-color(); @include reset-typography(); @include font-size(regular); position: absolute; transform: translateY(em($gap)); line-height: 1.25; // =20px when font-size is 16px. left: em($gap-smaller + 1px); top: 0; transform-origin: top left; color: $universal-body-low-emphasis; transition: all 200ms ease; margin: 0; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - #{2 * $gap}); cursor: text; .has-dark-controls & { color: $input-placeholder-dark; } @media screen and (prefers-reduced-motion: reduce) { transition: none; } } input[type="number"] { -moz-appearance: textfield; &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { appearance: none; margin: 0; } } input[type="tel"], input[type="url"], input[type="text"], input[type="number"], input[type="email"] { @include font-size(regular); padding: em($gap); line-height: em($gap); width: 100%; border-radius: $universal-border-radius; border: 1px solid $input-border-gray; font-family: inherit; margin: 0; box-sizing: border-box; min-height: 0; background-color: #fff; color: $input-text-active; &:focus { background-color: #fff; color: $input-text-active; outline: 0; box-shadow: 0 0 0 1px $input-border-gray; } .has-dark-controls & { background-color: $input-background-dark; border-color: $input-border-dark; color: $input-text-dark; &:focus { background-color: $input-background-dark; color: $input-text-dark; box-shadow: 0 0 0 1px $input-border-dark; } } } input:-webkit-autofill, &.is-active input[type="tel"], &.is-active input[type="url"], &.is-active input[type="text"], &.is-active input[type="number"], &.is-active input[type="email"] { padding: em($gap + $gap-smaller) em($gap-smaller) em($gap-smaller); } &.is-active label, input:-webkit-autofill + label { transform: translateY(em($gap-smaller)) scale(0.875); } &.has-error input { &, &:hover, &:focus, &:active { border-color: $alert-red; } &:focus { box-shadow: 0 0 0 1px $alert-red; } .has-dark-controls &, .has-dark-controls &:hover, .has-dark-controls &:focus, .has-dark-controls &:active { border-color: color.adjust($alert-red, $lightness: 30%); } .has-dark-controls &:focus { box-shadow: 0 0 0 1px color.adjust($alert-red, $lightness: 30%); } } &.has-error label { color: $alert-red; .has-dark-controls & { color: color.adjust($alert-red, $lightness: 30%); } } &:only-child { margin-top: 1.5em; } }