diff --git a/package-lock.json b/package-lock.json index 27d85cb4b..a03d894f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11167,6 +11167,10 @@ "resolved": "packages/uui-input-lock", "link": true }, + "node_modules/@umbraco-ui/uui-input-otp": { + "resolved": "packages/uui-input-otp", + "link": true + }, "node_modules/@umbraco-ui/uui-input-password": { "resolved": "packages/uui-input-password", "link": true @@ -33079,6 +33083,13 @@ "@umbraco-ui/uui-input": "1.8.0" } }, + "packages/uui-input-otp": { + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@umbraco-ui/uui-base": "1.8.0" + } + }, "packages/uui-input-password": { "name": "@umbraco-ui/uui-input-password", "version": "1.8.0", diff --git a/packages/uui-input-otp/README.md b/packages/uui-input-otp/README.md new file mode 100644 index 000000000..678a69cfc --- /dev/null +++ b/packages/uui-input-otp/README.md @@ -0,0 +1,31 @@ +# uui-input-otp + +![npm](https://img.shields.io/npm/v/@umbraco-ui/uui-input-otp?logoColor=%231B264F) + +Umbraco style input-otp component. + +## Installation + +### ES imports + +```zsh +npm i @umbraco-ui/uui-input-otp +``` + +Import the registration of `` via: + +```javascript +import '@umbraco-ui/uui-input-otp'; +``` + +When looking to leverage the `UUIInputOtpElement` base class as a type and/or for extension purposes, do so via: + +```javascript +import { UUIInputOtpElement } from '@umbraco-ui/uui-input-otp'; +``` + +## Usage + +```html + +``` diff --git a/packages/uui-input-otp/lib/index.ts b/packages/uui-input-otp/lib/index.ts new file mode 100644 index 000000000..dc4dbf594 --- /dev/null +++ b/packages/uui-input-otp/lib/index.ts @@ -0,0 +1 @@ +export * from './uui-input-otp.element'; diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts new file mode 100644 index 000000000..20fdaa5e4 --- /dev/null +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -0,0 +1,328 @@ +import { + LabelMixin, + UUIFormControlMixin, +} from '@umbraco-ui/uui-base/lib/mixins'; +import { defineElement } from '@umbraco-ui/uui-base/lib/registration'; +import { UUIInputEvent, type InputType } from '@umbraco-ui/uui-input/lib'; + +import { css, html, LitElement } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; + +/** + * @element uui-input-otp + */ +@defineElement('uui-input-otp') +export class UUIInputOtpElement extends UUIFormControlMixin( + LabelMixin('', LitElement), + '', +) { + /** + * This is a static class field indicating that the element is can be used inside a native form and participate in its events. It may require a polyfill, check support here https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/attachInternals. Read more about form controls here https://web.dev/more-capable-form-controls/ + */ + static readonly formAssociated = true; + + /** + * Accepts only numbers + * @default false + * @attr + */ + @property({ type: Boolean, attribute: 'integer-only' }) + set integerOnly(value: boolean) { + this.inputMode = value ? 'numeric' : 'text'; + } + get integerOnly() { + return this.inputMode === 'numeric'; + } + + /** + * If true, the input will be masked + * @default false + * @attr + */ + @property({ type: Boolean }) + set masked(value: boolean) { + this._input = value ? 'password' : 'text'; + } + get masked() { + return this._input === 'password'; + } + + /** + * The number of characters in the input + * @default 6 + * @attr + */ + @property({ type: Number }) + length = 6; + + /** + * The template for the item label + */ + @property({ type: String, attribute: false }) + itemLabelTemplate = (index: number) => `Character number ${index + 1}`; + + /** + * Set to true to make this input readonly. + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + readonly = false; + + /** + * Set to true to disable this input. + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Set to true to autofocus this input. + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'autofocus' }) + autoFocus = false; + + /** + * Add a placeholder to the inputs in the group + * @remark The placeholder should be a string with the same length as the `length` attribute and will be distributed to each input in the group + * @attr + * @default '' + */ + @property() + placeholder = ''; + + /** + * The autocomplete attribute specifies whether or not an input field should have autocomplete enabled. + * @remark Set the autocomplete attribute to "one-time-code" to enable autofill of one-time-code inputs + * @attr + * @default '' + * @type {string} + */ + @property({ type: String, reflect: true }) + autocomplete?: string; + + /** + * Min length validation message. + * @attr + * @default + */ + @property({ type: String, attribute: 'minlength-message' }) + minlengthMessage = 'This field need more characters'; + + @state() + _input: InputType = 'text'; + + @state() + _tokens: string[] = []; + + set value(value: string) { + this._tokens = value.split(''); + + super.value = value; + this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE)); + } + get value() { + return super.value.toString(); + } + + constructor() { + super(); + this.addEventListener('paste', this.onPaste.bind(this)); + + this.addValidator( + 'tooShort', + () => this.minlengthMessage, + () => !!this.length && String(this.value).length < this.length, + ); + } + + protected getFormElement(): HTMLElement | null | undefined { + return this; + } + + protected onFocus(event: FocusEvent) { + (event.target as HTMLInputElement)?.select(); + this.dispatchEvent(event); + } + + protected onBlur(event: FocusEvent) { + this.dispatchEvent(event); + } + + protected onInput(event: InputEvent, index: number) { + const target = event.target as HTMLInputElement; + this._tokens[index] = target?.value; + this.value = this._tokens.join(''); + + if (event.inputType === 'deleteContentBackward') { + this.moveToPrev(event); + } else if ( + event.inputType === 'insertText' || + event.inputType === 'deleteContentForward' + ) { + this.moveToNext(event); + } + } + + protected onKeyDown(event: KeyboardEvent) { + if (event.ctrlKey || event.metaKey) { + return; + } + + switch (event.code) { + case 'ArrowLeft': + this.moveToPrev(event); + event.preventDefault(); + + break; + + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + + break; + + case 'Backspace': + if ((event.target as HTMLInputElement)?.value.length === 0) { + this.moveToPrev(event); + event.preventDefault(); + } + + break; + + case 'ArrowRight': + this.moveToNext(event); + event.preventDefault(); + + break; + + default: + if ( + (this.integerOnly && + !(Number(event.key) >= 0 && Number(event.key) <= 9)) || + (this._tokens.join('').length >= this.length && + event.code !== 'Delete') + ) { + event.preventDefault(); + } + + break; + } + } + + protected onPaste(event: ClipboardEvent) { + const paste = event.clipboardData?.getData('text'); + + if (paste?.length) { + const pastedCode = paste.substring(0, this.length + 1); + + if (!this.integerOnly || !isNaN(pastedCode as any)) { + this.value = pastedCode; + } + } + + event.preventDefault(); + } + + protected moveToPrev(event: Event) { + if (!event.target) return; + const prevInput = this.findPrevInput(event.target); + + if (prevInput) { + prevInput.focus(); + prevInput.select(); + } + } + + protected moveToNext(event: Event) { + if (!event.target) return; + const nextInput = this.findNextInput(event.target); + + if (nextInput) { + nextInput.focus(); + nextInput.select(); + } + } + + protected findNextInput(element: EventTarget): HTMLInputElement | null { + const nextElement = (element as Element).nextElementSibling; + + if (!nextElement) return null; + + return nextElement.nodeName === 'INPUT' + ? (nextElement as HTMLInputElement) + : this.findNextInput(nextElement); + } + + protected findPrevInput(element: EventTarget): HTMLInputElement | null { + const prevElement = (element as Element).previousElementSibling; + + if (!prevElement) return null; + + return prevElement.nodeName === 'INPUT' + ? (prevElement as HTMLInputElement) + : this.findPrevInput(prevElement); + } + + protected renderInput(index: number) { + return html` + this.onInput(e, index)} + @keydown=${this.onKeyDown} /> + `; + } + + render() { + return html` +
+ ${repeat(Array.from({ length: this.length }), (_, i) => + this.renderInput(i), + )} +
+ `; + } + + static readonly styles = [ + css` + :host(:not([pristine]):invalid) .otp-input, + :host(:not([pristine])) .otp-input:invalid, + /* polyfill support */ + :host(:not([pristine])[internals-invalid]) .otp-input:invalid { + border-color: var(--uui-color-danger); + } + + #otp-input-group { + display: flex; + border: 0; /* Reset fieldset */ + } + + .otp-input { + width: 3em; + height: 3em; + text-align: center; + font-size: 1.5em; + margin-right: 0.5em; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'uui-input-otp': UUIInputOtpElement; + } +} diff --git a/packages/uui-input-otp/lib/uui-input-otp.test.ts b/packages/uui-input-otp/lib/uui-input-otp.test.ts new file mode 100644 index 000000000..2a472706c --- /dev/null +++ b/packages/uui-input-otp/lib/uui-input-otp.test.ts @@ -0,0 +1,143 @@ +import { html, fixture, expect } from '@open-wc/testing'; +import { UUIInputOtpElement } from './uui-input-otp.element'; + +describe('UUIInputOtpElement', () => { + let element: UUIInputOtpElement; + + beforeEach(async () => { + element = await fixture(html` + + `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UUIInputOtpElement); + }); + + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(); + }); + + describe('properties', () => { + it('has a default length of 6', () => { + expect(element.length).to.equal(6); + expect(element.shadowRoot?.querySelectorAll('input').length).to.equal(6); + }); + + it('can set the length', async () => { + element.length = 4; + await element.updateComplete; + expect(element.shadowRoot?.querySelectorAll('input').length).to.equal(4); + }); + + it('can set integerOnly', async () => { + element.integerOnly = true; + await element.updateComplete; + expect(element.inputMode).to.equal('numeric'); + + element.integerOnly = false; + await element.updateComplete; + expect(element.inputMode).to.equal('text'); + }); + + it('can set masked', async () => { + element.masked = true; + await element.updateComplete; + expect(element._input).to.equal('password'); + + element.masked = false; + await element.updateComplete; + expect(element._input).to.equal('text'); + }); + + it('can set readonly', async () => { + element.readonly = true; + await element.updateComplete; + expect(element.hasAttribute('readonly')).to.be.true; + + element.readonly = false; + await element.updateComplete; + expect(element.hasAttribute('readonly')).to.be.false; + }); + + it('can set disabled', async () => { + element.disabled = true; + await element.updateComplete; + expect(element.hasAttribute('disabled')).to.be.true; + + element.disabled = false; + await element.updateComplete; + expect(element.hasAttribute('disabled')).to.be.false; + }); + + it('can set autofocus', async () => { + element.autofocus = true; + await element.updateComplete; + expect(element.hasAttribute('autofocus')).to.be.true; + + element.autofocus = false; + await element.updateComplete; + expect(element.hasAttribute('autofocus')).to.be.false; + }); + + it('can set required', async () => { + element.required = true; + await element.updateComplete; + expect(element.hasAttribute('required')).to.be.true; + + element.required = false; + await element.updateComplete; + expect(element.hasAttribute('required')).to.be.false; + }); + + it('can set error', async () => { + element.error = true; + await element.updateComplete; + expect(element.hasAttribute('error')).to.be.true; + + element.error = false; + await element.updateComplete; + expect(element.hasAttribute('error')).to.be.false; + }); + + it('can set autocomplete', async () => { + element.autocomplete = 'one-time-code'; + await element.updateComplete; + expect(element.getAttribute('autocomplete')).to.equal('one-time-code'); + }); + }); + + describe('logic', () => { + it('can distribute a value', async () => { + element.value = '123456'; + await element.updateComplete; + const inputs = element.shadowRoot?.querySelectorAll('input'); + if (!inputs) { + throw new Error('inputs not found'); + } + + expect(inputs[0].value).to.equal('1'); + expect(inputs[1].value).to.equal('2'); + expect(inputs[2].value).to.equal('3'); + expect(inputs[3].value).to.equal('4'); + expect(inputs[4].value).to.equal('5'); + expect(inputs[5].value).to.equal('6'); + }); + + it('can distribute a value with a different length', async () => { + element.length = 4; + element.value = '123456'; + await element.updateComplete; + + const inputs = element.shadowRoot?.querySelectorAll('input'); + if (!inputs) { + throw new Error('inputs not found'); + } + + expect(inputs[0].value).to.equal('1'); + expect(inputs[1].value).to.equal('2'); + expect(inputs[2].value).to.equal('3'); + expect(inputs[3].value).to.equal('4'); + }); + }); +}); diff --git a/packages/uui-input-otp/package.json b/packages/uui-input-otp/package.json new file mode 100644 index 000000000..e587c922f --- /dev/null +++ b/packages/uui-input-otp/package.json @@ -0,0 +1,44 @@ +{ + "name": "@umbraco-ui/uui-input-otp", + "version": "0.0.0", + "license": "MIT", + "keywords": [ + "Umbraco", + "Custom elements", + "Web components", + "UI", + "Lit", + "Input Otp" + ], + "description": "Umbraco UI input-otp component", + "repository": { + "type": "git", + "url": "https://github.com/umbraco/Umbraco.UI.git", + "directory": "packages/uui-input-otp" + }, + "bugs": { + "url": "https://github.com/umbraco/Umbraco.UI/issues" + }, + "main": "./lib/index.js", + "module": "./lib/index.js", + "types": "./lib/index.d.ts", + "type": "module", + "customElements": "custom-elements.json", + "files": [ + "lib/**/*.d.ts", + "lib/**/*.js", + "custom-elements.json" + ], + "dependencies": { + "@umbraco-ui/uui-base": "1.8.0" + }, + "scripts": { + "build": "npm run analyze && tsc --build --force && rollup -c rollup.config.js", + "clean": "tsc --build --clean && rimraf -g dist lib/*.js lib/**/*.js *.tgz lib/**/*.d.ts custom-elements.json", + "analyze": "web-component-analyzer **/*.element.ts --outFile custom-elements.json" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://uui.umbraco.com/?path=/story/uui-input-otp" +} diff --git a/packages/uui-input-otp/rollup.config.js b/packages/uui-input-otp/rollup.config.js new file mode 100644 index 000000000..34524a90d --- /dev/null +++ b/packages/uui-input-otp/rollup.config.js @@ -0,0 +1,5 @@ +import { UUIProdConfig } from '../rollup-package.config.mjs'; + +export default UUIProdConfig({ + entryPoints: ['index'], +}); diff --git a/packages/uui-input-otp/stories/uui-input-otp.automatic-otp.mdx b/packages/uui-input-otp/stories/uui-input-otp.automatic-otp.mdx new file mode 100644 index 000000000..733a31945 --- /dev/null +++ b/packages/uui-input-otp/stories/uui-input-otp.automatic-otp.mdx @@ -0,0 +1,48 @@ +import { Meta, Canvas, Story } from '@storybook/addon-docs/blocks'; + +import * as OtpStories from './uui-input-otp.story'; + + + +# OTP With Autocomplete + +The OTP input field can be used with an autocomplete feature. This feature is useful to utilise the browser's autocomplete feature to autofill the OTP code. + +## Usage + +First you need to configure the input field to autocomplete the OTP code. This can be done by setting the `autocomplete` attribute to `one-time-code`. + + + +This allows browsers that support the `one-time-code` autocomplete to autofill the OTP code. It is mostly used in Safari. + +Safari does not yet support the Web OTP API, so this is a workaround to autofill the OTP code. + +Other browsers that support the Web OTP API will not autofill the OTP code, as the Web OTP API is more secure than the `one-time-code` autocomplete. + +Therefore, we need to listen for Web OTP API events to autofill the OTP code in browsers that support it. +We can add the following code to listen for the Web OTP API events: + +```js +// Feature detect the Web OTP API +if ('OTPCredential' in window) { + // Request the OTP credential + navigator.credentials + .get({ + otp: { transport: ['sms'] }, // This can be 'sms' or other transports + }) + .then(cred => { + // Locate the OTP input from before + const otpInput = document.getElementById('otp-input'); + + // Autofill the OTP input if the credential is available and the OTP input is found + if (cred && otpInput) { + this.otpInput.value = cred.id; + } + }) + .catch(() => { + // optionally catch errors, which could mean the OTP retrieval has timed out + // in most cases, it is not necessary to handle this error + }); +} +``` diff --git a/packages/uui-input-otp/stories/uui-input-otp.story.ts b/packages/uui-input-otp/stories/uui-input-otp.story.ts new file mode 100644 index 000000000..a8cf61bd7 --- /dev/null +++ b/packages/uui-input-otp/stories/uui-input-otp.story.ts @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; + +import '../lib/uui-input-otp.element'; +import type { UUIInputOtpElement } from '../lib/uui-input-otp.element'; +import readme from '../README.md?raw'; + +const meta: Meta = { + id: 'uui-input-otp', + title: 'Inputs/Input Otp', + component: 'uui-input-otp', + parameters: { + readme: { markdown: readme }, + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Overview: Story = {}; + +export const IntegerOnly: Story = { + args: { + integerOnly: true, + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export const Masked: Story = { + args: { + masked: true, + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export const Required: Story = { + args: { + required: true, + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export const WithError: Story = { + args: { + error: true, + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export const AutocompleteOtp: Story = { + name: 'Autocomplete OTP', + args: { + autocomplete: 'one-time-code', + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; diff --git a/packages/uui-input-otp/tsconfig.json b/packages/uui-input-otp/tsconfig.json new file mode 100644 index 000000000..40d176776 --- /dev/null +++ b/packages/uui-input-otp/tsconfig.json @@ -0,0 +1,17 @@ +// Don't edit this file directly. It is generated by /scripts/generate-ts-config.js + +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./lib", + "composite": true + }, + "include": ["./**/*.ts"], + "exclude": ["./**/*.test.ts", "./**/*.story.ts"], + "references": [ + { + "path": "../uui-base" + } + ] +} diff --git a/packages/uui/lib/index.ts b/packages/uui/lib/index.ts index 7a6290236..6a2d7fe33 100644 --- a/packages/uui/lib/index.ts +++ b/packages/uui/lib/index.ts @@ -36,6 +36,7 @@ export * from '@umbraco-ui/uui-icon-registry/lib'; export * from '@umbraco-ui/uui-icon/lib'; export * from '@umbraco-ui/uui-input-file/lib'; export * from '@umbraco-ui/uui-input-lock/lib'; +export * from '@umbraco-ui/uui-input-otp/lib/index.js'; export * from '@umbraco-ui/uui-input-password/lib'; export * from '@umbraco-ui/uui-input/lib'; export * from '@umbraco-ui/uui-keyboard-shortcut/lib';