Skip to content

Commit f647073

Browse files
committed
fix(CounterInput): prevent value clipping
1 parent 411a3bf commit f647073

4 files changed

Lines changed: 75 additions & 6 deletions

File tree

packages/blade/src/components/CounterInput/CounterInput.native.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useCallback, useEffect, useRef } from 'react';
22
import { Pressable, TextInput, StyleSheet } from 'react-native';
3+
import type { TextStyle } from 'react-native';
34
import Animated, {
45
useAnimatedStyle,
56
useSharedValue,
@@ -15,6 +16,7 @@ import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects';
1516
import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute';
1617
import type { BladeElementRef } from '~utils/types';
1718
import { useControllableState } from '~utils/useControllable';
19+
import { baseInputCounterInputPaddingTokens } from '~components/Input/BaseInput/baseInputTokens';
1820
import BaseBox from '~components/Box/BaseBox';
1921
import { FormLabel } from '~components/Form';
2022
import { useId } from '~utils/useId';
@@ -160,25 +162,40 @@ const _CounterInput = React.forwardRef<BladeElementRef, CounterInputProps>(
160162
size: COUNTER_INPUT_SIZE_TO_TEXT_SIZE[size],
161163
weight: 'semibold',
162164
});
165+
const fontSize = theme.typography.fonts.size[fontSizeToken];
166+
const counterValueDigitCount = Math.max(2, String(internalValue ?? min).length);
167+
const counterInputHorizontalPadding =
168+
theme.spacing[baseInputCounterInputPaddingTokens.left[size]] +
169+
theme.spacing[baseInputCounterInputPaddingTokens.right[size]];
170+
const counterInputFieldWidth =
171+
counterValueDigitCount * fontSize + counterInputHorizontalPadding;
163172

164173
const containerStyle = StyleSheet.create({
165174
box: {
166-
width: COUNTER_INPUT_TOKEN.width[size],
175+
minWidth: COUNTER_INPUT_TOKEN.width[size],
167176
height: COUNTER_INPUT_TOKEN.height[size],
177+
alignSelf: 'flex-start',
168178
},
169179
});
170180

171181
const textInputStyle = StyleSheet.create({
172182
input: {
173-
flex: 1,
183+
width: '100%',
174184
textAlign: 'center',
175185
textAlignVertical: 'center',
176186
includeFontPadding: false,
177187
padding: 0,
178188
color: valueColor,
179-
fontSize: theme.typography.fonts.size[fontSizeToken],
189+
fontSize,
180190
fontFamily: theme.typography.fonts.family.text,
181191
fontWeight: '600',
192+
fontVariant: ['tabular-nums'] as TextStyle['fontVariant'],
193+
},
194+
});
195+
196+
const inputWrapperStyle = StyleSheet.create({
197+
wrapper: {
198+
width: counterInputFieldWidth,
182199
},
183200
});
184201

