Skip to content

Instantly share code, notes, and snippets.

@WarrenBuffering
Last active April 13, 2025 15:06
Show Gist options
  • Save WarrenBuffering/98105c22ac75e65cf1782571506eee21 to your computer and use it in GitHub Desktop.
Save WarrenBuffering/98105c22ac75e65cf1782571506eee21 to your computer and use it in GitHub Desktop.
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