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 <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-11-06 16:29:41 +01:00
parent 30fe9aac29
commit 01476f00de
7 changed files with 272 additions and 183 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Improved visual consistency of PasswordField, SearchField, and MenuAutocomplete components.
+5 -2
View File
@@ -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';
};
+28 -14
View File
@@ -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;
@@ -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));
}
}
@@ -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<HTMLDivElement, PasswordFieldProps>(
@@ -47,8 +46,6 @@ export const PasswordField = forwardRef<HTMLDivElement, PasswordFieldProps>(
}
}, [label, ariaLabel, ariaLabelledBy]);
const { classNames: textFieldClassNames } = useStyles('TextField');
const {
classNames: classNamesPasswordField,
dataAttributes,
@@ -96,16 +93,16 @@ export const PasswordField = forwardRef<HTMLDivElement, PasswordFieldProps>(
/>
<div
className={clsx(
textFieldClassNames.inputWrapper,
stylesTextField[textFieldClassNames.inputWrapper],
classNamesPasswordField.inputWrapper,
stylesPasswordField[classNamesPasswordField.inputWrapper],
)}
data-size={dataAttributes['data-size']}
>
{icon && (
<div
className={clsx(
textFieldClassNames.inputIcon,
stylesTextField[textFieldClassNames.inputIcon],
classNamesPasswordField.inputIcon,
stylesPasswordField[classNamesPasswordField.inputIcon],
)}
data-size={dataAttributes['data-size']}
aria-hidden="true"
@@ -113,36 +110,29 @@ export const PasswordField = forwardRef<HTMLDivElement, PasswordFieldProps>(
{icon}
</div>
)}
<div
className={clsx(
textFieldClassNames.inputAction,
stylesTextField[textFieldClassNames.inputAction],
)}
>
<RAButton
data-size={dataAttributes['data-size']}
data-variant={'tertiary'}
aria-label={isVisible ? 'Hide value' : 'Show value'}
aria-controls={isVisible ? 'text' : 'password'}
aria-expanded={isVisible}
onPress={() => setIsVisible(v => !v)}
className={clsx(
classNamesPasswordField.inputVisibility,
stylesPasswordField[classNamesPasswordField.inputVisibility],
)}
>
{isVisible ? <RiEyeLine /> : <RiEyeOffLine />}
</RAButton>
</div>
<Input
className={clsx(
textFieldClassNames.input,
stylesTextField[textFieldClassNames.input],
classNamesPasswordField.input,
stylesPasswordField[classNamesPasswordField.input],
)}
{...(icon && { 'data-icon': true })}
placeholder={placeholder}
type={isVisible ? 'text' : 'password'}
/>
<RAButton
data-size={dataAttributes['data-size']}
data-variant={'tertiary'}
aria-label={isVisible ? 'Hide value' : 'Show value'}
aria-controls={isVisible ? 'text' : 'password'}
aria-expanded={isVisible}
onPress={() => setIsVisible(v => !v)}
className={clsx(
classNamesPasswordField.inputVisibility,
stylesPasswordField[classNamesPasswordField.inputVisibility],
)}
>
{isVisible ? <RiEyeLine /> : <RiEyeOffLine />}
</RAButton>
</div>
<FieldError />
</AriaTextField>
@@ -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;
@@ -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',
},