From b4a4911c473ecab17f23e70ca104368a2b8b03fc Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 14 Dec 2025 09:28:02 +0000 Subject: [PATCH 1/6] Fixes SearchField in Backstage UI Signed-off-by: Charles de Dreuille --- .changeset/hot-colts-float.md | 5 ++++ .../SearchField/SearchField.module.css | 19 +++++++++---- .../SearchField/SearchField.stories.tsx | 7 +++-- .../components/SearchField/SearchField.tsx | 28 +++++++++++++++---- 4 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 .changeset/hot-colts-float.md diff --git a/.changeset/hot-colts-float.md b/.changeset/hot-colts-float.md new file mode 100644 index 0000000000..8bfa32256c --- /dev/null +++ b/.changeset/hot-colts-float.md @@ -0,0 +1,5 @@ +--- +'@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). diff --git a/packages/ui/src/components/SearchField/SearchField.module.css b/packages/ui/src/components/SearchField/SearchField.module.css index e99c212b29..db11825364 100644 --- a/packages/ui/src/components/SearchField/SearchField.module.css +++ b/packages/ui/src/components/SearchField/SearchField.module.css @@ -40,24 +40,27 @@ } &[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 +82,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..5b6cf2df07 100644 --- a/packages/ui/src/components/SearchField/SearchField.stories.tsx +++ b/packages/ui/src/components/SearchField/SearchField.stories.tsx @@ -165,9 +165,12 @@ export const StartCollapsed: Story = { }, render: args => ( - + + + + + - ), }; diff --git a/packages/ui/src/components/SearchField/SearchField.tsx b/packages/ui/src/components/SearchField/SearchField.tsx index 775b7accc6..f9068ec8d6 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,6 +68,10 @@ export const SearchField = forwardRef( ...rest } = cleanedProps; + const [isCollapsed, setIsCollapsed] = useState(startCollapsed); + const [shouldCollapse, setShouldCollapse] = useState(true); + 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); @@ -79,13 +80,26 @@ export const SearchField = forwardRef( props.onFocusChange?.(isFocused); if (shouldCollapse) { if (isFocused) { - setIsCollapsed(true); - } else { + // When focusing, expand the field setIsCollapsed(false); + } else { + // When blurring, collapse the field + setIsCollapsed(true); } } }; + const handleContainerClick = () => { + // If the field is collapsed (small), expand it and focus the input + if (startCollapsed && isCollapsed) { + setIsCollapsed(false); + // Focus the input after state update + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + }; + const handleChange = (value: string) => { props.onChange?.(value); if (value.length > 0) { @@ -119,6 +133,7 @@ export const SearchField = forwardRef( styles[classNames.inputWrapper], )} data-size={dataAttributes['data-size']} + onClick={handleContainerClick} > {icon !== false && (
(
)} Date: Sun, 14 Dec 2025 09:51:31 +0000 Subject: [PATCH 2/6] Update SearchField.module.css Signed-off-by: Charles de Dreuille --- packages/ui/src/components/SearchField/SearchField.module.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ui/src/components/SearchField/SearchField.module.css b/packages/ui/src/components/SearchField/SearchField.module.css index db11825364..765527c8c3 100644 --- a/packages/ui/src/components/SearchField/SearchField.module.css +++ b/packages/ui/src/components/SearchField/SearchField.module.css @@ -40,9 +40,7 @@ } &[data-startCollapsed='true'] { - transition: - flex-basis 0.3s ease-in-out, - width 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; From dde89f7ee5735a6943ccff14b44ba2ebcc25409a Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 16 Dec 2025 15:00:35 +0000 Subject: [PATCH 3/6] Improve SearchField Signed-off-by: Charles de Dreuille --- .changeset/hot-colts-float.md | 2 ++ .../SearchField/SearchField.stories.tsx | 9 ++++++ .../components/SearchField/SearchField.tsx | 28 ++++++++++++++----- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/.changeset/hot-colts-float.md b/.changeset/hot-colts-float.md index 8bfa32256c..093dcfb12a 100644 --- a/.changeset/hot-colts-float.md +++ b/.changeset/hot-colts-float.md @@ -3,3 +3,5 @@ --- 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.stories.tsx b/packages/ui/src/components/SearchField/SearchField.stories.tsx index 5b6cf2df07..21f5040a6f 100644 --- a/packages/ui/src/components/SearchField/SearchField.stories.tsx +++ b/packages/ui/src/components/SearchField/SearchField.stories.tsx @@ -175,6 +175,15 @@ export const StartCollapsed: Story = { ), }; +export const StartCollapsedWithValue: Story = { + args: { + ...StartCollapsed.args, + defaultValue: 'https://example.com', + }, + + render: args => , +}; + export const InHeader: Story = { decorators: [ Story => ( diff --git a/packages/ui/src/components/SearchField/SearchField.tsx b/packages/ui/src/components/SearchField/SearchField.tsx index f9068ec8d6..aa5e22d900 100644 --- a/packages/ui/src/components/SearchField/SearchField.tsx +++ b/packages/ui/src/components/SearchField/SearchField.tsx @@ -68,19 +68,35 @@ export const SearchField = forwardRef( ...rest } = cleanedProps; - const [isCollapsed, setIsCollapsed] = useState(startCollapsed); - const [shouldCollapse, setShouldCollapse] = useState(true); + const hasProvidedValue = props.value || props.defaultValue; + const initialCollapsed = !!startCollapsed && !hasProvidedValue; const inputRef = useRef(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 = secondaryLabel || (isRequired ? 'Required' : null); - const handleClick = (isFocused: boolean) => { + const handleFocusChange = (isFocused: boolean) => { props.onFocusChange?.(isFocused); if (shouldCollapse) { if (isFocused) { - // When focusing, expand the field + // When focusing, expand the field (unless startCollapsed + a value/defaultValue is provided) setIsCollapsed(false); } else { // When blurring, collapse the field @@ -90,9 +106,7 @@ export const SearchField = forwardRef( }; const handleContainerClick = () => { - // If the field is collapsed (small), expand it and focus the input if (startCollapsed && isCollapsed) { - setIsCollapsed(false); // Focus the input after state update setTimeout(() => { inputRef.current?.focus(); @@ -116,7 +130,7 @@ export const SearchField = forwardRef( aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} data-collapsed={isCollapsed} - onFocusChange={handleClick} + onFocusChange={handleFocusChange} onChange={handleChange} style={style} {...rest} From 3e876e182892f86ac7336c4369022b7c4db9e571 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Wed, 17 Dec 2025 09:59:50 +0100 Subject: [PATCH 4/6] chore(ui): Add SearchField StartCollapsedControlled story. Signed-off-by: Johan Persson --- .../SearchField/SearchField.stories.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/ui/src/components/SearchField/SearchField.stories.tsx b/packages/ui/src/components/SearchField/SearchField.stories.tsx index 21f5040a6f..203e11c73c 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'; @@ -305,3 +306,22 @@ export const StartCollapsedWithOnChange: Story = { ); }, }; + +export const StartCollapsedControlled: Story = { + args: { + ...StartCollapsed.args, + }, + render: function Render(args) { + const [value, setValue] = useState(''); + + return ( + + + + ); + }, +}; From 42536b45128a59445526e44e4d736569206b71f8 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Wed, 17 Dec 2025 10:41:01 +0100 Subject: [PATCH 5/6] 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 --- .../SearchField/SearchField.stories.tsx | 21 +++++++- .../components/SearchField/SearchField.tsx | 48 ++++--------------- 2 files changed, 30 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/components/SearchField/SearchField.stories.tsx b/packages/ui/src/components/SearchField/SearchField.stories.tsx index 203e11c73c..96f0310e73 100644 --- a/packages/ui/src/components/SearchField/SearchField.stories.tsx +++ b/packages/ui/src/components/SearchField/SearchField.stories.tsx @@ -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 ( + + + + ); + }, +}; diff --git a/packages/ui/src/components/SearchField/SearchField.tsx b/packages/ui/src/components/SearchField/SearchField.tsx index aa5e22d900..2dffd12a93 100644 --- a/packages/ui/src/components/SearchField/SearchField.tsx +++ b/packages/ui/src/components/SearchField/SearchField.tsx @@ -68,25 +68,8 @@ export const SearchField = forwardRef( ...rest } = cleanedProps; - const hasProvidedValue = props.value || props.defaultValue; - const initialCollapsed = !!startCollapsed && !hasProvidedValue; + const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(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( 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 ( Date: Wed, 17 Dec 2025 11:25:02 +0100 Subject: [PATCH 6/6] fix(ui): Fix SearchField focus change listener bug. Signed-off-by: Johan Persson --- packages/ui/src/components/SearchField/SearchField.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/ui/src/components/SearchField/SearchField.tsx b/packages/ui/src/components/SearchField/SearchField.tsx index 2dffd12a93..eea749e46f 100644 --- a/packages/ui/src/components/SearchField/SearchField.tsx +++ b/packages/ui/src/components/SearchField/SearchField.tsx @@ -84,10 +84,6 @@ export const SearchField = forwardRef( inputRef.current?.focus(); }; - const handleChange = (value: string) => { - props.onChange?.(value); - }; - const hasInputRef = !!inputRef.current; const hasValue = !!inputRef.current?.value; @@ -102,10 +98,9 @@ export const SearchField = forwardRef( aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} data-collapsed={isCollapsed} - onFocusChange={handleFocusChange} - onChange={handleChange} style={style} {...rest} + onFocusChange={handleFocusChange} ref={ref} >