|
1 | 1 | import React from 'react';
|
2 | 2 | import propTypes from 'prop-types';
|
3 |
| - |
4 | 3 | import styled, { css } from 'styled-components';
|
5 | 4 |
|
| 5 | +import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled'; |
| 6 | +import { clamp } from '../common/utils'; |
| 7 | + |
6 | 8 | import Button from '../Button/Button';
|
7 | 9 | import { blockSizes } from '../common/system';
|
8 | 10 | import TextField from '../TextField/TextField';
|
9 | 11 |
|
10 |
| -// ⭕⭕⭕⭕⭕ fix functionality and use hooks |
11 |
| - |
12 | 12 | const StyledNumberFieldWrapper = styled.div`
|
13 | 13 | display: inline-flex;
|
14 | 14 | align-items: center;
|
15 | 15 | `;
|
16 | 16 |
|
17 |
| -const StyledButtonWrapper = styled.div` |
18 |
| - height: ${blockSizes.md}; |
19 |
| - display: flex; |
20 |
| - flex-direction: column; |
21 |
| - flex-wrap: nowrap; |
22 |
| - margin-left: 2px; |
23 |
| - margin-top: ${({ variant }) => (variant === 'default' ? '-2px' : '0')}; |
24 |
| -`; |
25 |
| - |
26 | 17 | const StyledButton = styled(Button)`
|
27 |
| - height: 50%; |
28 | 18 | width: 30px;
|
29 | 19 | padding: 0;
|
30 | 20 | flex-shrink: 0;
|
31 | 21 |
|
32 | 22 | ${({ isFlat }) =>
|
33 |
| - !isFlat && |
34 |
| - css` |
35 |
| - &:before { |
36 |
| - border-left-color: ${({ theme }) => theme.borderLight}; |
37 |
| - border-top-color: ${({ theme }) => theme.borderLight}; |
38 |
| - box-shadow: inset 1px 1px 0px 1px ${({ theme }) => theme.borderLightest}, |
39 |
| - inset -1px -1px 0 1px ${({ theme }) => theme.borderDark}; |
40 |
| - } |
41 |
| - `} |
| 23 | + isFlat |
| 24 | + ? css` |
| 25 | + height: calc(50% - 1px); |
| 26 | + ` |
| 27 | + : css` |
| 28 | + height: 50%; |
| 29 | + &:before { |
| 30 | + border-left-color: ${({ theme }) => theme.borderLight}; |
| 31 | + border-top-color: ${({ theme }) => theme.borderLight}; |
| 32 | + box-shadow: inset 1px 1px 0px 1px |
| 33 | + ${({ theme }) => theme.borderLightest}, |
| 34 | + inset -1px -1px 0 1px ${({ theme }) => theme.borderDark}; |
| 35 | + } |
| 36 | + `} |
| 37 | +`; |
| 38 | + |
| 39 | +const StyledButtonWrapper = styled.div` |
| 40 | + display: flex; |
| 41 | + flex-direction: column; |
| 42 | + flex-wrap: nowrap; |
| 43 | + justify-content: space-between; |
| 44 | +
|
| 45 | + ${({ isFlat }) => |
| 46 | + isFlat |
| 47 | + ? css` |
| 48 | + height: calc(${blockSizes.md} - 4px); |
| 49 | + ` |
| 50 | + : css` |
| 51 | + height: ${blockSizes.md}; |
| 52 | + margin-left: 2px; |
| 53 | + `} |
42 | 54 | `;
|
43 | 55 |
|
44 | 56 | const StyledButtonIcon = styled.span`
|
45 |
| - position: absolute; |
46 |
| - left: 50%; |
47 |
| - top: 50%; |
48 |
| - transform: translate(-50%, -50%) ${props => props.invert && 'rotateZ(180deg)'}; |
49 | 57 | width: 0px;
|
50 | 58 | height: 0px;
|
51 |
| - border-left: 4px solid transparent; |
52 |
| - border-right: 4px solid transparent; |
53 | 59 | display: inline-block;
|
54 |
| - border-top: 4px solid ${({ theme }) => theme.text}; |
55 |
| - ${StyledButton}:active & { |
56 |
| - margin-top: 2px; |
| 60 | + ${({ invert }) => |
| 61 | + invert |
| 62 | + ? css` |
| 63 | + border-left: 4px solid transparent; |
| 64 | + border-right: 4px solid transparent; |
| 65 | + border-bottom: 4px solid ${({ theme }) => theme.text}; |
| 66 | + ` |
| 67 | + : css` |
| 68 | + border-left: 4px solid transparent; |
| 69 | + border-right: 4px solid transparent; |
| 70 | + border-top: 4px solid ${({ theme }) => theme.text}; |
| 71 | + `} |
| 72 | + ${StyledButton}:disabled & { |
| 73 | + filter: drop-shadow(1px 1px 0px ${({ theme }) => theme.textDisabledShadow}); |
| 74 | + ${({ invert }) => |
| 75 | + invert |
| 76 | + ? css` |
| 77 | + border-bottom-color: ${({ theme }) => theme.textDisabled}; |
| 78 | + ` |
| 79 | + : css` |
| 80 | + border-top-color: ${({ theme }) => theme.textDisabled}; |
| 81 | + `} |
57 | 82 | }
|
58 | 83 | `;
|
59 | 84 |
|
60 |
| -class NumberField extends React.Component { |
61 |
| - static propTypes = { |
62 |
| - variant: propTypes.oneOf(['default', 'flat']), |
63 |
| - onChange: propTypes.func.isRequired, |
64 |
| - value: propTypes.number.isRequired, |
65 |
| - min: propTypes.number, |
66 |
| - max: propTypes.number, |
67 |
| - width: propTypes.oneOfType([propTypes.string, propTypes.number]), |
68 |
| - disabled: propTypes.bool, |
69 |
| - disableKeyboardInput: propTypes.bool, |
70 |
| - className: propTypes.string, |
71 |
| - style: propTypes.shape([propTypes.string, propTypes.number]) |
72 |
| - }; |
73 |
| - |
74 |
| - static defaultProps = { |
75 |
| - variant: 'default', |
76 |
| - disabled: false, |
77 |
| - min: null, |
78 |
| - max: null, |
79 |
| - width: null, |
80 |
| - disableKeyboardInput: false, |
81 |
| - className: '', |
82 |
| - style: {} |
83 |
| - }; |
84 |
| - |
85 |
| - state = { |
86 |
| - // eslint-disable-next-line |
87 |
| - value: parseInt(this.props.value, 10) || 0 |
| 85 | +const NumberField = React.forwardRef(function NumberField(props, ref) { |
| 86 | + const { |
| 87 | + value, |
| 88 | + defaultValue, |
| 89 | + disabled, |
| 90 | + className, |
| 91 | + variant, |
| 92 | + step, |
| 93 | + width, |
| 94 | + min, |
| 95 | + max, |
| 96 | + onChange, |
| 97 | + style |
| 98 | + } = props; |
| 99 | + |
| 100 | + const [valueDerived, setValueState] = useControlledOrUncontrolled({ |
| 101 | + value, |
| 102 | + defaultValue |
| 103 | + }); |
| 104 | + |
| 105 | + const handleInputChange = e => { |
| 106 | + const newValue = e.target.value; |
| 107 | + setValueState(newValue); |
88 | 108 | };
|
89 | 109 |
|
90 |
| - add = addValue => { |
91 |
| - const { value } = this.state; |
92 |
| - const { onChange } = this.props; |
93 |
| - |
94 |
| - const newValue = this.normalize(value + addValue); |
95 |
| - onChange(newValue); |
96 |
| - this.setState({ value: newValue }); |
97 |
| - }; |
| 110 | + const handleClick = val => { |
| 111 | + const newValue = clamp( |
| 112 | + +parseFloat(valueDerived + val).toFixed(2), |
| 113 | + min, |
| 114 | + max |
| 115 | + ); |
98 | 116 |
|
99 |
| - handleChange = e => { |
100 |
| - let newValue = |
101 |
| - e.target.value === '-' ? '-' : this.normalize(e.target.value); |
102 |
| - // eslint-disable-next-line |
103 |
| - newValue = newValue ? newValue : newValue === 0 ? 0 : ''; |
| 117 | + setValueState(newValue); |
104 | 118 |
|
105 |
| - if (e.target.validity.valid) { |
106 |
| - const { onChange } = this.props; |
107 |
| - this.setState({ value: newValue }); |
| 119 | + if (onChange) { |
108 | 120 | onChange(newValue);
|
109 | 121 | }
|
110 | 122 | };
|
111 | 123 |
|
112 |
| - normalize = value => { |
113 |
| - const { min, max } = this.props; |
114 |
| - |
115 |
| - if (min && value < min) return min; |
116 |
| - if (max && value > max) return max; |
117 |
| - |
118 |
| - return parseInt(value, 10); |
| 124 | + const onBlur = () => { |
| 125 | + if (onChange) { |
| 126 | + onChange(valueDerived); |
| 127 | + } |
119 | 128 | };
|
120 | 129 |
|
121 |
| - render() { |
122 |
| - const { |
123 |
| - disabled, |
124 |
| - disableKeyboardInput, |
125 |
| - className, |
126 |
| - variant, |
127 |
| - width, |
128 |
| - style |
129 |
| - } = this.props; |
130 |
| - const { value } = this.state; |
131 |
| - return ( |
132 |
| - <StyledNumberFieldWrapper |
133 |
| - className={className} |
134 |
| - style={{ ...style, width: width || 'auto' }} |
135 |
| - > |
136 |
| - <TextField |
137 |
| - value={value} |
| 130 | + return ( |
| 131 | + <StyledNumberFieldWrapper |
| 132 | + className={className} |
| 133 | + style={{ ...style, width: width || 'auto' }} |
| 134 | + > |
| 135 | + <TextField |
| 136 | + value={valueDerived} |
| 137 | + variant={variant} |
| 138 | + onChange={handleInputChange} |
| 139 | + disabled={disabled} |
| 140 | + type='number' |
| 141 | + ref={ref} |
| 142 | + width='100%' |
| 143 | + data-testid='input' |
| 144 | + onBlur={onBlur} |
| 145 | + /> |
| 146 | + <StyledButtonWrapper isFlat={variant === 'flat'}> |
| 147 | + <StyledButton |
| 148 | + data-testid='increment' |
| 149 | + isFlat={variant === 'flat'} |
138 | 150 | variant={variant}
|
139 |
| - onChange={ |
140 |
| - disabled || disableKeyboardInput ? undefined : this.handleChange |
141 |
| - } |
142 |
| - readOnly={disabled || disableKeyboardInput} |
143 | 151 | disabled={disabled}
|
144 |
| - type='tel' |
145 |
| - pattern='^-?[0-9]\d*\.?\d*$' |
146 |
| - width='100%' |
147 |
| - /> |
148 |
| - <StyledButtonWrapper> |
149 |
| - <StyledButton |
150 |
| - isFlat={variant === 'flat'} |
151 |
| - variant={variant} |
152 |
| - disabled={disabled} |
153 |
| - onClick={() => this.add(1)} |
154 |
| - > |
155 |
| - <StyledButtonIcon invert /> |
156 |
| - </StyledButton> |
157 |
| - <StyledButton |
158 |
| - isFlat={variant === 'flat'} |
159 |
| - variant={variant} |
160 |
| - disabled={disabled} |
161 |
| - onClick={() => this.add(-1)} |
162 |
| - > |
163 |
| - <StyledButtonIcon /> |
164 |
| - </StyledButton> |
165 |
| - </StyledButtonWrapper> |
166 |
| - </StyledNumberFieldWrapper> |
167 |
| - ); |
168 |
| - } |
169 |
| -} |
| 152 | + onClick={() => handleClick(step)} |
| 153 | + > |
| 154 | + <StyledButtonIcon invert /> |
| 155 | + </StyledButton> |
| 156 | + <StyledButton |
| 157 | + data-testid='decrement' |
| 158 | + isFlat={variant === 'flat'} |
| 159 | + variant={variant} |
| 160 | + disabled={disabled} |
| 161 | + onClick={() => handleClick(-step)} |
| 162 | + > |
| 163 | + <StyledButtonIcon /> |
| 164 | + </StyledButton> |
| 165 | + </StyledButtonWrapper> |
| 166 | + </StyledNumberFieldWrapper> |
| 167 | + ); |
| 168 | +}); |
| 169 | + |
| 170 | +NumberField.defaultProps = { |
| 171 | + className: '', |
| 172 | + defaultValue: undefined, |
| 173 | + disabled: false, |
| 174 | + max: null, |
| 175 | + min: null, |
| 176 | + step: 1, |
| 177 | + onChange: null, |
| 178 | + style: {}, |
| 179 | + value: undefined, |
| 180 | + variant: 'default', |
| 181 | + width: null |
| 182 | +}; |
| 183 | + |
| 184 | +NumberField.propTypes = { |
| 185 | + className: propTypes.string, |
| 186 | + defaultValue: propTypes.number, |
| 187 | + disabled: propTypes.bool, |
| 188 | + max: propTypes.number, |
| 189 | + min: propTypes.number, |
| 190 | + step: propTypes.number, |
| 191 | + onChange: propTypes.func, |
| 192 | + style: propTypes.shape([propTypes.string, propTypes.number]), |
| 193 | + value: propTypes.number, |
| 194 | + variant: propTypes.oneOf(['default', 'flat']), |
| 195 | + width: propTypes.oneOfType([propTypes.string, propTypes.number]) |
| 196 | +}; |
170 | 197 |
|
171 | 198 | export default NumberField;
|
0 commit comments