141 lines
4 KiB
TypeScript
141 lines
4 KiB
TypeScript
import { useEffect, useId, useRef, useState } from 'react';
|
|
import type { InputHTMLAttributes } from 'react';
|
|
import type { Issue } from '../lib/types';
|
|
import { AlertCircle, Info } from 'lucide-react';
|
|
|
|
const numberFromDraft = (text: string): number | null => {
|
|
if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return null;
|
|
const number = Number(text);
|
|
return Number.isFinite(number) ? number : null;
|
|
};
|
|
|
|
/** Only unfinished typing is local; every complete numeric value is committed immediately. */
|
|
export function NumericInput({
|
|
value,
|
|
onChange,
|
|
onBlur,
|
|
onKeyDown,
|
|
...props
|
|
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> & {
|
|
value: number;
|
|
onChange: (value: number) => void;
|
|
}) {
|
|
const [draft, setDraft] = useState(String(value));
|
|
const emitted = useRef(value);
|
|
useEffect(() => {
|
|
if (value !== emitted.current) setDraft(String(value));
|
|
emitted.current = value;
|
|
}, [value]);
|
|
return (
|
|
<input
|
|
{...props}
|
|
type="text"
|
|
inputMode={props.inputMode ?? (Number(props.min) < 0 ? 'text' : 'decimal')}
|
|
value={draft}
|
|
onChange={(event) => {
|
|
const text = event.target.value;
|
|
setDraft(text);
|
|
const number = numberFromDraft(text);
|
|
if (number !== null && number !== value) {
|
|
emitted.current = number;
|
|
onChange(number);
|
|
}
|
|
}}
|
|
onBlur={(event) => {
|
|
setDraft(String(value));
|
|
onBlur?.(event);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
onKeyDown?.(event);
|
|
if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) {
|
|
event.preventDefault();
|
|
event.currentTarget.blur();
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function NumberField({
|
|
label,
|
|
value,
|
|
onChange,
|
|
unit = 'mm',
|
|
min = 0,
|
|
max,
|
|
step = 1,
|
|
hint,
|
|
tooltip,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
onChange: (n: number) => void;
|
|
unit?: string;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
hint?: string;
|
|
tooltip?: string;
|
|
}) {
|
|
const id = useId();
|
|
const [showTooltip, setShowTooltip] = useState(false);
|
|
return (
|
|
<div className="field">
|
|
<span className="field-label">
|
|
<label htmlFor={id}>{label}</label>
|
|
{tooltip && (
|
|
<span className="field-tooltip">
|
|
<button
|
|
type="button"
|
|
className="tooltip-button"
|
|
aria-label={`What is ${label.toLowerCase()}?`}
|
|
aria-expanded={showTooltip}
|
|
aria-controls={`${id}-tooltip`}
|
|
aria-describedby={showTooltip ? `${id}-tooltip` : undefined}
|
|
onClick={() => setShowTooltip((shown) => !shown)}
|
|
onBlur={() => setShowTooltip(false)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') setShowTooltip(false);
|
|
}}
|
|
>
|
|
?
|
|
</button>
|
|
{showTooltip && (
|
|
<span id={`${id}-tooltip`} role="tooltip" className="tooltip-content">
|
|
{tooltip}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="number-input">
|
|
<NumericInput
|
|
id={id}
|
|
value={value}
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
aria-describedby={hint ? `${id}-hint` : undefined}
|
|
onChange={onChange}
|
|
/>
|
|
<span>{unit}</span>
|
|
</span>
|
|
{hint && <small id={`${id}-hint`}>{hint}</small>}
|
|
</div>
|
|
);
|
|
}
|
|
export function Issues({ issues, compact = false }: { issues: Issue[]; compact?: boolean }) {
|
|
const unique = issues.filter(
|
|
(issue, i) => issues.findIndex((item) => item.message === issue.message) === i,
|
|
);
|
|
return (
|
|
<div className={`issues ${compact ? 'compact' : ''}`} role="status">
|
|
{unique.map((issue, i) => (
|
|
<div className={`issue ${issue.severity}`} key={i}>
|
|
{issue.severity === 'info' ? <Info size={16} /> : <AlertCircle size={16} />}
|
|
<span>{issue.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|