Update input field error/warning behavior and time picker handling
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user