Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions libs/components/src/lib/dial-pad/VARIATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
<vwc-dial-pad aria-label="Dial a telephone number"></vwc-dial-pad>
```

## International Numbers

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).

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

## Labelling

### Helper Text
Expand Down
254 changes: 254 additions & 0 deletions libs/components/src/lib/dial-pad/dial-pad.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ describe('vwc-dial-pad', () => {
return getTextField(component).querySelector('vwc-button')!;
}

function getZeroButton(component: HTMLElement) {
return getDigitButtons(component)[10] as Button;
}

async function setValue(value: string) {
element.value = value;
await Updates.next();
Expand All @@ -47,6 +51,96 @@ describe('vwc-dial-pad', () => {
return activeEl;
}

function withFakeTimers(callback: () => void) {
vi.useFakeTimers();
try {
callback();
} finally {
vi.useRealTimers();
}
}

function createPointerEvent(
type: string,
options: EventInit = {}
): PointerEvent {
// Create a mock PointerEvent for test environment
const event = new Event(type, options) as PointerEvent;
Object.defineProperty(event, 'pointerType', {
value: 'mouse',
writable: true,
});
Object.defineProperty(event, 'pointerId', {
value: 1,
writable: true,
});
return event;
}

function simulatePointerLongPress(
button: HTMLElement,
options: {
duration?: number;
onComplete?: () => void;
onLeave?: () => void;
} = {}
) {
const { duration = 600, onComplete, onLeave } = options;
const pointerDown = createPointerEvent('pointerdown', {
bubbles: true,
});
const pointerUp = createPointerEvent('pointerup', {
bubbles: true,
});

Object.defineProperty(pointerDown, 'currentTarget', {
value: button,
writable: true,
});
button.dispatchEvent(pointerDown);
vi.advanceTimersByTime(duration);

if (onLeave) {
onLeave();
}
button.dispatchEvent(pointerUp);

if (onComplete) {
onComplete();
}
vi.runAllTimers();
}

function simulateKeyboardLongPress(
input: HTMLInputElement,
options: {
key?: string;
pressDuration?: number;
releaseAfter?: number;
} = {}
) {
const {
key = ' ',
pressDuration = 650,
releaseAfter: providedReleaseAfter,
} = options;
const releaseAfter =
providedReleaseAfter !== undefined ? providedReleaseAfter : pressDuration;
const keyDown = new KeyboardEvent('keydown', {
key,
bubbles: true,
repeat: false,
});
input.dispatchEvent(keyDown);
vi.advanceTimersByTime(releaseAfter);
const keyUp = new KeyboardEvent('keyup', {
key,
bubbles: true,
});
input.dispatchEvent(keyUp);
vi.runAllTimers();
}

beforeEach(async () => {
element = (await fixture(
`<${COMPONENT_TAG}></${COMPONENT_TAG}>`
Expand Down Expand Up @@ -474,6 +568,66 @@ describe('vwc-dial-pad', () => {
});
});

describe('long press on 0', () => {
it('should add "0" when tapping 0', async () => {
getZeroButton(element).click();
await Updates.next();
expect(getTextField(element).value).toEqual('0');
});

it('should add "+" when long pressing 0 and suppress subsequent click', async () => {
element.pattern = '^\\+?[0-9#*]*$';
await Updates.next();
const btn = getZeroButton(element);
withFakeTimers(() => {
simulatePointerLongPress(btn, {
duration: 600,
onComplete: () => {
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
},
});
});
await Updates.next();

await Updates.next();
expect(getTextField(element).value).toEqual('+');
});

it('should not trigger long press on 0 when disabled', async () => {
element.pattern = '^\\+?[0-9#*]*$';
element.disabled = true;
await Updates.next();
const btn = getZeroButton(element);
withFakeTimers(() => {
simulatePointerLongPress(btn, { duration: 600 });
});
await Updates.next();
expect(getTextField(element).value).toEqual('');
});

it('should not add "+" when pointer leaves 0 button before long press completes', async () => {
element.pattern = '^\\+?[0-9#*]*$';
await Updates.next();
const btn = getZeroButton(element);
withFakeTimers(() => {
simulatePointerLongPress(btn, {
duration: 300,
onLeave: () => {
// Leave before long press completes
btn.dispatchEvent(
createPointerEvent('pointerleave', {
bubbles: true,
})
);
},
});
vi.advanceTimersByTime(300);
});
await Updates.next();
expect(getTextField(element).value).toEqual('');
});
});

describe('Methods', () => {
describe('focus', function () {
it('should set the focus on the text field', async function () {
Expand Down Expand Up @@ -718,4 +872,104 @@ describe('vwc-dial-pad', () => {
expect(element._errorAnnouncement).toBe('');
});
});

describe('keyboard events', function () {
it('should add "+" when long pressing Space in input field', async () => {
element.pattern = '^\\+?[0-9#*]*$';
await Updates.next();
const inputEl = getInput(element);
withFakeTimers(() => {
simulateKeyboardLongPress(inputEl, { pressDuration: 650 });
});
await Updates.next();

expect(getTextField(element).value).toEqual('+');
});

it('should add space when short pressing Space in input field', async () => {
element.pattern = '^\\+?[0-9#*]*$';
await Updates.next();
const inputEl = getInput(element);
withFakeTimers(() => {
simulateKeyboardLongPress(inputEl, { pressDuration: 300 });
});
await Updates.next();

withFakeTimers(() => {
simulateKeyboardLongPress(inputEl, {
key: 'Space',
pressDuration: 300,
});
});
await Updates.next();

expect(getTextField(element).value).toEqual(' ');
});

it('should not start keyboard long press when disabled', async () => {
element.pattern = '^\\+?[0-9#*]*$';
element.disabled = true;
await Updates.next();
const inputEl = getInput(element);
withFakeTimers(() => {
simulateKeyboardLongPress(inputEl, { pressDuration: 650 });
});
await Updates.next();

expect(getTextField(element).value).toEqual('');
});

it('should handle edge cases: prevent timer restart and reset "skip next click" flag when click does not fire', async () => {
element.pattern = '^\\+?[0-9#*]*$';
await Updates.next();

// keyboard long press timer should not restart if already running
const inputEl = getInput(element);
withFakeTimers(() => {
// Start first long press
const keyDown1 = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
repeat: false,
});
inputEl.dispatchEvent(keyDown1);
vi.advanceTimersByTime(300);
// start another long press while timer is running (should be prevented)
const keyDown2 = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
repeat: false,
});
inputEl.dispatchEvent(keyDown2);
vi.advanceTimersByTime(350);
const keyUp = new KeyboardEvent('keyup', {
key: ' ',
bubbles: true,
});
inputEl.dispatchEvent(keyUp);
vi.runAllTimers();
});
await Updates.next();

// Should only add one '+' from the first long press
expect(getTextField(element).value).toEqual('+');

// pointer long press should reset suppressNextClick flag when click does not fire
element.value = '';
await Updates.next();
const btn = getDigitButtons(element)[10] as Button;
withFakeTimers(() => {
simulatePointerLongPress(btn, {
duration: 600,
});
// advance timers to execute the setTimeout in _endLongPress
vi.advanceTimersByTime(1);
});
await Updates.next();

btn.click();
await Updates.next();
expect(getTextField(element).value).toEqual('+0');
});
});
});
38 changes: 38 additions & 0 deletions libs/components/src/lib/dial-pad/dial-pad.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ function handleKeyDown(x: DialPad, e: KeyboardEvent) {
e.target instanceof HTMLInputElement
) {
x._onDial();
} else if (e.key === ' ' || e.key === 'Space') {
// Handle long-press Space for '0' button when input is active
if (e.target instanceof HTMLInputElement) {
e.preventDefault();
// Only start on first keydown, ignore repeat events
// keydown events repeat while key is held, so we only start the timer once
if (!e.repeat) {
x._startKeyboardLongPress();
}
}
} else {
const elementIndex = DIAL_PAD_BUTTONS.findIndex((x) => x.value === e.key);
if (elementIndex > -1) {
Expand All @@ -93,6 +103,23 @@ function handleKeyDown(x: DialPad, e: KeyboardEvent) {
return true;
}

function handleKeyUp(x: DialPad, e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Space') {
if (e.target instanceof HTMLInputElement) {
e.preventDefault();

const wasLongPress = x._endKeyboardLongPress();
if (!wasLongPress && !x.disabled && !x.callActive) {
// Short press - add space character (normal space input)
x.value += ' ';
x.$emit('input');
x.$emit('change');
}
}
}
return true;
}

function syncFieldAndPadValues(x: DialPad) {
x.value = x._textFieldEl.value;
}
Expand Down Expand Up @@ -122,6 +149,7 @@ function renderTextField(
x.helperText}" pattern="${(x) => x.pattern}"
aria-label="${(x) => x.locale.dialPad.inputLabel}"
@keydown="${(x, c) => handleKeyDown(x, c.event as KeyboardEvent)}"
@keyup="${(x, c) => handleKeyUp(x, c.event as KeyboardEvent)}"
@input="${syncFieldAndPadValues}"
@change="${syncFieldAndPadValues}"
@focus="${stopPropagation}"
Expand All @@ -147,6 +175,11 @@ function onDigitClick(
digit: DialPadButton,
{ parent: dialPad, event }: { parent: DialPad; event: MouseEvent }
) {
if (dialPad._suppressNextClick) {
// Skip the click insertion if a long-press already handled it
dialPad._suppressNextClick = false;
return;
}
dialPad.value += digit.value;

dialPad.$emit('keypad-click', event.currentTarget);
Expand Down Expand Up @@ -175,6 +208,11 @@ function renderDigits(
c.parent.autofocus && c.parent.noInput && c.index === 0}"
aria-label="${(x, c) => c.parent.locale.dialPad[x.ariaLabel]}"
?disabled="${(_, c) => c.parent.disabled}"
@pointerdown="${(x, c) =>
c.parent._startLongPress(x.value, c.event as PointerEvent)}"
@pointerup="${(_, c) => c.parent._endLongPress()}"
@pointercancel="${(_, c) => c.parent._cancelLongPress()}"
@pointerleave="${(_, c) => c.parent._cancelLongPress()}"
@click="${onDigitClick}">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about long-pressing with the keyboard? It should probably long-press with space as well
Maybe it would be easier to track the time when the pointer or keyboard press started and determine in the click handler if it was a long press. Just note that pointer/keyup is fired before click

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For 'long-press with space' it should be handled by input, but not digit buttons. I have added the implementation

<${iconTag} slot="icon"
name="${(x) => x.icon}"
Expand Down
Loading
Loading