fix(ui): Simplify SearchField collapsed tracking logic

Removes the internal state tracking almost completely, and instead
computes collapsed status based on focus and input value.

This fixes a bug where a controlled search field would contract when
the user writes something and then deletes the value via keyboard.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-12-17 10:41:01 +01:00
parent 3e876e1828
commit 42536b4512
2 changed files with 30 additions and 39 deletions
@@ -307,7 +307,7 @@ export const StartCollapsedWithOnChange: Story = {
},
};
export const StartCollapsedControlled: Story = {
export const StartCollapsedControlledEmpty: Story = {
args: {
...StartCollapsed.args,
},
@@ -325,3 +325,22 @@ export const StartCollapsedControlled: Story = {
);
},
};
export const StartCollapsedControlledWithValue: Story = {
args: {
...StartCollapsed.args,
},
render: function Render(args) {
const [value, setValue] = useState('Component');
return (
<Flex
direction="row"
gap="2"
style={{ width: '100%', maxWidth: '600px' }}
>
<SearchField {...args} size="small" value={value} onChange={setValue} />
</Flex>
);
},
};
@@ -68,25 +68,8 @@ export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
...rest
} = cleanedProps;
const hasProvidedValue = props.value || props.defaultValue;
const initialCollapsed = !!startCollapsed && !hasProvidedValue;
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
const [shouldCollapse, setShouldCollapse] = useState(!hasProvidedValue);
// If a value becomes available (controlled or defaultValue), force expand so the user can see it.
// If it becomes empty again and startCollapsed is enabled, allow collapse behavior again.
useEffect(() => {
if (hasProvidedValue) {
setShouldCollapse(false);
setIsCollapsed(false);
} else {
setShouldCollapse(true);
if (startCollapsed) {
setIsCollapsed(true);
}
}
}, [hasProvidedValue, startCollapsed]);
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
const secondaryLabelText =
@@ -94,35 +77,24 @@ export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
const handleFocusChange = (isFocused: boolean) => {
props.onFocusChange?.(isFocused);
if (shouldCollapse) {
if (isFocused) {
// When focusing, expand the field (unless startCollapsed + a value/defaultValue is provided)
setIsCollapsed(false);
} else {
// When blurring, collapse the field
setIsCollapsed(true);
}
}
setIsFocused(isFocused);
};
const handleContainerClick = () => {
if (startCollapsed && isCollapsed) {
// Focus the input after state update
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}
inputRef.current?.focus();
};
const handleChange = (value: string) => {
props.onChange?.(value);
if (value.length > 0) {
setShouldCollapse(false);
} else {
setShouldCollapse(true);
}
};
const hasInputRef = !!inputRef.current;
const hasValue = !!inputRef.current?.value;
const isCollapsed = hasInputRef
? startCollapsed && !hasValue && !isFocused
: startCollapsed && !rest.value && !rest.defaultValue && !isFocused;
return (
<AriaSearchField
className={clsx(classNames.root, styles[classNames.root], className)}