Skip to content

Commit 9571da8

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

File tree

4 files changed

+207
-82
lines changed

4 files changed

+207
-82
lines changed

libs/components/src/lib/dial-pad/VARIATIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
## International Numbers
88

9-
To support international numbers, you can use the pattern like `^\+?[0-9#*]*$` which allows an optional `+` at the beginning. Users can **long press** the `0` button to insert a `+` symbol (commonly used for international numbers).
9+
To support international numbers, you can use the pattern like `^\+?[0-9#*]*$` which allows an optional `+` at the beginning. Users can **long press** the `0` button or **long press the Space key** on the keyboard (when the input field is active) to insert a `+` symbol (commonly used for international numbers).
1010

1111
```html preview
1212
<vwc-dial-pad pattern="^\+?[0-9#*]*$"></vwc-dial-pad>

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

Lines changed: 133 additions & 73 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,30 +574,16 @@ 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,
580+
onComplete: () => {
581+
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
582+
},
517583
});
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-
}
584+
});
529585
await Updates.next();
530586

531-
// Simulate potential click following pointerup; should be suppressed
532-
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
533587
await Updates.next();
534588
expect(getTextField(element).value).toEqual('+');
535589
});
@@ -539,21 +593,9 @@ describe('vwc-dial-pad', () => {
539593
element.disabled = true;
540594
await Updates.next();
541595
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-
}
596+
withFakeTimers(() => {
597+
simulatePointerLongPress(btn, { duration: 600 });
598+
});
557599
await Updates.next();
558600
expect(getTextField(element).value).toEqual('');
559601
});
@@ -562,28 +604,20 @@ describe('vwc-dial-pad', () => {
562604
element.pattern = '^\\+?[0-9#*]*$';
563605
await Updates.next();
564606
const btn = getZeroButton(element);
565-
vi.useFakeTimers();
566-
try {
567-
const pointerDown = createPointerEvent('pointerdown', {
568-
bubbles: true,
607+
withFakeTimers(() => {
608+
simulatePointerLongPress(btn, {
609+
duration: 300,
610+
onLeave: () => {
611+
// Leave before long press completes
612+
btn.dispatchEvent(
613+
createPointerEvent('pointerleave', {
614+
bubbles: true,
615+
})
616+
);
617+
},
569618
});
570-
Object.defineProperty(pointerDown, 'currentTarget', {
571-
value: btn,
572-
writable: true,
573-
});
574-
btn.dispatchEvent(pointerDown);
575619
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-
}
620+
});
587621
await Updates.next();
588622
expect(getTextField(element).value).toEqual('');
589623
});
@@ -833,4 +867,30 @@ describe('vwc-dial-pad', () => {
833867
expect(element._errorAnnouncement).toBe('');
834868
});
835869
});
870+
871+
describe('keyboard events', function () {
872+
it('should add "+" when long pressing Space in input field', async () => {
873+
element.pattern = '^\\+?[0-9#*]*$';
874+
await Updates.next();
875+
const inputEl = getInput(element);
876+
withFakeTimers(() => {
877+
simulateKeyboardLongPress(inputEl, { pressDuration: 650 });
878+
});
879+
await Updates.next();
880+
881+
expect(getTextField(element).value).toEqual('+');
882+
});
883+
884+
it('should add space when short pressing Space in input field', async () => {
885+
element.pattern = '^\\+?[0-9#*]*$';
886+
await Updates.next();
887+
const inputEl = getInput(element);
888+
withFakeTimers(() => {
889+
simulateKeyboardLongPress(inputEl, { pressDuration: 300 });
890+
});
891+
await Updates.next();
892+
893+
expect(getTextField(element).value).toEqual(' ');
894+
});
895+
});
836896
});

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: 45 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,51 @@ 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+
// Reset suppress flag after a delay to handle cases where click doesn't fire.
209+
// This ensures the next click won't be incorrectly suppressed.
210+
window.setTimeout(() => {
211+
if (this._suppressNextClick) {
212+
this._suppressNextClick = false;
213+
}
214+
}, 0);
215+
}
216+
217+
/**
218+
* @internal
219+
* @returns true if long press completed (timer fired), false otherwise
220+
*/
221+
_endKeyboardLongPress(): boolean {
222+
const wasLongPress = this._keyboardLongPressCompleted;
223+
this._clearLongPressTimer();
224+
225+
window.setTimeout(() => {
226+
this._keyboardLongPressCompleted = false;
227+
}, 0);
228+
return wasLongPress;
187229
}
188230

189231
/**

0 commit comments

Comments
 (0)