Merge pull request #31238 from birdhb/add-clear-and-visibility-actions-to-text-field

Add PasswordField to BUI
This commit is contained in:
Charles de Dreuille
2025-10-08 11:27:15 +01:00
committed by GitHub
18 changed files with 608 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': minor
---
**BREAKING**: Added a new `PasswordField` component. As part of this change, the `password` and `search` types have been removed from `TextField`.
File diff suppressed because one or more lines are too long
+5
View File
@@ -85,6 +85,11 @@ building it incrementally with not conflict with the existing theming system.
description="A search field component to help you search for items."
href="/components/search-field"
/>
<ComponentCard
title="PasswordField"
description="A password field component to help you input passwords."
href="/components/password-field"
/>
<ComponentCard
title="Select"
description="A select component to help you select items."
@@ -0,0 +1,64 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { PasswordFieldSnippet } from '@/snippets/stories-snippets';
import {
inputPropDefs,
passwordFieldUsageSnippet,
passwordFieldDefaultSnippet,
passwordFieldSizesSnippet,
passwordFieldDescriptionSnippet,
} from './password-field.props';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { CodeBlock } from '@/components/CodeBlock';
<PageTitle
title="PasswordField"
description="A password field component for your forms."
/>
<Snippet
align="center"
py={4}
preview={<PasswordFieldSnippet story="WithLabel" />}
code={passwordFieldDefaultSnippet}
/>
## Usage
<CodeBlock code={passwordFieldUsageSnippet} />
## API reference
<PropsTable data={inputPropDefs} />
## Examples
### Sizes
We support two different sizes: `small`, `medium`.
<Snippet
align="center"
py={4}
open
preview={<PasswordFieldSnippet story="Sizes" />}
code={passwordFieldSizesSnippet}
/>
### With description
Here's a simple PasswordField with a description.
<Snippet
align="center"
py={4}
open
preview={<PasswordFieldSnippet story="WithDescription" />}
code={passwordFieldDescriptionSnippet}
/>
<Theming component="PasswordField" />
<ChangelogComponent component="password-field" />
@@ -0,0 +1,43 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
export const inputPropDefs: Record<string, PropDef> = {
size: {
type: 'enum',
values: ['small', 'medium'],
default: 'small',
responsive: true,
},
label: {
type: 'string',
},
icon: {
type: 'enum',
values: ['ReactNode'],
},
description: {
type: 'string',
},
name: {
type: 'string',
required: true,
},
...classNamePropDefs,
...stylePropDefs,
};
export const passwordFieldUsageSnippet = `import { PasswordField } from '@backstage/ui';
<PasswordField />`;
export const passwordFieldDefaultSnippet = `<PasswordField label="Label" placeholder="Enter a secret" />`;
export const passwordFieldSizesSnippet = `<Flex direction="row" gap="4">
<PasswordField size="small" placeholder="Small" icon={<Icon name="sparkling" />} />
<PasswordField size="medium" placeholder="Medium" icon={<Icon name="sparkling" />} />
</Flex>`;
export const passwordFieldDescriptionSnippet = `<PasswordField label="Label" description="Description" placeholder="Enter a secret" />`;
@@ -28,6 +28,7 @@ import * as HeaderStories from '../../../packages/ui/src/components/Header/Heade
import * as HeaderPageStories from '../../../packages/ui/src/components/HeaderPage/HeaderPage.stories';
import * as TableStories from '../../../packages/ui/src/components/Table/Table.stories';
import * as TagGroupStories from '../../../packages/ui/src/components/TagGroup/TagGroup.stories';
import * as PasswordFieldStories from '../../../packages/ui/src/components/PasswordField/PasswordField.stories';
// Helper function to create snippet components
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -53,6 +54,8 @@ export const ContainerSnippet = createSnippetComponent(ContainerStories);
export const GridSnippet = createSnippetComponent(GridStories);
export const IconSnippet = createSnippetComponent(IconStories);
export const TextFieldSnippet = createSnippetComponent(TextFieldStories);
export const PasswordFieldSnippet =
createSnippetComponent(PasswordFieldStories);
export const TextSnippet = createSnippetComponent(TextStories);
export const FlexSnippet = createSnippetComponent(FlexStories);
export const SelectSnippet = createSnippetComponent(SelectStories);
+2 -1
View File
@@ -25,7 +25,8 @@ export type Component =
| 'radio-group'
| 'card'
| 'skeleton'
| 'header';
| 'header'
| 'passwordfield';
export type Version = `${number}.${number}.${number}`;
+5
View File
@@ -126,6 +126,11 @@ export const components: Page[] = [
slug: 'menu',
status: 'alpha',
},
{
title: 'PasswordField',
slug: 'password-field',
status: 'alpha',
},
{
title: 'RadioGroup',
slug: 'radio-group',
+53
View File
@@ -10522,6 +10522,59 @@
}
}
.bui-PasswordField {
font-family: var(--bui-font-regular);
--bui-passwordmanager-icon-width: var(--bui-space-1);
flex-direction: column;
flex-shrink: 0;
width: 100%;
display: flex;
}
.bui-InputVisibility {
right: var(--bui-passwordmanager-icon-width);
cursor: pointer;
color: var(--bui-fg-primary);
background-color: #0000;
border: none;
justify-content: center;
align-items: center;
margin: 0;
padding: 0;
display: flex;
position: absolute;
top: 0;
bottom: 0;
&[data-size="small"] {
width: 2rem;
height: 2rem;
}
&[data-size="small"] svg {
width: 1rem;
height: 1rem;
}
&[data-size="medium"] {
width: 2.5rem;
height: 2.5rem;
}
&[data-size="medium"] svg {
width: 1.25rem;
height: 1.25rem;
}
}
.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));
}
.bui-SearchField {
flex: 1 0;
+8
View File
@@ -525,6 +525,12 @@ export const componentDefinitions: {
readonly emptyState: 'bui-MenuEmptyState';
};
};
readonly PasswordField: {
readonly classNames: {
readonly root: 'bui-PasswordField';
readonly inputVisibility: 'bui-InputVisibility';
};
};
readonly Popover: {
readonly classNames: {
readonly root: 'bui-Popover';
@@ -643,6 +649,7 @@ export const componentDefinitions: {
readonly inputWrapper: 'bui-InputWrapper';
readonly input: 'bui-Input';
readonly inputIcon: 'bui-InputIcon';
readonly inputAction: 'bui-InputAction';
};
readonly dataAttributes: {
readonly invalid: readonly [true, false];
@@ -1760,6 +1767,7 @@ export interface TextFieldProps
icon?: ReactNode;
placeholder?: string;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
type?: 'text' | 'email' | 'tel' | 'url';
}
// @public (undocumented)
@@ -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,70 @@
/*
* 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;
/* Reserve space for browser/password manager icon (e.g. 1Password) */
--bui-passwordmanager-icon-width: var(--bui-space-1);
}
.bui-InputVisibility {
position: absolute;
right: var(--bui-passwordmanager-icon-width);
top: 0;
bottom: 0;
background-color: transparent;
cursor: pointer;
border: none;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--bui-fg-primary);
/* Size: small */
&[data-size='small'] {
width: 2rem;
height: 2rem;
}
&[data-size='small'] svg {
width: 1rem;
height: 1rem;
}
/* Size: medium */
&[data-size='medium'] {
width: 2.5rem;
height: 2.5rem;
}
&[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));
}
@@ -0,0 +1,126 @@
/*
* 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,
Button as RAButton,
} 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 { RiEyeLine, RiEyeOffLine } from '@remixicon/react';
/** @public */
export const PasswordField = forwardRef<HTMLDivElement, PasswordFieldProps>(
(props, ref) => {
const {
className,
icon,
size = 'small',
label,
secondaryLabel,
description,
isRequired,
'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: passwordFieldClassNames, dataAttributes } = useStyles(
'PasswordField',
{
size,
},
);
const { classNames: textFieldClassNames } = useStyles('TextField', {});
// 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(passwordFieldClassNames.root, className)}
{...dataAttributes}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
type="password"
{...rest}
ref={ref}
>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
<div
className={textFieldClassNames.inputWrapper}
data-size={dataAttributes['data-size']}
>
{icon && (
<div
className={textFieldClassNames.inputIcon}
data-size={dataAttributes['data-size']}
aria-hidden="true"
>
{icon}
</div>
)}
<div className={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={passwordFieldClassNames.inputVisibility}
>
{isVisible ? <RiEyeLine /> : <RiEyeOffLine />}
</RAButton>
</div>
<Input
className={textFieldClassNames.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,41 @@
/*
* 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;
}
@@ -23,6 +23,14 @@ import type { FieldLabelProps } from '../FieldLabel/types';
export interface TextFieldProps
extends AriaTextFieldProps,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
/**
* The HTML input type for the text field
*
* @remarks
* Use `SearchField` for
* search inputs and `PasswordField` for password inputs.
*/
type?: 'text' | 'email' | 'tel' | 'url';
/**
* An icon to render before the input
*/
+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/PasswordField/PasswordField.styles.css';
@import '../components/SearchField/SearchField.styles.css';
@import '../components/Skeleton/Skeleton.styles.css';
@import '../components/Tooltip/Tooltip.styles.css';
@@ -174,6 +174,12 @@ export const componentDefinitions = {
emptyState: 'bui-MenuEmptyState',
},
},
PasswordField: {
classNames: {
root: 'bui-PasswordField',
inputVisibility: 'bui-InputVisibility',
},
},
Popover: {
classNames: {
root: 'bui-Popover',
@@ -286,6 +292,7 @@ export const componentDefinitions = {
inputWrapper: 'bui-InputWrapper',
input: 'bui-Input',
inputIcon: 'bui-InputIcon',
inputAction: 'bui-InputAction',
},
dataAttributes: {
invalid: [true, false] as const,