diff --git a/packages/web-components/src/pin-input/Validator.ts b/packages/web-components/src/pin-input/Validator.ts new file mode 100644 index 00000000..a1371e5d --- /dev/null +++ b/packages/web-components/src/pin-input/Validator.ts @@ -0,0 +1,71 @@ +import { Validator } from "../utils"; + +export type PinInputState = { + /** + * The value of the input. + */ + readonly value: string; + + /** + * Whether the input is required. + */ + readonly required: boolean; + + /** + * The number of inputs. + * Defaults to 4. + */ + readonly pins: number; + + /** + * The number of each input's length. + * Defaults to 1. + */ + readonly pinLength: number; +}; + +class PinInputValidator extends Validator { + private _control?: HTMLInputElement; + + protected override computeValidity(state: PinInputState) { + if (!this._control) { + // Lazily create the platform input + this._control = document.createElement("input"); + this._control.type = "text"; + } + + const { pinLength, pins, value, required } = state; + + const expectedLength = pins * pinLength; + + this._control.value = expectedLength === value.length ? value : ""; + this._control.required = required; + + return { + validity: this._control.validity, + validationMessage: this._control.validationMessage, + }; + } + + protected override equals(prev: PinInputState, next: PinInputState) { + return ( + prev.value === next.value && + prev.required === next.required && + prev.pins === next.pins && + prev.pinLength === next.pinLength + ); + } + + protected override copy(state: PinInputState) { + const { value, required, pinLength, pins } = state; + + return { + value, + required, + pinLength, + pins, + }; + } +} + +export default PinInputValidator; diff --git a/packages/web-components/src/pin-input/constants.ts b/packages/web-components/src/pin-input/constants.ts new file mode 100644 index 00000000..eb6d195b --- /dev/null +++ b/packages/web-components/src/pin-input/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_DISPLAY_VALUE = (v: string) => v; diff --git a/packages/web-components/src/pin-input/events.ts b/packages/web-components/src/pin-input/events.ts new file mode 100644 index 00000000..d17d0ca8 --- /dev/null +++ b/packages/web-components/src/pin-input/events.ts @@ -0,0 +1,13 @@ +import { BaseEvent } from "../utils"; + +export class CompleteEvent extends BaseEvent { + public static readonly type = "complete"; + + constructor() { + super(CompleteEvent.type, { + details: null, + composed: true, + bubbles: true, + }); + } +} diff --git a/packages/web-components/src/pin-input/index.ts b/packages/web-components/src/pin-input/index.ts new file mode 100644 index 00000000..4562ea1f --- /dev/null +++ b/packages/web-components/src/pin-input/index.ts @@ -0,0 +1,83 @@ +import { customElement } from "lit/decorators.js"; +import { PinInput } from "./pin-input"; +import styles from "./pin-input.style"; + +/** + * @summary The pin-input component. + * + * @tag tapsi-pin-input + * + * @prop {string} [value=""] - + * The current value of the input. It is always a string. + * @prop {string} [name=""] - + * The HTML name to use in form submission. + * @prop {boolean} [disabled=false] - + * Whether or not the element is disabled. + * @prop {boolean} [required=false] - + * Indicates that the user must specify a value for the input before the + * owning form can be submitted and will render an error state when + * `reportValidity()` is invoked when value is empty. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/required + * @prop {boolean} [readonly=false] - + * Indicates whether or not a user should be able to edit the input's + * value. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#readonly + * @prop {string} [placeholder=""] - + * Defines the text displayed in the input when it has no value. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/placeholder + * @prop {string} [autocomplete=""] - + * Describes what, if any, type of autocomplete functionality the input + * should provide. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete + * @prop {string} [supporting-text=""] - + * Conveys additional information below the text input, such as how it should + * be used. + * @prop {boolean} [error=false] - + * Gets or sets whether or not the text input is in a visually invalid state. + * + * This error state overrides the error state controlled by + * `reportValidity()`. + * @prop {string} [error-text=""] - + * The error message that replaces supporting text when `error` is true. If + * `errorText` is an empty string, then the supporting text will continue to + * show. + * + * This error message overrides the error message displayed by + * `reportValidity()`. + * @prop {string} [label=""] - + * The label of the input. + * - If the `hideLabel` property is true, the label will be hidden visually + * but still accessible to screen readers. + * - Otherwise, a visible label element will be rendered. + * + * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label + * @prop {string} [labelledby=""] - + * Identifies the element (or elements) that labels the input. + * + * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby + * @prop {boolean} [hide-label=false] - Whether to hide the label or not. + * @prop {boolean} [masked=false] - Determines whether input values should be masked or not. + * @prop {number} [pins=4] - + * The number of inputs. + * Defaults to 4. + * @prop {number} [pinlength=1] - + * The number of each input's length. + * Defaults to 1. + * @prop {"alphanumeric" | "numeric"} [type="alphanumeric"] - + * Determines which values can be entered. + * Defaults to "alphanumeric". + */ +@customElement("tapsi-pin-input") +export class TapsiPinInput extends PinInput { + public static override readonly styles = [styles]; +} + +declare global { + interface HTMLElementTagNameMap { + "tapsi-pin-input": TapsiPinInput; + } +} diff --git a/packages/web-components/src/pin-input/pin-input.style.ts b/packages/web-components/src/pin-input/pin-input.style.ts new file mode 100644 index 00000000..c3799e28 --- /dev/null +++ b/packages/web-components/src/pin-input/pin-input.style.ts @@ -0,0 +1,126 @@ +import { css } from "lit"; + +const styles = css` + *, + *::before, + *::after { + box-sizing: border-box; + } + + :host { + --pin-bg-color: var(--tapsi-color-surface-tertiary); + --pin-border-color: transparent; + --support-color: var(--tapsi-color-content-tertiary); + --input-color: var(--tapsi-color-content-primary); + --input-placeholder-color: var(--tapsi-color-content-tertiary); + --label-color: var(--tapsi-color-content-primary); + + display: inline-block; + vertical-align: middle; + } + + .sr-only { + position: absolute; + + width: 1px; + height: 1px; + padding: 0; + + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + .root.disabled { + --pin-bg-color: var(--tapsi-color-surface-disabled); + --pin-border-color: transparent; + --support-color: var(--tapsi-color-content-disabled); + --input-color: var(--tapsi-color-content-disabled); + --label-color: var(--tapsi-color-content-disabled); + --input-placeholder-color: var(--tapsi-color-content-disabled); + + cursor: not-allowed; + } + + .root.error { + --pin-bg-color: var(--tapsi-color-surface-negative-light); + --pin-border-color: var(--tapsi-color-border-negative); + --support-color: var(--tapsi-color-content-negative); + } + + .root:not(.error) .input:focus { + --pin-bg-color: var(--tapsi-color-surface-secondary); + --pin-border-color: var(--tapsi-color-border-inverse-primary); + --support-color: var(--tapsi-color-content-secondary); + } + + .root { + font-family: var(--tapsi-font-family); + + display: flex; + flex-direction: column; + + gap: var(--tapsi-spacing-4); + } + + .label { + color: var(--label-color); + + font-family: var(--tapsi-font-family); + line-height: var(--tapsi-typography-label-md-height); + font-size: var(--tapsi-typography-label-md-size); + font-weight: var(--tapsi-typography-label-md-weight); + } + + .supporting-text { + color: var(--support-color); + + font-family: var(--tapsi-font-family); + line-height: var(--tapsi-typography-body-sm-height); + font-size: var(--tapsi-typography-body-sm-size); + font-weight: var(--tapsi-typography-body-sm-weight); + } + + .pins { + direction: ltr; + + display: flex; + align-items: center; + + padding-right: var(--tapsi-spacing-6); + padding-left: var(--tapsi-spacing-6); + gap: var(--tapsi-spacing-5); + } + + .input { + border: 0; + outline: none; + + width: 3.25rem; + height: 3.25rem; + + padding: var(--tapsi-spacing-4); + + color: var(--input-color); + background-color: var(--pin-bg-color); + caret-color: var(--tapsi-color-surface-accent); + + text-align: center; + + box-shadow: 0 0 0 var(--tapsi-stroke-2) var(--pin-border-color); + border-radius: var(--tapsi-radius-3); + + font-family: var(--tapsi-font-family); + line-height: var(--tapsi-typography-headline-sm-height); + font-size: var(--tapsi-typography-headline-sm-size); + font-weight: var(--tapsi-typography-headline-sm-weight); + } + + .input::placeholder { + color: var(--input-placeholder-color); + } +`; + +export default styles; diff --git a/packages/web-components/src/pin-input/pin-input.ts b/packages/web-components/src/pin-input/pin-input.ts new file mode 100644 index 00000000..0465383c --- /dev/null +++ b/packages/web-components/src/pin-input/pin-input.ts @@ -0,0 +1,684 @@ +import { html, LitElement, nothing, type PropertyValues } from "lit"; +import { property, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { live } from "lit/directives/live.js"; +import { requestFormSubmit } from "../base-input/utils"; +import { KeyboardKeys } from "../internals"; +import { + createValidator, + dispatchActivationClick, + getFormValue, + getValidityAnchor, + isActivationClick, + isHTMLElement, + isHTMLInputElement, + isSSR, + logger, + onReportValidity, + redispatchEvent, + runAfterRepaint, + runImmediatelyBeforeRepaint, + waitAMicrotask, + withConstraintValidation, + withElementInternals, + withFormAssociated, + withOnReportValidity, + type Validator, +} from "../utils"; +import { DEFAULT_DISPLAY_VALUE } from "./constants"; +import { CompleteEvent } from "./events"; +import { isAlphaNumberic, isNumeric, stringConverter } from "./utils"; +import PinInputValidator from "./Validator"; + +const BaseClass = withOnReportValidity( + withConstraintValidation( + withFormAssociated(withElementInternals(LitElement)), + ), +); + +export class PinInput extends BaseClass { + /** + * Indicates that the user must specify a value for the input before the + * owning form can be submitted and will render an error state when + * `reportValidity()` is invoked when value is empty. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/required + */ + @property({ type: Boolean, reflect: true }) + public required = false; + + /** + * Indicates whether or not a user should be able to edit the input's + * value. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#readonly + */ + @property({ type: Boolean, reflect: true }) + public readOnly = false; + + /** + * The label of the input. + * - If the `hideLabel` property is true, the label will be hidden visually + * but still accessible to screen readers. + * - Otherwise, a visible label element will be rendered. + * + * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label + */ + @property({ type: String }) + public label = ""; + + /** + * Identifies the element (or elements) that labels the input. + * + * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby + */ + @property({ type: String }) + public labelledBy = ""; + + /** + * Whether to hide the label or not. + */ + @property({ type: Boolean, attribute: "hide-label" }) + public hideLabel = false; + + /** + * Defines the text displayed in the inputs when they have no value. Provides + * a brief hint to the user as to the expected type of data that should be + * entered into the control. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/placeholder + */ + @property({ type: String, converter: stringConverter }) + public placeholder = ""; + + /** + * Describes what, if any, type of autocomplete functionality the input + * should provide. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete + */ + @property({ type: String }) + public autocomplete: AutoFill = ""; + + /** + * Conveys additional information below the input, such as how it should + * be used. + */ + @property({ type: String, attribute: "supporting-text" }) + public supportingText = ""; + + /** + * Gets or sets whether or not the text input is in a visually invalid state. + * + * This error state overrides the error state controlled by + * `reportValidity()`. + */ + @property({ type: Boolean, reflect: true }) + public error = false; + + /** + * The error message that replaces supporting text when `error` is true. If + * `errorText` is an empty string, then the supporting text will continue to + * show. + * + * This error message overrides the error message displayed by + * `reportValidity()`. + */ + @property({ attribute: "error-text" }) + public errorText = ""; + + /** + * Determines which values can be entered. + * Defaults to "alphanumeric". + */ + @property() + public type: "numeric" | "alphanumeric" = "alphanumeric"; + + /** + * Determines whether input values should be masked or not. + */ + @property({ type: Boolean }) + public masked = false; + + /** + * The number of inputs. + * Defaults to 4. + */ + @property({ type: Number }) + public pins = 4; + + /** + * The number of each input's length. + * Defaults to 1. + */ + @property({ type: Number }) + public pinLength = 1; + + @state() + private _dirty = false; + + @state() + private _nativeError = false; + + @state() + private _nativeErrorText = ""; + + /** + * When set to true, the error text's `role="alert"` will be removed, then + * re-added after an animation frame. This will re-announce an error message + * to screen readers. + */ + @state() + private _refreshErrorAlert = false; + + private _displayValue = DEFAULT_DISPLAY_VALUE; + private _values: string[] = []; + private _value = ""; + + constructor() { + super(); + + this._handleActivationClick = this._handleActivationClick.bind(this); + this._handleHostKeyDown = this._handleHostKeyDown.bind(this); + } + + public override connectedCallback(): void { + super.connectedCallback(); + + /* eslint-disable @typescript-eslint/no-misused-promises */ + this.addEventListener("click", this._handleActivationClick); + this.addEventListener("keydown", this._handleHostKeyDown); + /* eslint-enable @typescript-eslint/no-misused-promises */ + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + + /* eslint-disable @typescript-eslint/no-misused-promises */ + this.removeEventListener("click", this._handleActivationClick); + this.removeEventListener("keydown", this._handleHostKeyDown); + /* eslint-enable @typescript-eslint/no-misused-promises */ + } + + public override attributeChangedCallback( + attribute: string, + newValue: string | null, + oldValue: string | null, + ) { + if (attribute === "value" && this._dirty) { + // After user input, changing the value attribute no longer updates the + // text field's value (until reset). This matches native behavior. + return; + } + + super.attributeChangedCallback(attribute, newValue, oldValue); + } + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + + runAfterRepaint(() => { + if (!this.autofocus) return; + + this.focus(); + }); + } + + protected override updated(changed: PropertyValues) { + super.updated(changed); + + if (this._refreshErrorAlert) { + // The past render cycle removed the role="alert" from the error message. + // Re-add it after an animation frame to re-announce the error. + runImmediatelyBeforeRepaint(() => { + this._refreshErrorAlert = false; + }); + } + } + + public override focus(options?: FocusOptions): void { + const firstPin = this._pins[0]; + + if (!firstPin) return; + + this._focusPin(firstPin, options); + } + + public override blur(): void { + const firstPin = this._pins[0]; + + firstPin?.blur(); + } + + /** + * Re-announces the field's error supporting text to screen readers. + * + * Error text announces to screen readers anytime it is visible and changes. + * Use the method to re-announce the message when the text has not changed, + * but announcement is still needed (such as for `reportValidity()`). + */ + private _reannounceError() { + this._refreshErrorAlert = true; + } + + private get _pins() { + return Array.from( + this.renderRoot.querySelectorAll("input:not(#shadow)"), + ); + } + + private _getPreviousPin(target: HTMLInputElement) { + const pins = this._pins; + const idx = pins.findIndex(pin => pin === target); + + if (idx === -1 || idx === 0) return null; + + return pins[idx - 1] ?? null; + } + + private _getNextPin(target: HTMLInputElement) { + const pins = this._pins; + const idx = pins.findIndex(pin => pin === target); + + if (idx === -1 || idx === this.pins - 1) return null; + + return pins[idx + 1] ?? null; + } + + private _hasError() { + return this.error || this._nativeError; + } + + private _getErrorText() { + return this.error ? this.errorText : this._nativeErrorText; + } + + private _getSupportingOrErrorText() { + const errorText = this._getErrorText(); + + return this._hasError() && errorText ? errorText : this.supportingText; + } + + private _createPinArray(value: string) { + return Array.from({ length: this.pins }).map((_, idx) => { + const start = this.pinLength * idx; + const end = start + this.pinLength; + + return value.substring(start, end); + }); + } + + /** + * Reset the input to its default value. + */ + public reset() { + this.value = this.getAttribute("value") ?? ""; + this._dirty = false; + this._nativeError = false; + this._nativeErrorText = ""; + } + + /** + * The current value of the input. It is always a string. + */ + @property() + public get value() { + return this._value; + } + + public set value(newValue: string) { + const update = () => { + this._value = newValue; + this._values = this._createPinArray(newValue); + + this.requestUpdate(); + }; + + if (!this.hasUpdated) { + void this.updateComplete.then(update); + } else update(); + } + + /** + * The value as an array. + */ + public get valueAsArray() { + if (isSSR() || !this.isConnected) return this._values; + + return this._pins.map(pin => pin.value); + } + + private _setValues(arr: string[], shouldRender = false) { + let newValues: string[] = []; + + if (arr.length > this.pins) { + newValues = arr.slice(0, this.pins); + } else if (arr.length < this.pins) { + newValues = Array.from({ length: this.pins }).fill("") as string[]; + arr.forEach((v, i) => { + newValues[i] = v; + }); + } else { + newValues = arr; + } + + this._values = newValues; + this._value = newValues.join(""); + + const isComplete = this.pins * this.pinLength === this.value.length; + + if (shouldRender) this.requestUpdate(); + if (isComplete) this.dispatchEvent(new CompleteEvent()); + } + + /** + * Retrieves or sets the function used to display values on inputs. + */ + public get displayValue() { + if (!this._displayValue) return DEFAULT_DISPLAY_VALUE; + + return this._displayValue; + } + + public set displayValue(fn: (value: string) => string) { + this._displayValue = fn; + } + + private _setFocusOnArray(pinArray: string[]) { + const filled = pinArray.filter(v => v.length !== 0); + const pins = this._pins; + + if (pinArray.length === filled.length) { + const lastPin = pins[pins.length - 1]; + + if (lastPin) this._focusPin(lastPin); + } else { + const lastFilledIdx = filled.length - 1; + const lastFilled = filled[lastFilledIdx]!; + + const idx = + lastFilled.length === this.pinLength + ? lastFilledIdx + 1 + : lastFilledIdx; + + const pin = pins[idx]; + + if (pin) this._focusPin(pin); + } + } + + private _focusPin(pin: HTMLInputElement, options?: FocusOptions) { + pin.select(); + pin.focus(options); + } + + private _isValidValue(value: string) { + if (!value) return true; + if (this.type === "numeric") return isNumeric(value); + + return isAlphaNumberic(value); + } + + private async _handleActivationClick(event: MouseEvent) { + if (this.disabled) return; + + // allow event to propagate to user code after a microtask. + await waitAMicrotask(); + + if (event.defaultPrevented) return; + + const firstPin = this._pins[0]; + + if (!isActivationClick(event) || !firstPin) return; + + this._focusPin(firstPin); + + dispatchActivationClick(firstPin); + } + + private async _handleHostKeyDown(event: KeyboardEvent) { + if (this.disabled) { + event.preventDefault(); + + return; + } + + const target = event.composedPath()[0] || event.target; + + if (!target) return; + if (!(target instanceof HTMLInputElement)) return; + + if (event.key === KeyboardKeys.ENTER) { + // allow event to propagate to user code after a microtask. + await waitAMicrotask(); + + if (event.defaultPrevented) return; + + requestFormSubmit(this); + } + + if ( + event.key === KeyboardKeys.BACKSPACE || + event.key === KeyboardKeys.DELETE + ) { + if (target.value) return; + + event.preventDefault(); + + const previousPin = this._getPreviousPin(target); + + if (!isHTMLElement(previousPin)) return; + + this._focusPin(previousPin); + } + } + + private _handleChange(event: Event) { + redispatchEvent(this, event); + } + + private _handleInput(event: InputEvent) { + this._dirty = true; + + if (!isHTMLInputElement(event.target)) return; + + const idx = Number(event.target.getAttribute("data-index") ?? "-1"); + + if (idx === -1 || Number.isNaN(idx)) return; + + const inputValue = event.target.value; + const isValid = this._isValidValue(inputValue); + + if (!isValid) { + this._setValues(this._values, true); + + return; + } + + if (inputValue.length <= this.pinLength) { + const values = this._values; + + values[idx] = inputValue; + + this._setValues(values, true); + + if (inputValue.length === this.pinLength) { + const nextPin = this._getNextPin(event.target); + + if (nextPin) this._focusPin(nextPin); + } + + return; + } + + if (!event.data) return; + + if (idx === this.pins - 1 && event.data.length === 1) { + this._setValues(this._values, true); + + return; + } + + const pinArray = this._createPinArray(event.data); + + this._setValues(pinArray, true); + this._setFocusOnArray(pinArray); + } + + public override [getFormValue]() { + return this.value; + } + + public override formResetCallback() { + this.reset(); + } + + public override formStateRestoreCallback(state: string) { + this.value = state; + } + + public override formDisabledCallback(disabled: boolean) { + this.disabled = disabled; + } + + public override [getValidityAnchor]() { + return null; + } + + public override [onReportValidity](invalidEvent: Event | null) { + // Prevent default pop-up behavior. + invalidEvent?.preventDefault(); + + const prevMessage = this._getErrorText(); + + this._nativeError = !!invalidEvent; + this._nativeErrorText = this.validationMessage; + + if (prevMessage === this._getErrorText()) this._reannounceError(); + } + + public override [createValidator](): Validator { + return new PinInputValidator(() => ({ + pinLength: this.pinLength ?? 1, + pins: this.pins ?? 4, + value: this.value ?? "", + required: this.required ?? false, + })); + } + + private _renderPins() { + const type = this.masked ? "password" : "text"; + const inputMode = this.type === "numeric" ? "numeric" : "text"; + const invalid = this._hasError(); + const label = this.hideLabel ? this.label || nothing : nothing; + const labelledBy = this.label ? nothing : this.labelledBy || nothing; + const describedBy = this.supportingText ? "supporting-text" : nothing; + + return Array.from({ length: this.pins }).map( + (_, idx) => html` + + `, + ); + } + + private _renderLabel() { + if (this.hideLabel) return null; + if (!this.label) return null; + + return html` + + `; + } + + private _renderHelperText() { + const text = this._getSupportingOrErrorText(); + + if (!text) return null; + + // Announce if there is an error and error text visible. + // If `_refreshErrorAlert` is true, do not announce. This will remove the + // role="alert" attribute. Another render cycle will happen after an + // animation frame to re-add the role. + const shouldAnnounceError = + this._hasError() && this._getErrorText() && !this._refreshErrorAlert; + + const role = shouldAnnounceError ? "alert" : nothing; + + return html` +
+ + ${this.supportingText} + + ${text} +
+ `; + } + + protected override render() { + const hasValidLabel = Boolean(this.label || this.labelledBy); + + const rootClasses = classMap({ + root: true, + disabled: this.disabled, + error: this._hasError(), + readonly: this.readOnly, + }); + + if (!hasValidLabel) { + logger( + "Expected a valid `label` or `labelledby` attribute, received none.", + "pin-input", + "error", + ); + } + + return html` +
+ ${this._renderLabel()} +
+ ${this._renderPins()} +
+ ${this._renderHelperText()} +
+ `; + } +} diff --git a/packages/web-components/src/pin-input/utils.ts b/packages/web-components/src/pin-input/utils.ts new file mode 100644 index 00000000..038b697e --- /dev/null +++ b/packages/web-components/src/pin-input/utils.ts @@ -0,0 +1,13 @@ +import type { PropertyDeclaration } from "lit"; + +export const stringConverter: PropertyDeclaration["converter"] = { + fromAttribute(value: string | null): string { + return value ?? ""; + }, + toAttribute(value: string): string | null { + return value || null; + }, +}; + +export const isNumeric = (str: string) => /^[0-9]+$/.test(str); +export const isAlphaNumberic = (str: string) => /^[a-zA-Z0-9]+$/i.test(str);