diff --git a/.changeset/hot-colts-float.md b/.changeset/hot-colts-float.md new file mode 100644 index 0000000000..093dcfb12a --- /dev/null +++ b/.changeset/hot-colts-float.md @@ -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 diff --git a/packages/ui/src/components/SearchField/SearchField.module.css b/packages/ui/src/components/SearchField/SearchField.module.css index e99c212b29..765527c8c3 100644 --- a/packages/ui/src/components/SearchField/SearchField.module.css +++ b/packages/ui/src/components/SearchField/SearchField.module.css @@ -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; + } } } diff --git a/packages/ui/src/components/SearchField/SearchField.stories.tsx b/packages/ui/src/components/SearchField/SearchField.stories.tsx index 81bc090835..96f0310e73 100644 --- a/packages/ui/src/components/SearchField/SearchField.stories.tsx +++ b/packages/ui/src/components/SearchField/SearchField.stories.tsx @@ -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 => ( - + + + + + - ), }; +export const StartCollapsedWithValue: Story = { + args: { + ...StartCollapsed.args, + defaultValue: 'https://example.com', + }, + + render: args => , +}; + 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 ( + + + + ); + }, +}; + +export const StartCollapsedControlledWithValue: Story = { + args: { + ...StartCollapsed.args, + }, + render: function Render(args) { + const [value, setValue] = useState('Component'); + + return ( + + + + ); + }, +}; diff --git a/packages/ui/src/components/SearchField/SearchField.tsx b/packages/ui/src/components/SearchField/SearchField.tsx index 775b7accc6..eea749e46f 100644 --- a/packages/ui/src/components/SearchField/SearchField.tsx +++ b/packages/ui/src/components/SearchField/SearchField.tsx @@ -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( '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( ...rest } = cleanedProps; + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(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 ( ( aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} data-collapsed={isCollapsed} - onFocusChange={handleClick} - onChange={handleChange} style={style} {...rest} + onFocusChange={handleFocusChange} ref={ref} > ( styles[classNames.inputWrapper], )} data-size={dataAttributes['data-size']} + onClick={handleContainerClick} > {icon !== false && (
(
)}