support contenteditable div validation

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

105
index.ts
View File

@@ -1,9 +1,36 @@
import type { Action } from 'svelte/action'; import type { Action } from 'svelte/action';
/** InputElement is a union of all input types that can be validated. */ /** 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. */ /** 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. */ /** ValidatorOptions configures the behavior of a validator. */
export type ValidatorOptions = { export type ValidatorOptions = {
@@ -23,7 +50,9 @@ export type ValidatorOptions = {
baseval?: string; baseval?: string;
/** autovalOnInvalid is a flag that automatically adds a keyup listener to the input on invalid. */ /** autovalOnInvalid is a flag that automatically adds a keyup listener to the input on invalid. */
autovalOnInvalid?: boolean; autovalOnInvalid?: boolean;
/** func is checked after other validation rules */
func?: (node: InputElement) => boolean | Promise<boolean>; func?: (node: InputElement) => boolean | Promise<boolean>;
/** valfunc is called early in validation */
valfunc?: (val: string) => boolean | Promise<boolean>; 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 = { export type InputState = {
value: string; value: string;
selectionStart: number; selectionStart?: number;
selectionEnd: number; selectionEnd?: number;
}; };
let nextValidatorID = 0; let nextValidatorID = 0;
@@ -128,17 +157,16 @@ export const keydownValidator: Action<
{ {
constrain?: boolean; constrain?: boolean;
} }
> = (el, opts) => { > = (node, opts) => {
const node = el as HTMLInputElement;
// prevent duplicate listeners // prevent duplicate listeners
if (node.dataset.validateKeydown !== undefined) return; if (node.dataset.validateKeydown !== undefined) return;
node.dataset.validateKeydown = 'true'; 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 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) { if (!valid && opts.constrain) {
event.preventDefault(); event.preventDefault();
} }
@@ -191,9 +219,7 @@ export const liveValidator: Action<
{ {
constrain?: boolean; constrain?: boolean;
} }
> = (el, opts) => { > = (node, opts) => {
const node = el as HTMLInputElement;
// prevent duplicate listeners // prevent duplicate listeners
if (node.dataset.validateLive !== undefined) return; if (node.dataset.validateLive !== undefined) return;
node.dataset.validateLive = 'true'; node.dataset.validateLive = 'true';
@@ -205,35 +231,64 @@ export const liveValidator: Action<
} }
// propagateState updates the stored state of the input element. // propagateState updates the stored state of the input element.
const propagateState = (e: HTMLInputElement) => { const propagateState = (e: InputElement) => {
lastState[id] = { lastState[id] = {
value: e.value, value: getNodeValue(e)
selectionStart: e.selectionStart || 0,
selectionEnd: e.selectionEnd || 0
}; };
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. // revertToState reverts the input element to a previous state.
const revertToState = (e: HTMLInputElement, state: InputState) => { const revertToState = (e: InputElement, state: InputState) => {
e.value = state.value; setNodeValue(node, state.value);
e.setSelectionRange(state.selectionStart, state.selectionEnd); 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. // keydownHandler is used to track the last state of the input element.
const keydownHandler = (event: KeyboardEvent) => { const keydownHandler = (event: Event) => {
if (event.target) { if (event.target) {
propagateState(event.target as HTMLInputElement); propagateState(event.target as InputElement);
} }
}; };
// inputHandler is used to validate the input element. // inputHandler is used to validate the input element.
const inputHandler = async (event: Event) => { const inputHandler = async (event: Event) => {
const n = event.target as HTMLInputElement; const n = event.target as InputElement;
const state = lastState[id]; const state = lastState[id];
if (!state || !event.target) return; if (!state || !event.target) return;
// if the input value is the same as the last state, do nothing // 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 // if the input value is different, validate it
const valid = await validateInput(n); const valid = await validateInput(n);
@@ -298,7 +353,7 @@ export const validateInput = async (
input.dataset[DATA_STATE] = valid ? VALIDATOR_VALID : VALIDATOR_INVALID; input.dataset[DATA_STATE] = valid ? VALIDATOR_VALID : VALIDATOR_INVALID;
input.dispatchEvent( input.dispatchEvent(
new InputValidatorEvent('validate', { detail: { value: input.value, valid } }) new InputValidatorEvent('validate', { detail: { value: getNodeValue(input), valid } })
); );
// if invalid, consider autovalOnInvalid // if invalid, consider autovalOnInvalid
@@ -353,7 +408,7 @@ export const isInputValid = async (
): Promise<boolean> => { ): Promise<boolean> => {
const node = input as HTMLInputElement; const node = input as HTMLInputElement;
const val = valueOverride || node.value.trim(); const val = valueOverride || getNodeValue(node);
const opts = getopts(input); const opts = getopts(input);
if (!opts) { if (!opts) {
console.warn('input has no validation rules', node); console.warn('input has no validation rules', node);