support contenteditable div validation

This commit is contained in:
Elijah Duffy
2025-07-07 19:32:33 -07:00
parent 72a3c963cd
commit 9e8f9f966e

103
index.ts
View File

@@ -1,9 +1,36 @@
import type { Action } from 'svelte/action';
/** InputElement is a union of all input types that can be validated. */
export type InputElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
export type InputElement =
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| HTMLDivElement;
/** InputNodeList is a union of all node lists that can be validated. */
export type InputNodeList = NodeListOf<HTMLInputElement> | NodeListOf<HTMLTextAreaElement> | [];
// export type InputNodeList = NodeListOf<HTMLInputElement> | NodeListOf<HTMLTextAreaElement> | [];
export type InputNodeList = NodeListOf<InputElement>;
// getNodeValue is a utility function that returns the value of an input element.
export const getNodeValue = (node: InputElement): string => {
if (node instanceof HTMLDivElement) {
return node.textContent || '';
} else {
return node.value;
}
};
// setNodeValue is a utility function that sets the value of an input element.
export const setNodeValue = (node: InputElement, value: string): void => {
if (node instanceof HTMLDivElement) {
node.textContent = value;
} else {
node.value = value;
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
// Update selection range to the end of the input value
node.setSelectionRange(value.length, value.length);
}
}
};
/** ValidatorOptions configures the behavior of a validator. */
export type ValidatorOptions = {
@@ -23,7 +50,9 @@ export type ValidatorOptions = {
baseval?: string;
/** autovalOnInvalid is a flag that automatically adds a keyup listener to the input on invalid. */
autovalOnInvalid?: boolean;
/** func is checked after other validation rules */
func?: (node: InputElement) => boolean | Promise<boolean>;
/** valfunc is called early in validation */
valfunc?: (val: string) => boolean | Promise<boolean>;
};
@@ -67,8 +96,8 @@ export const score: RegExp = new RegExp(String.raw`^(-?\d{0,2}|100)$`);
*/
export type InputState = {
value: string;
selectionStart: number;
selectionEnd: number;
selectionStart?: number;
selectionEnd?: number;
};
let nextValidatorID = 0;
@@ -128,17 +157,16 @@ export const keydownValidator: Action<
{
constrain?: boolean;
}
> = (el, opts) => {
const node = el as HTMLInputElement;
> = (node, opts) => {
// prevent duplicate listeners
if (node.dataset.validateKeydown !== undefined) return;
node.dataset.validateKeydown = 'true';
node.addEventListener('keydown', async (event: KeyboardEvent) => {
node.addEventListener('keydown', async (evt: Event) => {
const event = evt as 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, getNodeValue(node) + event.key);
if (!valid && opts.constrain) {
event.preventDefault();
}
@@ -191,9 +219,7 @@ export const liveValidator: Action<
{
constrain?: boolean;
}
> = (el, opts) => {
const node = el as HTMLInputElement;
> = (node, opts) => {
// prevent duplicate listeners
if (node.dataset.validateLive !== undefined) return;
node.dataset.validateLive = 'true';
@@ -205,35 +231,64 @@ export const liveValidator: Action<
}
// propagateState updates the stored state of the input element.
const propagateState = (e: HTMLInputElement) => {
const propagateState = (e: InputElement) => {
lastState[id] = {
value: e.value,
selectionStart: e.selectionStart || 0,
selectionEnd: e.selectionEnd || 0
value: getNodeValue(e)
};
if (e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement) {
lastState[id].selectionStart = e.selectionStart || 0;
lastState[id].selectionEnd = e.selectionEnd || 0;
} else if (e instanceof HTMLDivElement) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const preRange = range.cloneRange();
preRange.selectNodeContents(e);
preRange.setEnd(range.startContainer, range.startOffset);
lastState[id].selectionStart = preRange.toString().length;
lastState[id].selectionEnd = lastState[id].selectionStart + range.toString().length;
}
}
};
// revertToState reverts the input element to a previous state.
const revertToState = (e: HTMLInputElement, state: InputState) => {
e.value = state.value;
const revertToState = (e: InputElement, state: InputState) => {
setNodeValue(node, state.value);
if (
(e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement) &&
state.selectionStart !== undefined &&
state.selectionEnd !== undefined
) {
e.setSelectionRange(state.selectionStart, state.selectionEnd);
} else if (e instanceof HTMLDivElement) {
const selection = window.getSelection();
if (selection) {
const range = document.createRange();
range.selectNodeContents(e);
range.setStart(e.firstChild || e, state.selectionStart || 0);
range.setEnd(e.firstChild || e, state.selectionEnd || 0);
selection.removeAllRanges();
selection.addRange(range);
}
}
};
// keydownHandler is used to track the last state of the input element.
const keydownHandler = (event: KeyboardEvent) => {
const keydownHandler = (event: Event) => {
if (event.target) {
propagateState(event.target as HTMLInputElement);
propagateState(event.target as InputElement);
}
};
// inputHandler is used to validate the input element.
const inputHandler = async (event: Event) => {
const n = event.target as HTMLInputElement;
const n = event.target as InputElement;
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 (getNodeValue(n) === state.value) return;
// if the input value is different, validate it
const valid = await validateInput(n);
@@ -298,7 +353,7 @@ export const validateInput = async (
input.dataset[DATA_STATE] = valid ? VALIDATOR_VALID : VALIDATOR_INVALID;
input.dispatchEvent(
new InputValidatorEvent('validate', { detail: { value: input.value, valid } })
new InputValidatorEvent('validate', { detail: { value: getNodeValue(input), valid } })
);
// if invalid, consider autovalOnInvalid
@@ -353,7 +408,7 @@ export const isInputValid = async (
): Promise<boolean> => {
const node = input as HTMLInputElement;
const val = valueOverride || node.value.trim();
const val = valueOverride || getNodeValue(node);
const opts = getopts(input);
if (!opts) {
console.warn('input has no validation rules', node);