Skip to content

Commit ff75698

Browse files
authored
Merge pull request #4611 from Ivy-Interactive/fix/bool-input-nullable-cycle
fix(widgets): resolve nullable boolean and numeric binding state three-state cycling and serialization
2 parents b4c9c88 + d63ab4b commit ff75698

3 files changed

Lines changed: 30 additions & 13 deletions

File tree

src/Ivy/Widgets/Inputs/BoolInput.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,15 @@ internal BoolInput(string? label = null, bool disabled = false, BoolInputVariant
101101
Label = label;
102102
Disabled = disabled;
103103
Variant = variant;
104+
Nullable = typeof(TBool) == typeof(bool?);
104105
}
105106

106-
internal BoolInput() { }
107+
internal BoolInput()
108+
{
109+
}
107110

108111
[Prop(AlwaysSerialize = true)] public TBool Value { get; init; } = default!;
109112

110-
[Prop] public new bool Nullable { get; set; } = typeof(TBool) == typeof(bool?);
111-
112113
[Event] public EventHandler<Event<IInput<TBool>, TBool>>? OnChange { get; }
113114
}
114115

@@ -154,8 +155,7 @@ public static BoolInputBase ToBoolInput(this IAnyState state, string? label = nu
154155
Type genericType = typeof(BoolInput<>).MakeGenericType(stateType);
155156

156157
BoolInputBase input = (BoolInputBase)Activator.CreateInstance(genericType, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, null, new object?[] { state, label, disabled, variant }, null)!;
157-
var nullableProperty = genericType.GetProperty("Nullable", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
158-
nullableProperty?.SetValue(input, isNullable);
158+
input.Nullable = isNullable;
159159

160160
input.ScaffoldDefaults(null!, stateType);
161161
return input;

src/frontend/src/components/ui/checkbox.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,14 @@ const Checkbox = React.forwardRef<
4545
},
4646
ref,
4747
) => {
48-
// Map undefined to null when nullable, then null to 'indeterminate' for Radix
49-
const normalizedChecked = nullable && checked === undefined ? null : checked;
48+
// Map checked value helper for safe boolean/null states
49+
const normalizedChecked = React.useMemo(() => {
50+
if (checked === undefined || checked === null) return nullable ? null : false;
51+
if (checked === true || (checked as any) === 1) return true;
52+
if (checked === false || (checked as any) === 0) return false;
53+
return !!checked;
54+
}, [checked, nullable]);
55+
5056
const uiChecked =
5157
nullable && normalizedChecked === null ? "indeterminate" : !!normalizedChecked;
5258

src/frontend/src/widgets/inputs/BoolInputWidget.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,17 @@ const InputLabel: React.FC<{
9595
const cursorClass = onClick ? (disabled ? "cursor-not-allowed" : "cursor-pointer") : undefined;
9696

9797
return (
98-
<div onClick={onClick} className={cursorClass}>
98+
<div onClick={onClick} className={cn("min-w-0 flex-1", cursorClass)}>
9999
{label && (
100-
<Label htmlFor={id} className={labelSizeVariant({ density: d })}>
100+
<Label htmlFor={id} className={cn("block truncate", labelSizeVariant({ density: d }))}>
101101
{label}
102102
</Label>
103103
)}
104-
{description && <p className={descriptionSizeVariant({ density: d })}>{description}</p>}
104+
{description && (
105+
<p className={cn("block truncate", descriptionSizeVariant({ density: d }))}>
106+
{description}
107+
</p>
108+
)}
105109
</div>
106110
);
107111
});
@@ -150,8 +154,10 @@ const VariantComponents = {
150154
const handleLabelClick = () => {
151155
if (disabled || loading) return;
152156
if (nullable) {
153-
if (value === null) onCheckedChange(true);
154-
else if (value === true) onCheckedChange(false);
157+
const isTrue = value === true || (value as any) === 1;
158+
const isNull = value === null || value === undefined;
159+
if (isNull) onCheckedChange(true);
160+
else if (isTrue) onCheckedChange(false);
155161
else onCheckedChange(null);
156162
} else {
157163
onCheckedChange(!value);
@@ -365,7 +371,12 @@ export const BoolInputWidget: React.FC<BoolInputWidgetProps> = ({
365371
}) => {
366372
const eventHandler = useEventHandler();
367373

368-
const normalizedValue = nullable && value === undefined ? null : value;
374+
const normalizedValue = useMemo(() => {
375+
if (value === undefined || value === null) return nullable ? null : false;
376+
if (value === true || (value as any) === 1) return true;
377+
if (value === false || (value as any) === 0) return false;
378+
return !!value;
379+
}, [value, nullable]);
369380

370381
const [localValue, setLocalValue] = useOptimisticValue(normalizedValue, false);
371382

0 commit comments

Comments
 (0)