Skip to content

Commit e8c5a22

Browse files
authored
[TextField] Add onStepperChange optional prop, and support Spinbutton pattern keyboard interactions (#8773)
1 parent e0ff210 commit e8c5a22

File tree

3 files changed

+261
-6
lines changed

3 files changed

+261
-6
lines changed

.changeset/beige-eggs-join.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@shopify/polaris': minor
3+
---
4+
5+
- Added an optional `onSpinnerChange` prop to`TextField`
6+
- Added an optional `largeStep` prop to `TextField`
7+
- Added `TextField` `Spinner` keypress interactions for Home, End, Page Up, Page Down

polaris-react/src/components/TextField/TextField.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ interface NonMutuallyExclusiveProps {
122122
role?: string;
123123
/** Limit increment value for numeric and date-time inputs */
124124
step?: number;
125+
/** Increment value for numeric and date-time inputs when using Page Up or Page Down */
126+
largeStep?: number;
125127
/** Enable automatic completion by the browser. Set to "off" when you do not want the browser to fill in info */
126128
autoComplete: string;
127129
/** Mimics the behavior of the native HTML attribute, limiting the maximum value */
@@ -162,6 +164,8 @@ interface NonMutuallyExclusiveProps {
162164
onClearButtonClick?(id: string): void;
163165
/** Callback fired when value is changed */
164166
onChange?(value: string, id: string): void;
167+
/** When provided, callback fired instead of onChange when value is changed via the number step control */
168+
onSpinnerChange?(value: string, id: string): void;
165169
/** Callback fired when input is focused */
166170
onFocus?: (event?: React.FocusEvent) => void;
167171
/** Callback fired when input is blurred */
@@ -207,6 +211,7 @@ export function TextField({
207211
id: idProp,
208212
role,
209213
step,
214+
largeStep,
210215
autoComplete,
211216
max,
212217
maxLength,
@@ -229,6 +234,7 @@ export function TextField({
229234
suggestion,
230235
onClearButtonClick,
231236
onChange,
237+
onSpinnerChange,
232238
onFocus,
233239
onBlur,
234240
borderless,
@@ -353,8 +359,8 @@ export function TextField({
353359
) : null;
354360

355361
const handleNumberChange = useCallback(
356-
(steps: number) => {
357-
if (onChange == null) {
362+
(steps: number, stepAmount = normalizedStep) => {
363+
if (onChange == null && onSpinnerChange == null) {
358364
return;
359365
}
360366
// Returns the length of decimal places in a number
@@ -367,16 +373,28 @@ export function TextField({
367373

368374
// Making sure the new value has the same length of decimal places as the
369375
// step / value has.
370-
const decimalPlaces = Math.max(dpl(numericValue), dpl(normalizedStep));
376+
const decimalPlaces = Math.max(dpl(numericValue), dpl(stepAmount));
371377

372378
const newValue = Math.min(
373379
Number(normalizedMax),
374-
Math.max(numericValue + steps * normalizedStep, Number(normalizedMin)),
380+
Math.max(numericValue + steps * stepAmount, Number(normalizedMin)),
375381
);
376382

377-
onChange(String(newValue.toFixed(decimalPlaces)), id);
383+
if (onSpinnerChange != null) {
384+
onSpinnerChange(String(newValue.toFixed(decimalPlaces)), id);
385+
} else if (onChange != null) {
386+
onChange(String(newValue.toFixed(decimalPlaces)), id);
387+
}
378388
},
379-
[id, normalizedMax, normalizedMin, onChange, normalizedStep, value],
389+
[
390+
id,
391+
normalizedMax,
392+
normalizedMin,
393+
onChange,
394+
onSpinnerChange,
395+
normalizedStep,
396+
value,
397+
],
380398
);
381399

382400
const handleButtonRelease = useCallback(() => {
@@ -531,6 +549,7 @@ export function TextField({
531549
onBlur: handleOnBlur,
532550
onClick: handleClickChild,
533551
onKeyPress: handleKeyPress,
552+
onKeyDown: handleKeyDown,
534553
onChange: !suggestion ? handleChange : undefined,
535554
onInput: suggestion ? handleChange : undefined,
536555
});
@@ -645,6 +664,40 @@ export function TextField({
645664
event.preventDefault();
646665
}
647666

667+
function handleKeyDown(event: React.KeyboardEvent) {
668+
if (type !== 'number') {
669+
return;
670+
}
671+
672+
const {key, which} = event;
673+
if ((which === Key.Home || key === 'Home') && min !== undefined) {
674+
if (onSpinnerChange != null) {
675+
onSpinnerChange(String(min), id);
676+
} else if (onChange != null) {
677+
onChange(String(min), id);
678+
}
679+
}
680+
681+
if ((which === Key.End || key === 'End') && max !== undefined) {
682+
if (onSpinnerChange != null) {
683+
onSpinnerChange(String(max), id);
684+
} else if (onChange != null) {
685+
onChange(String(max), id);
686+
}
687+
}
688+
689+
if ((which === Key.PageUp || key === 'PageUp') && largeStep !== undefined) {
690+
handleNumberChange(1, largeStep);
691+
}
692+
693+
if (
694+
(which === Key.PageDown || key === 'PageDown') &&
695+
largeStep !== undefined
696+
) {
697+
handleNumberChange(-1, largeStep);
698+
}
699+
}
700+
648701
function handleOnBlur(event: React.FocusEvent) {
649702
setFocus(false);
650703

polaris-react/src/components/TextField/tests/TextField.test.tsx

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {Tag} from '../../Tag';
99
import {Resizer, Spinner} from '../components';
1010
import {TextField} from '../TextField';
1111
import styles from '../TextField.scss';
12+
import {Key} from '../../../types';
1213

1314
describe('<TextField />', () => {
1415
it('allows specific props to pass through properties on the input', () => {
@@ -1073,6 +1074,200 @@ describe('<TextField />', () => {
10731074
expect(spy).not.toHaveBeenCalled();
10741075
});
10751076

1077+
describe('onSpinnerChange()', () => {
1078+
it('is called with the new value when incrementing by step', () => {
1079+
const spy = jest.fn();
1080+
const element = mountWithApp(
1081+
<TextField
1082+
id="MyTextField"
1083+
label="TextField"
1084+
type="number"
1085+
value="2"
1086+
step={4}
1087+
onSpinnerChange={spy}
1088+
autoComplete="off"
1089+
/>,
1090+
);
1091+
1092+
element.findAll('div', {role: 'button'})[0].trigger('onClick');
1093+
expect(spy).toHaveBeenCalledWith('6', 'MyTextField');
1094+
});
1095+
1096+
it('is called with the new value instead of onChange when incrementing by step', () => {
1097+
const onStepperSpy = jest.fn();
1098+
const onChangeSpy = jest.fn();
1099+
const element = mountWithApp(
1100+
<TextField
1101+
id="MyTextField"
1102+
label="TextField"
1103+
type="number"
1104+
value="2"
1105+
step={4}
1106+
onSpinnerChange={onStepperSpy}
1107+
onChange={onChangeSpy}
1108+
autoComplete="off"
1109+
/>,
1110+
);
1111+
1112+
element.findAll('div', {role: 'button'})[0].trigger('onClick');
1113+
expect(onStepperSpy).toHaveBeenCalledWith('6', 'MyTextField');
1114+
expect(onChangeSpy).not.toHaveBeenCalled();
1115+
});
1116+
1117+
it('is not called when new values are typed', () => {
1118+
const onStepperSpy = jest.fn();
1119+
const onChangeSpy = jest.fn();
1120+
const element = mountWithApp(
1121+
<TextField
1122+
id="MyTextField"
1123+
label="TextField"
1124+
type="number"
1125+
value="2"
1126+
step={4}
1127+
onSpinnerChange={onStepperSpy}
1128+
onChange={onChangeSpy}
1129+
autoComplete="off"
1130+
/>,
1131+
);
1132+
1133+
element.find('input')!.trigger('onChange', {
1134+
currentTarget: {
1135+
value: '6',
1136+
},
1137+
});
1138+
expect(onStepperSpy).not.toHaveBeenCalled();
1139+
expect(onChangeSpy).toHaveBeenCalledWith('6', 'MyTextField');
1140+
});
1141+
});
1142+
1143+
describe('keydown events', () => {
1144+
it('decrements by largeStep when provided and Page Down is pressed', () => {
1145+
const spy = jest.fn();
1146+
const textField = mountWithApp(
1147+
<TextField
1148+
id="MyTextField"
1149+
label="TextField"
1150+
type="number"
1151+
value="10"
1152+
step={1}
1153+
largeStep={4}
1154+
onChange={spy}
1155+
autoComplete="off"
1156+
/>,
1157+
);
1158+
textField.find('input')!.trigger('onKeyDown', {
1159+
key: 'PageDown',
1160+
which: Key.PageDown,
1161+
});
1162+
expect(spy).toHaveBeenCalledWith('6', 'MyTextField');
1163+
});
1164+
1165+
it('increments by largeStep when provided and Page Up is pressed', () => {
1166+
const spy = jest.fn();
1167+
const textField = mountWithApp(
1168+
<TextField
1169+
id="MyTextField"
1170+
label="TextField"
1171+
type="number"
1172+
value="10"
1173+
step={1}
1174+
largeStep={4}
1175+
onChange={spy}
1176+
autoComplete="off"
1177+
/>,
1178+
);
1179+
textField.find('input')!.trigger('onKeyDown', {
1180+
key: 'PageUp',
1181+
which: Key.PageUp,
1182+
});
1183+
expect(spy).toHaveBeenCalledWith('14', 'MyTextField');
1184+
});
1185+
1186+
it('calls onChange(min) if Home is pressed and min was provided', () => {
1187+
const spy = jest.fn();
1188+
const textField = mountWithApp(
1189+
<TextField
1190+
id="MyTextField"
1191+
label="TextField"
1192+
type="number"
1193+
value="10"
1194+
min={1}
1195+
step={1}
1196+
onChange={spy}
1197+
autoComplete="off"
1198+
/>,
1199+
);
1200+
textField.find('input')!.trigger('onKeyDown', {
1201+
key: 'Home',
1202+
which: Key.Home,
1203+
});
1204+
expect(spy).toHaveBeenCalledWith('1', 'MyTextField');
1205+
});
1206+
1207+
it('calls onChange(max) if End is pressed and max was provided', () => {
1208+
const spy = jest.fn();
1209+
const textField = mountWithApp(
1210+
<TextField
1211+
id="MyTextField"
1212+
label="TextField"
1213+
type="number"
1214+
value="10"
1215+
max={100}
1216+
step={1}
1217+
onChange={spy}
1218+
autoComplete="off"
1219+
/>,
1220+
);
1221+
textField.find('input')!.trigger('onKeyDown', {
1222+
key: 'End',
1223+
which: Key.End,
1224+
});
1225+
expect(spy).toHaveBeenCalledWith('100', 'MyTextField');
1226+
});
1227+
1228+
it('calls onSpinnerChange(min) if Home is pressed and min was provided', () => {
1229+
const spy = jest.fn();
1230+
const textField = mountWithApp(
1231+
<TextField
1232+
id="MyTextField"
1233+
label="TextField"
1234+
type="number"
1235+
value="10"
1236+
min={1}
1237+
step={1}
1238+
onSpinnerChange={spy}
1239+
autoComplete="off"
1240+
/>,
1241+
);
1242+
textField.find('input')!.trigger('onKeyDown', {
1243+
key: 'Home',
1244+
which: Key.Home,
1245+
});
1246+
expect(spy).toHaveBeenCalledWith('1', 'MyTextField');
1247+
});
1248+
1249+
it('calls onSpinnerChange(max) if End is pressed and max was provided', () => {
1250+
const spy = jest.fn();
1251+
const textField = mountWithApp(
1252+
<TextField
1253+
id="MyTextField"
1254+
label="TextField"
1255+
type="number"
1256+
value="10"
1257+
max={100}
1258+
step={1}
1259+
onSpinnerChange={spy}
1260+
autoComplete="off"
1261+
/>,
1262+
);
1263+
textField.find('input')!.trigger('onKeyDown', {
1264+
key: 'End',
1265+
which: Key.End,
1266+
});
1267+
expect(spy).toHaveBeenCalledWith('100', 'MyTextField');
1268+
});
1269+
});
1270+
10761271
describe('document events', () => {
10771272
type EventCallback = (mockEventData?: {[key: string]: any}) => void;
10781273

0 commit comments

Comments
 (0)