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`` + : 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..26e19e804 --- /dev/null +++ b/src/components/pin-input/themes/light/pin-input.shared.scss @@ -0,0 +1 @@ +@use 'styles/utilities' as *; 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..d08a74cd3 --- /dev/null +++ b/src/components/pin-input/themes/pin-input.base.scss @@ -0,0 +1,70 @@ +@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; + appearance: textfield; + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + 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` +
+
+ + Please fill in all fields. + +
+ ${formControls()} +
+ `, +}; + +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:

+ + `, +};