Skip to content

Commit

Permalink
NumberControl: Add custom spin buttons (#45333)
Browse files Browse the repository at this point in the history
* Rough pass at adding custom spin buttons to NumberControl

* Adjust spacing of step buttons to match design

* Pass along suffix if provided

* DRY up onClick handlers

* Remove spin buttons from tab order

* Add unit tests

* Update changelog

* Make reducer and spin buttons use same logic

* Make InputControl's onChange callback type less strict

Typing the event attribute as PointerEvent<T> | ChangeEvent<T> isn't
great as it means that future ways to input a value into InputControl,
NumberControl and UnitControl will result in BC breaking type changes.
Consumers of the component don't need to know the specifics of the event
beyond that it's a synthetic event and can use `is` to determine more if
necessary.

* Fake event.target so that event.target.validity works

* Replace hideHTMLArrows with spinControls prop

* Update CHANGELOG.md

* Use custom spin controls for Line Height

* Deprecate hideHTMLArrows instead of removing it
  • Loading branch information
noisysocks authored Nov 2, 2022
1 parent 9448204 commit 2961b34
Show file tree
Hide file tree
Showing 13 changed files with 219 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const LineHeightControl = ( {
step={ STEP }
value={ value }
min={ 0 }
spinControls="custom"
/>
</div>
);
Expand Down
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
- `SelectControl`: Add `onChange`, `onBlur` and `onFocus` to storybook actions ([#45432](https://github.com/WordPress/gutenberg/pull/45432/)).
- `FontSizePicker`: Add more comprehensive tests ([#45298](https://github.com/WordPress/gutenberg/pull/45298)).

### Experimental

- `NumberControl`: Replace `hideHTMLArrows` prop with `spinControls` prop. Allow custom spin controls via `spinControls="custom"` ([#45333](https://github.com/WordPress/gutenberg/pull/45333)).

## 21.3.0 (2022-10-19)

### Bug Fix
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/angle-picker-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default function AnglePickerControl( {
size="__unstable-large"
step="1"
value={ value }
hideHTMLArrows
spinControls="none"
suffix={
<Spacer
as={ Text }
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/color-picker/input-with-slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const InputWithSlider = ( {
{ abbreviation }
</Spacer>
}
hideHTMLArrows
spinControls="none"
size="__unstable-large"
/>
<RangeControl
Expand Down
8 changes: 4 additions & 4 deletions packages/components/src/date-time/time/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export function TimePicker( {
min={ 1 }
max={ 31 }
required
hideHTMLArrows
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
Expand Down Expand Up @@ -246,7 +246,7 @@ export function TimePicker( {
min={ is12Hour ? 1 : 0 }
max={ is12Hour ? 12 : 23 }
required
hideHTMLArrows
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
Expand All @@ -273,7 +273,7 @@ export function TimePicker( {
min={ 0 }
max={ 59 }
required
hideHTMLArrows
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
Expand Down Expand Up @@ -344,7 +344,7 @@ export function TimePicker( {
min={ 1 }
max={ 9999 }
required
hideHTMLArrows
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
Expand Down
10 changes: 4 additions & 6 deletions packages/components/src/input-control/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
import type {
CSSProperties,
ReactNode,
ChangeEvent,
SyntheticEvent,
PointerEvent,
HTMLInputTypeAttribute,
} from 'react';
import type { useDrag } from '@use-gesture/react';
Expand Down Expand Up @@ -62,10 +60,10 @@ interface BaseProps {
size?: Size;
}

export type InputChangeCallback<
E = ChangeEvent< HTMLInputElement > | PointerEvent< HTMLInputElement >,
P = {}
> = ( nextValue: string | undefined, extra: { event: E } & P ) => void;
export type InputChangeCallback< P = {} > = (
nextValue: string | undefined,
extra: { event: SyntheticEvent } & P
) => void;

export interface InputFieldProps extends BaseProps {
/**
Expand Down
13 changes: 9 additions & 4 deletions packages/components/src/number-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@ If `isDragEnabled` is true, this controls the amount of `px` to have been dragge
- Required: No
- Default: `10`

### hideHTMLArrows
### spinControls

If true, the default `input` HTML arrows will be hidden.
The type of spin controls to display. These are butons that allow the user to
quickly increment and decrement the number.

- 'none' - Do not show spin controls.
- 'native' - Use browser's native HTML `input` controls.
- 'custom' - Use plus and minus icon buttons.

- Type: `Boolean`
- Type: `String`
- Required: No
- Default: `false`
- Default: `'native'`

### isDragEnabled

Expand Down
133 changes: 100 additions & 33 deletions packages/components/src/number-control/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,38 @@
* External dependencies
*/
import classNames from 'classnames';
import type { ForwardedRef } from 'react';
import type { ForwardedRef, KeyboardEvent, MouseEvent } from 'react';

/**
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';
import { isRTL } from '@wordpress/i18n';
import { useRef, forwardRef } from '@wordpress/element';
import { isRTL, __ } from '@wordpress/i18n';
import { plus as plusIcon, reset as resetIcon } from '@wordpress/icons';
import { useMergeRefs } from '@wordpress/compose';
import deprecated from '@wordpress/deprecated';

/**
* Internal dependencies
*/
import { Input } from './styles/number-control-styles';
import { Input, SpinButton } from './styles/number-control-styles';
import * as inputControlActionTypes from '../input-control/reducer/actions';
import { add, subtract, roundClamp } from '../utils/math';
import { ensureNumber, isValueEmpty } from '../utils/values';
import type { WordPressComponentProps } from '../ui/context/wordpress-component';
import type { NumberControlProps } from './types';
import { HStack } from '../h-stack';
import { Spacer } from '../spacer';

const noop = () => {};

function UnforwardedNumberControl(
{
__unstableStateReducer: stateReducerProp,
className,
dragDirection = 'n',
hideHTMLArrows = false,
spinControls = 'native',
isDragEnabled = true,
isShiftStepEnabled = true,
label,
Expand All @@ -36,10 +44,25 @@ function UnforwardedNumberControl(
step = 1,
type: typeProp = 'number',
value: valueProp,
size = 'default',
suffix,
onChange = noop,
...props
}: WordPressComponentProps< NumberControlProps, 'input', false >,
ref: ForwardedRef< any >
forwardedRef: ForwardedRef< any >
) {
if ( hideHTMLArrows ) {
deprecated( 'hideHTMLArrows', {
alternative: 'spinControls="none"',
since: '6.2',
version: '6.3',
} );
spinControls = 'none';
}

const inputRef = useRef< HTMLInputElement >();
const mergedRef = useMergeRefs( [ inputRef, forwardedRef ] );

const isStepAny = step === 'any';
const baseStep = isStepAny ? 1 : ensureNumber( step );
const baseValue = roundClamp( 0, min, max, baseStep );
Expand All @@ -56,6 +79,23 @@ function UnforwardedNumberControl(
const autoComplete = typeProp === 'number' ? 'off' : undefined;
const classes = classNames( 'components-number-control', className );

const spinValue = (
value: string | number | undefined,
direction: 'up' | 'down',
event: KeyboardEvent | MouseEvent | undefined
) => {
event?.preventDefault();
const shift = event?.shiftKey && isShiftStepEnabled;
const delta = shift ? ensureNumber( shiftStep ) * baseStep : baseStep;
let nextValue = isValueEmpty( value ) ? baseValue : value;
if ( direction === 'up' ) {
nextValue = add( nextValue, delta );
} else if ( direction === 'down' ) {
nextValue = subtract( nextValue, delta );
}
return constrainValue( nextValue, shift ? delta : undefined );
};

/**
* "Middleware" function that intercepts updates from InputControl.
* This allows us to tap into actions to transform the (next) state for
Expand All @@ -78,33 +118,11 @@ function UnforwardedNumberControl(
type === inputControlActionTypes.PRESS_UP ||
type === inputControlActionTypes.PRESS_DOWN
) {
const enableShift =
( event as KeyboardEvent | undefined )?.shiftKey &&
isShiftStepEnabled;

const incrementalValue = enableShift
? ensureNumber( shiftStep ) * baseStep
: baseStep;
let nextValue = isValueEmpty( currentValue )
? baseValue
: currentValue;

if ( event?.preventDefault ) {
event.preventDefault();
}

if ( type === inputControlActionTypes.PRESS_UP ) {
nextValue = add( nextValue, incrementalValue );
}

if ( type === inputControlActionTypes.PRESS_DOWN ) {
nextValue = subtract( nextValue, incrementalValue );
}

// @ts-expect-error TODO: Resolve discrepancy between `value` types in InputControl based components
nextState.value = constrainValue(
nextValue,
enableShift ? incrementalValue : undefined
nextState.value = spinValue(
currentValue,
type === inputControlActionTypes.PRESS_UP ? 'up' : 'down',
event as KeyboardEvent | undefined
);
}

Expand Down Expand Up @@ -178,19 +196,31 @@ function UnforwardedNumberControl(
return nextState;
};

const buildSpinButtonClickHandler =
( direction: 'up' | 'down' ) =>
( event: MouseEvent< HTMLButtonElement > ) =>
onChange( String( spinValue( valueProp, direction, event ) ), {
// Set event.target to the <input> so that consumers can use
// e.g. event.target.validity.
event: {
...event,
target: inputRef.current!,
},
} );

return (
<Input
autoComplete={ autoComplete }
inputMode="numeric"
{ ...props }
className={ classes }
dragDirection={ dragDirection }
hideHTMLArrows={ hideHTMLArrows }
hideHTMLArrows={ spinControls !== 'native' }
isDragEnabled={ isDragEnabled }
label={ label }
max={ max }
min={ min }
ref={ ref }
ref={ mergedRef }
required={ required }
step={ step }
type={ typeProp }
Expand All @@ -200,6 +230,43 @@ function UnforwardedNumberControl(
const baseState = numberControlStateReducer( state, action );
return stateReducerProp?.( baseState, action ) ?? baseState;
} }
size={ size }
suffix={
spinControls === 'custom' ? (
<>
{ suffix }
<Spacer marginBottom={ 0 } marginRight={ 2 }>
<HStack spacing={ 1 }>
<SpinButton
icon={ plusIcon }
isSmall
aria-hidden="true"
aria-label={ __( 'Increment' ) }
tabIndex={ -1 }
onClick={ buildSpinButtonClickHandler(
'up'
) }
size={ size }
/>
<SpinButton
icon={ resetIcon }
isSmall
aria-hidden="true"
aria-label={ __( 'Decrement' ) }
tabIndex={ -1 }
onClick={ buildSpinButtonClickHandler(
'down'
) }
size={ size }
/>
</HStack>
</Spacer>
</>
) : (
suffix
)
}
onChange={ onChange }
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
*/
import { css } from '@emotion/react';
import styled from '@emotion/styled';

/**
* Internal dependencies
*/
import InputControl from '../../input-control';
import { COLORS } from '../../utils';
import Button from '../../button';
import { space } from '../../ui/utils/space';

const htmlArrowStyles = ( { hideHTMLArrows } ) => {
if ( ! hideHTMLArrows ) return ``;
if ( ! hideHTMLArrows ) {
return ``;
}

return css`
input[type='number']::-webkit-outer-spin-button,
Expand All @@ -28,3 +34,22 @@ const htmlArrowStyles = ( { hideHTMLArrows } ) => {
export const Input = styled( InputControl )`
${ htmlArrowStyles };
`;

const spinButtonSizeStyles = ( { size } ) => {
if ( size !== 'small' ) {
return ``;
}

return css`
width: ${ space( 5 ) };
min-width: ${ space( 5 ) };
height: ${ space( 5 ) };
`;
};

export const SpinButton = styled( Button )`
&&&&& {
color: ${ COLORS.ui.theme };
${ spinButtonSizeStyles }
}
`;
Loading

0 comments on commit 2961b34

Please sign in to comment.