Skip to content

Commit b0c2456

Browse files
committed
VIV-2876 add support to render '+' when long press 'space' on keyboard
1 parent 60d1c3e commit b0c2456

File tree

3 files changed

+196
-79
lines changed

3 files changed

+196
-79
lines changed

libs/components/src/lib/dial-pad/dial-pad.spec.ts

Lines changed: 130 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,91 @@ describe('vwc-dial-pad', () => {
4747
return activeEl;
4848
}
4949

50+
function withFakeTimers(callback: () => void) {
51+
vi.useFakeTimers();
52+
try {
53+
callback();
54+
} finally {
55+
vi.useRealTimers();
56+
}
57+
}
58+
59+
function createPointerEvent(
60+
type: string,
61+
options: EventInit = {}
62+
): PointerEvent {
63+
// Create a mock PointerEvent for test environment
64+
const event = new Event(type, options) as PointerEvent;
65+
Object.defineProperty(event, 'pointerType', {
66+
value: 'mouse',
67+
writable: true,
68+
});
69+
Object.defineProperty(event, 'pointerId', {
70+
value: 1,
71+
writable: true,
72+
});
73+
return event;
74+
}
75+
76+
function simulatePointerLongPress(
77+
button: HTMLElement,
78+
options: {
79+
duration?: number;
80+
onComplete?: () => void;
81+
onLeave?: () => void;
82+
} = {}
83+
) {
84+
const { duration = 600, onComplete, onLeave } = options;
85+
const pointerDown = createPointerEvent('pointerdown', {
86+
bubbles: true,
87+
});
88+
const pointerUp = createPointerEvent('pointerup', {
89+
bubbles: true,
90+
});
91+
92+
Object.defineProperty(pointerDown, 'currentTarget', {
93+
value: button,
94+
writable: true,
95+
});
96+
button.dispatchEvent(pointerDown);
97+
vi.advanceTimersByTime(duration);
98+
99+
if (onLeave) {
100+
onLeave();
101+
}
102+
button.dispatchEvent(pointerUp);
103+
104+
if (onComplete) {
105+
onComplete();
106+
}
107+
vi.runAllTimers();
108+
}
109+
110+
function simulateKeyboardLongPress(
111+
input: HTMLInputElement,
112+
options: {
113+
pressDuration?: number;
114+
releaseAfter?: number;
115+
} = {}
116+
) {
117+
const { pressDuration = 650, releaseAfter: providedReleaseAfter } = options;
118+
const releaseAfter =
119+
providedReleaseAfter !== undefined ? providedReleaseAfter : pressDuration;
120+
const keyDown = new KeyboardEvent('keydown', {
121+
key: ' ',
122+
bubbles: true,
123+
repeat: false,
124+
});
125+
input.dispatchEvent(keyDown);
126+
vi.advanceTimersByTime(releaseAfter);
127+
const keyUp = new KeyboardEvent('keyup', {
128+
key: ' ',
129+
bubbles: true,
130+
});
131+
input.dispatchEvent(keyUp);
132+
vi.runAllTimers();
133+
}
134+
50135
beforeEach(async () => {
51136
element = (await fixture(
52137
`<${COMPONENT_TAG}></${COMPONENT_TAG}>`
@@ -479,23 +564,6 @@ describe('vwc-dial-pad', () => {
479564
return getDigitButtons(component)[10] as Button;
480565
}
481566

482-
function createPointerEvent(
483-
type: string,
484-
options: EventInit = {}
485-
): PointerEvent {
486-
// Create a mock PointerEvent for test environment
487-
const event = new Event(type, options) as PointerEvent;
488-
Object.defineProperty(event, 'pointerType', {
489-
value: 'mouse',
490-
writable: true,
491-
});
492-
Object.defineProperty(event, 'pointerId', {
493-
value: 1,
494-
writable: true,
495-
});
496-
return event;
497-
}
498-
499567
it('should add "0" when tapping 0', async () => {
500568
getZeroButton(element).click();
501569
await Updates.next();
@@ -506,26 +574,11 @@ describe('vwc-dial-pad', () => {
506574
element.pattern = '^\\+?[0-9#*]*$';
507575
await Updates.next();
508576
const btn = getZeroButton(element);
509-
vi.useFakeTimers();
510-
try {
511-
const pointerDown = createPointerEvent('pointerdown', {
512-
bubbles: true,
513-
});
514-
Object.defineProperty(pointerDown, 'currentTarget', {
515-
value: btn,
516-
writable: true,
577+
withFakeTimers(() => {
578+
simulatePointerLongPress(btn, {
579+
duration: 600,
517580
});
518-
btn.dispatchEvent(pointerDown);
519-
vi.advanceTimersByTime(600);
520-
btn.dispatchEvent(
521-
createPointerEvent('pointerup', {
522-
bubbles: true,
523-
})
524-
);
525-
vi.runAllTimers();
526-
} finally {
527-
vi.useRealTimers();
528-
}
581+
});
529582
await Updates.next();
530583

531584
// Simulate potential click following pointerup; should be suppressed
@@ -539,21 +592,9 @@ describe('vwc-dial-pad', () => {
539592
element.disabled = true;
540593
await Updates.next();
541594
const btn = getZeroButton(element);
542-
vi.useFakeTimers();
543-
try {
544-
const pointerDown = createPointerEvent('pointerdown', {
545-
bubbles: true,
546-
});
547-
Object.defineProperty(pointerDown, 'currentTarget', {
548-
value: btn,
549-
writable: true,
550-
});
551-
btn.dispatchEvent(pointerDown);
552-
vi.advanceTimersByTime(600);
553-
vi.runAllTimers();
554-
} finally {
555-
vi.useRealTimers();
556-
}
595+
withFakeTimers(() => {
596+
simulatePointerLongPress(btn, { duration: 600 });
597+
});
557598
await Updates.next();
558599
expect(getTextField(element).value).toEqual('');
559600
});
@@ -562,28 +603,20 @@ describe('vwc-dial-pad', () => {
562603
element.pattern = '^\\+?[0-9#*]*$';
563604
await Updates.next();
564605
const btn = getZeroButton(element);
565-
vi.useFakeTimers();
566-
try {
567-
const pointerDown = createPointerEvent('pointerdown', {
568-
bubbles: true,
606+
withFakeTimers(() => {
607+
simulatePointerLongPress(btn, {
608+
duration: 300,
609+
onLeave: () => {
610+
// Leave before long press completes
611+
btn.dispatchEvent(
612+
createPointerEvent('pointerleave', {
613+
bubbles: true,
614+
})
615+
);
616+
},
569617
});
570-
Object.defineProperty(pointerDown, 'currentTarget', {
571-
value: btn,
572-
writable: true,
573-
});
574-
btn.dispatchEvent(pointerDown);
575618
vi.advanceTimersByTime(300);
576-
// Leave before long press completes
577-
btn.dispatchEvent(
578-
createPointerEvent('pointerleave', {
579-
bubbles: true,
580-
})
581-
);
582-
vi.advanceTimersByTime(300);
583-
vi.runAllTimers();
584-
} finally {
585-
vi.useRealTimers();
586-
}
619+
});
587620
await Updates.next();
588621
expect(getTextField(element).value).toEqual('');
589622
});
@@ -833,4 +866,30 @@ describe('vwc-dial-pad', () => {
833866
expect(element._errorAnnouncement).toBe('');
834867
});
835868
});
869+
870+
describe('keyboard events', function () {
871+
it('should add "+" when long pressing Space in input field', async () => {
872+
element.pattern = '^\\+?[0-9#*]*$';
873+
await Updates.next();
874+
const inputEl = getInput(element);
875+
withFakeTimers(() => {
876+
simulateKeyboardLongPress(inputEl, { pressDuration: 650 });
877+
});
878+
await Updates.next();
879+
880+
expect(getTextField(element).value).toEqual('+');
881+
});
882+
883+
it('should add space when short pressing Space in input field', async () => {
884+
element.pattern = '^\\+?[0-9#*]*$';
885+
await Updates.next();
886+
const inputEl = getInput(element);
887+
withFakeTimers(() => {
888+
simulateKeyboardLongPress(inputEl, { pressDuration: 300 });
889+
});
890+
await Updates.next();
891+
892+
expect(getTextField(element).value).toEqual(' ');
893+
});
894+
});
836895
});

libs/components/src/lib/dial-pad/dial-pad.template.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ function handleKeyDown(x: DialPad, e: KeyboardEvent) {
7777
e.target instanceof HTMLInputElement
7878
) {
7979
x._onDial();
80+
} else if (e.key === ' ' || e.key === 'Space') {
81+
// Handle long-press Space for '0' button when input is active
82+
if (e.target instanceof HTMLInputElement) {
83+
e.preventDefault();
84+
// Only start on first keydown, ignore repeat events
85+
// keydown events repeat while key is held, so we only start the timer once
86+
if (!e.repeat) {
87+
x._startKeyboardLongPress();
88+
}
89+
}
8090
} else {
8191
const elementIndex = DIAL_PAD_BUTTONS.findIndex((x) => x.value === e.key);
8292
if (elementIndex > -1) {
@@ -93,6 +103,23 @@ function handleKeyDown(x: DialPad, e: KeyboardEvent) {
93103
return true;
94104
}
95105

106+
function handleKeyUp(x: DialPad, e: KeyboardEvent) {
107+
if (e.key === ' ' || e.key === 'Space') {
108+
if (e.target instanceof HTMLInputElement) {
109+
e.preventDefault();
110+
111+
const wasLongPress = x._endKeyboardLongPress();
112+
if (!wasLongPress) {
113+
// Short press - add space character (normal space input)
114+
x.value += ' ';
115+
x.$emit('input');
116+
x.$emit('change');
117+
}
118+
}
119+
}
120+
return true;
121+
}
122+
96123
function syncFieldAndPadValues(x: DialPad) {
97124
x.value = x._textFieldEl.value;
98125
}
@@ -122,6 +149,7 @@ function renderTextField(
122149
x.helperText}" pattern="${(x) => x.pattern}"
123150
aria-label="${(x) => x.locale.dialPad.inputLabel}"
124151
@keydown="${(x, c) => handleKeyDown(x, c.event as KeyboardEvent)}"
152+
@keyup="${(x, c) => handleKeyUp(x, c.event as KeyboardEvent)}"
125153
@input="${syncFieldAndPadValues}"
126154
@change="${syncFieldAndPadValues}"
127155
@focus="${stopPropagation}"
@@ -157,11 +185,6 @@ function onDigitClick(
157185
dialPad.$emit('keypad-click', event.currentTarget);
158186
dialPad.$emit('input');
159187
dialPad.$emit('change');
160-
// Reset suppress flag after a delay to handle cases where click doesn't fire.
161-
// This ensures the next click won't be incorrectly suppressed
162-
window.setTimeout(() => {
163-
dialPad._suppressNextClick = false;
164-
}, 0);
165188
}
166189

167190
function renderDigits(

libs/components/src/lib/dial-pad/dial-pad.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ export class DialPad extends Localized(VividElement) {
157157
* @internal
158158
*/
159159
_suppressNextClick = false;
160+
/**
161+
* Tracks if long press completed for keyboard events
162+
* @internal
163+
*/
164+
private _keyboardLongPressCompleted = false;
160165

161166
/**
162167
* @internal
@@ -176,14 +181,44 @@ export class DialPad extends Localized(VividElement) {
176181
}, 600);
177182
}
178183

184+
/**
185+
* @internal
186+
*/
187+
_startKeyboardLongPress() {
188+
if (this.disabled || this.callActive) return;
189+
// If timer is already running, don't restart it (keydown events repeat while key is held)
190+
if (this._longPressTimeoutId !== null) return;
191+
192+
this._keyboardLongPressCompleted = false;
193+
194+
this._longPressTimeoutId = window.setTimeout(() => {
195+
this._keyboardLongPressCompleted = true;
196+
197+
this.value += '+';
198+
this.$emit('input');
199+
this.$emit('change');
200+
}, 650);
201+
}
202+
179203
/**
180204
* @internal
181205
*/
182206
_endLongPress() {
183207
this._clearLongPressTimer();
184-
// Don't reset suppress flag here - let the click handler reset it
185-
// This ensures the click event can check the flag and suppress if needed
186-
// If click doesn't fire, the flag will be reset on the next click
208+
}
209+
210+
/**
211+
* @internal
212+
* @returns true if long press completed (timer fired), false otherwise
213+
*/
214+
_endKeyboardLongPress(): boolean {
215+
const wasLongPress = this._keyboardLongPressCompleted;
216+
this._clearLongPressTimer();
217+
218+
window.setTimeout(() => {
219+
this._keyboardLongPressCompleted = false;
220+
}, 0);
221+
return wasLongPress;
187222
}
188223

189224
/**

0 commit comments

Comments
 (0)