From ea13f67f376d64791a5946541e586fea23c0db81 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 11:24:31 +0200 Subject: [PATCH 01/19] feat: add new component 'uui-input-otp' for one time codes --- package-lock.json | 11 + packages/uui-input-otp/README.md | 31 ++ packages/uui-input-otp/lib/index.ts | 1 + .../lib/uui-input-otp.element.ts | 295 ++++++++++++++++++ .../uui-input-otp/lib/uui-input-otp.story.ts | 76 +++++ .../uui-input-otp/lib/uui-input-otp.test.ts | 18 ++ packages/uui-input-otp/package.json | 44 +++ packages/uui-input-otp/rollup.config.js | 5 + packages/uui-input-otp/tsconfig.json | 17 + packages/uui/lib/index.ts | 1 + 10 files changed, 499 insertions(+) create mode 100644 packages/uui-input-otp/README.md create mode 100644 packages/uui-input-otp/lib/index.ts create mode 100644 packages/uui-input-otp/lib/uui-input-otp.element.ts create mode 100644 packages/uui-input-otp/lib/uui-input-otp.story.ts create mode 100644 packages/uui-input-otp/lib/uui-input-otp.test.ts create mode 100644 packages/uui-input-otp/package.json create mode 100644 packages/uui-input-otp/rollup.config.js create mode 100644 packages/uui-input-otp/tsconfig.json 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..f7579a41e --- /dev/null +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -0,0 +1,295 @@ +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'; + } + + /** + * 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; + + @property() + placeholder = ''; + + set value(value: string) { + this._tokens = value.split(''); + this.requestUpdate('_tokens'); + } + + /** + * The number of characters in the input + * @default 6 + * @attr + */ + @property({ type: Number }) + length = 6; + + @state() + _input: InputType = 'text'; + + @state() + _tokens: string[] = []; + + constructor() { + super(); + this.addEventListener('paste', this.onPaste); + } + + protected getFormElement(): HTMLElement | null | undefined { + return this.shadowRoot?.querySelector('.otp-input'); + } + + 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.#updateValue(); + + 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._tokens = pastedCode.split(''); + this.#updateValue(); + } + } + + 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); + } + + #updateValue() { + const newValue = this._tokens.join(''); + if (this.value !== newValue) { + this.value = newValue; + this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE)); + } + } + + 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 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.story.ts b/packages/uui-input-otp/lib/uui-input-otp.story.ts new file mode 100644 index 000000000..03018578a --- /dev/null +++ b/packages/uui-input-otp/lib/uui-input-otp.story.ts @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; + +import './uui-input-otp.element'; +import type { UUIInputOtpElement } from './uui-input-otp.element'; +import readme from '../README.md?raw'; + +const meta: Meta = { + id: 'uui-input-otp', + title: '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 Error: Story = { + args: { + error: true, + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; 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..0d4dc59a0 --- /dev/null +++ b/packages/uui-input-otp/lib/uui-input-otp.test.ts @@ -0,0 +1,18 @@ +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(); + }); +}); 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/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'; From 3c26c7fca8c8a77c3b3a42f2d574389765d8828b Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 11:45:30 +0200 Subject: [PATCH 02/19] feat: unify value/tokenisation setter --- .../uui-input-otp/lib/uui-input-otp.element.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index f7579a41e..454d58fbf 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -78,7 +78,9 @@ export class UUIInputOtpElement extends UUIFormControlMixin( set value(value: string) { this._tokens = value.split(''); - this.requestUpdate('_tokens'); + + super.value = value; + this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE)); } /** @@ -116,7 +118,7 @@ export class UUIInputOtpElement extends UUIFormControlMixin( protected onInput(event: InputEvent, index: number) { const target = event.target as HTMLInputElement; this._tokens[index] = target?.value; - this.#updateValue(); + this.value = this._tokens.join(''); if (event.inputType === 'deleteContentBackward') { this.moveToPrev(event); @@ -181,8 +183,7 @@ export class UUIInputOtpElement extends UUIFormControlMixin( const pastedCode = paste.substring(0, this.length + 1); if (!this.integerOnly || !isNaN(pastedCode as any)) { - this._tokens = pastedCode.split(''); - this.#updateValue(); + this.value = pastedCode; } } @@ -229,14 +230,6 @@ export class UUIInputOtpElement extends UUIFormControlMixin( : this.findPrevInput(prevElement); } - #updateValue() { - const newValue = this._tokens.join(''); - if (this.value !== newValue) { - this.value = newValue; - this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE)); - } - } - protected renderInput(index: number) { return html` Date: Fri, 21 Jun 2024 11:49:12 +0200 Subject: [PATCH 03/19] docs: add jsdoc for placeholder --- packages/uui-input-otp/lib/uui-input-otp.element.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index 454d58fbf..b6b0aac32 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -73,6 +73,12 @@ export class UUIInputOtpElement extends UUIFormControlMixin( @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 = ''; From 281209a82e4ce2e295f32ef68bd19428196bd172 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 11:55:42 +0200 Subject: [PATCH 04/19] feat: add autocomplete attribute --- packages/uui-input-otp/lib/uui-input-otp.element.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index b6b0aac32..dcd6cfbce 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -82,6 +82,16 @@ export class UUIInputOtpElement extends UUIFormControlMixin( @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 autocomplete + * @default '' + * @type {string} + */ + @property({ type: String, reflect: true, attribute: 'autocomplete' }) + autoComplete?: string; + set value(value: string) { this._tokens = value.split(''); From cd9751db69931aa9e109e2a6bf494e83bd388287 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 11:57:29 +0200 Subject: [PATCH 05/19] feat: rename "autoComplete" to "autocomplete" --- packages/uui-input-otp/lib/uui-input-otp.element.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index dcd6cfbce..77989bb19 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -85,12 +85,12 @@ export class UUIInputOtpElement extends UUIFormControlMixin( /** * 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 autocomplete + * @attr * @default '' * @type {string} */ - @property({ type: String, reflect: true, attribute: 'autocomplete' }) - autoComplete?: string; + @property({ type: String, reflect: true }) + autocomplete?: string; set value(value: string) { this._tokens = value.split(''); From a50bbe56ee908648e6c56f599a6843f4daaaffe7 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 12:16:00 +0200 Subject: [PATCH 06/19] docs: add storybook mdx for automatic otp --- .../stories/uui-input-otp.automatic-otp.mdx | 48 +++++++++++++++++++ .../{lib => stories}/uui-input-otp.story.ts | 20 ++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 packages/uui-input-otp/stories/uui-input-otp.automatic-otp.mdx rename packages/uui-input-otp/{lib => stories}/uui-input-otp.story.ts (75%) 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/lib/uui-input-otp.story.ts b/packages/uui-input-otp/stories/uui-input-otp.story.ts similarity index 75% rename from packages/uui-input-otp/lib/uui-input-otp.story.ts rename to packages/uui-input-otp/stories/uui-input-otp.story.ts index 03018578a..c48aa9635 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.story.ts +++ b/packages/uui-input-otp/stories/uui-input-otp.story.ts @@ -1,12 +1,12 @@ import type { Meta, StoryObj } from '@storybook/web-components'; -import './uui-input-otp.element'; -import type { UUIInputOtpElement } from './uui-input-otp.element'; +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: 'Input Otp', + title: 'Inputs/Input Otp', component: 'uui-input-otp', parameters: { readme: { markdown: readme }, @@ -74,3 +74,17 @@ export const Error: Story = { }, }, }; + +export const AutocompleteOtp: Story = { + name: 'Autocomplete OTP', + args: { + autocomplete: 'one-time-code', + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; From 766fe1d77de35c4419c2af41c70d00435ff3d526 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:05:54 +0200 Subject: [PATCH 07/19] chore: make styles property readonly --- packages/uui-input-otp/lib/uui-input-otp.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index 77989bb19..3f6ab6c7d 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -272,7 +272,7 @@ export class UUIInputOtpElement extends UUIFormControlMixin( `; } - static styles = [ + static readonly styles = [ css` :host(:not([pristine]):invalid) .otp-input, :host(:not([pristine])) .otp-input:invalid, From a748553184cc3b8c55d95f9e82916640c976d4fe Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:06:13 +0200 Subject: [PATCH 08/19] docs: find a better name than 'Error' --- packages/uui-input-otp/stories/uui-input-otp.story.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uui-input-otp/stories/uui-input-otp.story.ts b/packages/uui-input-otp/stories/uui-input-otp.story.ts index c48aa9635..a8cf61bd7 100644 --- a/packages/uui-input-otp/stories/uui-input-otp.story.ts +++ b/packages/uui-input-otp/stories/uui-input-otp.story.ts @@ -62,7 +62,7 @@ export const Required: Story = { }, }; -export const Error: Story = { +export const WithError: Story = { args: { error: true, }, From 4ff950cd4f0705a9cb65625390333e18574d5a1b Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:56:13 +0200 Subject: [PATCH 09/19] chore: sort props and states --- .../uui-input-otp/lib/uui-input-otp.element.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index 3f6ab6c7d..63c883332 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -92,13 +92,6 @@ export class UUIInputOtpElement extends UUIFormControlMixin( @property({ type: String, reflect: true }) autocomplete?: string; - set value(value: string) { - this._tokens = value.split(''); - - super.value = value; - this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE)); - } - /** * The number of characters in the input * @default 6 @@ -113,6 +106,13 @@ export class UUIInputOtpElement extends UUIFormControlMixin( @state() _tokens: string[] = []; + set value(value: string) { + this._tokens = value.split(''); + + super.value = value; + this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE)); + } + constructor() { super(); this.addEventListener('paste', this.onPaste); @@ -254,6 +254,7 @@ export class UUIInputOtpElement extends UUIFormControlMixin( .placeholder=${this.placeholder.charAt(index) || ''} type=${this._input} inputmode=${this.inputMode} + .ariaLabel=${this.label} ?readonly=${this.readonly} ?disabled=${this.disabled} ?autofocus=${this.autoFocus && index === 0} From 1a585e26d392c8fe7756918fea179c865e9cbd49 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:56:38 +0200 Subject: [PATCH 10/19] chore: put custom props at the top --- .../uui-input-otp/lib/uui-input-otp.element.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index 63c883332..a2f3e25f4 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -49,6 +49,14 @@ export class UUIInputOtpElement extends UUIFormControlMixin( return this._input === 'password'; } + /** + * The number of characters in the input + * @default 6 + * @attr + */ + @property({ type: Number }) + length = 6; + /** * Set to true to make this input readonly. * @attr @@ -92,14 +100,6 @@ export class UUIInputOtpElement extends UUIFormControlMixin( @property({ type: String, reflect: true }) autocomplete?: string; - /** - * The number of characters in the input - * @default 6 - * @attr - */ - @property({ type: Number }) - length = 6; - @state() _input: InputType = 'text'; From 9d09c704ea676343b3c51cc58ea455f733eb6b6e Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:03:06 +0200 Subject: [PATCH 11/19] fix: add an itemLabelTemplate to allow a11y for the individual elements --- packages/uui-input-otp/lib/uui-input-otp.element.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index a2f3e25f4..eebbe5317 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -57,6 +57,12 @@ export class UUIInputOtpElement extends UUIFormControlMixin( @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 @@ -250,14 +256,14 @@ export class UUIInputOtpElement extends UUIFormControlMixin( return html` this.onInput(e, index)} @keydown=${this.onKeyDown} /> `; From 598ebea1f07c4e333c6edb0f922046c5b4a690aa Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:03:17 +0200 Subject: [PATCH 12/19] test: add label to elements --- packages/uui-input-otp/lib/uui-input-otp.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.test.ts b/packages/uui-input-otp/lib/uui-input-otp.test.ts index 0d4dc59a0..063ff059e 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.test.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.test.ts @@ -5,7 +5,9 @@ describe('UUIInputOtpElement', () => { let element: UUIInputOtpElement; beforeEach(async () => { - element = await fixture(html` `); + element = await fixture(html` + + `); }); it('is defined with its own instance', () => { From 0a2ff2dff0321bd98b785b398fc9a98c984fb6d7 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:57:48 +0200 Subject: [PATCH 13/19] fix: add getter for value --- packages/uui-input-otp/lib/uui-input-otp.element.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index eebbe5317..c520ec23f 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -118,6 +118,9 @@ export class UUIInputOtpElement extends UUIFormControlMixin( super.value = value; this.dispatchEvent(new UUIInputEvent(UUIInputEvent.CHANGE)); } + get value() { + return super.value.toString(); + } constructor() { super(); From e84a74cdb06ce8ecb255e8acf397b0c2904d68ac Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:58:18 +0200 Subject: [PATCH 14/19] fix: bind paste event --- packages/uui-input-otp/lib/uui-input-otp.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index c520ec23f..d757c2abe 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -124,7 +124,7 @@ export class UUIInputOtpElement extends UUIFormControlMixin( constructor() { super(); - this.addEventListener('paste', this.onPaste); + this.addEventListener('paste', this.onPaste.bind(this)); } protected getFormElement(): HTMLElement | null | undefined { From 73bcb35cf1efe1bcd86f168796839d15d908cfd1 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:58:39 +0200 Subject: [PATCH 15/19] add validation for tooShort --- .../uui-input-otp/lib/uui-input-otp.element.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index d757c2abe..7cf50ac8c 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -106,6 +106,14 @@ export class UUIInputOtpElement extends UUIFormControlMixin( @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'; @@ -125,6 +133,12 @@ export class UUIInputOtpElement extends UUIFormControlMixin( 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 { From 5f6023d6c9b4a7510a86ccbc2cac6e396dc4c131 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:59:24 +0200 Subject: [PATCH 16/19] test: add tests for element --- .../uui-input-otp/lib/uui-input-otp.test.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/packages/uui-input-otp/lib/uui-input-otp.test.ts b/packages/uui-input-otp/lib/uui-input-otp.test.ts index 063ff059e..1c962c56a 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.test.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.test.ts @@ -17,4 +17,136 @@ describe('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; + expect(element.shadowRoot?.querySelectorAll('input')[0].value).to.equal( + '1', + ); + expect(element.shadowRoot?.querySelectorAll('input')[1].value).to.equal( + '2', + ); + expect(element.shadowRoot?.querySelectorAll('input')[2].value).to.equal( + '3', + ); + expect(element.shadowRoot?.querySelectorAll('input')[3].value).to.equal( + '4', + ); + expect(element.shadowRoot?.querySelectorAll('input')[4].value).to.equal( + '5', + ); + expect(element.shadowRoot?.querySelectorAll('input')[5].value).to.equal( + '6', + ); + }); + + it('can distribute a value with a different length', async () => { + element.length = 4; + element.value = '123456'; + await element.updateComplete; + expect(element.shadowRoot?.querySelectorAll('input')[0].value).to.equal( + '1', + ); + expect(element.shadowRoot?.querySelectorAll('input')[1].value).to.equal( + '2', + ); + expect(element.shadowRoot?.querySelectorAll('input')[2].value).to.equal( + '3', + ); + expect(element.shadowRoot?.querySelectorAll('input')[3].value).to.equal( + '4', + ); + }); + }); }); From 42647a365a204832a20362a072bb537c769afc73 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:04:43 +0200 Subject: [PATCH 17/19] test: avoid duplication --- .../uui-input-otp/lib/uui-input-otp.test.ts | 49 +++++++------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.test.ts b/packages/uui-input-otp/lib/uui-input-otp.test.ts index 1c962c56a..7b2611130 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.test.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.test.ts @@ -111,42 +111,31 @@ describe('UUIInputOtpElement', () => { it('can distribute a value', async () => { element.value = '123456'; await element.updateComplete; - expect(element.shadowRoot?.querySelectorAll('input')[0].value).to.equal( - '1', - ); - expect(element.shadowRoot?.querySelectorAll('input')[1].value).to.equal( - '2', - ); - expect(element.shadowRoot?.querySelectorAll('input')[2].value).to.equal( - '3', - ); - expect(element.shadowRoot?.querySelectorAll('input')[3].value).to.equal( - '4', - ); - expect(element.shadowRoot?.querySelectorAll('input')[4].value).to.equal( - '5', - ); - expect(element.shadowRoot?.querySelectorAll('input')[5].value).to.equal( - '6', - ); + 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; - expect(element.shadowRoot?.querySelectorAll('input')[0].value).to.equal( - '1', - ); - expect(element.shadowRoot?.querySelectorAll('input')[1].value).to.equal( - '2', - ); - expect(element.shadowRoot?.querySelectorAll('input')[2].value).to.equal( - '3', - ); - expect(element.shadowRoot?.querySelectorAll('input')[3].value).to.equal( - '4', - ); + + 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'); }); }); }); From 14ff014cf0233e1a2a6f3b74efff4454a279ea99 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:11:00 +0200 Subject: [PATCH 18/19] fix: getformelement --- packages/uui-input-otp/lib/uui-input-otp.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uui-input-otp/lib/uui-input-otp.element.ts b/packages/uui-input-otp/lib/uui-input-otp.element.ts index 7cf50ac8c..20fdaa5e4 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.element.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.element.ts @@ -142,7 +142,7 @@ export class UUIInputOtpElement extends UUIFormControlMixin( } protected getFormElement(): HTMLElement | null | undefined { - return this.shadowRoot?.querySelector('.otp-input'); + return this; } protected onFocus(event: FocusEvent) { From a13d41bb58a7aa0c68538fba2aee018aa3c10934 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:11:23 +0200 Subject: [PATCH 19/19] test: formatting --- packages/uui-input-otp/lib/uui-input-otp.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/uui-input-otp/lib/uui-input-otp.test.ts b/packages/uui-input-otp/lib/uui-input-otp.test.ts index 7b2611130..2a472706c 100644 --- a/packages/uui-input-otp/lib/uui-input-otp.test.ts +++ b/packages/uui-input-otp/lib/uui-input-otp.test.ts @@ -115,6 +115,7 @@ describe('UUIInputOtpElement', () => { 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'); @@ -132,6 +133,7 @@ describe('UUIInputOtpElement', () => { 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');