Update input field error/warning behavior and time picker handling

This commit is contained in:
2025-12-03 22:38:59 +00:00
parent 41ffce1c23
commit 63d6124ce3
6 changed files with 154 additions and 71 deletions

View File

@@ -93,6 +93,9 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
const duplicateTimeCount = day.doses.filter(d => d.time === dose.time).length;
const hasDuplicateTime = duplicateTimeCount > 1;
// Check for zero dose
const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0';
return (
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto] gap-3 items-center">
<FormTimeInput
@@ -110,7 +113,9 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
min={0}
unit="mg"
required={true}
warning={isZeroDose}
errorMessage={t('errorNumberRequired')}
warningMessage={t('warningZeroDose')}
/>
<Button
onClick={() => onRemoveDose(day.id, dose.id)}

View File

@@ -50,9 +50,10 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
className,
...props
}, ref) => {
const [showError, setShowError] = React.useState(false)
const [showWarning, setShowWarning] = React.useState(false)
const [, setShowError] = React.useState(false)
const [, setShowWarning] = React.useState(false)
const [touched, setTouched] = React.useState(false)
const [isFocused, setIsFocused] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
// Check if value is invalid (check validity regardless of touch state)
@@ -114,20 +115,25 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
}
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const val = e.target.value
const inputValue = e.target.value.trim()
setTouched(true)
setIsFocused(false)
setShowError(false)
setShowWarning(false)
if (val === '' && !allowEmpty) {
if (inputValue === '' && !allowEmpty) {
// Update parent with empty value so validation works
onChange('')
return
}
if (val !== '' && !isNaN(Number(val))) {
onChange(formatValue(val))
if (inputValue !== '' && !isNaN(Number(inputValue))) {
onChange(formatValue(inputValue))
}
}
const handleFocus = () => {
setIsFocused(true)
setShowError(hasError)
setShowWarning(hasWarning)
}
@@ -162,7 +168,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
size="icon"
className={cn(
"h-9 w-9 rounded-r-none border-r-0",
hasError && "border-destructive"
hasError && "border-destructive",
hasWarning && !hasError && "border-yellow-500"
)}
onClick={() => updateValue(-1)}
tabIndex={-1}
@@ -181,7 +188,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
"w-20 h-9 z-20",
"rounded-none",
getAlignmentClass(),
hasError && "border-destructive focus-visible:ring-destructive"
hasError && "border-destructive focus-visible:ring-destructive",
hasWarning && !hasError && "border-yellow-500 focus-visible:ring-yellow-500"
)}
{...props}
/>
@@ -192,7 +200,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
className={cn(
"h-9 w-9",
clearButton && allowEmpty ? "rounded-l-none rounded-r-none border-x-0" : "rounded-l-none border-l-0",
hasError && "border-destructive"
hasError && "border-destructive",
hasWarning && !hasError && "border-yellow-500"
)}
onClick={() => updateValue(1)}
tabIndex={-1}
@@ -206,7 +215,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
size="icon"
className={cn(
"h-9 w-9 rounded-l-none",
hasError && "border-destructive"
hasError && "border-destructive",
hasWarning && !hasError && "border-yellow-500"
)}
onClick={() => onChange('')}
tabIndex={-1}
@@ -216,13 +226,13 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
)}
</div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && showError && errorMessage && (
{hasError && isFocused && errorMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
{errorMessage}
</div>
)}
{hasWarning && showWarning && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-yellow-950 text-xs p-2 rounded-md shadow-lg">
{hasWarning && isFocused && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg">
{warningMessage}
</div>
)}

View File

