add TimeInput component with time-of-day form option

This commit is contained in:
Elijah Duffy
2025-05-06 09:08:36 -07:00
parent d3bf5a61c0
commit e04c5f3274

111
index.ts
View File

@@ -53,8 +53,17 @@ export const phone: RegExp = new RegExp(String.raw`^(\+)?(\(?\d+\)?)(([\s-]+)?(\
// score is a regex pattern for validating lesson grades. // score is a regex pattern for validating lesson grades.
export const score: RegExp = new RegExp(String.raw`^(-?\d{0,2}|100)$`); 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; let nextValidatorID = 0;
const validators: Record<number, ValidatorOptions> = {}; const validators: Record<number, ValidatorOptions> = {};
const lastState: Record<number, InputState> = {};
// validate attaches rules to an input element that can be used to validate the input. // 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. // If opts is undefined, no validation rules will be attached.
@@ -113,11 +122,11 @@ export const keydownValidator: Action<
node.dataset.validateKeydown = 'true'; node.dataset.validateKeydown = 'true';
node.addEventListener('keydown', async (event: KeyboardEvent) => { 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); const valid = await validateInput(node, node.value + event.key);
if (event.key.length === 1 && !valid) { if (!valid && opts.constrain) {
if (opts.constrain) { event.preventDefault();
event.preventDefault();
}
} }
}); });
}; };
@@ -132,11 +141,92 @@ export const keyupValidator: Action<InputElement> = (el) => {
if (node.dataset.validateKeyup !== undefined) return; if (node.dataset.validateKeyup !== undefined) return;
node.dataset.validateKeyup = 'true'; node.dataset.validateKeyup = 'true';
node.addEventListener('keyup', () => { node.addEventListener('keyup', (event: KeyboardEvent) => {
if (event.key.length > 1) return; // ignore non-character keys
validateInput(node); 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<InputElement> = (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 // submitValidator attaches a submit event listener to a form element that will
// process the input values of all children according to any attached validation // process the input values of all children according to any attached validation
// rules, triggering the validate event for individual input elements // rules, triggering the validate event for individual input elements
@@ -334,9 +424,16 @@ export const isValueValid = async (val: string, opts: ValidatorOptions): Promise
return true; return true;
}; };
// getopts returns the validation rules attached to an input element. // getid returns the validator ID attached to an input element. Returns -1 if
export const getopts = (input: InputElement): ValidatorOptions | undefined => { // no ID is attached.
export const getid = (input: InputElement): number => {
const idstr = input.dataset[DATA_ID] || '-1'; const idstr = input.dataset[DATA_ID] || '-1';
const id = parseInt(idstr); 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]; return validators[id];
}; };