Merge pull request #32123 from backstage/bui-fix-searchfield

BUI - Fixes startCollapsed prop in SearchField
This commit is contained in:
Charles de Dreuille
2025-12-17 18:40:27 +00:00
committed by GitHub
4 changed files with 90 additions and 28 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Fixed SearchField `startCollapsed` prop not working correctly in Backstage UI. The field now properly starts in a collapsed state, expands when clicked and focused, and collapses back when unfocused with no input. Also fixed CSS logic to work correctly in all layout contexts (flex row, flex column, and regular containers).
Affected components: SearchField
@@ -40,24 +40,25 @@
}
&[data-startCollapsed='true'] {
transition: flex-basis 0.3s ease-in-out;
transition: flex-basis 0.3s ease-in-out, width 0.3s ease-in-out,
max-width 0.3s ease-in-out;
padding: 0;
flex: 0 1 auto;
&[data-collapsed='true'] {
flex-basis: 200px;
}
&[data-collapsed='false'] {
cursor: pointer;
&[data-size='medium'] {
flex-basis: 2.5rem;
width: 2.5rem;
max-width: 2.5rem;
height: 2.5rem;
}
&[data-size='small'] {
flex-basis: 2rem;
width: 2rem;
max-width: 2rem;
height: 2rem;
}
@@ -79,6 +80,12 @@
}
}
}
&[data-collapsed='false'] {
flex-basis: 200px;
width: 200px;
max-width: 200px;
}
}
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SearchField } from './SearchField';
import { Form } from 'react-aria-components';
@@ -165,13 +166,25 @@ export const StartCollapsed: Story = {
},
render: args => (
<Flex direction="row" gap="4">
<Flex direction="column" gap="4">
<Flex direction="row" gap="4">
<SearchField {...args} size="small" />
<SearchField {...args} size="medium" />
</Flex>
<SearchField {...args} size="small" />
<SearchField {...args} size="medium" />
</Flex>
),
};
export const StartCollapsedWithValue: Story = {
args: {
...StartCollapsed.args,
defaultValue: 'https://example.com',
},
render: args => <SearchField {...args} size="small" />,
};
export const InHeader: Story = {
decorators: [
Story => (
@@ -293,3 +306,41 @@ export const StartCollapsedWithOnChange: Story = {
);
},
};
export const StartCollapsedControlledEmpty: Story = {
args: {
...StartCollapsed.args,
},
render: function Render(args) {
const [value, setValue] = useState('');
return (
<Flex
direction="row"
gap="2"
style={{ width: '100%', maxWidth: '600px' }}
>
<SearchField {...args} size="small" value={value} onChange={setValue} />
</Flex>
);
},
};
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>
);
},
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { forwardRef, useEffect, useState } from 'react';
import { forwardRef, useEffect, useState, useRef } from 'react';
import {
Input,
SearchField as AriaSearchField,
@@ -39,9 +39,6 @@ export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
'aria-labelledby': ariaLabelledBy,
} = props;
const [isCollapsed, setIsCollapsed] = useState(false);
const [shouldCollapse, setShouldCollapse] = useState(true);
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
@@ -71,30 +68,29 @@ export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
...rest
} = cleanedProps;
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
const secondaryLabelText =
secondaryLabel || (isRequired ? 'Required' : null);
const handleClick = (isFocused: boolean) => {
const handleFocusChange = (isFocused: boolean) => {
props.onFocusChange?.(isFocused);
if (shouldCollapse) {
if (isFocused) {
setIsCollapsed(true);
} else {
setIsCollapsed(false);
}
}
setIsFocused(isFocused);
};
const handleChange = (value: string) => {
props.onChange?.(value);
if (value.length > 0) {
setShouldCollapse(false);
} else {
setShouldCollapse(true);
}
const handleContainerClick = () => {
inputRef.current?.focus();
};
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)}
@@ -102,10 +98,9 @@ export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
data-collapsed={isCollapsed}
onFocusChange={handleClick}
onChange={handleChange}
style={style}
{...rest}
onFocusChange={handleFocusChange}
ref={ref}
>
<FieldLabel
@@ -119,6 +114,7 @@ export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
styles[classNames.inputWrapper],
)}
data-size={dataAttributes['data-size']}
onClick={handleContainerClick}
>
{icon !== false && (
<div
@@ -133,6 +129,7 @@ export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
</div>
)}
<Input
ref={inputRef}
className={clsx(classNames.input, styles[classNames.input])}
{...(icon !== false && { 'data-icon': true })}
placeholder={placeholder}