SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
import { FloatingLabelInput } from '@/components/interior/floating-label';
|
||||
|
||||
const CROSSFADE = { type: 'spring', stiffness: 260, damping: 34, mass: 0.8 } as const;
|
||||
const INSTANT = { duration: 0 } as const;
|
||||
|
||||
const LINE = 16;
|
||||
|
||||
export type ValidationStatus = 'idle' | 'pending' | 'valid' | 'invalid';
|
||||
|
||||
export type Validator = (value: string) => string | null;
|
||||
|
||||
export type UseInlineValidationOptions = {
|
||||
value: string;
|
||||
validate: Validator;
|
||||
debounce?: number;
|
||||
validationKey?: unknown;
|
||||
};
|
||||
|
||||
export type UseInlineValidationReturn = {
|
||||
status: ValidationStatus;
|
||||
error: string | null;
|
||||
message: string;
|
||||
touched: boolean;
|
||||
commit: () => void;
|
||||
reset: () => void;
|
||||
fieldProps: {
|
||||
onBlur: () => void;
|
||||
'aria-invalid': boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type Settled = {
|
||||
status: ValidationStatus;
|
||||
error: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const CLEAN: Settled = { status: 'idle', error: null, message: '' };
|
||||
|
||||
export function useInlineValidation({
|
||||
value,
|
||||
validate,
|
||||
debounce = 400,
|
||||
validationKey,
|
||||
}: UseInlineValidationOptions): UseInlineValidationReturn {
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [settled, setSettled] = useState<Settled>(CLEAN);
|
||||
|
||||
const check = useRef(validate);
|
||||
const latest = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
check.current = validate;
|
||||
latest.current = value;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!touched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = check.current(value);
|
||||
const resolved: ValidationStatus = value.length > 0 ? 'valid' : 'idle';
|
||||
|
||||
if (next === null) {
|
||||
setSettled((prev) =>
|
||||
prev.status === resolved && prev.error === null
|
||||
? prev
|
||||
: { status: resolved, error: null, message: prev.message },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSettled((prev) =>
|
||||
prev.status === 'invalid'
|
||||
? prev
|
||||
: { status: 'pending', error: null, message: prev.message },
|
||||
);
|
||||
|
||||
const t = setTimeout(() => {
|
||||
setSettled((prev) =>
|
||||
prev.error === next ? prev : { status: 'invalid', error: next, message: next },
|
||||
);
|
||||
}, debounce);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [value, touched, debounce, validationKey]);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
setTouched(true);
|
||||
const v = latest.current;
|
||||
const next = check.current(v);
|
||||
setSettled((prev) =>
|
||||
next === null
|
||||
? { status: v.length > 0 ? 'valid' : 'idle', error: null, message: prev.message }
|
||||
: { status: 'invalid', error: next, message: next },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setTouched(false);
|
||||
setSettled(CLEAN);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status: settled.status,
|
||||
error: settled.error,
|
||||
message: settled.message,
|
||||
touched,
|
||||
commit,
|
||||
reset,
|
||||
fieldProps: { onBlur: commit, 'aria-invalid': settled.status === 'invalid' },
|
||||
};
|
||||
}
|
||||
|
||||
export type InlineValidationProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
validate: Validator;
|
||||
hint?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'search';
|
||||
autoComplete?: string;
|
||||
inputMode?: React.ComponentProps<'input'>['inputMode'];
|
||||
debounce?: number;
|
||||
validationKey?: unknown;
|
||||
reserveLines?: number;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
spellCheck?: boolean;
|
||||
endAdornment?: React.ReactNode;
|
||||
showValidIcon?: boolean;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InlineValidation({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
validate,
|
||||
hint,
|
||||
id,
|
||||
name,
|
||||
type = 'text',
|
||||
autoComplete,
|
||||
inputMode,
|
||||
debounce = 400,
|
||||
validationKey,
|
||||
reserveLines = 1,
|
||||
disabled = false,
|
||||
required = false,
|
||||
spellCheck,
|
||||
endAdornment,
|
||||
showValidIcon = true,
|
||||
onFocus,
|
||||
onBlur,
|
||||
className = '',
|
||||
}: InlineValidationProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const fade = reduced ? INSTANT : CROSSFADE;
|
||||
|
||||
const auto = useId();
|
||||
const fieldId = id ?? `${auto}-field`;
|
||||
const hintId = `${auto}-hint`;
|
||||
const errorId = `${auto}-error`;
|
||||
|
||||
const { status, error, message, fieldProps } = useInlineValidation({
|
||||
value,
|
||||
validate,
|
||||
debounce,
|
||||
validationKey,
|
||||
});
|
||||
|
||||
const invalid = status === 'invalid';
|
||||
const valid = status === 'valid';
|
||||
|
||||
const described = [hint ? hintId : null, invalid ? errorId : null].filter(Boolean).join(' ');
|
||||
|
||||
const clamp = {
|
||||
display: '-webkit-box' as const,
|
||||
WebkitBoxOrient: 'vertical' as const,
|
||||
WebkitLineClamp: reserveLines,
|
||||
overflow: 'hidden' as const,
|
||||
};
|
||||
|
||||
const statusGlyph = (
|
||||
<span className="pointer-events-none grid size-3.5 shrink-0 place-items-center" aria-hidden>
|
||||
<motion.svg
|
||||
viewBox="0 0 12 12"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
className="col-start-1 row-start-1 text-success"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: showValidIcon && valid ? 1 : 0,
|
||||
scale: showValidIcon && valid ? 1 : 0.7,
|
||||
}}
|
||||
transition={fade}
|
||||
>
|
||||
<path
|
||||
d="M2 6.3 4.7 9 10 3.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
<motion.svg
|
||||
viewBox="0 0 12 12"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
className="col-start-1 row-start-1 text-destructive"
|
||||
initial={false}
|
||||
animate={{ opacity: invalid ? 1 : 0, scale: invalid ? 1 : 0.7 }}
|
||||
transition={fade}
|
||||
>
|
||||
<path d="M6 2v4.4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
|
||||
<rect x="5.15" y="8.4" width="1.7" height="1.7" rx="0.5" fill="currentColor" />
|
||||
</motion.svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<FloatingLabelInput
|
||||
id={fieldId}
|
||||
label={label}
|
||||
name={name}
|
||||
type={type}
|
||||
value={value}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
spellCheck={spellCheck}
|
||||
invalid={invalid}
|
||||
ariaDescribedBy={described || undefined}
|
||||
reserveHintSpace={false}
|
||||
onFocus={onFocus}
|
||||
onBlur={() => {
|
||||
fieldProps.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={onChange}
|
||||
endAdornment={
|
||||
<div className="flex items-center gap-1">
|
||||
{statusGlyph}
|
||||
{endAdornment}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="relative mt-1.5 grid" style={{ height: reserveLines * LINE }}>
|
||||
{hint ? (
|
||||
<motion.p
|
||||
aria-hidden
|
||||
style={clamp}
|
||||
className="col-start-1 row-start-1 text-[11.5px] leading-[16px] text-muted-foreground"
|
||||
initial={false}
|
||||
animate={{ opacity: invalid ? 0 : 1, y: invalid ? 3 : 0 }}
|
||||
transition={fade}
|
||||
>
|
||||
{hint}
|
||||
</motion.p>
|
||||
) : null}
|
||||
|
||||
<motion.p
|
||||
aria-hidden
|
||||
style={clamp}
|
||||
className="col-start-1 row-start-1 text-[11.5px] leading-[16px] text-destructive"
|
||||
initial={false}
|
||||
animate={{ opacity: invalid ? 1 : 0, y: invalid ? 0 : -3 }}
|
||||
transition={fade}
|
||||
>
|
||||
{error ?? message}
|
||||
</motion.p>
|
||||
|
||||
{hint ? (
|
||||
<span id={hintId} className="sr-only">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span
|
||||
id={errorId}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{error ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user