Skip to content

Commit 3034755

Browse files
bengfarrellWestbrook Johnson
authored andcommitted
fix(number-field): add support for scrubbing (#1535)
* feat(number-field): added features required for internal Adobe project - scrubbable (drag left/right for increment decrementing) - allow per pixel amount for scrubbing (defaults to using existing step) - shift modifier step multiplier for fast keyboard increment/decrement * feat(number-field): add shift modifier to multiply key input * feat(number-field): put stepper buttons in shift modifier path * fix(number-field): fix issue with PR where there are runtime errors when buttons are hidden * fix(number-field): add property to indicate that the user is scrubbing * fix(number-field): delay scrub input by 250ms in case user clicked/dragged a bit meaning to focus * fix(number-field): issue with scrubbing off component lights up focus ring without having focus * fix(number-field): issues with intermittent inner focus-visible and misfiring on negative distance
1 parent 5d189c2 commit 3034755

File tree

3 files changed

+229
-17
lines changed

3 files changed

+229
-17
lines changed

packages/number-field/src/NumberField.ts

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ export class NumberField extends TextfieldBase {
106106

107107
_forcedUnit = '';
108108

109+
@property({ type: Boolean, reflect: true })
110+
public scrubbing = false;
111+
109112
/**
110113
* An `<sp-number-field>` element will process its numeric value with
111114
* `new Intl.NumberFormat(this.resolvedLanguage, this.formatOptions).format(this.valueAsNumber)`
@@ -149,6 +152,9 @@ export class NumberField extends TextfieldBase {
149152
@property({ type: Number, reflect: true, attribute: 'step-modifier' })
150153
public stepModifier = 10;
151154

155+
@property({ type: Number })
156+
public stepperpixel?: number;
157+
152158
@property({ type: Number })
153159
public override set value(rawValue: number) {
154160
const value = this.validateInput(rawValue);
@@ -242,13 +248,29 @@ export class NumberField extends TextfieldBase {
242248
private change!: (event: PointerEvent) => void;
243249
private safty!: number;
244250
private languageResolver = new LanguageResolutionController(this);
251+
private pointerDragXLocation?: number;
252+
private pointerDownTime?: number;
253+
private scrubDistance = 0;
245254

246255
private handlePointerdown(event: PointerEvent): void {
247256
if (event.button !== 0) {
248257
event.preventDefault();
249258
return;
250259
}
251260
this.managedInput = true;
261+
262+
if (!this.focused) {
263+
this.setPointerCapture(event.pointerId);
264+
this.scrub(event);
265+
}
266+
}
267+
268+
private handleButtonPointerdown(event: PointerEvent): void {
269+
if (event.button !== 0) {
270+
event.preventDefault();
271+
return;
272+
}
273+
this.managedInput = true;
252274
this.buttons.setPointerCapture(event.pointerId);
253275
const stepUpRect = this.buttons.children[0].getBoundingClientRect();
254276
const stepDownRect = this.buttons.children[1].getBoundingClientRect();
@@ -287,11 +309,11 @@ export class NumberField extends TextfieldBase {
287309
this.change(event);
288310
}
289311

290-
private handlePointermove(event: PointerEvent): void {
312+
private handleButtonPointermove(event: PointerEvent): void {
291313
this.findChange(event);
292314
}
293315

294-
private handlePointerup(event: PointerEvent): void {
316+
private handleButtonPointerup(event: PointerEvent): void {
295317
this.buttons.releasePointerCapture(event.pointerId);
296318
cancelAnimationFrame(this.nextChange);
297319
clearTimeout(this.safty);
@@ -340,6 +362,81 @@ export class NumberField extends TextfieldBase {
340362
this.stepBy(-1 * factor);
341363
}
342364

365+
private handlePointermove(event: PointerEvent): void {
366+
this.scrub(event);
367+
}
368+
369+
private handlePointerup(event: PointerEvent): void {
370+
this.releasePointerCapture(event.pointerId);
371+
this.scrub(event);
372+
cancelAnimationFrame(this.nextChange);
373+
clearTimeout(this.safty);
374+
this.managedInput = false;
375+
this.setValue();
376+
}
377+
378+
private scrub(event: PointerEvent): void {
379+
switch (event.type) {
380+
case 'pointerdown':
381+
this.scrubbing = true;
382+
this.pointerDragXLocation = event.clientX;
383+
this.pointerDownTime = Date.now();
384+
this.inputElement.disabled = true;
385+
this.addEventListener('pointermove', this.handlePointermove);
386+
this.addEventListener('pointerup', this.handlePointerup);
387+
this.addEventListener('pointercancel', this.handlePointerup);
388+
event.preventDefault();
389+
break;
390+
391+
case 'pointermove':
392+
if (
393+
this.pointerDragXLocation &&
394+
this.pointerDownTime &&
395+
Date.now() - this.pointerDownTime > 250
396+
) {
397+
const amtPerPixel = this.stepperpixel || this._step;
398+
const dist: number =
399+
event.clientX - this.pointerDragXLocation;
400+
const delta =
401+
Math.round(dist * amtPerPixel) *
402+
(event.shiftKey ? this.stepModifier : 1);
403+
this.scrubDistance += Math.abs(dist);
404+
this.pointerDragXLocation = event.clientX;
405+
this.stepBy(delta);
406+
event.preventDefault();
407+
}
408+
break;
409+
410+
default:
411+
this.pointerDragXLocation = undefined;
412+
this.scrubbing = false;
413+
this.inputElement.disabled = false;
414+
this.removeEventListener('pointermove', this.handlePointermove);
415+
this.removeEventListener('pointerup', this.handlePointerup);
416+
this.removeEventListener('pointercancel', this.handlePointerup);
417+
418+
// if user has scrubbed, disallow focus of field
419+
const bounds = this.getBoundingClientRect();
420+
if (
421+
this.scrubDistance > 0 &&
422+
this.pointerDownTime &&
423+
Date.now() - this.pointerDownTime > 250
424+
) {
425+
event.preventDefault();
426+
} else if (
427+
event.clientX >= bounds.x &&
428+
event.clientX <= bounds.x + bounds.width &&
429+
event.clientY >= bounds.y &&
430+
event.clientY <= bounds.y + bounds.height
431+
) {
432+
this.focus();
433+
}
434+
this.scrubDistance = 0;
435+
this.pointerDownTime = undefined;
436+
break;
437+
}
438+
}
439+
343440
private handleKeydown(event: KeyboardEvent): void {
344441
if (this.isComposing) return;
345442
switch (event.code) {
@@ -375,6 +472,9 @@ export class NumberField extends TextfieldBase {
375472
}
376473

377474
protected override onFocus(): void {
475+
if (this.pointerDragXLocation) {
476+
return;
477+
}
378478
super.onFocus();
379479
this._trackingValue = this.inputValue;
380480
this.keyboardFocused = !this.readonly && true;
@@ -639,7 +739,10 @@ export class NumberField extends TextfieldBase {
639739
@focusin=${this.handleFocusin}
640740
@focusout=${this.handleFocusout}
641741
${streamingListener({
642-
start: ['pointerdown', this.handlePointerdown],
742+
start: [
743+
'pointerdown',
744+
this.handleButtonPointerdown,
745+
],
643746
streamInside: [
644747
[
645748
'pointermove',
@@ -648,15 +751,15 @@ export class NumberField extends TextfieldBase {
648751
'pointerover',
649752
'pointerout',
650753
],
651-
this.handlePointermove,
754+
this.handleButtonPointermove,
652755
],
653756
end: [
654757
[
655758
'pointerup',
656759
'pointercancel',
657760
'pointerleave',
658761
],
659-
this.handlePointerup,
762+
this.handleButtonPointerup,
660763
],
661764
})}
662765
>
@@ -729,6 +832,7 @@ export class NumberField extends TextfieldBase {
729832
this.addEventListener('keydown', this.handleKeydown);
730833
this.addEventListener('compositionstart', this.handleCompositionStart);
731834
this.addEventListener('compositionend', this.handleCompositionEnd);
835+
this.addEventListener('pointerdown', this.handlePointerdown);
732836
}
733837

734838
protected override updated(changes: PropertyValues<this>): void {

packages/number-field/src/number-field.css

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,99 @@ governing permissions and limitations under the License.
5858
visibility: hidden;
5959
}
6060

61+
:host(:not([focused], [disabled])) {
62+
cursor: ew-resize;
63+
}
64+
65+
:host(:not([focused], [disabled])) input {
66+
cursor: ew-resize;
67+
}
68+
69+
:host([hide-stepper]:not([quiet])) .input {
70+
border-radius: var(
71+
--spectrum-alias-border-radius-regular,
72+
var(--spectrum-global-dimension-size-50)
73+
);
74+
}
75+
76+
:host([dir='ltr'][invalid]:not([hide-stepper])) .icon {
77+
/* [dir=ltr] .spectrum-Textfield.is-invalid .spectrum-Textfield-validationIcon */
78+
right: calc(
79+
var(--spectrum-stepper-button-width) +
80+
var(--spectrum-textfield-error-icon-margin-left)
81+
);
82+
}
83+
84+
:host([dir='rtl'][invalid]:not([hide-stepper])) .icon {
85+
/* [dir=rtl] .spectrum-Textfield.is-invalid .spectrum-Textfield-validationIcon */
86+
left: calc(
87+
var(--spectrum-stepper-button-width) +
88+
var(--spectrum-textfield-error-icon-margin-left)
89+
);
90+
}
91+
92+
:host([dir='ltr'][valid]:not([hide-stepper])) .icon {
93+
/* [dir=ltr] .spectrum-Textfield.is-valid .spectrum-Textfield-validationIcon */
94+
right: calc(
95+
var(--spectrum-stepper-button-width) +
96+
var(--spectrum-textfield-error-icon-margin-left)
97+
);
98+
}
99+
100+
:host([dir='rtl'][valid]:not([hide-stepper])) .icon {
101+
/* [dir=rtl] .spectrum-Textfield.is-valid .spectrum-Textfield-validationIcon */
102+
left: calc(
103+
var(--spectrum-stepper-button-width) +
104+
var(--spectrum-textfield-error-icon-margin-left)
105+
);
106+
}
107+
108+
:host([dir='ltr'][quiet][invalid]:not([hide-stepper])) .icon {
109+
/* [dir=ltr] .spectrum-Textfield--quiet.spectrum-Textfield.is-invalid .spectrum-Textfield-validationIcon */
110+
right: var(--spectrum-stepper-button-width);
111+
}
112+
113+
:host([dir='rtl'][quiet][invalid]:not([hide-stepper])) .icon {
114+
/* [dir=rtl] .spectrum-Textfield--quiet.spectrum-Textfield.is-invalid .spectrum-Textfield-validationIcon */
115+
left: var(--spectrum-stepper-button-width);
116+
}
117+
118+
:host([dir='ltr'][quiet][valid]:not([hide-stepper])) .icon {
119+
/* [dir=ltr] .spectrum-Textfield--quiet.spectrum-Textfield.is-valid .spectrum-Textfield-validationIcon */
120+
right: var(--spectrum-stepper-button-width);
121+
}
122+
123+
:host([dir='rtl'][quiet][valid]:not([hide-stepper])) .icon {
124+
/* [dir=rtl] .spectrum-Textfield--quiet.spectrum-Textfield.is-valid .spectrum-Textfield-validationIcon */
125+
left: var(--spectrum-stepper-button-width);
126+
}
127+
128+
:host([dir='ltr']:not([hide-stepper])) .icon-workflow {
129+
/* [dir=ltr] .spectrum-Textfield-icon */
130+
left: calc(
131+
var(--spectrum-stepper-button-width) +
132+
var(--spectrum-textfield-error-icon-margin-left)
133+
);
134+
}
135+
136+
:host([dir='rtl']:not([hide-stepper])) .icon-workflow {
137+
/* [dir=rtl] .spectrum-Textfield-icon */
138+
right: calc(
139+
var(--spectrum-stepper-button-width) +
140+
var(--spectrum-textfield-error-icon-margin-left)
141+
);
142+
}
143+
144+
:host([dir='ltr'][quiet]:not([hide-stepper])) .icon-workflow {
145+
/* [dir=ltr] .spectrum-Textfield--quiet .spectrum-Textfield-icon */
146+
left: var(--spectrum-stepper-button-width);
147+
}
148+
149+
:host([dir='rtl'][quiet]:not([hide-stepper])) .icon-workflow {
150+
/* [dir=rtl] .spectrum-Textfield--quiet .spectrum-Textfield-icon */
151+
right: var(--spectrum-stepper-button-width);
152+
}
153+
61154
:host([readonly]:not([disabled], [invalid], [focused], [keyboard-focused]))
62155
#textfield:hover
63156
.input {

packages/number-field/test/number-field.test.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,12 @@ describe('NumberField', () => {
557557
clientX: stepUpRect.x + 1,
558558
clientY: stepUpRect.y + 1,
559559
};
560+
el.setPointerCapture = () => {
561+
return;
562+
};
563+
el.releasePointerCapture = () => {
564+
return;
565+
};
560566
(
561567
el as unknown as {
562568
buttons: HTMLDivElement;
@@ -656,7 +662,7 @@ describe('NumberField', () => {
656662
expect(inputSpy.callCount).to.equal(5);
657663
expect(changeSpy.callCount).to.equal(1);
658664
});
659-
it('no change in committed value - using buttons', async () => {
665+
it('no change in committed value - using buttons', async function () {
660666
const buttonUp = el.shadowRoot.querySelector(
661667
'.step-up'
662668
) as HTMLElement;
@@ -673,6 +679,7 @@ describe('NumberField', () => {
673679
buttonDownRect.x + buttonDownRect.width / 2,
674680
buttonDownRect.y + buttonDownRect.height / 2,
675681
];
682+
const initialVaue = el.value;
676683
sendMouse({
677684
steps: [
678685
{
@@ -686,27 +693,32 @@ describe('NumberField', () => {
686693
});
687694
await oneEvent(el, 'input');
688695
expect(el.value).to.equal(51);
689-
expect(inputSpy.callCount).to.equal(1);
696+
expect(inputSpy.callCount).to.equal(
697+
Math.abs(el.value - initialVaue)
698+
);
690699
expect(changeSpy.callCount).to.equal(0);
691700
await oneEvent(el, 'input');
692701
expect(el.value).to.equal(52);
693-
expect(inputSpy.callCount).to.equal(2);
702+
expect(inputSpy.callCount).to.equal(
703+
Math.abs(el.value - initialVaue)
704+
);
694705
expect(changeSpy.callCount).to.equal(0);
695-
sendMouse({
706+
const inputs = inputSpy.callCount;
707+
const intermediateValue = el.value;
708+
await sendMouse({
696709
steps: [
697710
{
698711
type: 'move',
699712
position: buttonDownPosition,
700713
},
701714
],
702715
});
703-
let framesToWait = FRAMES_PER_CHANGE * 2;
704-
while (framesToWait) {
705-
// input is only processed onces per FRAMES_PER_CHANGE number of frames
706-
framesToWait -= 1;
707-
await nextFrame();
716+
while (el.value > 50) {
717+
await oneEvent(el, 'input');
708718
}
709-
expect(inputSpy.callCount).to.equal(4);
719+
expect(inputSpy.callCount).to.equal(
720+
inputs + Math.abs(el.value - intermediateValue)
721+
);
710722
expect(changeSpy.callCount).to.equal(0);
711723
await sendMouse({
712724
steps: [
@@ -715,14 +727,17 @@ describe('NumberField', () => {
715727
},
716728
],
717729
});
718-
expect(inputSpy.callCount).to.equal(4);
730+
expect(el.value).to.equal(50);
731+
expect(inputSpy.callCount).to.equal(
732+
inputs + Math.abs(el.value - intermediateValue)
733+
);
719734
expect(
720735
changeSpy.callCount,
721736
'value does not change from initial value so no "change" event is dispatched'
722737
).to.equal(0);
723738
});
724739
});
725-
it('accepts pointer interactions with the stepper UI', async () => {
740+
it('accepts pointer interactions with the stepper UI', async function () {
726741
const inputSpy = spy();
727742
const el = await getElFrom(Default({ value: 50 }));
728743
el.addEventListener('input', () => inputSpy());

0 commit comments

Comments
 (0)