From 01476f00dea50b8870443eafbf22f0edd3041f63 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Thu, 6 Nov 2025 16:29:41 +0100 Subject: [PATCH] refactor(ui): adopt flexbox positioning pattern for input components Refactored PasswordField, SearchField, and MenuAutocomplete to use a consistent flexbox positioning approach, improving maintainability and visual consistency. Key changes: - Replaced absolute positioning with flexbox layouts - Moved border and background styling from input to wrapper - Used grid-centered containers for icons and buttons - Added component-specific CSS variables for item height Signed-off-by: Johan Persson --- .changeset/fast-tables-sink.md | 5 + packages/ui/report.api.md | 7 +- .../ui/src/components/Menu/Menu.module.css | 42 ++-- .../PasswordField/PasswordField.module.css | 156 +++++++++++---- .../PasswordField/PasswordField.tsx | 50 ++--- .../SearchField/SearchField.module.css | 188 +++++++++--------- packages/ui/src/utils/componentDefinitions.ts | 7 +- 7 files changed, 272 insertions(+), 183 deletions(-) create mode 100644 .changeset/fast-tables-sink.md diff --git a/.changeset/fast-tables-sink.md b/.changeset/fast-tables-sink.md new file mode 100644 index 0000000000..a7c240fc5d --- /dev/null +++ b/.changeset/fast-tables-sink.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Improved visual consistency of PasswordField, SearchField, and MenuAutocomplete components. diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 9636b5361c..018b2ccddf 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -628,7 +628,10 @@ export const componentDefinitions: { readonly PasswordField: { readonly classNames: { readonly root: 'bui-PasswordField'; - readonly inputVisibility: 'bui-InputVisibility'; + readonly inputWrapper: 'bui-PasswordFieldInputWrapper'; + readonly input: 'bui-PasswordFieldInput'; + readonly inputIcon: 'bui-PasswordFieldIcon'; + readonly inputVisibility: 'bui-PasswordFieldVisibility'; }; readonly dataAttributes: { readonly size: readonly ['small', 'medium']; @@ -650,7 +653,7 @@ export const componentDefinitions: { readonly classNames: { readonly root: 'bui-SearchField'; readonly clear: 'bui-SearchFieldClear'; - readonly inputWrapper: 'bui-SearchFieldWrapper'; + readonly inputWrapper: 'bui-SearchFieldInputWrapper'; readonly input: 'bui-SearchFieldInput'; readonly inputIcon: 'bui-SearchFieldInputIcon'; }; diff --git a/packages/ui/src/components/Menu/Menu.module.css b/packages/ui/src/components/Menu/Menu.module.css index f97f3f57ff..606714583e 100644 --- a/packages/ui/src/components/Menu/Menu.module.css +++ b/packages/ui/src/components/Menu/Menu.module.css @@ -188,48 +188,56 @@ } .bui-MenuSearchField { - position: relative; font-family: var(--bui-font-regular); width: 100%; flex-shrink: 0; + display: flex; + align-items: center; + border-bottom: 1px solid var(--bui-border); + background-color: var(--bui-bg-surface-1); + height: 2rem; &[data-empty] { .bui-MenuSearchFieldClear { - display: none; + visibility: hidden; } } } .bui-MenuSearchFieldInput { + flex: 1; display: flex; align-items: center; - padding: 0 var(--bui-space-3); + padding: 0; border: none; - border-bottom: 1px solid var(--bui-border); - background-color: var(--bui-bg-surface-1); + background-color: transparent; font-size: var(--bui-font-size-3); font-family: var(--bui-font-regular); font-weight: var(--bui-font-weight-regular); color: var(--bui-fg-primary); width: 100%; - height: 2rem; - cursor: inherit; + height: 100%; outline: none; + cursor: inherit; &::-webkit-search-cancel-button, &::-webkit-search-decoration { -webkit-appearance: none; } + + &::placeholder { + color: var(--bui-fg-secondary); + } + + &:first-child { + padding-inline: var(--bui-space-3) 0; + } } .bui-MenuSearchFieldClear { - position: absolute; - right: var(--bui-space-2); - top: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; + flex: 0 0 auto; + display: grid; + place-content: center; background-color: transparent; border: none; padding: 0; @@ -237,6 +245,12 @@ cursor: pointer; color: var(--bui-fg-secondary); transition: color 0.2s ease-in-out; + width: 2rem; + height: 2rem; + + &:hover { + color: var(--bui-fg-primary); + } & > svg { width: 1rem; diff --git a/packages/ui/src/components/PasswordField/PasswordField.module.css b/packages/ui/src/components/PasswordField/PasswordField.module.css index bf78ab477f..adad950816 100644 --- a/packages/ui/src/components/PasswordField/PasswordField.module.css +++ b/packages/ui/src/components/PasswordField/PasswordField.module.css @@ -23,52 +23,136 @@ font-family: var(--bui-font-regular); width: 100%; flex-shrink: 0; - /* Reserve space for browser/password manager icon (e.g. 1Password) */ - --bui-passwordmanager-icon-width: var(--bui-space-1); + + &[data-size='small'] { + --password-field-item-height: 2rem; + } + + &[data-size='medium'] { + --password-field-item-height: 2.5rem; + } } - .bui-InputVisibility { - position: absolute; - right: var(--bui-passwordmanager-icon-width); - top: 0; - bottom: 0; + .bui-PasswordFieldInputWrapper { + display: flex; + align-items: center; + border-radius: var(--bui-radius-2); + border: 1px solid var(--bui-border); + background-color: var(--bui-bg-surface-1); + transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out; + + &[data-size='small'] { + height: 2rem; + } + + &[data-size='medium'] { + height: 2.5rem; + } + + &:focus-within { + border-color: var(--bui-border-pressed); + outline-width: 0px; + } + + &:hover { + border-color: var(--bui-border-hover); + } + + &:has([data-invalid]) { + border-color: var(--bui-fg-danger); + } + + &:has([data-disabled]) { + opacity: 0.5; + cursor: not-allowed; + border: 1px solid var(--bui-border-disabled); + } + } + + .bui-PasswordFieldIcon { + flex: 0 0 auto; + display: grid; + place-content: center; + color: var(--bui-fg-primary); + pointer-events: none; + width: var(--password-field-item-height); + height: var(--password-field-item-height); + + & svg { + .bui-PasswordField[data-size='small'] & { + width: 1rem; + height: 1rem; + } + + .bui-PasswordField[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } + } + } + + .bui-PasswordFieldInput { + flex: 1; + display: flex; + align-items: center; + padding: 0; + border: none; + background-color: transparent; + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + color: var(--bui-fg-primary); + width: 100%; + height: 100%; + outline: none; + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + + &::placeholder { + color: var(--bui-fg-secondary); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &:first-child { + .bui-PasswordField[data-size='small'] & { + padding-inline: var(--bui-space-3) 0; + } + + .bui-PasswordField[data-size='medium'] & { + padding-inline: var(--bui-space-4) 0; + } + } + } + + .bui-PasswordFieldVisibility { + flex: 0 0 auto; + display: grid; + place-content: center; background-color: transparent; cursor: pointer; border: none; padding: 0; margin: 0; - display: flex; - align-items: center; - justify-content: center; color: var(--bui-fg-primary); + width: var(--password-field-item-height); + height: var(--password-field-item-height); - /* Size: small */ - &[data-size='small'] { - width: 2rem; - height: 2rem; - } - &[data-size='small'] svg { - width: 1rem; - height: 1rem; - } + & svg { + .bui-PasswordField[data-size='small'] & { + width: 1rem; + height: 1rem; + } - /* Size: medium */ - &[data-size='medium'] { - width: 2.5rem; - height: 2.5rem; + .bui-PasswordField[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } } - &[data-size='medium'] svg { - width: 1.25rem; - height: 1.25rem; - } - } - - /* Ensure input has enough right padding for our toggle + PM icon */ - .bui-PasswordField .bui-InputWrapper[data-size='small'] .bui-Input { - padding-right: calc(2rem + var(--bui-passwordmanager-icon-width)); - } - - .bui-PasswordField .bui-InputWrapper[data-size='medium'] .bui-Input { - padding-right: calc(2.5rem + var(--bui-passwordmanager-icon-width)); } } diff --git a/packages/ui/src/components/PasswordField/PasswordField.tsx b/packages/ui/src/components/PasswordField/PasswordField.tsx index d7cea3a6bd..412128ca32 100644 --- a/packages/ui/src/components/PasswordField/PasswordField.tsx +++ b/packages/ui/src/components/PasswordField/PasswordField.tsx @@ -28,7 +28,6 @@ import type { PasswordFieldProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import { RiEyeLine, RiEyeOffLine } from '@remixicon/react'; import stylesPasswordField from './PasswordField.module.css'; -import stylesTextField from '../TextField/TextField.module.css'; /** @public */ export const PasswordField = forwardRef( @@ -47,8 +46,6 @@ export const PasswordField = forwardRef( } }, [label, ariaLabel, ariaLabelledBy]); - const { classNames: textFieldClassNames } = useStyles('TextField'); - const { classNames: classNamesPasswordField, dataAttributes, @@ -96,16 +93,16 @@ export const PasswordField = forwardRef( />
{icon && ( )} -
- setIsVisible(v => !v)} - className={clsx( - classNamesPasswordField.inputVisibility, - stylesPasswordField[classNamesPasswordField.inputVisibility], - )} - > - {isVisible ? : } - -
+ setIsVisible(v => !v)} + className={clsx( + classNamesPasswordField.inputVisibility, + stylesPasswordField[classNamesPasswordField.inputVisibility], + )} + > + {isVisible ? : } +
diff --git a/packages/ui/src/components/SearchField/SearchField.module.css b/packages/ui/src/components/SearchField/SearchField.module.css index 63ebb815b8..e99c212b29 100644 --- a/packages/ui/src/components/SearchField/SearchField.module.css +++ b/packages/ui/src/components/SearchField/SearchField.module.css @@ -25,6 +25,14 @@ flex: 1; flex-shrink: 0; + &[data-size='small'] { + --search-field-item-height: 2rem; + } + + &[data-size='medium'] { + --search-field-item-height: 2.5rem; + } + &[data-empty] { .bui-SearchFieldClear { display: none; @@ -65,7 +73,7 @@ } } - .bui-SearchFieldWrapper { + .bui-SearchFieldInputWrapper { .bui-SearchFieldInput[data-icon] { padding-right: 0px; } @@ -74,102 +82,31 @@ } } - .bui-SearchFieldWrapper { - position: relative; - - .bui-SearchFieldInput[data-icon] { - padding-right: var(--bui-space-6); - } - - &[data-size='small'] .bui-SearchFieldInput { - height: 2rem; - } - - &[data-size='medium'] .bui-SearchFieldInput { - height: 2.5rem; - } - - &[data-size='small'] .bui-SearchFieldInput[data-icon] { - padding-left: var(--bui-space-8); - } - - &[data-size='medium'] .bui-SearchFieldInput[data-icon] { - padding-left: var(--bui-space-9); - } - } - - .bui-SearchFieldInputIcon { - position: absolute; - display: flex; - justify-content: center; - left: 0; - top: 50%; - transform: translateY(-50%); - margin-right: var(--bui-space-1); - color: var(--bui-fg-primary); - pointer-events: none; - /* To animate the icon when the input is collapsed */ - transition: left 0.2s ease-in-out; - - &[data-size='small'] { - width: 2rem; - } - - &[data-size='medium'] { - width: 2.5rem; - } - - &[data-size='small'] svg { - width: 1rem; - height: 1rem; - } - - &[data-size='medium'] svg { - width: 1.25rem; - height: 1.25rem; - } - } - - .bui-SearchFieldInput { + .bui-SearchFieldInputWrapper { display: flex; align-items: center; - padding: 0 var(--bui-space-3); border-radius: var(--bui-radius-2); border: 1px solid var(--bui-border); background-color: var(--bui-bg-surface-1); - font-size: var(--bui-font-size-3); - font-family: var(--bui-font-regular); - font-weight: var(--bui-font-weight-regular); - color: var(--bui-fg-primary); - transition: padding 0.3s ease-in-out, border-color 0.2s ease-in-out, - outline-color 0.2s ease-in-out; - width: 100%; - height: 100%; - cursor: inherit; + transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out; - &::-webkit-search-cancel-button, - &::-webkit-search-decoration { - -webkit-appearance: none; + &[data-size='small'] { + height: 2rem; } - &::placeholder { - color: var(--bui-fg-secondary); + &[data-size='medium'] { + height: 2.5rem; } - &[data-focused] { - outline-color: var(--bui-border-pressed); - outline-width: 0px; - } - - &[data-hovered] { - border-color: var(--bui-border-hover); - } - - &[data-focused] { + &:focus-within { border-color: var(--bui-border-pressed); outline-width: 0px; } + &:hover { + border-color: var(--bui-border-hover); + } + &[data-invalid] { border-color: var(--bui-fg-danger); } @@ -181,14 +118,75 @@ } } - .bui-SearchFieldClear { - position: absolute; - right: 0; - top: 0; - bottom: 0; + .bui-SearchFieldInputIcon { + flex: 0 0 auto; + display: grid; + place-content: center; + color: var(--bui-fg-primary); + pointer-events: none; + width: var(--search-field-item-height); + height: var(--search-field-item-height); + /* To animate the icon when the input is collapsed */ + transition: opacity 0.2s ease-in-out; + + & svg { + .bui-SearchField[data-size='small'] & { + width: 1rem; + height: 1rem; + } + + .bui-SearchField[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } + } + } + + .bui-SearchFieldInput { + flex: 1; display: flex; align-items: center; - justify-content: center; + padding: 0; + border: none; + background-color: transparent; + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + color: var(--bui-fg-primary); + transition: padding 0.3s ease-in-out; + width: 100%; + height: 100%; + outline: none; + cursor: inherit; + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + + &::placeholder { + color: var(--bui-fg-secondary); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &:first-child { + .bui-SearchField[data-size='small'] & { + padding-inline: var(--bui-space-3) 0; + } + + .bui-SearchField[data-size='medium'] & { + padding-inline: var(--bui-space-3) 0; + } + } + } + + .bui-SearchFieldClear { + flex: 0 0 auto; + display: grid; + place-content: center; background-color: transparent; border: none; padding: 0; @@ -196,21 +194,13 @@ cursor: pointer; color: var(--bui-fg-secondary); transition: color 0.2s ease-in-out; + width: var(--search-field-item-height); + height: var(--search-field-item-height); &:hover { color: var(--bui-fg-primary); } - &[data-size='small'] { - width: 2rem; - height: 2rem; - } - - &[data-size='medium'] { - width: 2.5rem; - height: 2.5rem; - } - & svg { width: 1rem; height: 1rem; diff --git a/packages/ui/src/utils/componentDefinitions.ts b/packages/ui/src/utils/componentDefinitions.ts index 167295c25a..526340e4be 100644 --- a/packages/ui/src/utils/componentDefinitions.ts +++ b/packages/ui/src/utils/componentDefinitions.ts @@ -267,7 +267,10 @@ export const componentDefinitions = { PasswordField: { classNames: { root: 'bui-PasswordField', - inputVisibility: 'bui-InputVisibility', + inputWrapper: 'bui-PasswordFieldInputWrapper', + input: 'bui-PasswordFieldInput', + inputIcon: 'bui-PasswordFieldIcon', + inputVisibility: 'bui-PasswordFieldVisibility', }, dataAttributes: { size: ['small', 'medium'] as const, @@ -289,7 +292,7 @@ export const componentDefinitions = { classNames: { root: 'bui-SearchField', clear: 'bui-SearchFieldClear', - inputWrapper: 'bui-SearchFieldWrapper', + inputWrapper: 'bui-SearchFieldInputWrapper', input: 'bui-SearchFieldInput', inputIcon: 'bui-SearchFieldInputIcon', },