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

View File

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

View File

@@ -14,6 +14,7 @@ import { Button } from "./button"
import { Input } from "./input" import { Input } from "./input"
import { Popover, PopoverContent, PopoverTrigger } from "./popover" import { Popover, PopoverContent, PopoverTrigger } from "./popover"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { useTranslation } from "react-i18next"
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> { interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
value: string value: string
@@ -41,13 +42,22 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
className, className,
...props ...props
}, ref) => { }, ref) => {
const { t } = useTranslation()
const [displayValue, setDisplayValue] = React.useState(value) const [displayValue, setDisplayValue] = React.useState(value)
const [isPickerOpen, setIsPickerOpen] = React.useState(false) const [isPickerOpen, setIsPickerOpen] = React.useState(false)
const [showError, setShowError] = React.useState(false) const [, setShowError] = React.useState(false)
const [showWarning, setShowWarning] = 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) const containerRef = React.useRef<HTMLDivElement>(null)
// Current committed value parsed from prop
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number) 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) // Check if value is invalid (check validity regardless of touch state)
const isInvalid = required && (!value || value.trim() === '') const isInvalid = required && (!value || value.trim() === '')
const hasError = error || isInvalid const hasError = error || isInvalid
@@ -57,9 +67,21 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
setDisplayValue(value) setDisplayValue(value)
}, [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 handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const inputValue = e.target.value.trim() const inputValue = e.target.value.trim()
setTouched(true)
setIsFocused(false)
setShowError(false) setShowError(false)
setShowWarning(false)
if (inputValue === '') { if (inputValue === '') {
// Update parent with empty value so validation works // Update parent with empty value so validation works
@@ -98,21 +120,41 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
} }
const handleFocus = () => { const handleFocus = () => {
setIsFocused(true)
setShowError(hasError) setShowError(hasError)
setShowWarning(hasWarning) setShowWarning(hasWarning)
} }
const handlePickerChange = (part: 'h' | 'm', val: number) => { const handlePickerOpen = (open: boolean) => {
let newHours = pickerHours, newMinutes = pickerMinutes setIsPickerOpen(open)
if (part === 'h') { if (open) {
newHours = val // Reset staging when opening picker
} else { setStagedHour(null)
newMinutes = val 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) 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 = () => { const getAlignmentClass = () => {
switch (align) { switch (align) {
case 'left': return 'text-left' case 'left': return 'text-left'
@@ -142,7 +184,7 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
)} )}
{...props} {...props}
/> />
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}> <Popover open={isPickerOpen} onOpenChange={handlePickerOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
@@ -158,59 +200,73 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-3 bg-popover shadow-md border"> <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 gap-2">
<div className="flex flex-col gap-1"> <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"> <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 <Button
key={i} key={i}
type="button" type="button"
variant={pickerHours === i ? "default" : "outline"} variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
size="sm" size="sm"
className="h-8 w-10" className="h-8 w-10"
onClick={() => { onClick={() => handleHourClick(i)}
handlePickerChange('h', i)
setIsPickerOpen(false)
}}
> >
{String(i).padStart(2, '0')} {String(i).padStart(2, '0')}
</Button> </Button>
))} )
})}
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <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"> <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 <Button
key={minute} key={minute}
type="button" type="button"
variant={pickerMinutes === minute ? "default" : "outline"} variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
size="sm" size="sm"
className="h-8 w-10" className="h-8 w-10"
onClick={() => { onClick={() => handleMinuteClick(minute)}
handlePickerChange('m', minute)
setIsPickerOpen(false)
}}
> >
{String(minute).padStart(2, '0')} {String(minute).padStart(2, '0')}
</Button> </Button>
))} )
})}
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={handleApply}
disabled={!canApply}
>
{t('timePickerApply')}
</Button>
</div>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>} {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"> <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} {errorMessage}
</div> </div>
)} )}
{hasWarning && showWarning && warningMessage && ( {hasWarning && isFocused && 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"> <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} {warningMessage}
</div> </div>
)} )}

View File

@@ -92,6 +92,7 @@ export const de = {
errorNumberRequired: "Bitte gib eine gültige Zahl ein.", errorNumberRequired: "Bitte gib eine gültige Zahl ein.",
errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.", errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.",
warningDuplicateTime: "Mehrere Dosen zur gleichen Zeit.", warningDuplicateTime: "Mehrere Dosen zur gleichen Zeit.",
warningZeroDose: "Nulldosis hat keine Auswirkung auf die Simulation.",
// Day-based schedule // Day-based schedule
regularPlan: "Regulärer Plan", regularPlan: "Regulärer Plan",
@@ -112,7 +113,12 @@ export const de = {
viewingSharedPlan: "Du siehst einen geteilten Plan", viewingSharedPlan: "Du siehst einen geteilten Plan",
saveAsMyPlan: "Als meinen Plan speichern", saveAsMyPlan: "Als meinen Plan speichern",
discardSharedPlan: "Verwerfen", 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; export default de;

View File

@@ -92,6 +92,12 @@ export const en = {
errorNumberRequired: "Please enter a valid number.", errorNumberRequired: "Please enter a valid number.",
errorTimeRequired: "Please enter a valid time.", errorTimeRequired: "Please enter a valid time.",
warningDuplicateTime: "Multiple doses at same 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 // Day-based schedule
regularPlan: "Regular Plan", regularPlan: "Regular Plan",
@@ -109,10 +115,10 @@ export const en = {
// URL sharing // URL sharing
sharePlan: "Share Plan", sharePlan: "Share Plan",
viewingSharedPlan: "You are viewing a shared plan", viewingSharedPlan: "Viewing shared plan",
saveAsMyPlan: "Save as My Plan", saveAsMyPlan: "Save as My Plan",
discardSharedPlan: "Discard", discardSharedPlan: "Discard",
planCopiedToClipboard: "Plan link copied to clipboard!", planCopiedToClipboard: "Plan link copied to clipboard!"
}; };
export default en; export default en;

View File

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