diff --git a/src/components.d.ts b/src/components.d.ts index 7e77e5078..8ad6a2693 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -417,6 +417,8 @@ export namespace Components { } interface SmoothlyInputDemoStandard { } + interface SmoothlyInputDemoTypes { + } interface SmoothlyInputDemoUserInput { } interface SmoothlyInputEdit { @@ -1607,6 +1609,12 @@ declare global { prototype: HTMLSmoothlyInputDemoStandardElement; new (): HTMLSmoothlyInputDemoStandardElement; }; + interface HTMLSmoothlyInputDemoTypesElement extends Components.SmoothlyInputDemoTypes, HTMLStencilElement { + } + var HTMLSmoothlyInputDemoTypesElement: { + prototype: HTMLSmoothlyInputDemoTypesElement; + new (): HTMLSmoothlyInputDemoTypesElement; + }; interface HTMLSmoothlyInputDemoUserInputElement extends Components.SmoothlyInputDemoUserInput, HTMLStencilElement { } var HTMLSmoothlyInputDemoUserInputElement: { @@ -2268,6 +2276,7 @@ declare global { "smoothly-input-date-time": HTMLSmoothlyInputDateTimeElement; "smoothly-input-demo": HTMLSmoothlyInputDemoElement; "smoothly-input-demo-standard": HTMLSmoothlyInputDemoStandardElement; + "smoothly-input-demo-types": HTMLSmoothlyInputDemoTypesElement; "smoothly-input-demo-user-input": HTMLSmoothlyInputDemoUserInputElement; "smoothly-input-edit": HTMLSmoothlyInputEditElement; "smoothly-input-file": HTMLSmoothlyInputFileElement; @@ -2707,6 +2716,8 @@ declare namespace LocalJSX { } interface SmoothlyInputDemoStandard { } + interface SmoothlyInputDemoTypes { + } interface SmoothlyInputDemoUserInput { } interface SmoothlyInputEdit { @@ -3087,6 +3098,7 @@ declare namespace LocalJSX { "smoothly-input-date-time": SmoothlyInputDateTime; "smoothly-input-demo": SmoothlyInputDemo; "smoothly-input-demo-standard": SmoothlyInputDemoStandard; + "smoothly-input-demo-types": SmoothlyInputDemoTypes; "smoothly-input-demo-user-input": SmoothlyInputDemoUserInput; "smoothly-input-edit": SmoothlyInputEdit; "smoothly-input-file": SmoothlyInputFile; @@ -3203,6 +3215,7 @@ declare module "@stencil/core" { "smoothly-input-date-time": LocalJSX.SmoothlyInputDateTime & JSXBase.HTMLAttributes; "smoothly-input-demo": LocalJSX.SmoothlyInputDemo & JSXBase.HTMLAttributes; "smoothly-input-demo-standard": LocalJSX.SmoothlyInputDemoStandard & JSXBase.HTMLAttributes; + "smoothly-input-demo-types": LocalJSX.SmoothlyInputDemoTypes & JSXBase.HTMLAttributes; "smoothly-input-demo-user-input": LocalJSX.SmoothlyInputDemoUserInput & JSXBase.HTMLAttributes; "smoothly-input-edit": LocalJSX.SmoothlyInputEdit & JSXBase.HTMLAttributes; "smoothly-input-file": LocalJSX.SmoothlyInputFile & JSXBase.HTMLAttributes; diff --git a/src/components/input/InputStateHandler.ts b/src/components/input/InputStateHandler.ts index 406d13aed..7c85d78b2 100644 --- a/src/components/input/InputStateHandler.ts +++ b/src/components/input/InputStateHandler.ts @@ -4,6 +4,7 @@ import { Adjacent } from "./Adjacent" type Formatter = tidily.Formatter & tidily.Converter type Handler = (event: E, unformatted: tidily.State, formatted: tidily.State) => tidily.State +type CommitState = (state: Readonly & tidily.Settings) => void export class InputStateHandler { constructor(private formatter: Formatter, private type: tidily.Type) {} @@ -79,56 +80,84 @@ export class InputStateHandler { input.value = result.value return result } - - public onInputEvent(event: InputEvent, state: tidily.State): Readonly & tidily.Settings { + private nextFormattedState?: Readonly & Readonly // Set in beforeinput event - used in input event + public onBeforeInputEvent(event: InputEvent, state: tidily.State): void { const input = event.target as HTMLInputElement state.selection.start = input.selectionStart ?? state.selection.start state.selection.end = input.selectionEnd ?? state.selection.end state.selection.direction = input.selectionDirection ?? state.selection.direction - const result = - event.type == "beforeinput" || event.type == "input" - ? this.eventHandlers[event.type][event.inputType]?.(event, this.unformatState(state), state) ?? state - : state - const formatted = this.partialFormatState(result) - if (event.defaultPrevented) { - input.value = formatted.value - input.selectionStart = formatted.selection.start - input.selectionEnd = formatted.selection.end - input.selectionDirection = formatted.selection.direction ?? null + const unformatted = this.eventHandlers.beforeinput[event.inputType]?.(event, this.unformatState(state), state) + const formatted = unformatted ? this.partialFormatState(unformatted) : undefined + this.nextFormattedState = formatted + } + private requestAnimationFrameId: number | undefined + private timeoutId: NodeJS.Timeout | undefined + public onInputEvent(event: InputEvent, state: tidily.State, commitState: CommitState): void { + const input = event.target as HTMLInputElement + if (!event.inputType) { + // Chrome will dispatch an input event without inputType on autofill (this event is not reliable, but it's be best Chrome provides) + const newValue = input.value + this.requestAnimationFrameId && cancelAnimationFrame(this.requestAnimationFrameId) + clearTimeout(this.timeoutId) + this.requestAnimationFrameId = requestAnimationFrame(() => { + const newState = { ...state, value: newValue } + const formatted = this.partialFormatState(newState) + commitState(formatted) + }) + } else if (this.nextFormattedState) { + const newState = this.nextFormattedState + this.nextFormattedState = undefined + input.value = newState.value + input.selectionStart = newState.selection.start + input.selectionEnd = newState.selection.end + input.selectionDirection = newState.selection.direction ?? null + commitState(newState) + } else { + state.selection.start = input.selectionStart ?? state.selection.start + state.selection.end = input.selectionEnd ?? state.selection.end + state.selection.direction = input.selectionDirection ?? state.selection.direction + const result = this.eventHandlers.input[event.inputType]?.(event, this.unformatState(state), state) ?? state + const formatted = this.partialFormatState(result) + if ( + input.value != formatted.value || + input.selectionStart != formatted.selection.start || + input.selectionEnd != formatted.selection.end + ) { + input.value = formatted.value + input.selectionStart = formatted.selection.start + input.selectionEnd = formatted.selection.end + input.selectionDirection = formatted.selection.direction ?? null + } + commitState(formatted) } - return formatted } private eventHandlers: Record<"beforeinput" | "input", { [inputType: string]: Handler | undefined }> = { beforeinput: { insertText: (event, state) => this.insert(event, state), insertFromPaste: (event, state) => this.insert(event, state), insertFromDrop: (event, state) => this.insert(event, state), - deleteContentBackward: (event, state) => { - event.preventDefault() + deleteContentBackward: (_, state) => { if (state.selection.start == state.selection.end) this.select(state, state.selection.start - 1, state.selection.end) this.erase(state) return state }, - deleteContentForward: (event, state) => { - event.preventDefault() + deleteContentForward: (_, state) => { if (state.selection.start == state.selection.end) this.select(state, state.selection.start, state.selection.end + 1) this.erase(state) return state }, - deleteWordBackward: (event, unformatted, formattedState) => { + deleteWordBackward: (_, unformatted, formattedState) => { let result = unformatted if (this.type != "password") { - event.preventDefault() result = this.deleteWord(formattedState, "backward") } return result }, - deleteWordForward: (event, unformatted, formattedState) => { + deleteWordForward: (_, unformatted, formattedState) => { let result = unformatted if (this.type != "password") { - event.preventDefault() result = this.deleteWord(formattedState, "forward") } return result @@ -148,13 +177,10 @@ export class InputStateHandler { this.type == "password" ? { ...state, value: (event.target as HTMLInputElement).value } : state, deleteWordForward: (event, state) => this.type == "password" ? { ...state, value: (event.target as HTMLInputElement).value } : state, - // Chrome will dispatch an input event without inputType when auto-filling - undefined: (event, state) => ({ ...state, value: (event.target as HTMLInputElement).value }), }, } private insert(event: InputEvent, unformatted: tidily.State): tidily.State { - event.preventDefault() if (typeof event.data == "string") for (const c of event.data) this.formatter.allowed(c, unformatted) && this.replace(unformatted, c.replace(/(\r|\n|\t)/g, "")) diff --git a/src/components/input/demo/index.tsx b/src/components/input/demo/index.tsx index 173b8cb14..da3d1ab47 100644 --- a/src/components/input/demo/index.tsx +++ b/src/components/input/demo/index.tsx @@ -18,6 +18,7 @@ export class SmoothlyInputDemo { +

Calendar

Calendar diff --git a/src/components/input/demo/types/index.tsx b/src/components/input/demo/types/index.tsx new file mode 100644 index 000000000..800b049cf --- /dev/null +++ b/src/components/input/demo/types/index.tsx @@ -0,0 +1,35 @@ +import { Component, h, Host } from "@stencil/core" +import { tidily } from "tidily" + +@Component({ + tag: "smoothly-input-demo-types", + styleUrl: "style.css", + scoped: true, +}) +export class SmoothlyInputDemoTypes { + types: tidily.Type[] = [ + "text", + "integer", + "price", + "percent", + "password", + "email", + "card-number", + "card-expires", + "card-csc", + ] as const + + render() { + return ( + +

Input Types

+ {this.types.map(type => ( + + {type} + + + ))} +
+ ) + } +} diff --git a/src/components/input/demo/types/style.css b/src/components/input/demo/types/style.css new file mode 100644 index 000000000..3ca219e18 --- /dev/null +++ b/src/components/input/demo/types/style.css @@ -0,0 +1,9 @@ +:host { + display: block; + max-width: min(calc(100% - 0.5rem), 48rem); + margin: auto; +} +:host smoothly-input { + width: 100%; +} + diff --git a/src/components/input/index.tsx b/src/components/input/index.tsx index b863b3500..f387c30fc 100644 --- a/src/components/input/index.tsx +++ b/src/components/input/index.tsx @@ -171,19 +171,23 @@ export class SmoothlyInput implements Clearable, Input, Editable { this.observer.publish() } componentDidLoad() { - if (this.inputElement) - this.inputElement.value = this.state.value + // if (this.inputElement) + // this.inputElement.value = this.state.value } async disconnectedCallback() { if (!this.element.isConnected) await this.unregister() } - @Listen("input") @Listen("beforeinput") - onEvent(event: InputEvent) { - this.state = this.stateHandler.onInputEvent(event, this.state) - if (event.type == "input" || event.defaultPrevented) + onBeforeInput(event: InputEvent) { + this.stateHandler.onBeforeInputEvent(event, this.state) + } + @Listen("input") + onInput(event: InputEvent) { + this.stateHandler.onInputEvent(event, this.state, state => { + this.state = state this.smoothlyUserInput.emit({ name: this.name, value: this.stateHandler.getValue(this.state) }) + }) } copyText(value?: string) { if (value) {