@@ -241,7 +258,7 @@ const _CounterInput = React.forwardRef<BladeElementRef, CounterInputProps>(
241258
/>
242259
</Pressable>
243260

244-
<Animated.View style={[{ flex: 1 }, animatedStyle]}>
261+
<Animated.View style={[inputWrapperStyle.wrapper, animatedStyle]}>
245262
<TextInput
246263
value={internalValue?.toString() ?? String(min)}
247264
onChangeText={handleInputChange}

packages/blade/src/components/CounterInput/CounterInput.web.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { StyledCounterInput } from './StyledCounterInput';
55
import { COUNTER_INPUT_TOKEN, COUNTER_INPUT_ICON_SIZE_MAP } from './token';
66
import { CounterInputProvider } from './CounterInputContext';
77
import { BaseInput } from '~components/Input/BaseInput';
8+
import { baseInputCounterInputPaddingTokens } from '~components/Input/BaseInput/baseInputTokens';
89
import { metaAttribute, MetaConstants } from '~utils/metaAttribute';
910
import { getStyledProps } from '~components/Box/styledProps';
1011
import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects';
@@ -111,6 +112,11 @@ const _CounterInput = React.forwardRef<BladeElementRef, CounterInputProps>(
111112
const lastActionRef = useRef<'increment' | 'decrement' | null>(null);
112113
const previousValueRef = useRef<number | undefined>(internalValue);
113114
const containerRef = useRef<HTMLDivElement>(null);
115+
const counterValueDigitCount = Math.max(2, String(internalValue ?? min).length);
116+
const counterInputHorizontalPadding =
117+
theme.spacing[baseInputCounterInputPaddingTokens.left[size]] +
118+
theme.spacing[baseInputCounterInputPaddingTokens.right[size]];
119+
const counterInputFieldWidth = `calc(${counterValueDigitCount}ch + ${counterInputHorizontalPadding}px)`;
114120

115121
// Track Tab navigation to show focus ring only on keyboard navigation (not mouse clicks)
116122
// Note: :focus-visible doesn't work for text inputs - shows ring on both Tab and click
@@ -218,6 +224,7 @@ const _CounterInput = React.forwardRef<BladeElementRef, CounterInputProps>(
218224
display="flex"
219225
flexDirection={isLabelLeftPositioned ? 'row' : 'column'}
220226
alignItems={isLabelLeftPositioned ? 'center' : undefined}
227+
width="fit-content"
221228
>
222229
{label && (
223230
<FormLabel
@@ -241,7 +248,8 @@ const _CounterInput = React.forwardRef<BladeElementRef, CounterInputProps>(
241248
? emphasisTokens.loadingOrDisabledBgColor
242249
: emphasisTokens.backgroundColor
243250
}
244-
width={`${COUNTER_INPUT_TOKEN.width[size]}px`}
251+
width="fit-content"
252+
minWidth={`${COUNTER_INPUT_TOKEN.width[size]}px`}
245253
height={`${COUNTER_INPUT_TOKEN.height[size]}px`}
246254
borderRadius={COUNTER_INPUT_TOKEN.containerBorderRadius[size]}
247255
borderWidth="thin"
@@ -269,7 +277,10 @@ const _CounterInput = React.forwardRef<BladeElementRef, CounterInputProps>(
269277
<MinusIcon size={COUNTER_INPUT_ICON_SIZE_MAP[size]} color="currentColor" />
270278
</StyledCounterButton>
271279

272-
<BaseBox className={animationClass}>
280+
<BaseBox
281+
className={`__blade-counter-input-number-wrapper ${animationClass}`.trim()}
282+
width={counterInputFieldWidth}
283+
>
273284
<BaseInput
274285
ref={ref}
275286
id={inputId}

packages/blade/src/components/CounterInput/StyledCounterInput.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const StyledCounterInput = styled(BaseBox)`
6060
6161
&.__blade-counter-input input[type='number'] {
6262
-moz-appearance: textfield; /* Firefox */
63+
font-variant-numeric: tabular-nums;
6364
}
6465
6566
/* Remove ProgressBar background */

packages/blade/src/components/CounterInput/__tests__/CounterInput.web.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import userEvent from '@testing-library/user-event';
2+
import 'jest-styled-components';
23
import { CounterInput } from '../CounterInput';
34
import renderWithTheme from '~utils/testing/renderWithTheme.web';
45
import assertAccessible from '~utils/testing/assertAccessible.web';
6+
import { baseInputCounterInputPaddingTokens } from '~components/Input/BaseInput/baseInputTokens';
7+
import { bladeTheme } from '~tokens/theme';
58

69
beforeAll(() => jest.spyOn(console, 'error').mockImplementation());
710
afterAll(() => jest.restoreAllMocks());
811

12+
const getCounterInputFieldWidth = ({
13+
digitCount,
14+
size,
15+
}: {
16+
digitCount: number;
17+
size: 'xsmall' | 'medium' | 'large';
18+
}): string => {
19+
const horizontalPadding =
20+
bladeTheme.spacing[baseInputCounterInputPaddingTokens.left[size]] +
21+
bladeTheme.spacing[baseInputCounterInputPaddingTokens.right[size]];
22+
return `calc(${digitCount}ch + ${horizontalPadding}px)`;
23+
};
24+
925
describe('<CounterInput />', () => {
1026
it('should render', () => {
1127
const { container } = renderWithTheme(<CounterInput label="Quantity" />);
@@ -194,6 +210,30 @@ describe('<CounterInput />', () => {
194210
expect(input).toHaveAttribute('name', name);
195211
});
196212

213+
it('should reserve two digits and expand for larger values', () => {
214+
const { container, rerender } = renderWithTheme(<CounterInput label="Quantity" value={5} />);
215+
216+
expect(container.querySelector('.__blade-counter-input-number-wrapper')).toHaveStyleRule(
217+
'width',
218+
getCounterInputFieldWidth({ digitCount: 2, size: 'medium' }),
219+
);
220+
221+
rerender(<CounterInput label="Quantity" value={100} />);
222+
223+
expect(container.querySelector('.__blade-counter-input-number-wrapper')).toHaveStyleRule(
224+
'width',
225+
getCounterInputFieldWidth({ digitCount: 3, size: 'medium' }),
226+
);
227+
});
228+
229+
it('should use tabular numbers', () => {
230+
const { container } = renderWithTheme(<CounterInput label="Quantity" value={100} />);
231+
232+
expect(container.firstChild).toHaveStyleRule('font-variant-numeric', 'tabular-nums', {
233+
modifier: "&.__blade-counter-input input[type='number']",
234+
});
235+
});
236+
197237
it('should pass a11y', async () => {
198238
const { getByRole } = renderWithTheme(
199239
<CounterInput label="Product Quantity" value={5} min={1} max={100} />,

0 commit comments

Comments
 (0)