From e04c5f3274656b462e8f4383cfb7d48862f87487 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 6 May 2025 09:08:36 -0700 Subject: [PATCH] add TimeInput component with time-of-day form option --- index.ts | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index 86f9514..0d1aa4a 100644 --- a/index.ts +++ b/index.ts @@ -53,8 +53,17 @@ export const phone: RegExp = new RegExp(String.raw`^(\+)?(\(?\d+\)?)(([\s-]+)?(\ // score is a regex pattern for validating lesson grades. export const score: RegExp = new RegExp(String.raw`^(-?\d{0,2}|100)$`); +// InputState is some historical state of an input element, tracking its value, +// selectionStart and selectionEnd. +export type InputState = { + value: string; + selectionStart: number; + selectionEnd: number; +}; + let nextValidatorID = 0; const validators: Record = {}; +const lastState: Record = {}; // validate attaches rules to an input element that can be used to validate the input. // If opts is undefined, no validation rules will be attached. @@ -113,11 +122,11 @@ export const keydownValidator: Action< node.dataset.validateKeydown = 'true'; node.addEventListener('keydown', async (event: KeyboardEvent) => { + if (event.key.length > 1) return; // ignore non-character keys + const valid = await validateInput(node, node.value + event.key); - if (event.key.length === 1 && !valid) { - if (opts.constrain) { - event.preventDefault(); - } + if (!valid && opts.constrain) { + event.preventDefault(); } }); }; @@ -132,11 +141,92 @@ export const keyupValidator: Action = (el) => { if (node.dataset.validateKeyup !== undefined) return; node.dataset.validateKeyup = 'true'; - node.addEventListener('keyup', () => { + node.addEventListener('keyup', (event: KeyboardEvent) => { + if (event.key.length > 1) return; // ignore non-character keys validateInput(node); }); }; +// inputValidator attaches an oninput event listener to an input element that will +// process the input value according to any attached validation rules, triggering +// the `validate` event with the input value and validation state. +export const inputValidator: Action = (el) => { + const node = el as HTMLInputElement; + + // prevent duplicate listeners + if (node.dataset.validateInput !== undefined) return; + node.dataset.validateInput = 'true'; + + node.addEventListener('input', () => { + validateInput(node); + }); +}; + +// liveValidator attaches multiple event listeners to an input element that will +// process the input value according to any attached validation rules, triggering +// the `validate` event with the input value and validation state. liveValidator +// supports the constrain option without introducing edge cases with text selection. +export const liveValidator: Action< + InputElement, + { + constrain?: boolean; + } +> = (el, opts) => { + const node = el as HTMLInputElement; + + // prevent duplicate listeners + if (node.dataset.validateLive !== undefined) return; + node.dataset.validateLive = 'true'; + + const id = getid(node); + if (id === -1) { + console.warn('liveValidator: no validator ID found', node); + return; + } + + // propagateState updates the stored state of the input element. + const propagateState = (e: HTMLInputElement) => { + lastState[id] = { + value: e.value, + selectionStart: e.selectionStart || 0, + selectionEnd: e.selectionEnd || 0 + }; + }; + + // revertToState reverts the input element to a previous state. + const revertToState = (e: HTMLInputElement, state: InputState) => { + e.value = state.value; + e.setSelectionRange(state.selectionStart, state.selectionEnd); + }; + + // keydownHandler is used to track the last state of the input element. + const keydownHandler = (event: KeyboardEvent) => { + if (event.target) { + propagateState(event.target as HTMLInputElement); + } + }; + + // inputHandler is used to validate the input element. + const inputHandler = async (event: Event) => { + const n = event.target as HTMLInputElement; + const state = lastState[id]; + if (!state || !event.target) return; + + // if the input value is the same as the last state, do nothing + if (n.value === state.value) return; + + // if the input value is different, validate it + const valid = await validateInput(n); + if (!valid && opts.constrain) { + revertToState(n, state); + event.preventDefault(); + } + }; + + node.addEventListener('keydown', keydownHandler); + node.addEventListener('input', inputHandler); +}; + // submitValidator attaches a submit event listener to a form element that will // process the input values of all children according to any attached validation // rules, triggering the validate event for individual input elements @@ -334,9 +424,16 @@ export const isValueValid = async (val: string, opts: ValidatorOptions): Promise return true; }; -// getopts returns the validation rules attached to an input element. -export const getopts = (input: InputElement): ValidatorOptions | undefined => { +// getid returns the validator ID attached to an input element. Returns -1 if +// no ID is attached. +export const getid = (input: InputElement): number => { const idstr = input.dataset[DATA_ID] || '-1'; const id = parseInt(idstr); + return id; +}; + +// getopts returns the validation rules attached to an input element. +export const getopts = (input: InputElement): ValidatorOptions | undefined => { + const id = getid(input); return validators[id]; };