From eb77b538ea3f41c70cd69931be494502e19ad136 Mon Sep 17 00:00:00 2001
From: Radoslav Karaivanov
Date: Thu, 7 May 2026 17:34:52 +0300
Subject: [PATCH 1/4] feat: OTP/PIN input component
---
.../common/controllers/key-bindings.ts | 2 +
src/components/common/utils.spec.ts | 13 +
src/components/pin-input/pin-input.spec.ts | 691 ++++++++++++++++++
src/components/pin-input/pin-input.ts | 543 ++++++++++++++
.../themes/dark/pin-input.bootstrap.scss | 7 +
.../themes/dark/pin-input.fluent.scss | 7 +
.../themes/dark/pin-input.indigo.scss | 7 +
.../themes/dark/pin-input.material.scss | 7 +
.../themes/light/pin-input.bootstrap.scss | 5 +
.../themes/light/pin-input.fluent.scss | 5 +
.../themes/light/pin-input.indigo.scss | 6 +
.../themes/light/pin-input.material.scss | 6 +
.../themes/light/pin-input.shared.scss | 4 +
.../pin-input/themes/pin-input.base.scss | 71 ++
.../themes/shared/pin-input.bootstrap.scss | 5 +
.../themes/shared/pin-input.fluent.scss | 5 +
.../themes/shared/pin-input.indigo.scss | 5 +
.../themes/shared/pin-input.material.scss | 5 +
src/components/pin-input/themes/themes.ts | 57 ++
src/components/pin-input/validators.ts | 8 +
src/index.ts | 2 +
stories/pin-input.stories.ts | 302 ++++++++
22 files changed, 1763 insertions(+)
create mode 100644 src/components/pin-input/pin-input.spec.ts
create mode 100644 src/components/pin-input/pin-input.ts
create mode 100644 src/components/pin-input/themes/dark/pin-input.bootstrap.scss
create mode 100644 src/components/pin-input/themes/dark/pin-input.fluent.scss
create mode 100644 src/components/pin-input/themes/dark/pin-input.indigo.scss
create mode 100644 src/components/pin-input/themes/dark/pin-input.material.scss
create mode 100644 src/components/pin-input/themes/light/pin-input.bootstrap.scss
create mode 100644 src/components/pin-input/themes/light/pin-input.fluent.scss
create mode 100644 src/components/pin-input/themes/light/pin-input.indigo.scss
create mode 100644 src/components/pin-input/themes/light/pin-input.material.scss
create mode 100644 src/components/pin-input/themes/light/pin-input.shared.scss
create mode 100644 src/components/pin-input/themes/pin-input.base.scss
create mode 100644 src/components/pin-input/themes/shared/pin-input.bootstrap.scss
create mode 100644 src/components/pin-input/themes/shared/pin-input.fluent.scss
create mode 100644 src/components/pin-input/themes/shared/pin-input.indigo.scss
create mode 100644 src/components/pin-input/themes/shared/pin-input.material.scss
create mode 100644 src/components/pin-input/themes/themes.ts
create mode 100644 src/components/pin-input/validators.ts
create mode 100644 stories/pin-input.stories.ts
diff --git a/src/components/common/controllers/key-bindings.ts b/src/components/common/controllers/key-bindings.ts
index 620638159..071433622 100644
--- a/src/components/common/controllers/key-bindings.ts
+++ b/src/components/common/controllers/key-bindings.ts
@@ -16,6 +16,8 @@ export const arrowLeft = 'ArrowLeft' as const;
export const arrowRight = 'ArrowRight' as const;
export const arrowUp = 'ArrowUp' as const;
export const arrowDown = 'ArrowDown' as const;
+export const backspaceKey = 'Backspace' as const;
+export const deleteKey = 'Delete' as const;
export const enterKey = 'Enter' as const;
export const spaceBar = ' ' as const;
export const escapeKey = 'Escape' as const;
diff --git a/src/components/common/utils.spec.ts b/src/components/common/utils.spec.ts
index 7d2aaea0e..df2f1a8fe 100644
--- a/src/components/common/utils.spec.ts
+++ b/src/components/common/utils.spec.ts
@@ -428,6 +428,19 @@ export function simulateDoubleClick(node: Element): void {
);
}
+export function simulatePaste(node: Element, pastedText: string): void {
+ const clipboardData = new DataTransfer();
+ clipboardData.setData('text/plain', pastedText);
+
+ node.dispatchEvent(
+ new ClipboardEvent('paste', {
+ bubbles: true,
+ composed: true,
+ clipboardData,
+ })
+ );
+}
+
/**
* Returns an array of all Animation objects affecting this element or which are scheduled to do so in the future.
* It can optionally return Animation objects for descendant elements too.
diff --git a/src/components/pin-input/pin-input.spec.ts b/src/components/pin-input/pin-input.spec.ts
new file mode 100644
index 000000000..a2e2ba12d
--- /dev/null
+++ b/src/components/pin-input/pin-input.spec.ts
@@ -0,0 +1,691 @@
+import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
+import { spy } from 'sinon';
+import { defineComponents } from '../common/definitions/defineComponents.js';
+import {
+ createFormAssociatedTestBed,
+ simulateInput,
+ simulatePaste,
+} from '../common/utils.spec.js';
+import {
+ runValidationContainerTests,
+ type ValidationContainerTestsParams,
+} from '../common/validity-helpers.spec.js';
+import IgcPinInputComponent from './pin-input.js';
+
+describe('PinInput', () => {
+ before(() => {
+ defineComponents(IgcPinInputComponent);
+ });
+
+ let element: IgcPinInputComponent;
+
+ function getCells(pinInput: IgcPinInputComponent): HTMLInputElement[] {
+ return Array.from(
+ pinInput.renderRoot.querySelectorAll('[part~="input"]')
+ );
+ }
+
+ async function typeIntoCell(
+ cell: HTMLInputElement,
+ char: string
+ ): Promise {
+ simulateInput(cell, { value: char });
+ await elementUpdated(element);
+ }
+
+ describe('Initialization', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('passes the a11y audit', async () => {
+ await expect(element).shadowDom.to.be.accessible();
+ await expect(element).to.be.accessible();
+ });
+
+ it('initializes with default values', () => {
+ expect(element.length).to.equal(4);
+ expect(element.inputMode).to.equal('numeric');
+ expect(element.mask).to.be.false;
+ expect(element.disabled).to.be.false;
+ expect(element.required).to.be.false;
+ expect(element.value).to.equal('');
+ });
+
+ it('renders the correct number of cells', () => {
+ expect(getCells(element)).lengthOf(4);
+ });
+
+ it('cells have type="text" by default', () => {
+ for (const cell of getCells(element)) {
+ expect(cell.type).to.equal('text');
+ }
+ });
+
+ it('cells have inputmode="numeric" for numeric type', () => {
+ for (const cell of getCells(element)) {
+ expect(cell.inputMode).to.equal('numeric');
+ }
+ });
+ });
+
+ describe('Length property', () => {
+ it('renders the specified number of cells', async () => {
+ element = await fixture(html``);
+ expect(getCells(element)).lengthOf(6);
+ });
+
+ it('clamps length to minimum of 1', async () => {
+ element = await fixture(html``);
+ element.length = 0;
+ await elementUpdated(element);
+ expect(element.length).to.equal(1);
+ expect(getCells(element)).lengthOf(1);
+ });
+
+ it('clamps length to maximum of 8', async () => {
+ element = await fixture(html``);
+ element.length = 99;
+ await elementUpdated(element);
+ expect(element.length).to.equal(8);
+ expect(getCells(element)).lengthOf(8);
+ });
+ });
+
+ describe('Value property', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('returns empty string when no cells are filled', () => {
+ expect(element.value).to.equal('');
+ });
+
+ it('returns empty string when only some cells are filled', async () => {
+ element.value = '12';
+ await elementUpdated(element);
+ expect(element.value).to.equal('');
+ });
+
+ it('returns concatenated string when all cells are filled', async () => {
+ element.value = '1234';
+ await elementUpdated(element);
+ expect(element.value).to.equal('1234');
+ });
+
+ it('distributes value across cells on setter', async () => {
+ element.value = '5678';
+ await elementUpdated(element);
+ const cells = getCells(element);
+ expect(cells[0].value).to.equal('5');
+ expect(cells[1].value).to.equal('6');
+ expect(cells[2].value).to.equal('7');
+ expect(cells[3].value).to.equal('8');
+ });
+
+ it('filters non-numeric characters in numeric type', async () => {
+ element.value = 'ab12';
+ await elementUpdated(element);
+ expect(element.value).to.equal('');
+ });
+
+ it('accepts alphanumeric characters when type is alphanumeric', async () => {
+ element.inputMode = 'alphanumeric';
+ element.value = 'a1B2';
+ await elementUpdated(element);
+ expect(element.value).to.equal('a1B2');
+ });
+ });
+
+ describe('Mask mode', () => {
+ it('renders cells as type="password" when mask is true', async () => {
+ element = await fixture(html``);
+ for (const cell of getCells(element)) {
+ expect(cell.type).to.equal('password');
+ }
+ });
+
+ it('renders cells as type="text" when mask is false', async () => {
+ element = await fixture(html``);
+ for (const cell of getCells(element)) {
+ expect(cell.type).to.equal('text');
+ }
+ });
+ });
+
+ describe('Type property', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('sets inputmode="numeric" for numeric type', () => {
+ for (const cell of getCells(element)) {
+ expect(cell.inputMode).to.equal('numeric');
+ }
+ });
+
+ it('sets inputmode="text" for alphanumeric type', async () => {
+ element.inputMode = 'alphanumeric';
+ await elementUpdated(element);
+ for (const cell of getCells(element)) {
+ expect(cell.inputMode).to.equal('text');
+ }
+ });
+ });
+
+ describe('Disabled state', () => {
+ it('disables all cells when disabled is set', async () => {
+ element = await fixture(html``);
+ for (const cell of getCells(element)) {
+ expect(cell.disabled).to.be.true;
+ }
+ });
+ });
+
+ describe('Events', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('emits igcInput when a cell value changes', async () => {
+ const handler = spy();
+ element.addEventListener('igcInput', handler);
+ const cells = getCells(element);
+ await typeIntoCell(cells[0], '1');
+ expect(handler.calledOnce).to.be.true;
+ });
+
+ it('emits igcComplete when all cells are filled', async () => {
+ const handler = spy();
+ element.addEventListener('igcComplete', handler);
+ const cells = getCells(element);
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[1], '2');
+ await typeIntoCell(cells[2], '3');
+ expect(handler.calledOnce).to.be.true;
+ expect(handler.firstCall.args[0].detail).to.equal('123');
+ });
+
+ it('does not emit igcComplete when cells are only partially filled', async () => {
+ const handler = spy();
+ element.addEventListener('igcComplete', handler);
+ const cells = getCells(element);
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[1], '2');
+ expect(handler.called).to.be.false;
+ });
+
+ it('does not emit igcChange immediately when the last cell is filled', async () => {
+ const handler = spy();
+ element.addEventListener('igcChange', handler);
+ const cells = getCells(element);
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[1], '2');
+ await typeIntoCell(cells[2], '3');
+ expect(handler.called).to.be.false;
+ });
+
+ it('emits igcChange on focusout when focus leaves the component and value has changed', async () => {
+ const handler = spy();
+ element.addEventListener('igcChange', handler);
+ const cells = getCells(element);
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[1], '2');
+ await typeIntoCell(cells[2], '3');
+
+ cells[2].dispatchEvent(
+ new FocusEvent('focusout', { bubbles: true, composed: true })
+ );
+ await elementUpdated(element);
+
+ expect(handler.calledOnce).to.be.true;
+ expect(handler.firstCall.args[0].detail).to.equal('123');
+ });
+
+ it('does not emit igcChange on focusout when focus moves between internal cells', async () => {
+ const handler = spy();
+ element.addEventListener('igcChange', handler);
+ const cells = getCells(element);
+
+ // Fill all cells via typing so _lastValue stays '' while value becomes '123'
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[1], '2');
+ await typeIntoCell(cells[2], '3');
+
+ // Simulate focusout re-targeted to the host — what the browser produces
+ // when focus moves between shadow-internal cells
+ cells[2].dispatchEvent(
+ new FocusEvent('focusout', {
+ bubbles: true,
+ composed: true,
+ relatedTarget: element,
+ })
+ );
+ await elementUpdated(element);
+
+ expect(handler.called).to.be.false;
+ });
+
+ it('does not emit igcChange on repeated focusout when value has not changed', async () => {
+ const handler = spy();
+ element.addEventListener('igcChange', handler);
+ const cells = getCells(element);
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[1], '2');
+ await typeIntoCell(cells[2], '3');
+
+ const focusout = () =>
+ cells[2].dispatchEvent(
+ new FocusEvent('focusout', { bubbles: true, composed: true })
+ );
+
+ focusout();
+ await elementUpdated(element);
+ focusout();
+ await elementUpdated(element);
+
+ expect(handler.calledOnce).to.be.true;
+ });
+ });
+
+ describe('Keyboard navigation', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ function pressKey(cell: HTMLInputElement, key: string): void {
+ cell.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
+ }
+
+ describe('Backspace', () => {
+ it('shifts subsequent filled cells left when pressed on a filled cell', async () => {
+ element.value = '1234';
+ await elementUpdated(element);
+ const cells = getCells(element);
+
+ pressKey(cells[1], 'Backspace');
+ await elementUpdated(element);
+
+ expect(cells[0].value).to.equal('1');
+ expect(cells[1].value).to.equal('3');
+ expect(cells[2].value).to.equal('4');
+ expect(cells[3].value).to.equal('');
+ });
+
+ it('clears the first cell and stays when pressed at index 0', async () => {
+ element.value = '1234';
+ await elementUpdated(element);
+ const cells = getCells(element);
+
+ pressKey(cells[0], 'Backspace');
+ await elementUpdated(element);
+
+ expect(cells[0].value).to.equal('2');
+ expect(cells[1].value).to.equal('3');
+ expect(cells[2].value).to.equal('4');
+ expect(cells[3].value).to.equal('');
+ });
+
+ it('deletes the previous filled cell and shifts left when pressed on an empty cell', async () => {
+ // Build cells = ['1','2','3',''] by typing into first three cells
+ const cells = getCells(element);
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[1], '2');
+ await typeIntoCell(cells[2], '3');
+
+ pressKey(cells[3], 'Backspace');
+ await elementUpdated(element);
+
+ expect(cells[0].value).to.equal('1');
+ expect(cells[1].value).to.equal('2');
+ expect(cells[2].value).to.equal('');
+ expect(cells[3].value).to.equal('');
+ });
+
+ it('is a no-op when pressed on the first empty cell', async () => {
+ const cells = getCells(element);
+
+ pressKey(cells[0], 'Backspace');
+ await elementUpdated(element);
+
+ expect(cells[0].value).to.equal('');
+ });
+ });
+
+ describe('Delete', () => {
+ it('shifts subsequent filled cells left when pressed on a filled cell', async () => {
+ element.value = '1234';
+ await elementUpdated(element);
+ const cells = getCells(element);
+
+ pressKey(cells[1], 'Delete');
+ await elementUpdated(element);
+
+ expect(cells[0].value).to.equal('1');
+ expect(cells[1].value).to.equal('3');
+ expect(cells[2].value).to.equal('4');
+ expect(cells[3].value).to.equal('');
+ });
+
+ it('deletes the next filled cell and shifts left when pressed on an empty cell', async () => {
+ // Build cells = ['1','','3','4']
+ const cells = getCells(element);
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[2], '3');
+ await typeIntoCell(cells[3], '4');
+
+ pressKey(cells[1], 'Delete');
+ await elementUpdated(element);
+
+ expect(cells[0].value).to.equal('1');
+ expect(cells[1].value).to.equal('');
+ expect(cells[2].value).to.equal('4');
+ expect(cells[3].value).to.equal('');
+ });
+
+ it('is a no-op when pressed on the last empty cell', async () => {
+ const cells = getCells(element);
+
+ pressKey(cells[3], 'Delete');
+ await elementUpdated(element);
+
+ expect(cells[3].value).to.equal('');
+ });
+ });
+
+ describe('Arrow keys', () => {
+ it('ArrowLeft moves focus to the previous cell', async () => {
+ element.value = '1234';
+ await elementUpdated(element);
+ const cells = getCells(element);
+
+ cells[2].focus();
+ pressKey(cells[2], 'ArrowLeft');
+ await elementUpdated(element);
+
+ expect(element.shadowRoot!.activeElement).to.equal(cells[1]);
+ });
+
+ it('ArrowRight moves focus to the next cell', async () => {
+ element.value = '1234';
+ await elementUpdated(element);
+ const cells = getCells(element);
+
+ cells[1].focus();
+ pressKey(cells[1], 'ArrowRight');
+ await elementUpdated(element);
+
+ expect(element.shadowRoot!.activeElement).to.equal(cells[2]);
+ });
+ });
+ });
+
+ describe('Focus behavior', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('selects the cell content when focusing a filled cell', async () => {
+ element.value = '1234';
+ await elementUpdated(element);
+ const cells = getCells(element);
+
+ cells[1].focus();
+ await elementUpdated(element);
+
+ expect(cells[1].selectionStart).to.equal(0);
+ expect(cells[1].selectionEnd).to.equal(cells[1].value.length);
+ });
+
+ it('does not select content when focusing an empty cell', async () => {
+ const cells = getCells(element);
+
+ cells[0].focus();
+ await elementUpdated(element);
+
+ expect(cells[0].selectionStart).to.equal(0);
+ expect(cells[0].selectionEnd).to.equal(0);
+ });
+ });
+
+ describe('Groups and separators', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('derives total length from group sizes', async () => {
+ element.groups = [3, 3];
+ await elementUpdated(element);
+
+ expect(element.length).to.equal(6);
+ expect(getCells(element)).lengthOf(6);
+ });
+
+ it('clamps derived length to the maximum of 8', async () => {
+ element.groups = [5, 5];
+ await elementUpdated(element);
+
+ expect(element.length).to.equal(8);
+ expect(getCells(element)).lengthOf(8);
+ });
+
+ it('renders the correct number of separator spans between groups', async () => {
+ element.groups = [2, 2, 2];
+ element.separator = '-';
+ await elementUpdated(element);
+
+ const separators =
+ element.renderRoot.querySelectorAll('[part="separator"]');
+ expect(separators.length).to.equal(2);
+ });
+
+ it('renders the separator text content correctly', async () => {
+ element.groups = [3, 3];
+ element.separator = '-';
+ await elementUpdated(element);
+
+ const separator = element.renderRoot.querySelector('[part="separator"]');
+ expect(separator).to.exist;
+ expect(separator!.textContent).to.equal('-');
+ });
+
+ it('does not render separators when separator is empty', async () => {
+ element.groups = [3, 3];
+ await elementUpdated(element);
+
+ const separators =
+ element.renderRoot.querySelectorAll('[part="separator"]');
+ expect(separators.length).to.equal(0);
+ });
+
+ it('ignores the length setter when groups is active', async () => {
+ element.groups = [3, 3];
+ await elementUpdated(element);
+
+ element.length = 4;
+ await elementUpdated(element);
+
+ expect(element.length).to.equal(6);
+ expect(getCells(element)).lengthOf(6);
+ });
+
+ it('preserves existing cell values when groups change total length', async () => {
+ element = await fixture(html``);
+ element.value = '1234';
+ await elementUpdated(element);
+
+ element.groups = [3, 3];
+ await elementUpdated(element);
+
+ const cells = getCells(element);
+ expect(cells[0].value).to.equal('1');
+ expect(cells[1].value).to.equal('2');
+ expect(cells[2].value).to.equal('3');
+ expect(cells[3].value).to.equal('4');
+ expect(cells[4].value).to.equal('');
+ expect(cells[5].value).to.equal('');
+ });
+
+ it('restores length control when groups is cleared', async () => {
+ element.groups = [3, 3];
+ await elementUpdated(element);
+ expect(element.length).to.equal(6);
+
+ element.groups = [];
+ await elementUpdated(element);
+
+ element.length = 4;
+ await elementUpdated(element);
+ expect(element.length).to.equal(4);
+ expect(getCells(element)).lengthOf(4);
+ });
+ });
+
+ describe('Paste', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('distributes pasted text across cells starting from focused cell', async () => {
+ const cells = getCells(element);
+
+ simulatePaste(cells[0], '5678');
+ await elementUpdated(element);
+
+ expect(element.value).to.equal('5678');
+ });
+
+ it('filters invalid characters during paste in numeric mode', async () => {
+ const cells = getCells(element);
+
+ simulatePaste(cells[0], 'ab12');
+ await elementUpdated(element);
+
+ expect(cells[0].value).to.equal('1');
+ expect(cells[1].value).to.equal('2');
+ });
+ });
+
+ describe('Clear method', () => {
+ it('clears all cells', async () => {
+ element = await fixture(html``);
+ element.value = '1234';
+ await elementUpdated(element);
+ element.clear();
+ await elementUpdated(element);
+ expect(element.value).to.equal('');
+ for (const cell of getCells(element)) {
+ expect(cell.value).to.equal('');
+ }
+ });
+
+ it('does not emit igcChange on focusout after clear()', async () => {
+ element = await fixture(html``);
+ const handler = spy();
+ element.addEventListener('igcChange', handler);
+ const cells = getCells(element);
+
+ await typeIntoCell(cells[0], '1');
+ await typeIntoCell(cells[1], '2');
+ await typeIntoCell(cells[2], '3');
+
+ element.clear();
+ await elementUpdated(element);
+
+ cells[0].dispatchEvent(
+ new FocusEvent('focusout', { bubbles: true, composed: true })
+ );
+ await elementUpdated(element);
+
+ expect(handler.called).to.be.false;
+ });
+ });
+
+ describe('Form association', () => {
+ const spec = createFormAssociatedTestBed(
+ html``
+ );
+
+ beforeEach(async () => {
+ await spec.setup('igc-pin-input');
+ });
+
+ it('submits value in form data when all cells are filled', async () => {
+ spec.element.value = '1234';
+ await elementUpdated(spec.element);
+
+ const data = spec.submit();
+ expect(data.get('pin')).to.equal('1234');
+ });
+
+ it('does not submit when cells are only partially filled', async () => {
+ spec.element.value = '12';
+ await elementUpdated(spec.element);
+
+ const data = spec.submit();
+ expect(data.get('pin')).to.be.null;
+ });
+
+ it('resets to empty when form is reset', async () => {
+ spec.element.value = '1234';
+ await elementUpdated(spec.element);
+
+ spec.reset();
+ await elementUpdated(spec.element);
+
+ expect(spec.element.value).to.equal('');
+ });
+
+ it('does not emit igcChange on focusout after form reset', async () => {
+ const handler = spy();
+ spec.element.addEventListener('igcChange', handler);
+ spec.element.value = '1234';
+ await elementUpdated(spec.element);
+
+ spec.reset();
+ await elementUpdated(spec.element);
+
+ const cells = getCells(spec.element);
+ cells[0].dispatchEvent(
+ new FocusEvent('focusout', { bubbles: true, composed: true })
+ );
+ await elementUpdated(spec.element);
+
+ expect(handler.called).to.be.false;
+ });
+
+ it('is valid when not required and empty', () => {
+ expect(spec.valid).to.be.true;
+ });
+
+ it('is invalid when required and empty', async () => {
+ await spec.setProperties({ required: true }, true);
+ expect(spec.valid).to.be.false;
+ });
+
+ it('is valid when required and all cells are filled', async () => {
+ await spec.setProperties({ required: true, value: '1234' }, true);
+ expect(spec.valid).to.be.true;
+ });
+ });
+
+ describe('Validation container slots', () => {
+ it('', async () => {
+ const params: ValidationContainerTestsParams[] = [
+ {
+ slots: ['valueMissing'],
+ props: { required: true },
+ },
+ {
+ slots: ['customError'],
+ },
+ {
+ slots: ['invalid'],
+ props: { required: true },
+ },
+ ];
+
+ runValidationContainerTests(IgcPinInputComponent, params);
+ });
+ });
+});
diff --git a/src/components/pin-input/pin-input.ts b/src/components/pin-input/pin-input.ts
new file mode 100644
index 000000000..96d02a2fb
--- /dev/null
+++ b/src/components/pin-input/pin-input.ts
@@ -0,0 +1,543 @@
+import { html, LitElement, nothing, type TemplateResult } from 'lit';
+import { property, queryAll, state } from 'lit/decorators.js';
+import { ifDefined } from 'lit/directives/if-defined.js';
+import { addThemingController } from '../../theming/theming-controller.js';
+import {
+ arrowLeft,
+ arrowRight,
+ backspaceKey,
+ deleteKey,
+} from '../common/controllers/key-bindings.js';
+import { addSlotController, setSlots } from '../common/controllers/slot.js';
+import { shadowOptions } from '../common/decorators/shadow-options.js';
+import { registerComponent } from '../common/definitions/register.js';
+import type { Constructor } from '../common/mixins/constructor.js';
+import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
+import { FormAssociatedRequiredMixin } from '../common/mixins/forms/associated-required.js';
+import { createFormValueState } from '../common/mixins/forms/form-value.js';
+import {
+ addSafeEventListener,
+ bindIf,
+ clamp,
+ stopPropagation,
+} from '../common/util.js';
+import IgcValidationContainerComponent from '../validation-container/validation-container.js';
+import { styles } from './themes/pin-input.base.css.js';
+import { all } from './themes/themes.js';
+import { pinRequiredValidator } from './validators.js';
+
+export interface IgcPinInputComponentEventMap {
+ igcInput: CustomEvent;
+ igcChange: CustomEvent;
+ igcComplete: CustomEvent;
+ /* skipWCPrefix */
+ focus: FocusEvent;
+ /* skipWCPrefix */
+ blur: FocusEvent;
+}
+
+const MIN_LENGTH = 1;
+const MAX_LENGTH = 8;
+const IS_DIGIT = /^\d$/;
+const IS_ALPHANUMERIC = /^[a-zA-Z0-9]$/;
+
+const Slots = setSlots(
+ 'helper-text',
+ 'value-missing',
+ 'custom-error',
+ 'invalid'
+);
+
+/**
+ * A PIN/OTP input component that renders individual character cells.
+ *
+ * @element igc-pin-input
+ *
+ * @slot helper-text - Renders content below the input.
+ * @slot value-missing - Renders content when the required validation fails.
+ * @slot custom-error - Renders content when setCustomValidity(message) is set.
+ * @slot invalid - Renders content when the component is in invalid state (validity.valid = false).
+ *
+ * @fires igcInput - Emitted when the value of the control changes through user interaction.
+ * @fires igcChange - Emitted when the control loses focus and its value has changed.
+ * @fires igcComplete - Emitted when all cells are filled.
+ *
+ * @csspart label - The label element.
+ * @csspart inputs - The container wrapping all cell inputs.
+ * @csspart input - Each individual cell input element.
+ * @csspart separator - The separator element rendered between cell groups.
+ */
+@shadowOptions({ delegatesFocus: true })
+export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
+ EventEmitterMixin>(
+ LitElement
+ )
+) {
+ public static readonly tagName = 'igc-pin-input';
+ public static styles = [styles];
+
+ /* blazorSuppress */
+ public static register(): void {
+ registerComponent(IgcPinInputComponent, IgcValidationContainerComponent);
+ }
+
+ //#region Internal state
+
+ protected readonly _slots = addSlotController(this, { slots: Slots });
+ protected override readonly _formValue = createFormValueState(this, {
+ initialValue: '',
+ });
+
+ protected override get __validators() {
+ return [pinRequiredValidator];
+ }
+
+ private _length = 4;
+ private _groups: number[] = [];
+ private _lastValue = '';
+
+ @queryAll('[part~="input"]')
+ private readonly _inputs!: NodeListOf;
+
+ @state()
+ private _cells: string[] = Array(4).fill('');
+
+ private get _cellsValue(): string {
+ return this._cells.join('');
+ }
+
+ private get _isNumeric(): boolean {
+ return this.inputMode === 'numeric';
+ }
+
+ //#endregion
+
+ //#region Public properties
+
+ /**
+ * The label for the control.
+ * @attr label
+ */
+ @property()
+ public label?: string;
+
+ /**
+ * The placeholder character shown in each empty cell.
+ * @attr placeholder
+ */
+ @property()
+ public placeholder?: string;
+
+ /**
+ * The number of input cells. Clamped between 1 and 8.
+ * @attr
+ * @default 4
+ */
+ @property({ type: Number, reflect: true })
+ public set length(value: number) {
+ if (this._groups.length > 0) return;
+ const clamped = clamp(value, MIN_LENGTH, MAX_LENGTH);
+ if (clamped === this._length) return;
+
+ this._cells = Array.from(
+ { length: clamped },
+ (_, i) => this._cells[i] ?? ''
+ );
+ this._length = clamped;
+ this._syncFormValue();
+ }
+
+ public get length(): number {
+ return this._length;
+ }
+
+ /**
+ * The type of allowed input.
+ * - `numeric` — only digits (0-9)
+ * - `alphanumeric` — letters and digits
+ * @attr input-mode
+ * @default 'numeric'
+ */
+ @property({ reflect: true, attribute: 'input-mode' })
+ public override inputMode: 'numeric' | 'alphanumeric' = 'numeric';
+
+ /**
+ * When set, the entered characters are visually hidden (displayed as password dots).
+ * @attr mask
+ * @default false
+ */
+ @property({ type: Boolean, reflect: true })
+ public mask = false;
+
+ /**
+ * The character(s) rendered between cell groups when `groups` is configured.
+ * Has no effect unless `groups` is also set.
+ * @attr
+ * @default ''
+ */
+ @property()
+ public separator = '';
+
+ /**
+ * Defines visual groupings of cells separated by `separator`.
+ * Each element in the array is the number of cells in that group.
+ * When set, `length` is derived from the sum of the group sizes (clamped to 1–8).
+ * @example // Two groups of three cells with a separator between them
+ * element.groups = [3, 3];
+ */
+ @property({ attribute: false })
+ public set groups(value: number[]) {
+ this._groups = value;
+ if (value.length > 0) {
+ const total = value.reduce((a, b) => a + b, 0);
+ const clamped = clamp(total, MIN_LENGTH, MAX_LENGTH);
+ this._cells = Array.from(
+ { length: clamped },
+ (_, i) => this._cells[i] ?? ''
+ );
+ this._length = clamped;
+ this._syncFormValue();
+ }
+ }
+
+ public get groups(): number[] {
+ return this._groups;
+ }
+
+ /* @tsTwoWayProperty(true, "igcChange", "detail", false) */
+ /**
+ * The concatenated value of all cells. Empty string when not all cells are filled.
+ * @attr value
+ */
+ @property()
+ public set value(value: string) {
+ const chars = value.split('');
+ this._cells = Array.from({ length: this._length }, (_, i) =>
+ this._filterChar(chars[i] ?? '')
+ );
+ this._syncFormValue();
+ this._lastValue = this.value;
+ }
+
+ public get value(): string {
+ return this._cells.every(Boolean) ? this._cellsValue : '';
+ }
+
+ //#endregion
+
+ //#region Lit lifecycle
+
+ constructor() {
+ super();
+
+ addThemingController(this, all);
+ addSafeEventListener(this, 'focusout', this._handleFocusOut);
+ }
+
+ /** @internal */
+ public override connectedCallback(): void {
+ super.connectedCallback();
+ this._syncFormValue();
+ }
+
+ protected override _restoreDefaultValue(): void {
+ super._restoreDefaultValue();
+ this._cells = Array(this._length).fill('');
+ this._lastValue = '';
+ }
+
+ //#endregion
+
+ //#region Event handlers
+
+ private _handleBackspace(index: number, event: KeyboardEvent): void {
+ event.preventDefault();
+
+ if (index === 0 && !this._cells[0]) return;
+
+ this._cells = this._shiftDeleteAt(this._cells[index] ? index : index - 1);
+ this._syncFormValue();
+ this._emitInputEvent(this._cellsValue);
+ this._focusCell(Math.max(0, index - 1));
+ }
+
+ private _handleDelete(index: number, event: KeyboardEvent): void {
+ event.preventDefault();
+
+ let input: HTMLInputElement;
+
+ if (this._cells[index]) {
+ this._cells = this._shiftDeleteAt(index);
+ input = this._inputs[index];
+ } else if (index < this._length - 1) {
+ this._cells = this._shiftDeleteAt(index + 1);
+ input = this._inputs[index + 1];
+ } else {
+ return;
+ }
+
+ this._syncFormValue();
+ this._emitInputEvent(this._cellsValue);
+ this.updateComplete.then(() => input.select());
+ }
+
+ private _handleArrowLeft(index: number, event: KeyboardEvent): void {
+ if (index > 0) {
+ event.preventDefault();
+ this._focusCell(index - 1);
+ }
+ }
+
+ private _handleArrowRight(index: number, event: KeyboardEvent): void {
+ if (index < this._length - 1) {
+ event.preventDefault();
+ this._focusCell(index + 1);
+ }
+ }
+
+ private _handleKeydown(index: number, event: KeyboardEvent): void {
+ const { key } = event;
+
+ switch (key) {
+ case backspaceKey:
+ this._handleBackspace(index, event);
+ break;
+ case deleteKey:
+ this._handleDelete(index, event);
+ break;
+ case arrowLeft:
+ this._handleArrowLeft(index, event);
+ break;
+ case arrowRight:
+ this._handleArrowRight(index, event);
+ break;
+ }
+ }
+
+ private _handleInput(index: number, event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const rawValue = input.value.slice(-1);
+ const filtered = this._filterChar(rawValue);
+
+ // Keep the displayed value consistent
+ input.value = filtered;
+
+ const prev = this._cells[index];
+ this._cells = this._cells.map((c, i) => (i === index ? filtered : c));
+ this._syncFormValue();
+
+ if (filtered && filtered !== prev) {
+ const value = this._cellsValue;
+ this._emitInputEvent(value);
+
+ if (index < this._length - 1) {
+ this._focusCell(index + 1);
+ }
+
+ this._emitCompleteIfFull(value);
+ }
+ }
+
+ private _handlePaste(index: number, event: ClipboardEvent): void {
+ event.preventDefault();
+ const text = event.clipboardData?.getData('text');
+ if (!text) return;
+
+ const chars = Iterator.from(text.split(''))
+ .map((c) => this._filterChar(c))
+ .filter(Boolean)
+ .toArray();
+
+ if (!chars.length) return;
+
+ const updated = [...this._cells];
+ let lastFilled = index;
+
+ for (let i = 0; i < chars.length && index + i < this._length; i++) {
+ updated[index + i] = chars[i];
+ lastFilled = index + i;
+ }
+
+ this._cells = updated;
+ this._syncFormValue();
+
+ const nextIdx = Math.min(lastFilled + 1, this._length - 1);
+ this._focusCell(nextIdx);
+
+ const value = this._cellsValue;
+ this._emitInputEvent(value);
+ this._emitCompleteIfFull(value);
+ }
+
+ private _handleFocusOut({ relatedTarget }: FocusEvent): void {
+ if (this.contains(relatedTarget as Node)) return;
+
+ super._handleBlur();
+
+ if (this.value !== this._lastValue) {
+ this._lastValue = this.value;
+ this.emitEvent('igcChange', { detail: this.value });
+ }
+ }
+
+ private _handleCellFocus(index: number, event: FocusEvent): void {
+ if (this._cells[index]) {
+ const target = event.target as HTMLInputElement;
+ target.select();
+ }
+ }
+
+ //#endregion
+
+ //#region Internal API
+
+ private _emitInputEvent(value: string): void {
+ this.emitEvent('igcInput', { detail: value });
+ }
+
+ private _emitCompleteIfFull(value: string): void {
+ if (value.length === this._length) {
+ this.emitEvent('igcComplete', { detail: value });
+ }
+ }
+
+ /** Removes the cell at `idx`, shifts subsequent cells left, and appends an empty cell at the end. */
+ private _shiftDeleteAt(idx: number): string[] {
+ return [...this._cells.toSpliced(idx, 1), ''];
+ }
+
+ private _filterChar(char: string): string {
+ if (!char) return '';
+ if (this._isNumeric) return IS_DIGIT.test(char) ? char : '';
+ return IS_ALPHANUMERIC.test(char) ? char : '';
+ }
+
+ private _syncFormValue(): void {
+ this._formValue.setValueAndFormState(this.value);
+ this._validate();
+ }
+
+ private _focusCell(idx: number, options?: FocusOptions): void {
+ this._inputs[idx]?.focus(options);
+ }
+
+ //#endregion
+
+ //#region Public API
+
+ /* alternateName: focusComponent */
+ /** Sets focus on the first empty cell, or the last cell if all are filled. */
+ public override focus(options?: FocusOptions): void {
+ const firstEmpty = this._cells.findIndex((c) => !c);
+ const index = firstEmpty === -1 ? this._length - 1 : firstEmpty;
+ this._focusCell(index, options);
+ }
+
+ /* alternateName: blurComponent */
+ /** Removes focus from the currently focused cell. */
+ public override blur(): void {
+ this.renderRoot.querySelector(':focus')?.blur();
+ }
+
+ /** Clears all cells. */
+ public clear(): void {
+ this._cells = Array(this._length).fill('');
+ this._syncFormValue();
+ this._lastValue = '';
+ }
+
+ //#endregion
+
+ private _renderLabel() {
+ return this.label
+ ? html``
+ : nothing;
+ }
+
+ private _renderCell(value: string, index: number): TemplateResult {
+ const inputId = `${this.id || this.tagName}-cell-${index}`;
+ const type = this.mask ? 'password' : 'text';
+ const cellInputMode = this._isNumeric ? 'numeric' : 'text';
+
+ return html`
+ this._handleKeydown(index, e)}
+ @focus=${(e: FocusEvent) => this._handleCellFocus(index, e)}
+ @input=${(e: Event) => this._handleInput(index, e)}
+ @change=${stopPropagation}
+ @paste=${(e: ClipboardEvent) => this._handlePaste(index, e)}
+ />
+ `;
+ }
+
+ private _renderCellGroup(
+ groupIndex: number,
+ start: number,
+ size: number
+ ): TemplateResult {
+ const cells = this._cells.slice(start, start + size);
+ return html`
+ ${cells.map((value, i) => this._renderCell(value, start + i))}
+ ${groupIndex < this._groups.length - 1 && this.separator
+ ? html`${this.separator}`
+ : nothing}
+ `;
+ }
+
+ private _renderCellGroups(): TemplateResult {
+ if (!this._groups.length) {
+ return html`${this._cells.map((value, i) => this._renderCell(value, i))}`;
+ }
+
+ let cellIdx = 0;
+ return html`${this._groups.map((size, groupIndex) => {
+ const start = cellIdx;
+ cellIdx += size;
+ return this._renderCellGroup(
+ groupIndex,
+ start,
+ Math.min(size, this._length - start)
+ );
+ })}`;
+ }
+
+ protected override render(): TemplateResult {
+ const hasHelperText = this._slots.hasAssignedElements('helper-text');
+
+ return html`
+ ${this._renderLabel()}
+
+ ${this._renderCellGroups()}
+
+ ${IgcValidationContainerComponent.create(this, {
+ id: 'helper-text',
+ hasHelperText: true,
+ })}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'igc-pin-input': IgcPinInputComponent;
+ }
+}
diff --git a/src/components/pin-input/themes/dark/pin-input.bootstrap.scss b/src/components/pin-input/themes/dark/pin-input.bootstrap.scss
new file mode 100644
index 000000000..25b06f37f
--- /dev/null
+++ b/src/components/pin-input/themes/dark/pin-input.bootstrap.scss
@@ -0,0 +1,7 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --_cell-border-color: var(--ig-gray-500);
+ --_cell-color: var(--ig-gray-100);
+ --_cell-background: var(--ig-surface-500);
+}
diff --git a/src/components/pin-input/themes/dark/pin-input.fluent.scss b/src/components/pin-input/themes/dark/pin-input.fluent.scss
new file mode 100644
index 000000000..25b06f37f
--- /dev/null
+++ b/src/components/pin-input/themes/dark/pin-input.fluent.scss
@@ -0,0 +1,7 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --_cell-border-color: var(--ig-gray-500);
+ --_cell-color: var(--ig-gray-100);
+ --_cell-background: var(--ig-surface-500);
+}
diff --git a/src/components/pin-input/themes/dark/pin-input.indigo.scss b/src/components/pin-input/themes/dark/pin-input.indigo.scss
new file mode 100644
index 000000000..25b06f37f
--- /dev/null
+++ b/src/components/pin-input/themes/dark/pin-input.indigo.scss
@@ -0,0 +1,7 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --_cell-border-color: var(--ig-gray-500);
+ --_cell-color: var(--ig-gray-100);
+ --_cell-background: var(--ig-surface-500);
+}
diff --git a/src/components/pin-input/themes/dark/pin-input.material.scss b/src/components/pin-input/themes/dark/pin-input.material.scss
new file mode 100644
index 000000000..25b06f37f
--- /dev/null
+++ b/src/components/pin-input/themes/dark/pin-input.material.scss
@@ -0,0 +1,7 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --_cell-border-color: var(--ig-gray-500);
+ --_cell-color: var(--ig-gray-100);
+ --_cell-background: var(--ig-surface-500);
+}
diff --git a/src/components/pin-input/themes/light/pin-input.bootstrap.scss b/src/components/pin-input/themes/light/pin-input.bootstrap.scss
new file mode 100644
index 000000000..caae109e1
--- /dev/null
+++ b/src/components/pin-input/themes/light/pin-input.bootstrap.scss
@@ -0,0 +1,5 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --_cell-border-radius: #{rem(4px)};
+}
diff --git a/src/components/pin-input/themes/light/pin-input.fluent.scss b/src/components/pin-input/themes/light/pin-input.fluent.scss
new file mode 100644
index 000000000..4c3bc5afd
--- /dev/null
+++ b/src/components/pin-input/themes/light/pin-input.fluent.scss
@@ -0,0 +1,5 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --_cell-border-radius: #{rem(2px)};
+}
diff --git a/src/components/pin-input/themes/light/pin-input.indigo.scss b/src/components/pin-input/themes/light/pin-input.indigo.scss
new file mode 100644
index 000000000..f09e23fe4
--- /dev/null
+++ b/src/components/pin-input/themes/light/pin-input.indigo.scss
@@ -0,0 +1,6 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --_cell-border-radius: #{rem(4px)};
+ --_cell-focus-border-color: var(--ig-secondary-500);
+}
diff --git a/src/components/pin-input/themes/light/pin-input.material.scss b/src/components/pin-input/themes/light/pin-input.material.scss
new file mode 100644
index 000000000..ca5ac5f94
--- /dev/null
+++ b/src/components/pin-input/themes/light/pin-input.material.scss
@@ -0,0 +1,6 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --_cell-border-radius: #{rem(4px)};
+ --_cell-border-color: var(--ig-gray-500);
+}
diff --git a/src/components/pin-input/themes/light/pin-input.shared.scss b/src/components/pin-input/themes/light/pin-input.shared.scss
new file mode 100644
index 000000000..eb03064c5
--- /dev/null
+++ b/src/components/pin-input/themes/light/pin-input.shared.scss
@@ -0,0 +1,4 @@
+@use 'styles/utilities' as *;
+
+:host {
+}
diff --git a/src/components/pin-input/themes/pin-input.base.scss b/src/components/pin-input/themes/pin-input.base.scss
new file mode 100644
index 000000000..f44967246
--- /dev/null
+++ b/src/components/pin-input/themes/pin-input.base.scss
@@ -0,0 +1,71 @@
+@use 'styles/common/component';
+@use 'styles/utilities' as *;
+
+:host {
+ display: block;
+ position: relative;
+
+ --_cell-size: #{rem(48px)};
+ --_cell-border-color: var(--ig-gray-400);
+ --_cell-background: transparent;
+ --_cell-focus-border-color: var(--ig-primary-500);
+ --_cell-invalid-border-color: var(--ig-error-500);
+ --_cell-color: var(--ig-gray-900);
+ --_cell-border-radius: #{rem(4px)};
+ --_cell-gap: #{rem(8px)};
+}
+
+[part='label'] {
+ display: block;
+ margin-block-end: rem(4px);
+ color: var(--ig-gray-700);
+}
+
+[part='inputs'] {
+ display: flex;
+ flex-direction: row;
+ gap: var(--_cell-gap);
+ align-items: center;
+}
+
+[part~='input'] {
+ width: var(--_cell-size);
+ height: var(--_cell-size);
+ border: 1px solid var(--_cell-border-color);
+ border-radius: var(--_cell-border-radius);
+ background: var(--_cell-background);
+ color: var(--_cell-color);
+ font-size: rem(20px);
+ text-align: center;
+ caret-color: var(--_cell-focus-border-color);
+ outline: none;
+ transition: border-color 0.2s ease;
+ -webkit-appearance: none;
+ -moz-appearance: textfield;
+
+ &::-webkit-inner-spin-button,
+ &::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+
+ &:focus {
+ border-color: var(--_cell-focus-border-color);
+ box-shadow: 0 0 0 3px
+ color-mix(in srgb, var(--_cell-focus-border-color) 20%, transparent);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+:host([invalid]) [part~='input'],
+:host(:state(ig-invalid)) [part~='input'] {
+ --_cell-border-color: var(--_cell-invalid-border-color);
+}
+
+:host([disabled]) [part='inputs'] {
+ pointer-events: none;
+}
diff --git a/src/components/pin-input/themes/shared/pin-input.bootstrap.scss b/src/components/pin-input/themes/shared/pin-input.bootstrap.scss
new file mode 100644
index 000000000..5d5eae690
--- /dev/null
+++ b/src/components/pin-input/themes/shared/pin-input.bootstrap.scss
@@ -0,0 +1,5 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --component-size: var(--ig-size, #{rem(48px)});
+}
diff --git a/src/components/pin-input/themes/shared/pin-input.fluent.scss b/src/components/pin-input/themes/shared/pin-input.fluent.scss
new file mode 100644
index 000000000..5d5eae690
--- /dev/null
+++ b/src/components/pin-input/themes/shared/pin-input.fluent.scss
@@ -0,0 +1,5 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --component-size: var(--ig-size, #{rem(48px)});
+}
diff --git a/src/components/pin-input/themes/shared/pin-input.indigo.scss b/src/components/pin-input/themes/shared/pin-input.indigo.scss
new file mode 100644
index 000000000..5d5eae690
--- /dev/null
+++ b/src/components/pin-input/themes/shared/pin-input.indigo.scss
@@ -0,0 +1,5 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --component-size: var(--ig-size, #{rem(48px)});
+}
diff --git a/src/components/pin-input/themes/shared/pin-input.material.scss b/src/components/pin-input/themes/shared/pin-input.material.scss
new file mode 100644
index 000000000..5d5eae690
--- /dev/null
+++ b/src/components/pin-input/themes/shared/pin-input.material.scss
@@ -0,0 +1,5 @@
+@use 'styles/utilities' as *;
+
+:host {
+ --component-size: var(--ig-size, #{rem(48px)});
+}
diff --git a/src/components/pin-input/themes/themes.ts b/src/components/pin-input/themes/themes.ts
new file mode 100644
index 000000000..1d3aec837
--- /dev/null
+++ b/src/components/pin-input/themes/themes.ts
@@ -0,0 +1,57 @@
+import { css } from 'lit';
+
+import type { Themes } from '../../../theming/types.js';
+// Dark Overrides
+import { styles as bootstrapDark } from './dark/pin-input.bootstrap.css.js';
+import { styles as fluentDark } from './dark/pin-input.fluent.css.js';
+import { styles as indigoDark } from './dark/pin-input.indigo.css.js';
+import { styles as materialDark } from './dark/pin-input.material.css.js';
+// Light Overrides
+import { styles as bootstrapLight } from './light/pin-input.bootstrap.css.js';
+import { styles as fluentLight } from './light/pin-input.fluent.css.js';
+import { styles as indigoLight } from './light/pin-input.indigo.css.js';
+import { styles as materialLight } from './light/pin-input.material.css.js';
+import { styles as shared } from './light/pin-input.shared.css.js';
+// Shared Styles
+import { styles as bootstrap } from './shared/pin-input.bootstrap.css.js';
+import { styles as fluent } from './shared/pin-input.fluent.css.js';
+import { styles as indigo } from './shared/pin-input.indigo.css.js';
+import { styles as material } from './shared/pin-input.material.css.js';
+
+const light = {
+ shared: css`
+ ${shared}
+ `,
+ bootstrap: css`
+ ${bootstrap} ${bootstrapLight}
+ `,
+ material: css`
+ ${material} ${materialLight}
+ `,
+ fluent: css`
+ ${fluent} ${fluentLight}
+ `,
+ indigo: css`
+ ${indigo} ${indigoLight}
+ `,
+};
+
+const dark = {
+ shared: css`
+ ${shared}
+ `,
+ bootstrap: css`
+ ${bootstrap} ${bootstrapDark}
+ `,
+ material: css`
+ ${material} ${materialDark}
+ `,
+ fluent: css`
+ ${fluent} ${fluentDark}
+ `,
+ indigo: css`
+ ${indigo} ${indigoDark}
+ `,
+};
+
+export const all: Themes = { light, dark };
diff --git a/src/components/pin-input/validators.ts b/src/components/pin-input/validators.ts
new file mode 100644
index 000000000..a3d031fe3
--- /dev/null
+++ b/src/components/pin-input/validators.ts
@@ -0,0 +1,8 @@
+import type { Validator } from '../common/validators.js';
+import type IgcPinInputComponent from './pin-input.js';
+
+export const pinRequiredValidator: Validator = {
+ key: 'valueMissing',
+ message: 'Please fill in all fields.',
+ isValid: (host) => (host.required ? host.value.length === host.length : true),
+};
diff --git a/src/index.ts b/src/index.ts
index 35f6af2ff..0400079a0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -47,6 +47,7 @@ export { default as IgcNavDrawerComponent } from './components/nav-drawer/nav-dr
export { default as IgcNavDrawerHeaderItemComponent } from './components/nav-drawer/nav-drawer-header-item.js';
export { default as IgcNavDrawerItemComponent } from './components/nav-drawer/nav-drawer-item.js';
export { default as IgcNavbarComponent } from './components/navbar/navbar.js';
+export { default as IgcPinInputComponent } from './components/pin-input/pin-input.js';
export { default as IgcRadioGroupComponent } from './components/radio-group/radio-group.js';
export { default as IgcRadioComponent } from './components/radio/radio.js';
export { default as IgcRatingComponent } from './components/rating/rating.js';
@@ -143,6 +144,7 @@ export type { IgcExpansionPanelComponentEventMap } from './components/expansion-
export type { IgcInputComponentEventMap } from './components/input/input-base.js';
export type { IgcInputComponentEventMap as IgcMaskInputComponentEventMap } from './components/input/input-base.js';
export type { IgcFileInputComponentEventMap } from './components/file-input/file-input.js';
+export type { IgcPinInputComponentEventMap } from './components/pin-input/pin-input.js';
export type { IgcRadioComponentEventMap } from './components/radio/radio.js';
export type { IgcRatingComponentEventMap } from './components/rating/rating.js';
export type { IgcSelectComponentEventMap } from './components/select/select.js';
diff --git a/stories/pin-input.stories.ts b/stories/pin-input.stories.ts
new file mode 100644
index 000000000..a27c0fbc1
--- /dev/null
+++ b/stories/pin-input.stories.ts
@@ -0,0 +1,302 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import { html } from 'lit';
+import { ifDefined } from 'lit/directives/if-defined.js';
+
+import { IgcPinInputComponent, defineComponents } from 'igniteui-webcomponents';
+import { formControls, formSubmitHandler } from './story.js';
+
+defineComponents(IgcPinInputComponent);
+
+// region default
+const metadata: Meta = {
+ title: 'PinInput',
+ component: 'igc-pin-input',
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'A PIN/OTP input component that renders individual character cells.',
+ },
+ },
+ actions: { handles: ['igcInput', 'igcChange', 'igcComplete'] },
+ },
+ argTypes: {
+ label: {
+ type: 'string',
+ description: 'The label for the control.',
+ control: 'text',
+ },
+ placeholder: {
+ type: 'string',
+ description: 'The placeholder character shown in each empty cell.',
+ control: 'text',
+ },
+ length: {
+ type: 'number',
+ description: 'The number of input cells. Clamped between 1 and 8.',
+ control: 'number',
+ table: { defaultValue: { summary: '4' } },
+ },
+ inputMode: {
+ type: '"numeric" | "alphanumeric"',
+ description:
+ 'The type of allowed input.\n- `numeric` — only digits (0-9)\n- `alphanumeric` — letters and digits',
+ options: ['numeric', 'alphanumeric'],
+ control: { type: 'inline-radio' },
+ table: { defaultValue: { summary: 'numeric' } },
+ },
+ mask: {
+ type: 'boolean',
+ description:
+ 'When set, the entered characters are visually hidden (displayed as password dots).',
+ control: 'boolean',
+ table: { defaultValue: { summary: 'false' } },
+ },
+ separator: {
+ type: 'string',
+ description:
+ 'The character(s) rendered between cell groups when `groups` is configured.\nHas no effect unless `groups` is also set.',
+ control: 'text',
+ table: { defaultValue: { summary: '' } },
+ },
+ value: {
+ type: 'string',
+ description:
+ 'The concatenated value of all cells. Empty string when not all cells are filled.',
+ control: 'text',
+ },
+ required: {
+ type: 'boolean',
+ description:
+ 'When set, makes the component a required field for validation.',
+ control: 'boolean',
+ table: { defaultValue: { summary: 'false' } },
+ },
+ name: {
+ type: 'string',
+ description: 'The name attribute of the control.',
+ control: 'text',
+ },
+ disabled: {
+ type: 'boolean',
+ description: 'The disabled state of the component.',
+ control: 'boolean',
+ table: { defaultValue: { summary: 'false' } },
+ },
+ invalid: {
+ type: 'boolean',
+ description: 'Sets the control into invalid state (visual state only).',
+ control: 'boolean',
+ table: { defaultValue: { summary: 'false' } },
+ },
+ },
+ args: {
+ length: 4,
+ inputMode: 'numeric',
+ mask: false,
+ separator: '',
+ required: false,
+ disabled: false,
+ invalid: false,
+ },
+};
+
+export default metadata;
+
+interface IgcPinInputArgs {
+ /** The label for the control. */
+ label: string;
+ /** The placeholder character shown in each empty cell. */
+ placeholder: string;
+ /** The number of input cells. Clamped between 1 and 8. */
+ length: number;
+ /**
+ * The type of allowed input.
+ * - `numeric` — only digits (0-9)
+ * - `alphanumeric` — letters and digits
+ */
+ inputMode: 'numeric' | 'alphanumeric';
+ /** When set, the entered characters are visually hidden (displayed as password dots). */
+ mask: boolean;
+ /**
+ * The character(s) rendered between cell groups when `groups` is configured.
+ * Has no effect unless `groups` is also set.
+ */
+ separator: string;
+ /** The concatenated value of all cells. Empty string when not all cells are filled. */
+ value: string;
+ /** When set, makes the component a required field for validation. */
+ required: boolean;
+ /** The name attribute of the control. */
+ name: string;
+ /** The disabled state of the component. */
+ disabled: boolean;
+ /** Sets the control into invalid state (visual state only). */
+ invalid: boolean;
+}
+type Story = StoryObj;
+
+// endregion
+
+export const Basic: Story = {
+ render: ({
+ length,
+ inputMode,
+ mask,
+ label,
+ placeholder,
+ required,
+ disabled,
+ invalid,
+ name,
+ }) => html`
+
+ `,
+};
+
+export const Masked: Story = {
+ args: {
+ mask: true,
+ label: 'Enter PIN',
+ length: 4,
+ },
+ render: ({
+ length,
+ inputMode,
+ mask,
+ label,
+ placeholder,
+ required,
+ disabled,
+ invalid,
+ }) => html`
+
+ `,
+};
+
+export const Alphanumeric: Story = {
+ args: {
+ inputMode: 'alphanumeric',
+ label: 'Enter Code',
+ length: 6,
+ },
+ render: ({
+ length,
+ inputMode,
+ mask,
+ label,
+ placeholder,
+ required,
+ disabled,
+ invalid,
+ }) => html`
+
+ `,
+};
+
+export const InForm: Story = {
+ render: ({
+ length,
+ inputMode,
+ mask,
+ label,
+ required,
+ disabled,
+ invalid,
+ }) => html`
+
+ `,
+};
+
+export const WithGroups: Story = {
+ args: {
+ label: 'License key',
+ separator: '-',
+ inputMode: 'alphanumeric',
+ length: 4,
+ },
+ render: ({
+ inputMode,
+ mask,
+ label,
+ placeholder,
+ disabled,
+ invalid,
+ separator,
+ }) => html`
+ Two groups of 4 (8 total), separated by a dash:
+
+
+
+ Three groups of 3 (capped at 8 total), separated by a space:
+
+
+
+ No separator set — groups are visual only:
+
+ `,
+};
From 684d10e6daea871ed2d57935aa209b5bec0fc235 Mon Sep 17 00:00:00 2001
From: Radoslav Karaivanov
Date: Thu, 7 May 2026 17:44:06 +0300
Subject: [PATCH 2/4] fix: stylelint errors
---
src/components/pin-input/themes/light/pin-input.shared.scss | 3 ---
src/components/pin-input/themes/pin-input.base.scss | 5 ++---
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/src/components/pin-input/themes/light/pin-input.shared.scss b/src/components/pin-input/themes/light/pin-input.shared.scss
index eb03064c5..26e19e804 100644
--- a/src/components/pin-input/themes/light/pin-input.shared.scss
+++ b/src/components/pin-input/themes/light/pin-input.shared.scss
@@ -1,4 +1 @@
@use 'styles/utilities' as *;
-
-:host {
-}
diff --git a/src/components/pin-input/themes/pin-input.base.scss b/src/components/pin-input/themes/pin-input.base.scss
index f44967246..d08a74cd3 100644
--- a/src/components/pin-input/themes/pin-input.base.scss
+++ b/src/components/pin-input/themes/pin-input.base.scss
@@ -40,12 +40,11 @@
caret-color: var(--_cell-focus-border-color);
outline: none;
transition: border-color 0.2s ease;
- -webkit-appearance: none;
- -moz-appearance: textfield;
+ appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
- -webkit-appearance: none;
+ appearance: none;
margin: 0;
}
From 80aada96601201923d16a6d71476c23f9e4091ad Mon Sep 17 00:00:00 2001
From: Radoslav Karaivanov
Date: Wed, 3 Jun 2026 13:25:39 +0300
Subject: [PATCH 3/4] refactor: Event delegation and edge case handling
- Refactored the pin input component to use event delegation for better performance and maintainability.
- Added edge case handling for focus events to ensure the correct input is focused and selected.
- Updated the storybook stories to reflect the changes in the component's API and behavior.
- Updated tests to cover the new event handling logic and edge cases.
---
.../carousel-indicator-container.spec.ts | 7 +-
src/components/carousel/carousel.spec.ts | 5 +-
.../common/controllers/focus-ring.spec.ts | 5 +-
src/components/common/utils.spec.ts | 9 ++
src/components/pin-input/pin-input.spec.ts | 67 ++++-----
src/components/pin-input/pin-input.ts | 127 ++++++++++--------
stories/pin-input.stories.ts | 42 +++---
7 files changed, 129 insertions(+), 133 deletions(-)
diff --git a/src/components/carousel/carousel-indicator-container.spec.ts b/src/components/carousel/carousel-indicator-container.spec.ts
index 4294664c0..648330bfa 100644
--- a/src/components/carousel/carousel-indicator-container.spec.ts
+++ b/src/components/carousel/carousel-indicator-container.spec.ts
@@ -5,6 +5,7 @@ import { defineComponents } from '../common/definitions/defineComponents.js';
import { first } from '../common/util.js';
import {
simulateClick,
+ simulateFocusOut,
simulateKeyboard,
simulatePointerDown,
simulatePointerUp,
@@ -100,7 +101,7 @@ describe('Carousel Indicator Container', () => {
`
);
- first(buttons).dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+ simulateFocusOut(first(buttons));
await elementUpdated(container);
expect(container).shadowDom.to.equal(
@@ -127,9 +128,7 @@ describe('Carousel Indicator Container', () => {
`
);
- first(buttons).dispatchEvent(
- new FocusEvent('focusout', { bubbles: true, relatedTarget: indicator })
- );
+ simulateFocusOut(first(buttons), { relatedTarget: indicator });
await elementUpdated(container);
expect(container).shadowDom.to.equal(
diff --git a/src/components/carousel/carousel.spec.ts b/src/components/carousel/carousel.spec.ts
index 0eb28c214..dba608b76 100644
--- a/src/components/carousel/carousel.spec.ts
+++ b/src/components/carousel/carousel.spec.ts
@@ -21,6 +21,7 @@ import { defineComponents } from '../common/definitions/defineComponents.js';
import {
finishAnimationsFor,
simulateClick,
+ simulateFocusOut,
simulateKeyboard,
simulateLostPointerCapture,
simulatePointerDown,
@@ -763,7 +764,7 @@ describe('Carousel', () => {
await elementUpdated(carousel);
// loose focus
- carousel.dispatchEvent(new FocusEvent('focusout'));
+ simulateFocusOut(carousel);
await elementUpdated(carousel);
expect(carousel.isPlaying).to.be.false;
@@ -820,7 +821,7 @@ describe('Carousel', () => {
expect(carousel.current).to.equal(0);
// loose focus
- carousel.dispatchEvent(new FocusEvent('focusout'));
+ simulateFocusOut(carousel);
await elementUpdated(carousel);
await clock.tickAsync(200);
diff --git a/src/components/common/controllers/focus-ring.spec.ts b/src/components/common/controllers/focus-ring.spec.ts
index cb3a9fb05..d92952453 100644
--- a/src/components/common/controllers/focus-ring.spec.ts
+++ b/src/components/common/controllers/focus-ring.spec.ts
@@ -10,6 +10,7 @@ import { css, LitElement } from 'lit';
import { partMap } from '../part-map.js';
import {
simulateClick,
+ simulateFocusOut,
simulateKeyboard,
simulatePointerDown,
simulatePointerUp,
@@ -79,9 +80,7 @@ describe('Focus ring controller', () => {
expect(hasKeyboardFocusStyles(instance.button)).to.be.true;
- instance.button.dispatchEvent(
- new FocusEvent('focusout', { bubbles: true, composed: true })
- );
+ simulateFocusOut(instance.button);
await elementUpdated(instance);
expect(hasKeyboardFocusStyles(instance.button)).to.be.false;
diff --git a/src/components/common/utils.spec.ts b/src/components/common/utils.spec.ts
index df2f1a8fe..8c3e826df 100644
--- a/src/components/common/utils.spec.ts
+++ b/src/components/common/utils.spec.ts
@@ -246,6 +246,15 @@ export function simulateBlur(node: Element): void {
node.dispatchEvent(new FocusEvent('blur'));
}
+export function simulateFocusOut(
+ node: Element,
+ options?: FocusEventInit
+): void {
+ node.dispatchEvent(
+ new FocusEvent('focusout', { bubbles: true, composed: true, ...options })
+ );
+}
+
export function simulatePointerDown(
node: Element,
options?: PointerEventInit,
diff --git a/src/components/pin-input/pin-input.spec.ts b/src/components/pin-input/pin-input.spec.ts
index a2e2ba12d..1e8995b1f 100644
--- a/src/components/pin-input/pin-input.spec.ts
+++ b/src/components/pin-input/pin-input.spec.ts
@@ -1,9 +1,17 @@
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import { spy } from 'sinon';
+import {
+ arrowLeft,
+ arrowRight,
+ backspaceKey,
+ deleteKey,
+} from '../common/controllers/key-bindings.js';
import { defineComponents } from '../common/definitions/defineComponents.js';
import {
createFormAssociatedTestBed,
+ simulateFocusOut,
simulateInput,
+ simulateKeyboard,
simulatePaste,
} from '../common/utils.spec.js';
import {
@@ -29,7 +37,7 @@ describe('PinInput', () => {
cell: HTMLInputElement,
char: string
): Promise {
- simulateInput(cell, { value: char });
+ simulateInput(cell, { value: char, bubbles: true, composed: true });
await elementUpdated(element);
}
@@ -45,7 +53,7 @@ describe('PinInput', () => {
it('initializes with default values', () => {
expect(element.length).to.equal(4);
- expect(element.inputMode).to.equal('numeric');
+ expect(element.mode).to.equal('numeric');
expect(element.mask).to.be.false;
expect(element.disabled).to.be.false;
expect(element.required).to.be.false;
@@ -130,7 +138,7 @@ describe('PinInput', () => {
});
it('accepts alphanumeric characters when type is alphanumeric', async () => {
- element.inputMode = 'alphanumeric';
+ element.mode = 'alphanumeric';
element.value = 'a1B2';
await elementUpdated(element);
expect(element.value).to.equal('a1B2');
@@ -165,7 +173,7 @@ describe('PinInput', () => {
});
it('sets inputmode="text" for alphanumeric type', async () => {
- element.inputMode = 'alphanumeric';
+ element.mode = 'alphanumeric';
await elementUpdated(element);
for (const cell of getCells(element)) {
expect(cell.inputMode).to.equal('text');
@@ -233,9 +241,7 @@ describe('PinInput', () => {
await typeIntoCell(cells[1], '2');
await typeIntoCell(cells[2], '3');
- cells[2].dispatchEvent(
- new FocusEvent('focusout', { bubbles: true, composed: true })
- );
+ simulateFocusOut(cells[2]);
await elementUpdated(element);
expect(handler.calledOnce).to.be.true;
@@ -254,13 +260,7 @@ describe('PinInput', () => {
// Simulate focusout re-targeted to the host — what the browser produces
// when focus moves between shadow-internal cells
- cells[2].dispatchEvent(
- new FocusEvent('focusout', {
- bubbles: true,
- composed: true,
- relatedTarget: element,
- })
- );
+ simulateFocusOut(cells[2], { relatedTarget: element });
await elementUpdated(element);
expect(handler.called).to.be.false;
@@ -274,14 +274,9 @@ describe('PinInput', () => {
await typeIntoCell(cells[1], '2');
await typeIntoCell(cells[2], '3');
- const focusout = () =>
- cells[2].dispatchEvent(
- new FocusEvent('focusout', { bubbles: true, composed: true })
- );
-
- focusout();
+ simulateFocusOut(element);
await elementUpdated(element);
- focusout();
+ simulateFocusOut(element);
await elementUpdated(element);
expect(handler.calledOnce).to.be.true;
@@ -293,17 +288,13 @@ describe('PinInput', () => {
element = await fixture(html``);
});
- function pressKey(cell: HTMLInputElement, key: string): void {
- cell.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
- }
-
describe('Backspace', () => {
it('shifts subsequent filled cells left when pressed on a filled cell', async () => {
element.value = '1234';
await elementUpdated(element);
const cells = getCells(element);
- pressKey(cells[1], 'Backspace');
+ simulateKeyboard(cells[1], backspaceKey);
await elementUpdated(element);
expect(cells[0].value).to.equal('1');
@@ -317,7 +308,7 @@ describe('PinInput', () => {
await elementUpdated(element);
const cells = getCells(element);
- pressKey(cells[0], 'Backspace');
+ simulateKeyboard(cells[0], backspaceKey);
await elementUpdated(element);
expect(cells[0].value).to.equal('2');
@@ -333,7 +324,7 @@ describe('PinInput', () => {
await typeIntoCell(cells[1], '2');
await typeIntoCell(cells[2], '3');
- pressKey(cells[3], 'Backspace');
+ simulateKeyboard(cells[3], backspaceKey);
await elementUpdated(element);
expect(cells[0].value).to.equal('1');
@@ -345,7 +336,7 @@ describe('PinInput', () => {
it('is a no-op when pressed on the first empty cell', async () => {
const cells = getCells(element);
- pressKey(cells[0], 'Backspace');
+ simulateKeyboard(cells[0], backspaceKey);
await elementUpdated(element);
expect(cells[0].value).to.equal('');
@@ -358,7 +349,7 @@ describe('PinInput', () => {
await elementUpdated(element);
const cells = getCells(element);
- pressKey(cells[1], 'Delete');
+ simulateKeyboard(cells[1], deleteKey);
await elementUpdated(element);
expect(cells[0].value).to.equal('1');
@@ -374,7 +365,7 @@ describe('PinInput', () => {
await typeIntoCell(cells[2], '3');
await typeIntoCell(cells[3], '4');
- pressKey(cells[1], 'Delete');
+ simulateKeyboard(cells[1], deleteKey);
await elementUpdated(element);
expect(cells[0].value).to.equal('1');
@@ -386,7 +377,7 @@ describe('PinInput', () => {
it('is a no-op when pressed on the last empty cell', async () => {
const cells = getCells(element);
- pressKey(cells[3], 'Delete');
+ simulateKeyboard(cells[3], deleteKey);
await elementUpdated(element);
expect(cells[3].value).to.equal('');
@@ -400,7 +391,7 @@ describe('PinInput', () => {
const cells = getCells(element);
cells[2].focus();
- pressKey(cells[2], 'ArrowLeft');
+ simulateKeyboard(cells[2], arrowLeft);
await elementUpdated(element);
expect(element.shadowRoot!.activeElement).to.equal(cells[1]);
@@ -412,7 +403,7 @@ describe('PinInput', () => {
const cells = getCells(element);
cells[1].focus();
- pressKey(cells[1], 'ArrowRight');
+ simulateKeyboard(cells[1], arrowRight);
await elementUpdated(element);
expect(element.shadowRoot!.activeElement).to.equal(cells[2]);
@@ -592,9 +583,7 @@ describe('PinInput', () => {
element.clear();
await elementUpdated(element);
- cells[0].dispatchEvent(
- new FocusEvent('focusout', { bubbles: true, composed: true })
- );
+ simulateFocusOut(cells[0]);
await elementUpdated(element);
expect(handler.called).to.be.false;
@@ -646,9 +635,7 @@ describe('PinInput', () => {
await elementUpdated(spec.element);
const cells = getCells(spec.element);
- cells[0].dispatchEvent(
- new FocusEvent('focusout', { bubbles: true, composed: true })
- );
+ simulateFocusOut(cells[0]);
await elementUpdated(spec.element);
expect(handler.called).to.be.false;
diff --git a/src/components/pin-input/pin-input.ts b/src/components/pin-input/pin-input.ts
index 96d02a2fb..632531920 100644
--- a/src/components/pin-input/pin-input.ts
+++ b/src/components/pin-input/pin-input.ts
@@ -3,6 +3,7 @@ import { property, queryAll, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { addThemingController } from '../../theming/theming-controller.js';
import {
+ addKeybindings,
arrowLeft,
arrowRight,
backspaceKey,
@@ -19,6 +20,7 @@ import {
addSafeEventListener,
bindIf,
clamp,
+ getElementFromPath,
stopPropagation,
} from '../common/util.js';
import IgcValidationContainerComponent from '../validation-container/validation-container.js';
@@ -107,7 +109,7 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
}
private get _isNumeric(): boolean {
- return this.inputMode === 'numeric';
+ return this.mode === 'numeric';
}
//#endregion
@@ -155,11 +157,11 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
* The type of allowed input.
* - `numeric` — only digits (0-9)
* - `alphanumeric` — letters and digits
- * @attr input-mode
+ * @attr mode
* @default 'numeric'
*/
- @property({ reflect: true, attribute: 'input-mode' })
- public override inputMode: 'numeric' | 'alphanumeric' = 'numeric';
+ @property({ reflect: true })
+ public mode: 'numeric' | 'alphanumeric' = 'numeric';
/**
* When set, the entered characters are visually hidden (displayed as password dots).
@@ -182,22 +184,30 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
* Defines visual groupings of cells separated by `separator`.
* Each element in the array is the number of cells in that group.
* When set, `length` is derived from the sum of the group sizes (clamped to 1–8).
- * @example // Two groups of three cells with a separator between them
- * element.groups = [3, 3];
+ *
+ * @example
+ * ```html
+ *
+ *
+ * ```
*/
@property({ attribute: false })
public set groups(value: number[]) {
this._groups = value;
- if (value.length > 0) {
- const total = value.reduce((a, b) => a + b, 0);
- const clamped = clamp(total, MIN_LENGTH, MAX_LENGTH);
- this._cells = Array.from(
- { length: clamped },
- (_, i) => this._cells[i] ?? ''
- );
- this._length = clamped;
- this._syncFormValue();
- }
+ const clamped =
+ value.length > 0
+ ? clamp(
+ value.reduce((a, b) => a + b, 0),
+ MIN_LENGTH,
+ MAX_LENGTH
+ )
+ : this._length;
+ this._cells = Array.from(
+ { length: clamped },
+ (_, i) => this._cells[i] ?? ''
+ );
+ this._length = clamped;
+ this._syncFormValue();
}
public get groups(): number[] {
@@ -231,7 +241,20 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
super();
addThemingController(this, all);
+ addKeybindings(this, {
+ skip: () => this.disabled,
+ bindingDefaults: { preventDefault: true, repeat: true },
+ })
+ .set(backspaceKey, this._handleBackspace)
+ .set(deleteKey, this._handleDelete)
+ .set(arrowLeft, this._handleArrowLeft)
+ .set(arrowRight, this._handleArrowRight);
+
+ addSafeEventListener(this, 'focusin', this._handleCellFocus);
addSafeEventListener(this, 'focusout', this._handleFocusOut);
+ addSafeEventListener(this, 'input', this._handleInput);
+ addSafeEventListener(this, 'paste', this._handlePaste);
+ addSafeEventListener(this, 'change', stopPropagation);
}
/** @internal */
@@ -250,10 +273,14 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
//#region Event handlers
- private _handleBackspace(index: number, event: KeyboardEvent): void {
- event.preventDefault();
+ private _getCellIndex(event: Event): number {
+ const cell = getElementFromPath('[part="input"]', event);
+ return cell ? Array.prototype.indexOf.call(this._inputs, cell) : -1;
+ }
- if (index === 0 && !this._cells[0]) return;
+ private _handleBackspace(event: KeyboardEvent): void {
+ const index = this._getCellIndex(event);
+ if (index === -1 || (index === 0 && !this._cells[0])) return;
this._cells = this._shiftDeleteAt(this._cells[index] ? index : index - 1);
this._syncFormValue();
@@ -261,8 +288,9 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
this._focusCell(Math.max(0, index - 1));
}
- private _handleDelete(index: number, event: KeyboardEvent): void {
- event.preventDefault();
+ private _handleDelete(event: KeyboardEvent): void {
+ const index = this._getCellIndex(event);
+ if (index === -1) return;
let input: HTMLInputElement;
@@ -281,41 +309,25 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
this.updateComplete.then(() => input.select());
}
- private _handleArrowLeft(index: number, event: KeyboardEvent): void {
+ private _handleArrowLeft(event: KeyboardEvent): void {
+ const index = this._getCellIndex(event);
if (index > 0) {
- event.preventDefault();
this._focusCell(index - 1);
}
}
- private _handleArrowRight(index: number, event: KeyboardEvent): void {
- if (index < this._length - 1) {
- event.preventDefault();
+ private _handleArrowRight(event: KeyboardEvent): void {
+ const index = this._getCellIndex(event);
+ if (index > -1 && index < this._length - 1) {
this._focusCell(index + 1);
}
}
- private _handleKeydown(index: number, event: KeyboardEvent): void {
- const { key } = event;
-
- switch (key) {
- case backspaceKey:
- this._handleBackspace(index, event);
- break;
- case deleteKey:
- this._handleDelete(index, event);
- break;
- case arrowLeft:
- this._handleArrowLeft(index, event);
- break;
- case arrowRight:
- this._handleArrowRight(index, event);
- break;
- }
- }
+ private _handleInput(event: Event): void {
+ const index = this._getCellIndex(event);
+ if (index === -1) return;
- private _handleInput(index: number, event: Event): void {
- const input = event.target as HTMLInputElement;
+ const input = this._inputs[index];
const rawValue = input.value.slice(-1);
const filtered = this._filterChar(rawValue);
@@ -338,10 +350,12 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
}
}
- private _handlePaste(index: number, event: ClipboardEvent): void {
- event.preventDefault();
+ private _handlePaste(event: ClipboardEvent): void {
+ const index = this._getCellIndex(event);
const text = event.clipboardData?.getData('text');
- if (!text) return;
+
+ if (index === -1 || !text) return;
+ event.preventDefault();
const chars = Iterator.from(text.split(''))
.map((c) => this._filterChar(c))
@@ -380,10 +394,10 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
}
}
- private _handleCellFocus(index: number, event: FocusEvent): void {
- if (this._cells[index]) {
- const target = event.target as HTMLInputElement;
- target.select();
+ private _handleCellFocus(event: FocusEvent): void {
+ const index = this._getCellIndex(event);
+ if (index !== -1 && this._cells[index]) {
+ this._inputs[index].select();
}
}
@@ -396,7 +410,7 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
}
private _emitCompleteIfFull(value: string): void {
- if (value.length === this._length) {
+ if (this._cells.every(Boolean)) {
this.emitEvent('igcComplete', { detail: value });
}
}
@@ -473,11 +487,6 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
.value=${value}
placeholder=${ifDefined(this.placeholder)}
aria-label=${`${this._isNumeric ? 'Digit' : 'Character'} ${index + 1} of ${this._length}`}
- @keydown=${(e: KeyboardEvent) => this._handleKeydown(index, e)}
- @focus=${(e: FocusEvent) => this._handleCellFocus(index, e)}
- @input=${(e: Event) => this._handleInput(index, e)}
- @change=${stopPropagation}
- @paste=${(e: ClipboardEvent) => this._handlePaste(index, e)}
/>
`;
}
diff --git a/stories/pin-input.stories.ts b/stories/pin-input.stories.ts
index a27c0fbc1..7c2234fd1 100644
--- a/stories/pin-input.stories.ts
+++ b/stories/pin-input.stories.ts
@@ -37,7 +37,7 @@ const metadata: Meta = {
control: 'number',
table: { defaultValue: { summary: '4' } },
},
- inputMode: {
+ mode: {
type: '"numeric" | "alphanumeric"',
description:
'The type of allowed input.\n- `numeric` — only digits (0-9)\n- `alphanumeric` — letters and digits',
@@ -92,7 +92,7 @@ const metadata: Meta = {
},
args: {
length: 4,
- inputMode: 'numeric',
+ mode: 'numeric',
mask: false,
separator: '',
required: false,
@@ -115,7 +115,7 @@ interface IgcPinInputArgs {
* - `numeric` — only digits (0-9)
* - `alphanumeric` — letters and digits
*/
- inputMode: 'numeric' | 'alphanumeric';
+ mode: 'numeric' | 'alphanumeric';
/** When set, the entered characters are visually hidden (displayed as password dots). */
mask: boolean;
/**
@@ -141,7 +141,7 @@ type Story = StoryObj;
export const Basic: Story = {
render: ({
length,
- inputMode,
+ mode,
mask,
label,
placeholder,
@@ -152,7 +152,7 @@ export const Basic: Story = {
}) => html`
html`
html`
html`
+ render: ({ length, mode, mask, label, required, disabled, invalid }) => html`
No separator set — groups are visual only:
From a451c6c876f65bd8d61540da201101b05efbf744 Mon Sep 17 00:00:00 2001
From: Radoslav Karaivanov
Date: Wed, 3 Jun 2026 13:57:33 +0300
Subject: [PATCH 4/4] refactor: Code cleanup
---
src/components/pin-input/pin-input.ts | 41 +++++++++++++--------------
1 file changed, 20 insertions(+), 21 deletions(-)
diff --git a/src/components/pin-input/pin-input.ts b/src/components/pin-input/pin-input.ts
index 632531920..4daedb440 100644
--- a/src/components/pin-input/pin-input.ts
+++ b/src/components/pin-input/pin-input.ts
@@ -194,14 +194,12 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
@property({ attribute: false })
public set groups(value: number[]) {
this._groups = value;
- const clamped =
- value.length > 0
- ? clamp(
- value.reduce((a, b) => a + b, 0),
- MIN_LENGTH,
- MAX_LENGTH
- )
- : this._length;
+ if (!value.length) return;
+ const clamped = clamp(
+ value.reduce((a, b) => a + b, 0),
+ MIN_LENGTH,
+ MAX_LENGTH
+ );
this._cells = Array.from(
{ length: clamped },
(_, i) => this._cells[i] ?? ''
@@ -230,7 +228,7 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
}
public get value(): string {
- return this._cells.every(Boolean) ? this._cellsValue : '';
+ return this._cells.includes('') ? '' : this._cellsValue;
}
//#endregion
@@ -292,21 +290,20 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
const index = this._getCellIndex(event);
if (index === -1) return;
- let input: HTMLInputElement;
-
if (this._cells[index]) {
this._cells = this._shiftDeleteAt(index);
- input = this._inputs[index];
- } else if (index < this._length - 1) {
- this._cells = this._shiftDeleteAt(index + 1);
- input = this._inputs[index + 1];
- } else {
+ this._syncFormValue();
+ this._emitInputEvent(this._cellsValue);
+ this.updateComplete.then(() => this._inputs[index].select());
return;
}
- this._syncFormValue();
- this._emitInputEvent(this._cellsValue);
- this.updateComplete.then(() => input.select());
+ if (index < this._length - 1) {
+ this._cells = this._shiftDeleteAt(index + 1);
+ this._syncFormValue();
+ this._emitInputEvent(this._cellsValue);
+ this.updateComplete.then(() => this._inputs[index + 1].select());
+ }
}
private _handleArrowLeft(event: KeyboardEvent): void {
@@ -335,7 +332,9 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
input.value = filtered;
const prev = this._cells[index];
- this._cells = this._cells.map((c, i) => (i === index ? filtered : c));
+ const updated = [...this._cells];
+ updated[index] = filtered;
+ this._cells = updated;
this._syncFormValue();
if (filtered && filtered !== prev) {
@@ -462,7 +461,7 @@ export default class IgcPinInputComponent extends FormAssociatedRequiredMixin(
//#endregion
- private _renderLabel() {
+ private _renderLabel(): TemplateResult | typeof nothing {
return this.label
? html`