@@ -14,6 +14,7 @@ import { Button } from "./button"
import { Input } from "./input"
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
import { cn } from "../../lib/utils"
import { useTranslation } from "react-i18next"
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
value: string
@@ -41,13 +42,22 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
className,
...props
}, ref) => {
const { t } = useTranslation()
const [displayValue, setDisplayValue] = React.useState(value)
const [isPickerOpen, setIsPickerOpen] = React.useState(false)
const [showError, setShowError] = React.useState(false)
const [showWarning, setShowWarning] = React.useState(false)
const [, setShowError] = React.useState(false)
const [, setShowWarning] = React.useState(false)
const [touched, setTouched] = React.useState(false)
const [isFocused, setIsFocused] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
// Current committed value parsed from prop
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
// Staged selections (pending confirmation)
const [stagedHour, setStagedHour] = React.useState<number | null>(null)
const [stagedMinute, setStagedMinute] = React.useState<number | null>(null)
// Check if value is invalid (check validity regardless of touch state)
const isInvalid = required && (!value || value.trim() === '')
const hasError = error || isInvalid
@@ -57,9 +67,21 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
setDisplayValue(value)
}, [value])
// Align error bubble behavior with numeric input: show when invalid after first blur
React.useEffect(() => {
if (isInvalid && touched) {
setShowError(true)
} else if (!isInvalid) {
setShowError(false)
}
}, [isInvalid, touched])
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const inputValue = e.target.value.trim()
setTouched(true)
setIsFocused(false)
setShowError(false)
setShowWarning(false)
if (inputValue === '') {
// Update parent with empty value so validation works
@@ -98,21 +120,41 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
}
const handleFocus = () => {
setIsFocused(true)
setShowError(hasError)
setShowWarning(hasWarning)
}
const handlePickerChange = (part: 'h' | 'm', val: number) => {
let newHours = pickerHours, newMinutes = pickerMinutes
if (part === 'h') {
newHours = val
} else {
newMinutes = val
const handlePickerOpen = (open: boolean) => {
setIsPickerOpen(open)
if (open) {
// Reset staging when opening picker
setStagedHour(null)
setStagedMinute(null)
}
const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`
}
const handleHourClick = (hour: number) => {
setStagedHour(hour)
}
const handleMinuteClick = (minute: number) => {
setStagedMinute(minute)
}
const handleApply = () => {
// Use staged values if selected, otherwise keep current values
const finalHour = stagedHour !== null ? stagedHour : pickerHours
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}`
onChange(formattedTime)
setIsPickerOpen(false)
}
// Apply button is enabled when both hour and minute have valid values (either staged or from current value)
const canApply = (stagedHour !== null || pickerHours !== undefined) &&
(stagedMinute !== null || pickerMinutes !== undefined)
const getAlignmentClass = () => {
switch (align) {
case 'left': return 'text-left'
@@ -142,7 +184,7 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
)}
{...props}
/>
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}>
<Popover open={isPickerOpen} onOpenChange={handlePickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
@@ -158,59 +200,73 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3 bg-popover shadow-md border">
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-center mb-1">Hour</div>
<div className="text-xs font-medium text-center mb-1">{t('timePickerHour')}</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
{Array.from({ length: 24 }, (_, i) => (
{Array.from({ length: 24 }, (_, i) => {
const isCurrentValue = pickerHours === i && stagedHour === null
const isStaged = stagedHour === i
return (
<Button
key={i}
type="button"
variant={pickerHours === i ? "default" : "outline"}
variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
size="sm"
className="h-8 w-10"
onClick={() => {
handlePickerChange('h', i)
setIsPickerOpen(false)
}}
onClick={() => handleHourClick(i)}
>
{String(i).padStart(2, '0')}
</Button>
))}
)
})}
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-center mb-1">Min</div>
<div className="text-xs font-medium text-center mb-1">{t('timePickerMinute')}</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => (
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => {
const isCurrentValue = pickerMinutes === minute && stagedMinute === null
const isStaged = stagedMinute === minute
return (
<Button
key={minute}
type="button"
variant={pickerMinutes === minute ? "default" : "outline"}
variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
size="sm"
className="h-8 w-10"
onClick={() => {
handlePickerChange('m', minute)
setIsPickerOpen(false)
}}
onClick={() => handleMinuteClick(minute)}
>
{String(minute).padStart(2, '0')}
</Button>
))}
)
})}
</div>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={handleApply}
disabled={!canApply}
>
{t('timePickerApply')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && showError && errorMessage && (
{hasError && isFocused && errorMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
{errorMessage}
</div>
)}
{hasWarning && showWarning && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-yellow-950 text-xs p-2 rounded-md shadow-lg">
{hasWarning && isFocused && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg">
{warningMessage}
</div>
)}

View File

@@ -92,6 +92,7 @@ export const de = {
errorNumberRequired: "Bitte gib eine gültige Zahl ein.",
errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.",
warningDuplicateTime: "Mehrere Dosen zur gleichen Zeit.",
warningZeroDose: "Nulldosis hat keine Auswirkung auf die Simulation.",
// Day-based schedule
regularPlan: "Regulärer Plan",
@@ -112,7 +113,12 @@ export const de = {
viewingSharedPlan: "Du siehst einen geteilten Plan",
saveAsMyPlan: "Als meinen Plan speichern",
discardSharedPlan: "Verwerfen",
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!"
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!",
// Time picker
timePickerHour: "Stunde",
timePickerMinute: "Minute",
timePickerApply: "Übernehmen"
};
export default de;

View File

@@ -92,6 +92,12 @@ export const en = {
errorNumberRequired: "Please enter a valid number.",
errorTimeRequired: "Please enter a valid time.",
warningDuplicateTime: "Multiple doses at same time.",
warningZeroDose: "Zero dose has no effect on simulation.",
// Time picker
timePickerHour: "Hour",
timePickerMinute: "Minute",
timePickerApply: "Apply",
// Day-based schedule
regularPlan: "Regular Plan",
@@ -109,10 +115,10 @@ export const en = {
// URL sharing
sharePlan: "Share Plan",
viewingSharedPlan: "You are viewing a shared plan",
viewingSharedPlan: "Viewing shared plan",
saveAsMyPlan: "Save as My Plan",
discardSharedPlan: "Discard",
planCopiedToClipboard: "Plan link copied to clipboard!",
planCopiedToClipboard: "Plan link copied to clipboard!"
};
export default en;

View File

@@ -10,9 +10,9 @@
--card-foreground: 0 0% 10%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 10%;
--primary: 0 0% 15%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 94%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 220 15% 88%;
--secondary-foreground: 0 0% 15%;
--muted: 220 10% 95%;
--muted-foreground: 0 0% 45%;