Last active
April 13, 2025 15:06
-
-
Save WarrenBuffering/98105c22ac75e65cf1782571506eee21 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { DropdownOption } from '../types'; | |
import { useRef, useState, useEffect } from 'react'; | |
/*================================================================================ | |
= Core Types = | |
================================================================================*/ | |
export enum InputStatus { | |
PENDING = 'pending', | |
VALID = 'valid', | |
INVALID = 'invalid' | |
} | |
export type ValidationResult = { isValid: true } | { isValid: false; message: string }; | |
export type ValidationMethod<T> = (value: T) => ValidationResult; | |
export type InputValueConfig<T> = { | |
afterChange?: (val?: T) => void; | |
afterValidate?: (data: ValidationResult) => void; | |
beforeChange?: (val?: T) => void; | |
beforeValidate?: (val?: T) => void; | |
initial?: T; | |
maxLength?: number; | |
persistKey?: string; | |
formPrefix?: string; | |
persistDebounceMs?: number; | |
resetOnEmpty?: boolean; | |
sanitize?: (val?: T) => T | undefined; | |
storage?: { | |
getItem(key: string): string | null | Promise<string | null>; | |
setItem(key: string, value: string): void | Promise<void>; | |
removeItem(key: string): void | Promise<void>; | |
}; | |
useSanitized?: boolean; | |
validate?: ValidationMethod<T | undefined>; | |
}; | |
export type InputState<T> = { | |
value?: T; | |
status: InputStatus; | |
message: string; | |
onChange(item: T): void; | |
}; | |
/*================================================================================ | |
= InputValue Class | |
================================================================================*/ | |
export class InputValue<T> { | |
private _value: T | undefined; | |
private _initialValue: T | undefined; | |
private _status: InputStatus = InputStatus.PENDING; | |
private _message: string = ''; | |
private _config: InputValueConfig<T>; | |
private _changeListeners: Array<() => void> = []; | |
private _debounceTimer: ReturnType<typeof setTimeout> | null = null; | |
private _sanitizedValue?: T | undefined; | |
private _persistKey: string | undefined; | |
public readonly state: InputState<T>; | |
constructor(config: InputValueConfig<T>) { | |
this._config = { | |
resetOnEmpty: true, | |
...config | |
}; | |
const initialValue = config.initial !== undefined ? config.initial : ( | |
Array.isArray(config.initial) ? [] as unknown as T : | |
typeof config.initial === 'string' ? '' as unknown as T : | |
typeof config.initial === 'number' ? NaN as unknown as T : | |
null as unknown as T | |
); | |
const hasWindow = typeof window !== 'undefined'; | |
const defaultStorage = hasWindow && window.localStorage ? window.localStorage : null; | |
if (this._config.persistKey && !this._config.storage && !defaultStorage) { | |
console.warn('Persistence is enabled but no storage is available. Provide `storage` in config.'); | |
} | |
const storage = this._config.storage || (typeof window !== 'undefined' ? window.localStorage : undefined); | |
let persistKey = this._config.persistKey; | |
if (persistKey && this._config.formPrefix) { | |
persistKey = `${this._config.formPrefix}.${persistKey}`; | |
} | |
this._persistKey = persistKey; | |
const processedInitialValue = this._config.useSanitized && typeof this._config.sanitize === 'function' | |
? (this._config.sanitize(initialValue) ?? undefined) | |
: initialValue; | |
this._value = processedInitialValue; | |
this._initialValue = processedInitialValue; | |
this.state = { | |
value: this._value, | |
status: this._status, | |
message: this._message, | |
onChange: this.onChange.bind(this) | |
}; | |
if (persistKey && storage) { | |
const stored = storage.getItem(persistKey); | |
if (stored instanceof Promise) { | |
stored.then(value => { | |
if (value !== null) { | |
try { | |
const parsed = JSON.parse(value); | |
this._value = parsed; | |
this._initialValue = parsed; | |
this.updateState(); | |
this.notifyListeners(); | |
} catch (e) { | |
console.warn('Failed to parse stored value:', e); | |
} | |
} | |
}).catch(err => { | |
console.warn('Error loading persisted value:', err); | |
}); | |
} else if (stored !== null) { | |
try { | |
const parsed = JSON.parse(stored); | |
this._value = parsed; | |
this._initialValue = parsed; | |
this.updateState(); | |
} catch (e) { | |
console.warn('Failed to parse stored value:', e); | |
} | |
} | |
} | |
} | |
public readonly onChange = (value: T): void => { | |
if (this._config.beforeChange) this._config.beforeChange(value); | |
const sanitizedValue = this._config.sanitize ? this._config.sanitize(value) : undefined; | |
this._sanitizedValue = sanitizedValue; | |
this._value = this._config.useSanitized ? this._sanitizedValue : value; | |
if (this._persistKey && this._config.storage) { | |
if (this._debounceTimer) { | |
clearTimeout(this._debounceTimer); | |
} | |
const delay = this._config.persistDebounceMs ?? 300; | |
this._debounceTimer = setTimeout(() => { | |
try { | |
if (this._persistKey && this._config.storage) { | |
this._config.storage.setItem(this._persistKey, JSON.stringify(value)); | |
} | |
} catch (e) { | |
console.warn('Failed to persist value:', e); | |
} | |
}, delay); | |
} | |
this.runValidate(value); | |
this.updateState(); | |
this.notifyListeners(); | |
if (this._config.afterChange) this._config.afterChange(value); | |
}; | |
public readonly flush = (): void => { | |
if (this._persistKey && this._config.storage) { | |
if (this._debounceTimer) { | |
clearTimeout(this._debounceTimer); | |
this._debounceTimer = null; | |
} | |
try { | |
this._config.storage.setItem(this._persistKey, JSON.stringify(this._value)); | |
} catch (e) { | |
console.warn('Failed to flush value to storage:', e); | |
} | |
} | |
}; | |
public readonly validate = (): void => { | |
if (this._config.validate) { | |
this.runValidate(this._value); | |
this.updateState(); | |
this.notifyListeners(); | |
} else { | |
this._status = InputStatus.VALID; | |
this._message = ''; | |
this.updateState(); | |
this.notifyListeners(); | |
} | |
}; | |
public readonly clearAll = (): void => { | |
this._value = this._initialValue; | |
this._status = InputStatus.PENDING; | |
this._message = ''; | |
this.updateState(); | |
this.notifyListeners(); | |
// Also clear from storage if persisted | |
if (this._persistKey && this._config.storage) { | |
try { | |
this._config.storage.removeItem(this._persistKey); | |
} catch (e) { | |
console.warn('Failed to remove value from storage:', e); | |
} | |
} | |
}; | |
public readonly clearStatus = (): void => { | |
this._status = InputStatus.PENDING; | |
this._message = ''; | |
this.updateState(); | |
this.notifyListeners(); | |
}; | |
public readonly clearMessage = (): void => { | |
this._message = ''; | |
this.updateState(); | |
this.notifyListeners(); | |
}; | |
public readonly invalidate = (msg: string): void => { | |
this._status = InputStatus.INVALID; | |
this._message = msg || ''; | |
this.updateState(); | |
this.notifyListeners(); | |
}; | |
public readonly setStatus = (status: InputStatus): void => { | |
this._status = status; | |
this.updateState(); | |
this.notifyListeners(); | |
}; | |
public readonly setStatusMessage = (message: string): void => { | |
this._message = message; | |
this.updateState(); | |
this.notifyListeners(); | |
}; | |
public readonly addChangeListener = (listener: () => void): void => { | |
this._changeListeners.push(listener); | |
}; | |
public readonly removeChangeListener = (listener: () => void): void => { | |
this._changeListeners = this._changeListeners.filter(l => l !== listener); | |
}; | |
public get isValid(): boolean { | |
return this._status === InputStatus.VALID; | |
} | |
public get isInvalid(): boolean { | |
return this._status === InputStatus.INVALID; | |
} | |
public get isPending(): boolean { | |
return this._status === InputStatus.PENDING; | |
} | |
public get value(): T | undefined { | |
return this._value; | |
} | |
public get status(): InputStatus { | |
return this._status; | |
} | |
public get sanitizedValue(): T | undefined { | |
return this._sanitizedValue; | |
} | |
public get message(): string { | |
return this._message; | |
} | |
public get persistKey(): string | undefined { | |
return this._persistKey; | |
} | |
private readonly runValidate = (val: T | undefined): void => { | |
if (this._config.beforeValidate) this._config.beforeValidate(val); | |
const isEmpty = | |
val === undefined || | |
val === null || | |
(typeof val === 'string' && val === '') || | |
(Array.isArray(val) && val.length === 0) || | |
(typeof val === 'number' && isNaN(val)); | |
if (isEmpty && this._config.resetOnEmpty) { | |
this._status = InputStatus.PENDING; | |
this._message = ''; | |
return; | |
} | |
if (!this._config.validate) { | |
this._status = InputStatus.VALID; | |
this._message = ''; | |
return; | |
} | |
if (this._config.validate && (!isEmpty || !this._config.resetOnEmpty)) { | |
const result = this._config.validate(val); | |
if (result.isValid) { | |
this._status = InputStatus.VALID; | |
this._message = ''; | |
} else { | |
this._status = InputStatus.INVALID; | |
this._message = result.message || 'Invalid'; | |
} | |
if (this._config.afterValidate) this._config.afterValidate(result); | |
} | |
}; | |
private readonly notifyListeners = (): void => { | |
for (let i = 0; i < this._changeListeners.length; i++) { | |
try { | |
this._changeListeners[i](); | |
} catch (e) { | |
console.warn('Error in change listener:', e); | |
} | |
} | |
}; | |
private readonly updateState = (): void => { | |
(this.state as any).value = this._value; | |
(this.state as any).status = this._status; | |
(this.state as any).message = this._message; | |
}; | |
} | |
/*================================================================================ | |
= React Hooks = | |
================================================================================*/ | |
export function useInputValue<T>(config: InputValueConfig<T> = {}): InputValue<T> { | |
const [, forceUpdate] = useState({}); | |
const ref = useRef<InputValue<T>>(); | |
if (!ref.current) { | |
ref.current = new InputValue<T>(config); | |
} | |
useEffect(() => { | |
const rerender = () => forceUpdate({}); | |
const currentInput = ref.current; | |
if (currentInput) { | |
currentInput.addChangeListener(rerender); | |
return () => { | |
currentInput.removeChangeListener(rerender); | |
if (currentInput.persistKey) { | |
currentInput.flush(); | |
} | |
}; | |
} | |
return undefined; | |
}, []); | |
return ref.current as InputValue<T>; | |
} | |
export function clearFormFromStorage( | |
formPrefix: string, | |
storage: Storage = typeof window !== 'undefined' ? window.localStorage : undefined as any | |
): void { | |
if (!storage) return; | |
const keysToRemove: string[] = []; | |
for (let i = 0; i < storage.length; i++) { | |
const key = storage.key(i); | |
if (key && key.startsWith(`${formPrefix}.`)) { | |
keysToRemove.push(key); | |
} | |
} | |
keysToRemove.forEach(key => { | |
storage.removeItem(key); | |
}); | |
} | |
/*================================================================================ | |
= Dropdown Option Input = | |
================================================================================*/ | |
export type DropdownInputConfig<T extends DropdownOption> = { | |
initial?: string; | |
options: T[]; | |
validate?: ValidationMethod<string | undefined>; | |
resetOnEmpty?: boolean; | |
persistKey?: string; | |
formPrefix?: string; | |
storage?: InputValueConfig<string | undefined>['storage']; | |
required?: boolean; | |
allowOnlyListedValues?: boolean; | |
}; | |
export function useDropdownInputValue<T extends DropdownOption>( | |
config: DropdownInputConfig<T> | |
): InputValue<string | undefined> { | |
const optionsValidator: ValidationMethod<string | undefined> = function(val?: string): ValidationResult { | |
if ((val === undefined || val === '') && !config.required) { | |
return { isValid: true }; | |
} | |
if ((val === undefined || val === '') && config.required) return { isValid: false, message: 'Please select an option' }; | |
if (config.allowOnlyListedValues !== false && val !== undefined) { | |
const exists = config.options.some(function(opt: T): boolean { | |
return opt.value === val; | |
}); | |
if (!exists) { | |
return { isValid: false, message: 'Please select a valid option' }; | |
} | |
} | |
return { isValid: true }; | |
}; | |
const combinedValidator: ValidationMethod<string | undefined> = function(val?: string): ValidationResult { | |
const baseResult = optionsValidator(val); | |
if (!baseResult.isValid) return baseResult; | |
return config.validate ? config.validate(val) : { isValid: true }; | |
}; | |
return useInputValue<string | undefined>({ | |
initial: config.initial || undefined, | |
validate: combinedValidator, | |
resetOnEmpty: config.resetOnEmpty === undefined ? true : config.resetOnEmpty, | |
persistKey: config.persistKey, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
} | |
export function findOptionByValue<T extends DropdownOption>( | |
options: T[], | |
value?: string | |
): T | undefined { | |
if (!value) return undefined; | |
return options.find(function(option: T): boolean { | |
return option.value === value; | |
}); | |
} | |
export function filterOptionsByText<T extends DropdownOption>( | |
options: T[], | |
searchText: string | |
): T[] { | |
if (!searchText.trim()) { | |
return options; | |
} | |
const searchLower = searchText.toLowerCase(); | |
return options.filter(function(opt: T): boolean { | |
if (opt.name.toLowerCase().includes(searchLower)) { | |
return true; | |
} | |
if (opt.alt_names && Array.isArray(opt.alt_names)) { | |
for (let i = 0; i < opt.alt_names.length; i++) { | |
const altName = opt.alt_names[i]; | |
if (typeof altName === 'string' && altName.toLowerCase().includes(searchLower)) { | |
return true; | |
} | |
} | |
} | |
return false; | |
}); | |
} | |
/*================================================================================ | |
= Integer Input = | |
================================================================================*/ | |
export type IntegerInputConfig = { | |
initial?: number; | |
min?: number; | |
max?: number; | |
resetOnEmpty?: boolean; | |
persistKey?: string; | |
formPrefix?: string; | |
storage?: InputValueConfig<number>['storage']; | |
}; | |
export function useIntegerInputValue(config: IntegerInputConfig = {}): InputValue<number> { | |
return useInputValue<number>({ | |
initial: config.initial ?? NaN, | |
validate: (val) => validateInteger(val, config.min, config.max), | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey, | |
formPrefix: config.formPrefix, | |
sanitize: sanitizeInteger, | |
storage: config.storage, | |
}); | |
} | |
export function sanitizeInteger(val: unknown, config?: IntegerInputConfig): number { | |
if (typeof val === 'number' || typeof val === 'string') { | |
const parsed = typeof val === 'number' | |
? val | |
: Number.parseInt(val, 10); | |
const rounded = Math.round(parsed); | |
if (config?.min !== undefined && rounded < config.min) return config.min; | |
if (config?.max !== undefined && rounded > config.max) return config.max; | |
return rounded; | |
} | |
return NaN; | |
} | |
export function validateInteger(val?: number, min?: number, max?: number): ValidationResult { | |
if (typeof val !== 'number' || isNaN(val)) { | |
return { isValid: false, message: 'Must be a number' }; | |
} | |
if (!Number.isInteger(val)) { | |
return { isValid: false, message: 'Value must be an integer' }; | |
} | |
if (min !== undefined && val < min) { | |
return { isValid: false, message: `Must be ≥ ${min}` }; | |
} | |
if (max !== undefined && val > max) { | |
return { isValid: false, message: `Must be ≤ ${max}` }; | |
} | |
return { isValid: true }; | |
} | |
/*================================================================================ | |
= Float Input = | |
================================================================================*/ | |
export type FloatInputConfig = { | |
initial?: number; | |
precision: number; | |
scale: number; | |
min?: number; | |
max?: number; | |
resetOnEmpty?: boolean; | |
persistKey?: string; | |
formPrefix?: string; | |
storage?: InputValueConfig<number>['storage']; | |
}; | |
export function useFloatInputValue(config: FloatInputConfig): InputValue<number> { | |
return useInputValue<number>({ | |
initial: config.initial ?? NaN, | |
validate: (val?: number) => validateFloat( | |
config.precision, | |
config.scale, | |
val, | |
config.min, | |
config.max | |
), | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey, | |
formPrefix: config.formPrefix, | |
sanitize: (val) => sanitizeFloat(val, config.precision, config.scale, config.min, config.max), | |
storage: config.storage | |
}); | |
} | |
export function sanitizeFloat(val: unknown, precision: number, scale: number, min?: number, max?: number): number { | |
if (typeof val === 'number' || typeof val === 'string') { | |
const parsed = typeof val === 'number' | |
? val | |
: Number.parseFloat(val); | |
const multiplier = Math.pow(10, scale); | |
const rounded = Math.round(parsed * multiplier) / multiplier; | |
const strValue = rounded.toString().replace('.', ''); | |
if (strValue.replace('-', '').length > precision) { | |
const factor = Math.pow(10, precision - scale); | |
return Math.round(rounded * factor) / factor; | |
} | |
if (min !== undefined && rounded < min) return min; | |
if (max !== undefined && rounded > max) return max; | |
return rounded; | |
} | |
return NaN; | |
} | |
export function validateFloat(precision: number, scale: number, val?: number, min?: number, max?: number): ValidationResult { | |
if (typeof val !== 'number' || isNaN(val)) return { isValid: false, message: 'Must be a number' }; | |
const parts = val.toString().split('.'); | |
const intPart = parts[0] || ''; | |
const decPart = parts.length > 1 ? parts[1] : ''; | |
if (intPart.replace('-', '').length + decPart.length > precision) return { isValid: false, message: `Max precision: ${precision}` }; | |
if (decPart.length > scale) return { isValid: false, message: `Max scale: ${scale}` }; | |
if (min !== undefined && val < min) return { isValid: false, message: `Must be ≥ ${min}` }; | |
if (max !== undefined && val > max) return { isValid: false, message: `Must be ≤ ${max}` }; | |
return { isValid: true }; | |
} | |
/*================================================================================ | |
= String Input = | |
================================================================================*/ | |
export type StringInputConfig = { | |
initial?: string; | |
pattern?: RegExp; | |
minLength?: number; | |
maxLength?: number; | |
validate?: ValidationMethod<string | undefined>; | |
resetOnEmpty?: boolean; | |
persistKey?: string; | |
formPrefix?: string; | |
storage?: InputValueConfig<string>['storage']; | |
}; | |
export function validatePattern(pattern: RegExp): ValidationMethod<string | undefined> { | |
return function(val?: string): ValidationResult { | |
if (val === undefined || val === '') { | |
return { isValid: true }; | |
} | |
if (typeof val !== 'string') { | |
return { isValid: false, message: 'Value must be a string' }; | |
} | |
return pattern.test(val) | |
? { isValid: true } | |
: { isValid: false, message: `Value doesn't match the required pattern` }; | |
}; | |
} | |
export function useStringInputValue(config: StringInputConfig = {}): InputValue<string> { | |
const patternValidator = config.pattern ? validatePattern(config.pattern) : undefined; | |
const lengthValidator = config.minLength || config.maxLength | |
? function(val?: string): ValidationResult { | |
return validateString(val, config.minLength, config.maxLength); | |
} | |
: undefined; | |
const combinedValidator: ValidationMethod<string | undefined> = function(val?: string): ValidationResult { | |
if (val === undefined || val === '') { | |
return { isValid: true }; | |
} | |
if (patternValidator) { | |
const patternResult = patternValidator(val); | |
if (!patternResult.isValid) return patternResult; | |
} | |
if (lengthValidator) { | |
const lengthResult = lengthValidator(val); | |
if (!lengthResult.isValid) return lengthResult; | |
} | |
return config.validate ? config.validate(val) : { isValid: true }; | |
}; | |
return useInputValue<string>({ | |
initial: config.initial ?? '', | |
maxLength: config.maxLength, | |
persistKey: config.persistKey, | |
formPrefix: config.formPrefix, | |
resetOnEmpty: config.resetOnEmpty, | |
sanitize: (val?: string) => sanitizeString(val, config.maxLength), | |
storage: config.storage, | |
validate: combinedValidator, | |
}); | |
} | |
export function sanitizeString(val?: string | undefined, maxLength?: number): string { | |
if (val === undefined) { | |
return ''; | |
} | |
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') { | |
let processed = String(val).trim(); | |
processed = processed.replace(/[\x00-\x1F\x7F]/g, ''); | |
processed = processed.replace(/\s+/g, ' '); | |
if (maxLength !== undefined) { | |
processed = processed.slice(0, maxLength); | |
} | |
return processed; | |
} | |
return ''; | |
} | |
export function validateString(val?: string, minLength?: number, maxLength?: number): ValidationResult { | |
if (val === undefined || val === '') { | |
return { isValid: true }; | |
} | |
if (typeof val !== 'string') { | |
return { isValid: false, message: 'Value must be a string' }; | |
} | |
if (minLength !== undefined && val.length < minLength) { | |
return { isValid: false, message: `Minimum length is ${minLength} characters` }; | |
} | |
if (maxLength !== undefined && val.length > maxLength) { | |
return { isValid: false, message: `Maximum length is ${maxLength} characters` }; | |
} | |
return { isValid: true }; | |
} | |
/*================================================================================ | |
= Email Input = | |
================================================================================*/ | |
export type EmailInputConfig = { | |
initial?: string; | |
validate?: ValidationMethod<string | undefined>; | |
resetOnEmpty?: boolean; | |
maxLength?: number; | |
persistKey?: string; | |
formPrefix?: string; | |
storage?: InputValueConfig<string>['storage']; | |
}; | |
export function useEmailInputValue(config: EmailInputConfig = {}): InputValue<string> { | |
const baseValidator = validateEmail(); | |
const combinedValidator: ValidationMethod<string | undefined> = (val?: string) => { | |
if (val === undefined || val === '') { | |
return { isValid: true }; | |
} | |
const base = baseValidator(val); | |
if (!base.isValid) return base; | |
return config.validate ? config.validate(val) : { isValid: true }; | |
}; | |
return useInputValue<string>({ | |
initial: config.initial ?? '', | |
validate: combinedValidator, | |
resetOnEmpty: config.resetOnEmpty, | |
maxLength: config.maxLength, | |
persistKey: config.persistKey, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
} | |
export function validateEmail(): ValidationMethod<string | undefined> { | |
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | |
return function(val?: string): ValidationResult { | |
if (val === undefined || val === '') { | |
return { isValid: true }; | |
} | |
if (typeof val !== 'string') { | |
return { isValid: false, message: 'Value must be a string' }; | |
} | |
return emailPattern.test(val) | |
? { isValid: true } | |
: { isValid: false, message: 'Invalid email format' }; | |
}; | |
} | |
/*================================================================================ | |
= Password Input = | |
================================================================================*/ | |
export type PasswordInputConfig = { | |
initial?: string; | |
minLen?: number; | |
validate?: ValidationMethod<string | undefined>; | |
resetOnEmpty?: boolean; | |
maxLength?: number; | |
persistKey?: string; | |
formPrefix?: string; | |
storage?: InputValueConfig<string>['storage']; | |
}; | |
export function usePasswordInputValue(config: PasswordInputConfig = {}): InputValue<string> { | |
const baseValidator = validatePassword(config.minLen); | |
const combinedValidator: ValidationMethod<string | undefined> = (val?: string) => { | |
if (val === undefined || val === '') { | |
return { isValid: true }; | |
} | |
const base = baseValidator(val); | |
if (!base.isValid) return base; | |
return config.validate ? config.validate(val) : { isValid: true }; | |
}; | |
return useInputValue<string>({ | |
initial: config.initial ?? '', | |
validate: combinedValidator, | |
resetOnEmpty: config.resetOnEmpty, | |
maxLength: config.maxLength, | |
persistKey: config.persistKey, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
} | |
export function validatePassword(minLen: number = 8): ValidationMethod<string | undefined> { | |
return function(val?: string): ValidationResult { | |
if (val === undefined || val === '') { | |
return { isValid: true }; | |
} | |
if (typeof val !== 'string') { | |
return { isValid: false, message: 'Value must be a string' }; | |
} | |
return val.length >= minLen | |
? { isValid: true } | |
: { isValid: false, message: `Min length is ${minLen} characters` }; | |
}; | |
} | |
/*================================================================================ | |
= Date Input = | |
================================================================================*/ | |
export type ComposedDateInput = { | |
day: InputValue<number>; | |
month: InputValue<number>; | |
year: InputValue<number>; | |
date: Date | undefined; | |
status: InputStatus; | |
message: string; | |
validate(): void; | |
clear(): void; | |
}; | |
export type ComposedDateInputConfig = { | |
initial?: Date; | |
minDate?: Date; | |
maxDate?: Date; | |
beforeDate?: Date; | |
afterDate?: Date; | |
validate?: ValidationMethod<Date | undefined>; | |
resetOnEmpty?: boolean; | |
persistKey?: string; | |
formPrefix?: string; | |
storage?: InputValueConfig<number>['storage']; | |
}; | |
export function useComposedDateInput(config: ComposedDateInputConfig = {}): ComposedDateInput { | |
const initialDate = config.initial; | |
const initialDay = initialDate?.getDate(); | |
const initialMonth = initialDate ? initialDate.getMonth() + 1 : undefined; | |
const initialYear = initialDate?.getFullYear(); | |
const year = useIntegerInputValue({ | |
initial: initialYear, | |
min: 1000, | |
max: 9999, | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey ? `${config.persistKey}.year` : undefined, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
const month = useIntegerInputValue({ | |
initial: initialMonth, | |
min: 1, | |
max: 12, | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey ? `${config.persistKey}.month` : undefined, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
const day = useIntegerInputValue({ | |
initial: initialDay, | |
min: 1, | |
max: 31, | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey ? `${config.persistKey}.day` : undefined, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
const rawDate = sanitizeDayMonthYear({ | |
day: day.value, | |
month: month.value, | |
year: year.value | |
}); | |
const builtInValidator = validateDate({ | |
min: config.minDate, | |
max: config.maxDate, | |
before: config.beforeDate, | |
after: config.afterDate | |
}); | |
const validationResult: ValidationResult = (() => { | |
if (rawDate === undefined) { | |
return { isValid: false, message: 'Incomplete or invalid date' }; | |
} | |
const base = builtInValidator(rawDate); | |
if (!base.isValid) return base; | |
if (config.validate !== undefined) { | |
return config.validate(rawDate); | |
} | |
return { isValid: true }; | |
})(); | |
const status = validationResult.isValid ? InputStatus.VALID : InputStatus.INVALID; | |
const message = validationResult.isValid ? '' : validationResult.message; | |
return { | |
day, | |
month, | |
year, | |
date: rawDate, | |
status, | |
message, | |
validate: function(): void { | |
day.validate(); | |
month.validate(); | |
year.validate(); | |
}, | |
clear: function(): void { | |
day.clearAll(); | |
month.clearAll(); | |
year.clearAll(); | |
} | |
}; | |
} | |
export function sanitizeDayMonthYear(val: { | |
day?: number; | |
month?: number; | |
year?: number; | |
}): Date | undefined { | |
if ( | |
typeof val.day !== 'number' || isNaN(val.day) || | |
typeof val.month !== 'number' || isNaN(val.month) || | |
typeof val.year !== 'number' || isNaN(val.year) | |
) { | |
return undefined; | |
} | |
const date = new Date(val.year, val.month - 1, val.day); | |
if ( | |
date.getFullYear() !== val.year || | |
date.getMonth() !== val.month - 1 || | |
date.getDate() !== val.day | |
) { | |
return undefined; | |
} | |
return date; | |
} | |
export function validateDate(args?: { | |
min?: Date; | |
max?: Date; | |
before?: Date; | |
after?: Date; | |
}): ValidationMethod<Date | undefined> { | |
const formatter = new Intl.DateTimeFormat(undefined, { | |
year: 'numeric', | |
month: '2-digit', | |
day: '2-digit' | |
}); | |
return function(val?: Date): ValidationResult { | |
if (val === undefined) { | |
return { isValid: true }; | |
} | |
if (!(val instanceof Date) || isNaN(val.getTime())) { | |
return { isValid: false, message: 'Invalid date' }; | |
} | |
if (args?.min !== undefined && val < args.min) { | |
const minFormatted = formatter.format(args.min); | |
return { isValid: false, message: `Date must be on or after ${minFormatted}` }; | |
} | |
if (args?.max !== undefined && val > args.max) { | |
const maxFormatted = formatter.format(args.max); | |
return { isValid: false, message: `Date must be on or before ${maxFormatted}` }; | |
} | |
if (args?.before !== undefined && val >= args.before) { | |
const beforeFormatted = formatter.format(args.before); | |
return { isValid: false, message: `Date must be before ${beforeFormatted}` }; | |
} | |
if (args?.after !== undefined && val <= args.after) { | |
const afterFormatted = formatter.format(args.after); | |
return { isValid: false, message: `Date must be after ${afterFormatted}` }; | |
} | |
return { isValid: true }; | |
}; | |
} | |
/*================================================================================ | |
= Time Input = | |
================================================================================*/ | |
export type TimeValue = { | |
hours: number; | |
minutes: number; | |
seconds?: number; | |
milliseconds?: number; | |
}; | |
export type ComposedTimeInput = { | |
hours: InputValue<number>; | |
minutes: InputValue<number>; | |
seconds: InputValue<number>; | |
milliseconds: InputValue<number>; | |
time: TimeValue | undefined; | |
status: InputStatus; | |
message: string; | |
validate(): void; | |
clear(): void; | |
}; | |
export function useComposedTimeInput(config: { | |
initial?: TimeValue; | |
minTime?: TimeValue; | |
maxTime?: TimeValue; | |
beforeTime?: TimeValue; | |
afterTime?: TimeValue; | |
validate?: ValidationMethod<TimeValue | undefined>; | |
resetOnEmpty?: boolean; | |
persistKey?: string; | |
formPrefix?: string; | |
storage?: InputValueConfig<number>['storage']; | |
}): ComposedTimeInput { | |
const hours = useIntegerInputValue({ | |
initial: config.initial?.hours ?? NaN, | |
min: 0, | |
max: 23, | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey ? `${config.persistKey}.hours` : undefined, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
const minutes = useIntegerInputValue({ | |
initial: config.initial?.minutes ?? NaN, | |
min: 0, | |
max: 59, | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey ? `${config.persistKey}.minutes` : undefined, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
const seconds = useIntegerInputValue({ | |
initial: config.initial?.seconds ?? NaN, | |
min: 0, | |
max: 59, | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey ? `${config.persistKey}.seconds` : undefined, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
const milliseconds = useIntegerInputValue({ | |
initial: config.initial?.milliseconds ?? NaN, | |
min: 0, | |
max: 999, | |
resetOnEmpty: config.resetOnEmpty, | |
persistKey: config.persistKey ? `${config.persistKey}.milliseconds` : undefined, | |
formPrefix: config.formPrefix, | |
storage: config.storage | |
}); | |
const time: TimeValue | undefined = sanitizeTimeParts({ | |
hours: hours.value, | |
minutes: minutes.value, | |
seconds: seconds.value, | |
milliseconds: milliseconds.value | |
}); | |
const validator = validateTime({ | |
min: config.minTime, | |
max: config.maxTime, | |
before: config.beforeTime, | |
after: config.afterTime, | |
includeSeconds: true, | |
includeMilliseconds: true | |
}); | |
const baseResult = validator(time); | |
const finalResult = config.validate ? config.validate(time) : { isValid: true } as const; | |
const result = baseResult.isValid ? finalResult : baseResult; | |
const status = result.isValid ? InputStatus.VALID : InputStatus.INVALID; | |
const message = result.isValid ? '' : result.message; | |
return { | |
hours, | |
minutes, | |
seconds, | |
milliseconds, | |
time, | |
status, | |
message, | |
validate: function(): void { | |
hours.validate(); | |
minutes.validate(); | |
seconds.validate(); | |
milliseconds.validate(); | |
}, | |
clear: function(): void { | |
hours.clearAll(); | |
minutes.clearAll(); | |
seconds.clearAll(); | |
milliseconds.clearAll(); | |
} | |
}; | |
} | |
export function compareTime(a: TimeValue, b: TimeValue): number { | |
const aMs = (a.hours * 3600000) + (a.minutes * 60000) + ((a.seconds ?? 0) * 1000) + (a.milliseconds ?? 0); | |
const bMs = (b.hours * 3600000) + (b.minutes * 60000) + ((b.seconds ?? 0) * 1000) + (b.milliseconds ?? 0); | |
return aMs - bMs; | |
} | |
export function formatTime(time: TimeValue, includeSeconds = false, includeMilliseconds = false): string { | |
const h = time.hours.toString().padStart(2, '0'); | |
const m = time.minutes.toString().padStart(2, '0'); | |
const s = (time.seconds ?? 0).toString().padStart(2, '0'); | |
const ms = (time.milliseconds ?? 0).toString().padStart(3, '0'); | |
let formatted = `${h}:${m}`; | |
if (includeSeconds || time.seconds !== undefined || time.milliseconds !== undefined) { | |
formatted += `:${s}`; | |
} | |
if (includeMilliseconds || time.milliseconds !== undefined) { | |
formatted += `.${ms}`; | |
} | |
return formatted; | |
} | |
export function sanitizeTimeParts(raw: { | |
hours?: number; | |
minutes?: number; | |
seconds?: number; | |
milliseconds?: number; | |
}): TimeValue | undefined { | |
const hours = typeof raw.hours === 'number' && !isNaN(raw.hours) ? raw.hours : NaN; | |
const minutes = typeof raw.minutes === 'number' && !isNaN(raw.minutes) ? raw.minutes : NaN; | |
if (isNaN(hours) || isNaN(minutes)) return undefined; | |
const seconds = typeof raw.seconds === 'number' && !isNaN(raw.seconds) ? raw.seconds : 0; | |
const milliseconds = typeof raw.milliseconds === 'number' && !isNaN(raw.milliseconds) ? raw.milliseconds : 0; | |
return { | |
hours, | |
minutes, | |
seconds, | |
milliseconds | |
}; | |
} | |
export function validateTime(args?: { | |
min?: TimeValue; | |
max?: TimeValue; | |
before?: TimeValue; | |
after?: TimeValue; | |
includeSeconds?: boolean; | |
includeMilliseconds?: boolean; | |
}): ValidationMethod<TimeValue | undefined> { | |
return function(val?: TimeValue): ValidationResult { | |
if (val === undefined) return { isValid: true }; | |
if ( | |
typeof val.hours !== 'number' || val.hours < 0 || val.hours > 23 || | |
typeof val.minutes !== 'number' || val.minutes < 0 || val.minutes > 59 || | |
(val.seconds !== undefined && (val.seconds < 0 || val.seconds > 59)) || | |
(val.milliseconds !== undefined && (val.milliseconds < 0 || val.milliseconds > 999)) | |
) { | |
return { isValid: false, message: 'Invalid time format' }; | |
} | |
const includeSeconds = args?.includeSeconds ?? false; | |
const includeMilliseconds = args?.includeMilliseconds ?? false; | |
if (args?.min && compareTime(val, args.min) < 0) { | |
return { isValid: false, message: `Time must be at or after ${formatTime(args.min, includeSeconds, includeMilliseconds)}` }; | |
} | |
if (args?.max && compareTime(val, args.max) > 0) { | |
return { isValid: false, message: `Time must be at or before ${formatTime(args.max, includeSeconds, includeMilliseconds)}` }; | |
} | |
if (args?.before && compareTime(val, args.before) >= 0) { | |
return { isValid: false, message: `Time must be before ${formatTime(args.before, includeSeconds, includeMilliseconds)}` }; | |
} | |
if (args?.after && compareTime(val, args.after) <= 0) { | |
return { isValid: false, message: `Time must be after ${formatTime(args.after, includeSeconds, includeMilliseconds)}` }; | |
} | |
return { isValid: true }; | |
}; | |
} | |
/*================================================================================ | |
= DateTime Helpers | |
================================================================================*/ | |
export function getZonedDateTime(input: { | |
day: number; | |
month: number; | |
year: number; | |
time: TimeValue; | |
timezone: string; | |
}): { | |
localZonedDate: Date; | |
utcDate: Date; | |
} | undefined { | |
const { day, month, year, time, timezone } = input; | |
if ( | |
typeof day !== 'number' || isNaN(day) || | |
typeof month !== 'number' || isNaN(month) || | |
typeof year !== 'number' || isNaN(year) | |
) { | |
return undefined; | |
} | |
const hours = time.hours ?? 0; | |
const minutes = time.minutes ?? 0; | |
const seconds = time.seconds ?? 0; | |
const milliseconds = time.milliseconds ?? 0; | |
// Build a fake local date just to get a timestamp to format against | |
const naiveDate = new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds, milliseconds)); | |
const formatter = new Intl.DateTimeFormat('en-US', { | |
timeZone: timezone, | |
year: 'numeric', | |
month: '2-digit', | |
day: '2-digit', | |
hour: '2-digit', | |
minute: '2-digit', | |
second: '2-digit', | |
hour12: false | |
}); | |
const parts = formatter.formatToParts(naiveDate); | |
const getPart = (type: string) => Number(parts.find(p => p.type === type)?.value); | |
const zoned = new Date( | |
Date.UTC( | |
getPart('year'), | |
getPart('month') - 1, | |
getPart('day'), | |
getPart('hour'), | |
getPart('minute'), | |
getPart('second'), | |
milliseconds | |
) | |
); | |
return { | |
localZonedDate: zoned, | |
utcDate: new Date(zoned.getTime()) | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment