add PasswordField to BUI, remove additional props from TextField

Signed-off-by: Hermione Bird <hermioneb@spotify.com>
This commit is contained in:
Hermione Bird
2025-09-24 16:39:22 +01:00
parent c9f3c47c7e
commit 25ada301cf
11 changed files with 502 additions and 55 deletions
+8
View File
@@ -10431,6 +10431,14 @@
display: flex;
}
.bui-PasswordField {
font-family: var(--bui-font-regular);
flex-direction: column;
flex-shrink: 0;
width: 100%;
display: flex;
}
.bui-InputWrapper {
position: relative;
@@ -0,0 +1,148 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-vite';
import { PasswordField } from './PasswordField';
import { Form } from 'react-aria-components';
import { Icon } from '../Icon';
import { Flex } from '../Flex';
import { FieldLabel } from '../FieldLabel';
const meta = {
title: 'Backstage UI/PasswordField',
component: PasswordField,
argTypes: {
isRequired: {
control: 'boolean',
},
icon: {
control: 'object',
},
},
} satisfies Meta<typeof PasswordField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
name: 'secret',
placeholder: 'Enter a secret',
style: {
maxWidth: '300px',
},
},
};
export const Sizes: Story = {
args: {
...Default.args,
},
render: args => (
<Flex direction="row" gap="4" style={{ width: '100%', maxWidth: '600px' }}>
<PasswordField {...args} size="small" icon={<Icon name="sparkling" />} />
<PasswordField {...args} size="medium" icon={<Icon name="sparkling" />} />
</Flex>
),
};
export const DefaultValue: Story = {
args: {
...Default.args,
defaultValue: 'https://example.com',
},
};
export const WithLabel: Story = {
args: {
...Default.args,
label: 'Label',
},
};
export const WithDescription: Story = {
args: {
...WithLabel.args,
description: 'Description',
},
};
export const Required: Story = {
args: {
...WithLabel.args,
isRequired: true,
},
};
export const Disabled: Story = {
args: {
...Default.args,
isDisabled: true,
},
};
export const WithIcon: Story = {
args: {
...Default.args,
},
render: args => (
<PasswordField {...args} size="small" icon={<Icon name="sparkling" />} />
),
};
export const DisabledWithIcon: Story = {
args: {
...WithIcon.args,
isDisabled: true,
},
render: WithIcon.render,
};
export const ShowError: Story = {
args: {
...WithLabel.args,
},
render: args => (
<Form validationErrors={{ secret: 'Invalid secret' }}>
<PasswordField {...args} />
</Form>
),
};
export const Validation: Story = {
args: {
...WithLabel.args,
validate: value => (value === 'admin' ? 'Nice try!' : null),
},
};
export const CustomField: Story = {
render: () => (
<>
<FieldLabel
htmlFor="custom-field"
id="custom-field-label"
label="Custom Field"
/>
<PasswordField
id="custom-field"
aria-labelledby="custom-field-label"
name="custom-field"
defaultValue="Custom Field"
/>
</>
),
};
@@ -0,0 +1,141 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.bui-PasswordField {
display: flex;
flex-direction: column;
font-family: var(--bui-font-regular);
width: 100%;
flex-shrink: 0;
}
.bui-InputWrapper {
position: relative;
&[data-size='small'] .bui-Input {
height: 2rem;
}
&[data-size='medium'] .bui-Input {
height: 2.5rem;
}
&[data-size='small'] .bui-Input[data-icon] {
padding-left: var(--bui-space-8);
}
&[data-size='medium'] .bui-Input[data-icon] {
padding-left: var(--bui-space-9);
}
}
.bui-InputIcon {
position: absolute;
left: var(--bui-space-3);
top: 50%;
transform: translateY(-50%);
margin-right: var(--bui-space-1);
color: var(--bui-fg-primary);
flex-shrink: 0;
pointer-events: none;
/* To animate the icon when the input is collapsed */
transition: left 0.2s ease-in-out;
&[data-size='small'],
&[data-size='small'] svg {
width: 1rem;
height: 1rem;
}
&[data-size='medium'],
&[data-size='medium'] svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bui-Input {
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: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out;
width: 100%;
height: 100%;
cursor: inherit;
&::-webkit-search-cancel-button,
&::-webkit-search-decoration {
-webkit-appearance: none;
}
&::placeholder {
color: var(--bui-fg-secondary);
}
&[data-focused] {
outline-color: var(--bui-border-pressed);
outline-width: 0px;
}
&[data-hovered] {
border-color: var(--bui-border-hover);
}
&[data-focused] {
border-color: var(--bui-border-pressed);
outline-width: 0px;
}
&[data-invalid] {
border-color: var(--bui-fg-danger);
}
&[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
border: 1px solid var(--bui-border-disabled);
}
}
.bui-InputAction {
position: absolute;
right: var(--bui-space-1);
top: 50%;
transform: translateY(-50%);
color: var(--bui-fg-primary);
flex-shrink: 0;
/* To animate the icon when the input is collapsed */
transition: right 0.2s ease-in-out;
&[data-size='small'],
&[data-size='small'] svg {
width: 1rem;
height: 1rem;
}
&[data-size='medium'],
&[data-size='medium'] svg {
width: 1.25rem;
height: 1.25rem;
}
}
@@ -0,0 +1,116 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef, useEffect, useState } from 'react';
import { Input, TextField as AriaTextField } from 'react-aria-components';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
import { FieldError } from '../FieldError';
import type { PasswordFieldProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { Icon } from '../Icon';
import { ButtonIcon } from '../ButtonIcon';
/** @public */
export const PasswordField = forwardRef<HTMLDivElement, PasswordFieldProps>(
(props, ref) => {
const {
className,
icon,
size = 'small',
label,
secondaryLabel,
tertiaryLabel,
description,
isRequired,
tone,
message,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
placeholder,
...rest
} = props;
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'PasswordField requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
const { classNames, dataAttributes } = useStyles('PasswordField', {
size,
});
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
const secondaryLabelText =
secondaryLabel || (isRequired ? 'Required' : null);
// Manage secret visibility toggle
const [isVisible, setIsVisible] = useState(false);
return (
<AriaTextField
className={clsx(classNames.root, className)}
{...dataAttributes}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
{...rest}
ref={ref}
>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
<div
className={classNames.inputWrapper}
data-size={dataAttributes['data-size']}
>
{icon && (
<div
className={classNames.inputIcon}
data-size={dataAttributes['data-size']}
aria-hidden="true"
>
{icon}
</div>
)}
<div className={classNames.inputAction}>
<ButtonIcon
data-size={dataAttributes['data-size']}
aria-label={isVisible ? 'Hide value' : 'Show value'}
variant={'tertiary'}
onPress={() => setIsVisible(v => !v)}
icon={<Icon name={isVisible ? 'eye' : 'eye-off'} />}
/>
</div>
<Input
className={classNames.input}
{...(icon && { 'data-icon': true })}
placeholder={placeholder}
type={isVisible ? 'text' : 'password'}
/>
</div>
<FieldError />
</AriaTextField>
);
},
);
PasswordField.displayName = 'PasswordField';
@@ -0,0 +1,18 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './PasswordField';
export * from './types';
@@ -0,0 +1,56 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { TextFieldProps as AriaTextFieldProps } from 'react-aria-components';
import { ReactNode } from 'react';
import type { Breakpoint } from '../../types';
import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface PasswordFieldProps
extends AriaTextFieldProps,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
/**
* An icon to render before the input
*/
icon?: ReactNode;
/**
* The size of the password field
* @defaultValue 'medium'
*/
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
/**
* Text to display in the input when it has no value
*/
placeholder?: string;
/**
* A tertiary label to display on the right side of the field label
*/
tertiaryLabel?: string;
/**
* The visual tone of the message below the field, if any
*/
tone?: 'positive' | 'critical' | 'neutral' | 'caution';
/**
* A message to display below the field, such as validation feedback
*/
message?: string;
}
@@ -116,26 +116,3 @@
border: 1px solid var(--bui-border-disabled);
}
}
.bui-InputAction {
position: absolute;
right: var(--bui-space-1);
top: 50%;
transform: translateY(-50%);
color: var(--bui-fg-primary);
flex-shrink: 0;
/* To animate the icon when the input is collapsed */
transition: right 0.2s ease-in-out;
&[data-size='small'],
&[data-size='small'] svg {
width: 1rem;
height: 1rem;
}
&[data-size='medium'],
&[data-size='medium'] svg {
width: 1.25rem;
height: 1.25rem;
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { forwardRef, useEffect, useState } from 'react';
import { forwardRef, useEffect } from 'react';
import { Input, TextField as AriaTextField } from 'react-aria-components';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
@@ -22,8 +22,6 @@ import { FieldError } from '../FieldError';
import type { TextFieldProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { Icon } from '../Icon';
import { ButtonIcon } from '../ButtonIcon';
/** @public */
export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
@@ -36,7 +34,6 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
secondaryLabel,
description,
isRequired,
enableVisibility,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
placeholder,
@@ -59,10 +56,6 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
const secondaryLabelText =
secondaryLabel || (isRequired ? 'Required' : null);
// Manage secret visibility toggle
const [isVisible, setIsVisible] = useState(false);
const showSecretToggle = Boolean(enableVisibility);
return (
<AriaTextField
className={clsx(classNames.root, className)}
@@ -90,24 +83,10 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
{icon}
</div>
)}
{showSecretToggle && (
<div className={classNames.inputAction}>
<ButtonIcon
data-size={dataAttributes['data-size']}
aria-label={isVisible ? 'Hide value' : 'Show value'}
variant={'tertiary'}
onPress={() => setIsVisible(v => !v)}
icon={<Icon name={isVisible ? 'eye' : 'eye-off'} />}
/>
</div>
)}
<Input
className={classNames.input}
{...(icon && { 'data-icon': true })}
placeholder={placeholder}
type={
enableVisibility ? (isVisible ? 'text' : 'password') : undefined
}
/>
</div>
<FieldError />
@@ -38,14 +38,4 @@ export interface TextFieldProps
* Text to display in the input when it has no value
*/
placeholder?: string;
/**
* Displays icon to toggle between showing and hiding the input value
*/
enableVisibility?: boolean;
/**
* Displays a clear button when there is content in the input
*/
isClearable?: boolean;
}
+1
View File
@@ -39,6 +39,7 @@
@import '../components/TagGroup/TagGroup.styles.css';
@import '../components/Text/styles.css';
@import '../components/TextField/TextField.styles.css';
@import '../components/TextField/PasswordField.styles.css';
@import '../components/SearchField/SearchField.styles.css';
@import '../components/Skeleton/Skeleton.styles.css';
@import '../components/Tooltip/Tooltip.styles.css';
@@ -293,6 +293,19 @@ export const componentDefinitions = {
disabled: [true, false] as const,
},
},
PasswordField: {
classNames: {
root: 'bui-PasswordField',
inputWrapper: 'bui-InputWrapper',
input: 'bui-Input',
inputIcon: 'bui-InputIcon',
inputAction: 'bui-InputAction',
},
dataAttributes: {
invalid: [true, false] as const,
disabled: [true, false] as const,
},
},
Tooltip: {
classNames: {
tooltip: 'bui-Tooltip',