diff --git a/.changeset/otp-input.md b/.changeset/otp-input.md new file mode 100644 index 000000000..4fefa49df --- /dev/null +++ b/.changeset/otp-input.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added the `OtpInput` component with grouped slots, masking, and validation states. diff --git a/core/js/src/otp-input.ts b/core/js/src/otp-input.ts new file mode 100644 index 000000000..2e41a8a00 --- /dev/null +++ b/core/js/src/otp-input.ts @@ -0,0 +1,433 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap otp-input.ts + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './bootstrap/base-component' +import EventHandler from './bootstrap/dom/event-handler' +import SelectorEngine from './bootstrap/dom/selector-engine' +import type { ElementSelector } from './bootstrap/types' + +type OtpInputType = 'numeric' | 'alphanumeric' | 'alpha' + +type ComponentConfig = { + groups: number[] | null + length: number | null + mask: boolean + separator: string + type: string +} + +type ComponentConfigInput = Partial & Record + +/** + * Constants + */ + +const NAME = 'otpInput' +const DATA_KEY = `bs.${NAME}` +const EVENT_KEY = `.${DATA_KEY}` + +const EVENT_INPUT = `input${EVENT_KEY}` +const EVENT_COMPLETE = `complete${EVENT_KEY}` + +const CLASS_NAME_INPUT = 'otp-input' +const CLASS_NAME_RENDERED = 'otp-rendered' +const CLASS_NAME_SLOTS = 'otp-slots' +const CLASS_NAME_SLOT = 'otp-slot' +const CLASS_NAME_SLOT_FILLED = 'otp-slot-filled' +const CLASS_NAME_SLOT_ACTIVE = 'otp-slot-active' +const CLASS_NAME_SEPARATOR = 'otp-separator' + +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="otp"], [data-tblr-toggle="otp"]' +const SELECTOR_INPUT = 'input' + +// Keeps the active-slot highlight in sync with the caret as it moves without typing +const SYNC_EVENTS = ['blur', 'keyup', 'select'] + +const MASK_CHARACTER = '•' + +// Per-type input mode, validation pattern, and a filter that strips disallowed characters +const TYPES: Record = { + numeric: { inputmode: 'numeric', pattern: '[0-9]*', filter: /[^0-9]/g }, + alphanumeric: { inputmode: 'text', pattern: '[A-Za-z0-9]*', filter: /[^A-Za-z0-9]/g }, + alpha: { inputmode: 'text', pattern: '[A-Za-z]*', filter: /[^A-Za-z]/g }, +} + +const Default: ComponentConfig = { + groups: null, + length: null, + mask: false, + separator: '·', + type: 'numeric', +} + +const DefaultType: Record = { + groups: '(array|null)', + length: '(number|null)', + mask: 'boolean', + separator: 'string', + type: 'string', +} + +/** + * Class definition + * + * A single real `` inside `.otp` is turned into a transparent overlay + * once its value is rendered into one `.otp-slot` per character, so screen + * readers, password managers and SMS autofill still see one ordinary field. + */ + +class OtpInput extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig + _input!: HTMLInputElement + _type!: (typeof TYPES)[OtpInputType] + _length = 0 + _slots: HTMLElement[] = [] + _slotsContainer: HTMLElement | null = null + // Tracks a tap so focus (fired natively by the browser) can respect the + // clicked slot instead of jumping to the first empty one + _pointerActive = false + _pointerIndex = 0 + + _onInput = (): void => this._handleInput() + _onBeforeInput = (event: Event): void => this._handleBeforeInput(event as InputEvent) + _onFocus = (): void => this._handleFocus() + _onPointerDown = (event: Event): void => this._handlePointerDown(event as PointerEvent) + _onSync = (): void => this._render() + _onSelectionChange = (): void => { + if (document.activeElement === this._input) { + this._render() + } + } + + constructor(element: ElementSelector, config?: ComponentConfigInput) { + super(element, config) + + if (!this._element) { + return + } + + const input = SelectorEngine.findOne(SELECTOR_INPUT, this._element) as HTMLInputElement | null + if (!input) { + return + } + + this._input = input + this._type = TYPES[this._config.type as OtpInputType] ?? TYPES.numeric + this._length = this._resolveLength() + + this._setupInput() + this._renderSlots() + this._addEventListeners() + this._render() + } + + // Getters + static get Default(): ComponentConfig { + return Default + } + + static get DefaultType(): Record { + return DefaultType + } + + static get NAME(): string { + return NAME + } + + // Public + getValue(): string { + return this._input.value + } + + setValue(value: string | number): void { + this._input.value = this._sanitize(String(value)) + this._render() + this._checkComplete() + } + + clear(): void { + this._input.value = '' + this._render() + this._input.focus() + } + + focus(): void { + this._input.focus() + this._selectSlot(this._firstEmptyIndex()) + this._render() + } + + dispose(): void { + if (!this._input) { + super.dispose() + return + } + + this._input.removeEventListener('input', this._onInput) + this._input.removeEventListener('beforeinput', this._onBeforeInput) + this._input.removeEventListener('focus', this._onFocus) + this._input.removeEventListener('pointerdown', this._onPointerDown) + for (const type of SYNC_EVENTS) { + this._input.removeEventListener(type, this._onSync) + } + + document.removeEventListener('selectionchange', this._onSelectionChange) + + this._slotsContainer?.remove() + this._element.classList.remove(CLASS_NAME_RENDERED) + super.dispose() + } + + // Private + _resolveLength(): number { + if (this._config.length) { + return this._config.length + } + + const maxLength = Number.parseInt(this._input.getAttribute('maxlength') ?? '', 10) + return Number.isNaN(maxLength) || maxLength < 1 ? 6 : maxLength + } + + _setupInput(): void { + const input = this._input + + // A single text field backs the whole control so screen readers, password + // managers, and SMS autofill treat it like any other input. + if (input.type === 'number' || input.type === 'password') { + input.type = 'text' + } + + input.classList.add(CLASS_NAME_INPUT) + input.setAttribute('maxlength', String(this._length)) + input.setAttribute('inputmode', this._type.inputmode) + input.setAttribute('pattern', this._type.pattern) + + if (!input.getAttribute('autocomplete')) { + input.setAttribute('autocomplete', 'one-time-code') + } + + if (input.value) { + input.value = this._sanitize(input.value) + } + } + + _renderSlots(): void { + const container = document.createElement('div') + container.className = CLASS_NAME_SLOTS + container.setAttribute('aria-hidden', 'true') + + const { groups, separator } = this._config + let groupIndex = 0 + let inGroup = 0 + + for (let i = 0; i < this._length; i++) { + const slot = document.createElement('div') + slot.className = CLASS_NAME_SLOT + container.append(slot) + this._slots.push(slot) + + if (groups && groups.length > 0) { + inGroup++ + if (inGroup === groups[groupIndex] && i < this._length - 1) { + const separatorEl = document.createElement('div') + separatorEl.className = CLASS_NAME_SEPARATOR + separatorEl.textContent = separator + container.append(separatorEl) + groupIndex = Math.min(groupIndex + 1, groups.length - 1) + inGroup = 0 + } + } + } + + this._slotsContainer = container + this._element.append(container) + this._element.classList.add(CLASS_NAME_RENDERED) + } + + _addEventListeners(): void { + this._input.addEventListener('input', this._onInput) + this._input.addEventListener('beforeinput', this._onBeforeInput) + this._input.addEventListener('focus', this._onFocus) + this._input.addEventListener('pointerdown', this._onPointerDown) + document.addEventListener('selectionchange', this._onSelectionChange) + + for (const type of SYNC_EVENTS) { + this._input.addEventListener(type, this._onSync) + } + } + + _handleFocus(): void { + if (this._pointerActive) { + // Wait for focus to settle before moving the caret, or iOS raises then + // immediately dismisses the on-screen keyboard. + this._pointerActive = false + this._selectSlot(this._pointerIndex) + this._render() + return + } + + this._selectSlot(this._firstEmptyIndex()) + this._render() + } + + // Bulk path: paste, SMS autofill, or a programmatic change land here as a + // single multi-character `input` event. Single keystrokes are handled by + // `_handleBeforeInput` (overwrite semantics) and never reach this method. + _handleInput(): void { + const sanitized = this._sanitize(this._input.value) + if (sanitized !== this._input.value) { + this._input.value = sanitized + } + + if (document.activeElement === this._input) { + this._selectSlot(this._firstEmptyIndex()) + } + + this._afterValueChange() + } + + // Intercepts single-character typing and backspace so each slot is + // overwritten in place rather than inserting and shifting the value. + // Anything else (paste, autofill, IME) falls through to `_handleInput`. + _handleBeforeInput(event: InputEvent): void { + const { inputType, data } = event + + if (inputType === 'insertText' && data && data.length === 1) { + event.preventDefault() + + const char = this._sanitize(data) + if (!char) { + return + } + + const index = Math.min(this._input.selectionStart ?? 0, this._length - 1) + const chars = [...this._input.value] + chars[index] = char + this._input.value = chars.join('').slice(0, this._length) + + this._selectSlot(index + 1) + this._afterValueChange() + return + } + + if (inputType === 'deleteContentBackward') { + event.preventDefault() + + const start = this._input.selectionStart ?? 0 + const end = this._input.selectionEnd ?? start + const chars = [...this._input.value] + + if (end > start) { + chars.splice(start, end - start) + this._input.value = chars.join('') + this._selectSlot(start) + } else if (start > 0) { + chars.splice(start - 1, 1) + this._input.value = chars.join('') + this._selectSlot(start - 1) + } + + this._afterValueChange() + } + } + + _handlePointerDown(event: PointerEvent): void { + const index = this._slotIndexFromPoint(event.clientX) + if (index === null) { + return + } + + const target = Math.min(index, this._firstEmptyIndex()) + + if (document.activeElement === this._input) { + // Already focused: safe to take over caret placement, it won't dismiss + // the on-screen keyboard. + event.preventDefault() + this._selectSlot(target) + this._render() + return + } + + // Not yet focused: let the browser focus the input natively so a tap + // raises the on-screen keyboard; the caret is placed once focus settles. + this._pointerActive = true + this._pointerIndex = target + } + + // Maps a viewport x-coordinate to the slot under it, clamped to the last slot. + _slotIndexFromPoint(x: number): number | null { + const rtl = getComputedStyle(this._element).direction === 'rtl' + + for (const [index, slot] of this._slots.entries()) { + const rect = slot.getBoundingClientRect() + // RTL mirrors the visual slot order, so the boundary flips from the + // right edge (v6, LTR-only) to the left edge. + const reached = rtl ? x >= rect.left : x <= rect.right + + if (reached || index === this._slots.length - 1) { + return index + } + } + + return null + } + + _afterValueChange(): void { + this._render() + EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value }) + this._checkComplete() + } + + _firstEmptyIndex(): number { + return Math.min(this._input.value.length, this._length - 1) + } + + // Represents the active slot as a selection: a filled slot is selected so + // the next keystroke overwrites it; an empty slot gets a collapsed caret. + _selectSlot(index: number): void { + const clamped = Math.max(0, Math.min(index, this._length - 1)) + const end = clamped < this._input.value.length ? clamped + 1 : clamped + this._input.setSelectionRange(clamped, end) + } + + _sanitize(value: string): string { + return value.replace(this._type.filter, '').slice(0, this._length) + } + + _render(): void { + const { value } = this._input + const isFocused = document.activeElement === this._input + const caret = Math.min(this._input.selectionStart ?? value.length, this._length - 1) + + for (const [index, slot] of this._slots.entries()) { + const char = value[index] ?? '' + slot.textContent = char && this._config.mask ? MASK_CHARACTER : char + slot.classList.toggle(CLASS_NAME_SLOT_FILLED, Boolean(char)) + slot.classList.toggle(CLASS_NAME_SLOT_ACTIVE, isFocused && index === caret) + } + } + + _checkComplete(): void { + const { value } = this._input + if (value.length === this._length) { + EventHandler.trigger(this._element, EVENT_COMPLETE, { value }) + } + } +} + +/** + * Data API implementation + */ + +// js-docs-start otp-init +for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { + OtpInput.getOrCreateInstance(element) +} +// js-docs-end otp-init + +export default OtpInput diff --git a/core/js/tabler.ts b/core/js/tabler.ts index 50f8012ab..85b77d3be 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -10,6 +10,7 @@ import './src/switch-icon' import './src/tab' import './src/toast' import './src/sortable' +import './src/otp-input' import './src/sparkline' import './src/strength' @@ -21,6 +22,7 @@ export { default as Autosize } from './src/autosize' export { default as Confetti } from './src/confetti' export { default as CountUp } from './src/countup' export { default as InputMask } from './src/input-mask' +export { default as OtpInput } from './src/otp-input' export { default as Sortable } from './src/sortable' export { default as Sparkline } from './src/sparkline' export { default as Strength } from './src/strength' diff --git a/core/js/tests/unit/otp-input.spec.ts b/core/js/tests/unit/otp-input.spec.ts new file mode 100644 index 000000000..cc5ac0995 --- /dev/null +++ b/core/js/tests/unit/otp-input.spec.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' +import { clearFixture, getFixture } from '../helpers/fixture' +import OtpInput from '../../src/otp-input' + +const beforeInput = (input: HTMLInputElement, options: { inputType: string; data?: string }): InputEvent => { + const event = new InputEvent('beforeinput', { cancelable: true, bubbles: true, ...options }) + input.dispatchEvent(event) + return event +} + +const typeInto = (input: HTMLInputElement, value: string): void => { + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +const pointerDownOnSlot = (input: HTMLInputElement, slot: HTMLElement): void => { + const rect = slot.getBoundingClientRect() + input.dispatchEvent(new PointerEvent('pointerdown', { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, cancelable: true, bubbles: true })) +} + +// The test page loads no stylesheet, so `.otp-slot` has no real flex layout; +// force a horizontal row of fixed-width slots for coordinate-based hit tests. +const layoutSlotsHorizontally = (container: HTMLElement, slotWidth = 40): void => { + container.style.display = 'inline-flex' + for (const slot of container.querySelectorAll('.otp-slot')) { + slot.style.display = 'inline-block' + slot.style.flex = 'none' + slot.style.width = `${slotWidth}px` + } +} + +describe('OtpInput', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + beforeEach(() => { + fixtureEl.innerHTML = '
' + }) + + afterEach(() => { + clearFixture() + }) + + const otp = (): HTMLElement => fixtureEl.querySelector('.otp')! + const input = (): HTMLInputElement => fixtureEl.querySelector('input')! + const slots = (): HTMLElement[] => [...fixtureEl.querySelectorAll('.otp-slot')] + + describe('NAME', () => { + it('should return the plugin name', () => { + expect(OtpInput.NAME).toBe('otpInput') + }) + }) + + describe('constructor', () => { + it('should default the length to 6 with no maxlength or option', () => { + new OtpInput(otp()) + expect(slots()).toHaveLength(6) + expect(input().getAttribute('maxlength')).toBe('6') + }) + + it('should read the length from the native maxlength', () => { + input().setAttribute('maxlength', '4') + new OtpInput(otp()) + expect(slots()).toHaveLength(4) + }) + + it('should let data-bs-length override maxlength', () => { + input().setAttribute('maxlength', '4') + otp().setAttribute('data-bs-length', '8') + new OtpInput(otp()) + expect(slots()).toHaveLength(8) + expect(input().getAttribute('maxlength')).toBe('8') + }) + + it('should set inputmode and pattern from the type option', () => { + otp().setAttribute('data-bs-type', 'alphanumeric') + new OtpInput(otp()) + expect(input().getAttribute('inputmode')).toBe('text') + expect(input().getAttribute('pattern')).toBe('[A-Za-z0-9]*') + }) + + it('should default autocomplete to one-time-code', () => { + new OtpInput(otp()) + expect(input().getAttribute('autocomplete')).toBe('one-time-code') + }) + + it('should keep an author-supplied autocomplete', () => { + input().setAttribute('autocomplete', 'off') + new OtpInput(otp()) + expect(input().getAttribute('autocomplete')).toBe('off') + }) + + it('should mark the wrapper as rendered', () => { + new OtpInput(otp()) + expect(otp().classList.contains('otp-rendered')).toBe(true) + }) + + it('should sanitize a pre-filled value', () => { + input().value = '12-34' + new OtpInput(otp()) + expect(input().value).toBe('1234') + }) + + it('should do nothing without an inner input', () => { + fixtureEl.innerHTML = '
' + expect(() => new OtpInput(otp())).not.toThrow() + }) + }) + + describe('value handling', () => { + it('getValue should reflect the input value', () => { + const instance = new OtpInput(otp()) + typeInto(input(), '123456') + expect(instance.getValue()).toBe('123456') + }) + + it('setValue should render into the slots', () => { + const instance = new OtpInput(otp()) + instance.setValue('123456') + expect(slots().map((slot) => slot.textContent)).toEqual(['1', '2', '3', '4', '5', '6']) + }) + + it('setValue should sanitize and truncate', () => { + const instance = new OtpInput(otp()) + instance.setValue('1a2b3c4d5e6f7g') + expect(instance.getValue()).toBe('123456') + }) + + it('setValue should accept a number', () => { + const instance = new OtpInput(otp()) + instance.setValue(123) + expect(instance.getValue()).toBe('123') + }) + + it('clear should empty the value and focus the input', () => { + const instance = new OtpInput(otp()) + instance.setValue('123456') + instance.clear() + expect(instance.getValue()).toBe('') + expect(document.activeElement).toBe(input()) + }) + }) + + describe('input handling', () => { + it('should strip disallowed characters from a pasted value', () => { + new OtpInput(otp()) + typeInto(input(), '123-456') + expect(input().value).toBe('123456') + }) + + it('should keep letters for an alphanumeric type', () => { + otp().setAttribute('data-bs-type', 'alphanumeric') + new OtpInput(otp()) + typeInto(input(), 'aB3-xZ9') + expect(input().value).toBe('aB3xZ9') + }) + + it('should truncate a pasted value to the slot count', () => { + otp().setAttribute('data-bs-length', '4') + new OtpInput(otp()) + typeInto(input(), '123456') + expect(input().value).toBe('1234') + }) + }) + + describe('interaction', () => { + it('should overwrite the active slot when typing', () => { + new OtpInput(otp()) + input().focus() + beforeInput(input(), { inputType: 'insertText', data: '1' }) + beforeInput(input(), { inputType: 'insertText', data: '2' }) + expect(input().value).toBe('12') + expect(input().selectionStart).toBe(2) + }) + + it('should swallow a disallowed character on beforeinput', () => { + new OtpInput(otp()) + input().focus() + const event = beforeInput(input(), { inputType: 'insertText', data: '#' }) + expect(event.defaultPrevented).toBe(true) + expect(input().value).toBe('') + }) + + it('should delete the previous character on backspace with a collapsed caret', () => { + const instance = new OtpInput(otp()) + instance.setValue('123') + input().setSelectionRange(3, 3) + beforeInput(input(), { inputType: 'deleteContentBackward' }) + expect(input().value).toBe('12') + expect(input().selectionStart).toBe(2) + }) + + it('should clear a selected slot on backspace', () => { + const instance = new OtpInput(otp()) + instance.setValue('123') + input().setSelectionRange(1, 2) + beforeInput(input(), { inputType: 'deleteContentBackward' }) + expect(input().value).toBe('13') + expect(input().selectionStart).toBe(1) + }) + + it('should do nothing on backspace at the start of an empty field', () => { + new OtpInput(otp()) + input().setSelectionRange(0, 0) + const event = beforeInput(input(), { inputType: 'deleteContentBackward' }) + expect(event.defaultPrevented).toBe(true) + expect(input().value).toBe('') + }) + + it('should select the first empty slot on keyboard focus', () => { + const instance = new OtpInput(otp()) + instance.setValue('12') + input().focus() + expect(input().selectionStart).toBe(2) + expect(input().selectionEnd).toBe(2) + }) + + it('should select the last slot on focus when the value is full', () => { + const instance = new OtpInput(otp()) + instance.setValue('123456') + input().focus() + expect(input().selectionStart).toBe(5) + expect(input().selectionEnd).toBe(6) + }) + + it('should position the caret from a tap on a slot', () => { + const instance = new OtpInput(otp()) + layoutSlotsHorizontally(otp()) + instance.setValue('123456') + input().focus() + pointerDownOnSlot(input(), slots()[2]) + expect(input().selectionStart).toBe(2) + }) + + it('should clamp a tap past the filled slots to the first empty one', () => { + const instance = new OtpInput(otp()) + layoutSlotsHorizontally(otp()) + instance.setValue('12') + input().focus() + pointerDownOnSlot(input(), slots()[5]) + expect(input().selectionStart).toBe(2) + }) + + it('should mirror the tap position in RTL', () => { + otp().setAttribute('dir', 'rtl') + const instance = new OtpInput(otp()) + layoutSlotsHorizontally(otp()) + instance.setValue('123456') + input().focus() + pointerDownOnSlot(input(), slots()[4]) + expect(input().selectionStart).toBe(4) + }) + }) + + describe('mask', () => { + it('should render slots as the mask character while keeping the real value', () => { + otp().setAttribute('data-bs-mask', 'true') + const instance = new OtpInput(otp()) + instance.setValue('123456') + expect(instance.getValue()).toBe('123456') + expect(slots().every((slot) => slot.textContent === '•')).toBe(true) + }) + + it('should never switch the input to type=password', () => { + otp().setAttribute('data-bs-mask', 'true') + new OtpInput(otp()) + expect(input().type).toBe('text') + }) + }) + + describe('groups and separator', () => { + it('should render one separator between two groups', () => { + otp().setAttribute('data-bs-groups', '[3,3]') + new OtpInput(otp()) + expect(slots()).toHaveLength(6) + expect(fixtureEl.querySelectorAll('.otp-separator')).toHaveLength(1) + }) + + it('should use a custom separator character', () => { + otp().setAttribute('data-bs-groups', '[3,3]') + otp().setAttribute('data-bs-separator', '-') + new OtpInput(otp()) + expect(fixtureEl.querySelector('.otp-separator')!.textContent).toBe('-') + }) + }) + + describe('events', () => { + it('should trigger complete.bs.otpInput when the value fills every slot', () => { + new OtpInput(otp()) + const spy = vi.fn() + otp().addEventListener('complete.bs.otpInput', spy) + + typeInto(input(), '123456') + + expect(spy).toHaveBeenCalledTimes(1) + expect((spy.mock.calls[0][0] as Event & { value: string }).value).toBe('123456') + }) + + it('should trigger input.bs.otpInput on every change', () => { + new OtpInput(otp()) + const spy = vi.fn() + otp().addEventListener('input.bs.otpInput', spy) + + typeInto(input(), '12') + + expect(spy).toHaveBeenCalledTimes(1) + expect((spy.mock.calls[0][0] as Event & { value: string }).value).toBe('12') + }) + + it('should not trigger input.bs.otpInput from setValue', () => { + const instance = new OtpInput(otp()) + const spy = vi.fn() + otp().addEventListener('input.bs.otpInput', spy) + + instance.setValue('123456') + + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe('dispose', () => { + it('should remove the rendered slots and instance', () => { + const instance = new OtpInput(otp()) + instance.dispose() + + expect(OtpInput.getInstance(otp())).toBeNull() + expect(fixtureEl.querySelectorAll('.otp-slot')).toHaveLength(0) + expect(otp().classList.contains('otp-rendered')).toBe(false) + }) + + it('should stop reacting to input after dispose', () => { + const instance = new OtpInput(otp()) + instance.dispose() + + const event = beforeInput(input(), { inputType: 'insertText', data: '1' }) + expect(event.defaultPrevented).toBe(false) + }) + }) +}) diff --git a/core/package.json b/core/package.json index 0207a0c97..c8359a112 100644 --- a/core/package.json +++ b/core/package.json @@ -86,7 +86,7 @@ }, { "path": "./dist/css/tabler.min.css", - "maxSize": "78 kB" + "maxSize": "79 kB" }, { "path": "./dist/css/tabler.rtl.css", @@ -94,7 +94,7 @@ }, { "path": "./dist/css/tabler.rtl.min.css", - "maxSize": "78 kB" + "maxSize": "79 kB" }, { "path": "./dist/css/tabler-flags.css", diff --git a/core/scss/_core.scss b/core/scss/_core.scss index c239acc9e..47a6d3a8c 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -66,6 +66,7 @@ @forward 'ui/forms/form-selectgroup'; @forward 'ui/forms/form-custom'; @forward 'ui/forms/form-check'; +@forward 'ui/forms/form-otp'; @forward 'ui/forms/validation'; @forward 'ui/forms/strength'; @forward 'ui/legend'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 34d31d447..91e46f487 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -1232,6 +1232,20 @@ $input-height-sm: add($input-line-height-sm, add(calc($input-padding-y-sm * 2), $input-height-lg: add($input-line-height-lg, add(calc($input-padding-y-lg * 2), $input-height-border, false)) !default; // scss-docs-end input-variables +// OTP input +// scss-docs-start otp-variables +$otp-size: $input-height !default; +$otp-font-size: $input-font-size !default; +$otp-gap: 0.5rem !default; +$otp-slot-color: var(--body-color) !default; +$otp-slot-bg: $input-bg !default; +$otp-slot-border-width: $input-border-width !default; +$otp-slot-border-color: var(--border-color) !default; +$otp-slot-border-radius: $input-border-radius !default; +$otp-slot-box-shadow: var(--shadow-input) !default; +$otp-separator-color: var(--secondary) !default; +// scss-docs-end otp-variables + // Cards // scss-docs-start card-variables $card-spacer-y: 1.25rem !default; diff --git a/core/scss/layout/_accessibility.scss b/core/scss/layout/_accessibility.scss index 8a211f7da..047720ee7 100644 --- a/core/scss/layout/_accessibility.scss +++ b/core/scss/layout/_accessibility.scss @@ -28,7 +28,8 @@ .btn-animate-icon-tada .icon, .wave-1, .wave-2, - .wave-3 { + .wave-3, + .otp-slot-active:not(.otp-slot-filled)::after { animation: none !important; } diff --git a/core/scss/ui/forms/_form-otp.scss b/core/scss/ui/forms/_form-otp.scss new file mode 100644 index 000000000..a66af6f05 --- /dev/null +++ b/core/scss/ui/forms/_form-otp.scss @@ -0,0 +1,169 @@ +@use '../../config' as *; + +.otp { + --otp-size: #{$otp-size}; + --otp-font-size: #{$otp-font-size}; + --otp-gap: #{$otp-gap}; + --otp-slot-color: #{$otp-slot-color}; + --otp-slot-bg: #{$otp-slot-bg}; + --otp-slot-border-width: #{$otp-slot-border-width}; + --otp-slot-border-color: #{$otp-slot-border-color}; + --otp-slot-border-radius: #{$otp-slot-border-radius}; + --otp-slot-box-shadow: #{$otp-slot-box-shadow}; + --otp-separator-color: #{$otp-separator-color}; + position: relative; + display: inline-flex; + gap: var(--otp-gap); + align-items: center; + min-height: var(--otp-size); + // `.otp` is the only content on its line, so its own baseline (derived from + // whichever slot the flex baseline algorithm picks) never has to matter - + // otherwise an empty vs. active slot resolve to different baselines and the + // line box around `.otp` grows or shrinks by a few pixels between them. + vertical-align: bottom; +} + +// Before the script renders the slots, the input keeps a `.form-control`-like +// look and the `.otp` wrapper's `min-height` already matches it - no layout +// shift once `.otp-rendered .otp-input` below takes over. +.otp > input { + width: 100%; + height: var(--otp-size); + padding-inline: 0.75rem; + font-size: var(--otp-font-size); + color: var(--otp-slot-color); + background-color: var(--otp-slot-bg); + border: var(--otp-slot-border-width) solid var(--otp-slot-border-color); + box-shadow: var(--otp-slot-box-shadow); + @include border-radius(var(--otp-slot-border-radius)); +} + +// The real input is a transparent overlay on top of the rendered slots, so +// screen readers, password managers and SMS autofill see one ordinary field. +.otp-rendered .otp-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + color: transparent; + cursor: text; + caret-color: transparent; + background-color: transparent; + border: 0; + // The base `.otp > input` rule sets this for the pre-render fallback look; + // the overlay itself must stay invisible, or its shadow spans every slot. + box-shadow: none; + + // The active slot draws its own focus ring; the native outline would + // otherwise wrap the whole overlay input, spanning every slot at once. + &:focus { + outline: 0; + } + + &::selection { + background-color: transparent; + } +} + +.otp-slots { + display: inline-flex; + gap: var(--otp-gap); + align-items: center; + pointer-events: none; +} + +.otp-slot { + position: relative; + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: var(--otp-size); + height: var(--otp-size); + font-size: var(--otp-font-size); + color: var(--otp-slot-color); + background-color: var(--otp-slot-bg); + border: var(--otp-slot-border-width) solid var(--otp-slot-border-color); + box-shadow: var(--otp-slot-box-shadow); + @include border-radius(var(--otp-slot-border-radius)); + transition: + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; +} + +.otp-slot-active { + // Bring the focused slot to the front so its ring isn't clipped by the + // next slot in `.otp-connected`, where slots overlap by their border width. + z-index: 1; + + @include focus-ring($show-border: true); + + &:not(.otp-slot-filled)::after { + display: block; + width: 1px; + height: 1.2em; + content: ''; + background-color: var(--otp-slot-color); + animation: otp-caret-blink 1s step-end infinite; + } +} + +.otp-input:disabled ~ .otp-slots .otp-slot { + color: var(--disabled-color); + background-color: $input-disabled-bg; +} + +// Zero gap with shared, merged borders; only the first and last slot keep a +// rounded corner. Not meant to be combined with `groups`/`separator`. +.otp-connected { + --otp-gap: 0; + + .otp-slot { + margin-inline-start: calc(var(--otp-slot-border-width) * -1); + border-radius: 0; + + &:first-child { + margin-inline-start: 0; + border-start-start-radius: var(--otp-slot-border-radius); + border-end-start-radius: var(--otp-slot-border-radius); + } + + &:last-child { + border-start-end-radius: var(--otp-slot-border-radius); + border-end-end-radius: var(--otp-slot-border-radius); + } + } +} + +.otp-separator { + display: flex; + flex: none; + align-items: center; + justify-content: center; + padding-inline: calc(var(--otp-gap) / 2); + color: var(--otp-separator-color); +} + +.otp-sm { + --otp-size: #{$input-height-sm}; + --otp-font-size: #{$input-font-size-sm}; +} + +.otp-lg { + --otp-size: #{$input-height-lg}; + --otp-font-size: #{$input-font-size-lg}; +} + +@keyframes otp-caret-blink { + 0%, + 50% { + opacity: 1; + } + + 50.01%, + 100% { + opacity: 0; + } +} diff --git a/core/scss/ui/forms/_validation.scss b/core/scss/ui/forms/_validation.scss index d29c93fdd..3e8edb4af 100644 --- a/core/scss/ui/forms/_validation.scss +++ b/core/scss/ui/forms/_validation.scss @@ -1,3 +1,4 @@ +@use 'sass:map'; @use '../../config' as *; %validation-lite { @@ -70,4 +71,8 @@ textarea.form-control.is-#{$state} { @extend %validation-icon-size-textarea; } + + .otp.is-#{$state} .otp-slot { + --otp-slot-border-color: #{map.get($data, 'border-color')}; + } } diff --git a/docs/content/ui/forms/otp-input.mdx b/docs/content/ui/forms/otp-input.mdx new file mode 100644 index 000000000..206cf17f5 --- /dev/null +++ b/docs/content/ui/forms/otp-input.mdx @@ -0,0 +1,172 @@ +--- +title: OTP input +seoTitle: OTP input field +order: 13 +summary: A single input rendered as separate character slots, so a one-time password stays one real field for autofill, password managers and screen readers. +description: Add a one-time password (OTP) input with grouped slots, masking, and keyboard, paste and SMS autofill support. +related: [/ui/forms/elements, /ui/forms/validation] +source: core/scss/ui/forms/_form-otp.scss +classnames: + component: + - class: otp + desc: Wrapper; holds the real input and the rendered slots + part: + - class: otp-slots + desc: Container for the rendered, decorative slots + - class: otp-slot + desc: One character slot + - class: otp-separator + desc: Divider shown between groups + modifier: + - class: otp-connected + desc: Merges the slots into one bar with shared borders and no gap + size: + - class: otp-sm + desc: Smaller slots + - class: otp-lg + desc: Larger slots +--- + +import Example from '@components/Example.astro'; +import CodeDocs from '@components/CodeDocs.astro'; + +## Overview + +An OTP input looks like several boxes, but it is one real ``. JavaScript renders one `.otp-slot` per character on top of it and turns the field itself into a transparent overlay, so screen readers, password managers and SMS autofill still see a single ordinary text field. + +Add `data-bs-toggle="otp"` to a wrapper with a text input inside, and Tabler renders the slots for you. + + +
+ +
+
+ +## Usage + +### Connected slots + +Add `.otp-connected` to merge the slots into one bar with shared borders and no gap between them. + + +
+ +
+
+ +### Groups and a separator + +Use `data-bs-groups` to split the slots into visual groups, for example `[3,3]` for a "123 456" layout. `data-bs-separator` sets the character shown between groups; it defaults to `·`. + + +
+ +
+
+ +### Sizes + +Add `.otp-sm` or `.otp-lg` for a smaller or larger control. + + +
+ +
+
+ +
+
+ +### Alphanumeric and masked + +Set `data-bs-type` to `alphanumeric` or `alpha` to accept letters as well as digits. Add `data-bs-mask="true"` to show a mask character instead of the typed value - `getValue()` still returns the real value. + + +
+ +
+
+ +### Disabled + +Add `disabled` to the inner input. + + +
+ +
+
+ +### Validation + +Add `.is-valid` or `.is-invalid` to the `.otp` wrapper, the same way you would on a `.form-control`. + + +
+ +
+
+ +## Options + +Options are read from `data-bs-*` attributes on the `.otp` element (the `data-tblr-*` alias also works), or passed to the constructor. + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `length` | number | `null` | Number of slots. When `null`, it is read from the input's `maxlength`, or `6`. | +| `type` | string | `'numeric'` | `numeric`, `alphanumeric`, or `alpha` - which characters each slot accepts. | +| `mask` | boolean | `false` | Shows a mask character in filled slots instead of the real value. | +| `groups` | array or null | `null` | Slot counts per visual group, for example `[3, 3]`. | +| `separator` | string | `'·'` | Character shown between groups. | + +## JavaScript + +Tabler creates an `OtpInput` for every element matching `[data-bs-toggle="otp"]` (or `[data-tblr-toggle="otp"]`): + + + +Create one manually, or get an existing instance: + +```javascript +const el = document.querySelector('.otp') +const otp = tabler.OtpInput.getOrCreateInstance(el, { length: 6, type: 'numeric' }) +``` + +Listen for `complete.bs.otpInput` to know when every slot is filled: + +```javascript +el.addEventListener('complete.bs.otpInput', (event) => { + console.log(event.value) +}) +``` + +### Methods + +| Method | Description | +| --- | --- | +| `getValue()` | Returns the current value. | +| `setValue(value)` | Sets the value and re-renders the slots. | +| `clear()` | Empties the value and focuses the input. | +| `focus()` | Focuses the input and selects the first empty slot. | +| `dispose()` | Removes the rendered slots and the instance. | +| `getInstance(element)` | Static. Returns the instance bound to the element, or `null`. | +| `getOrCreateInstance(element, config)` | Static. Returns the existing instance, or creates one. | + +### Events + +| Event | Description | +| --- | --- | +| `input.bs.otpInput` | Fires on every change from typing, backspace, paste, or autofill. `event.value` holds the current value. | +| `complete.bs.otpInput` | Fires once the value fills every slot, including from `setValue()`. `event.value` holds the full value. | + +## Accessibility + +- The visible slots are decoration (`aria-hidden`); a screen reader only ever sees the one real input, so label it like any other field with `aria-label` or a `