Add NumberField component to @backstage/ui (#34264)

* Add NumberField component to @backstage/ui

Signed-off-by: James Brooks <jamesbrooks@spotify.com>

* Address review feedback on NumberField

Signed-off-by: James Brooks <jamesbrooks@spotify.com>

* Fix NumberField CSS formatting

Signed-off-by: James Brooks <jamesbrooks@spotify.com>

* Add increment/decrement buttons to NumberField

Signed-off-by: James Brooks <jamesbrooks@spotify.com>

* Fix NumberField looking disabled at min/max bounds

Signed-off-by: James Brooks <jamesbrooks@spotify.com>

---------

Signed-off-by: James Brooks <jamesbrooks@spotify.com>
This commit is contained in:
James Brooks
2026-05-27 16:20:27 +01:00
committed by GitHub
parent 58fb313f22
commit b33bb24b5a
15 changed files with 908 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added a new `NumberField` component for numeric input with support for min, max, step, and keyboard increment/decrement.
**Affected components:** NumberField
@@ -0,0 +1,51 @@
'use client';
import { NumberField } from '../../../../../packages/ui/src/components/NumberField/NumberField';
import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex';
import { RiTimeLine } from '@remixicon/react';
export const WithLabel = () => {
return (
<NumberField
name="quantity"
placeholder="Enter a number"
label="Label"
style={{ maxWidth: '300px' }}
/>
);
};
export const Sizes = () => {
return (
<Flex
direction="column"
gap="4"
style={{ width: '100%', maxWidth: '300px' }}
>
<NumberField
name="quantity"
placeholder="Enter a number"
size="small"
icon={<RiTimeLine />}
/>
<NumberField
name="quantity"
placeholder="Enter a number"
size="medium"
icon={<RiTimeLine />}
/>
</Flex>
);
};
export const WithDescription = () => {
return (
<NumberField
name="quantity"
placeholder="Enter a number"
label="Label"
description="Description"
style={{ maxWidth: '300px' }}
/>
);
};
@@ -0,0 +1,70 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { numberFieldPropDefs } from './props-definition';
import {
numberFieldUsageSnippet,
withLabelSnippet,
sizesSnippet,
withDescriptionSnippet,
} from './snippets';
import { WithLabel, Sizes, WithDescription } from './components';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { NumberFieldDefinition } from '../../../utils/definitions';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { CodeBlock } from '@/components/CodeBlock';
import { ReactAriaLink } from '@/components/ReactAriaLink';
export const reactAriaUrls = {
numberField: 'https://react-aria.adobe.com/NumberField',
};
<PageTitle
title="NumberField"
description="A numeric input with label, description, icon, and validation support."
/>
<Snippet
align="center"
py={4}
preview={<WithLabel />}
code={withLabelSnippet}
/>
## Usage
<CodeBlock code={numberFieldUsageSnippet} />
## API reference
<PropsTable data={numberFieldPropDefs} />
<ReactAriaLink component="NumberField" href={reactAriaUrls.numberField} />
## Examples
### Sizes
<Snippet
align="center"
py={4}
open
preview={<Sizes />}
code={sizesSnippet}
layout="side-by-side"
/>
### With description
<Snippet
align="center"
py={4}
open
preview={<WithDescription />}
code={withDescriptionSnippet}
layout="side-by-side"
/>
<Theming definition={NumberFieldDefinition} />
<ChangelogComponent component="number-field" />
@@ -0,0 +1,100 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
export const numberFieldPropDefs: Record<string, PropDef> = {
size: {
type: 'enum',
values: ['small', 'medium'],
default: 'small',
responsive: true,
description: (
<>
Visual size of the input. Use <Chip>small</Chip> for dense layouts,{' '}
<Chip>medium</Chip> for prominent fields.
</>
),
},
label: {
type: 'string',
description: 'Visible label displayed above the input.',
},
secondaryLabel: {
type: 'string',
description: (
<>
Secondary text shown next to the label. If not provided and isRequired
is true, displays <Chip>Required</Chip>.
</>
),
},
description: {
type: 'string',
description: 'Help text displayed below the label.',
},
icon: {
type: 'enum',
values: ['ReactNode'],
description: 'Icon rendered before the input.',
},
placeholder: {
type: 'string',
description: 'Text displayed when the input is empty.',
},
name: {
type: 'string',
description: 'Form field name for submission.',
},
minValue: {
type: 'number',
description: 'Minimum allowed value.',
},
maxValue: {
type: 'number',
description: 'Maximum allowed value.',
},
step: {
type: 'number',
description: 'Step increment for arrow key changes.',
},
formatOptions: {
type: 'enum',
values: ['Intl.NumberFormatOptions'],
description: (
<>
Number formatting options. Defaults to{' '}
<Chip>{'useGrouping: false'}</Chip>.
</>
),
},
isRequired: {
type: 'boolean',
description: 'Whether the field is required for form submission.',
},
isDisabled: {
type: 'boolean',
description: 'Whether the input is disabled.',
},
isReadOnly: {
type: 'boolean',
description: 'Whether the input is read-only.',
},
value: {
type: 'number',
description: 'Controlled value of the input.',
},
defaultValue: {
type: 'number',
description: 'Default value for uncontrolled usage.',
},
onChange: {
type: 'enum',
values: ['(value: number) => void'],
description: 'Handler called when the input value changes.',
},
...classNamePropDefs,
...stylePropDefs,
};
@@ -0,0 +1,31 @@
export const numberFieldUsageSnippet = `import { NumberField } from '@backstage/ui';
<NumberField label="Minutes" minValue={0} maxValue={59} step={1} />`;
export const withLabelSnippet = `<NumberField
name="quantity"
placeholder="Enter a number"
label="Label"
/>`;
export const sizesSnippet = `<Flex direction="column" gap="4">
<NumberField
size="small"
name="quantity"
placeholder="Enter a number"
icon={<RiTimeLine />}
/>
<NumberField
size="medium"
name="quantity"
placeholder="Enter a number"
icon={<RiTimeLine />}
/>
</Flex>`;
export const withDescriptionSnippet = `<NumberField
name="quantity"
placeholder="Enter a number"
label="Label"
description="Description"
/>`;
+4
View File
@@ -98,6 +98,10 @@ export const components: Page[] = [
title: 'Menu',
slug: 'menu',
},
{
title: 'NumberField',
slug: 'number-field',
},
{
title: 'PasswordField',
slug: 'password-field',
+50
View File
@@ -37,6 +37,7 @@ import type { MenuProps as MenuProps_2 } from 'react-aria-components';
import type { MenuSectionProps as MenuSectionProps_2 } from 'react-aria-components';
import type { MenuTriggerProps as MenuTriggerProps_2 } from 'react-aria-components';
import type { ModalOverlayProps } from 'react-aria-components';
import type { NumberFieldProps as NumberFieldProps_2 } from 'react-aria-components';
import { PopoverProps as PopoverProps_2 } from 'react-aria-components';
import type { RadioGroupProps as RadioGroupProps_2 } from 'react-aria-components';
import type { RadioProps as RadioProps_2 } from 'react-aria-components';
@@ -2329,6 +2330,55 @@ export interface NoPagination {
type: 'none';
}
// @public
export const NumberField: ForwardRefExoticComponent<
NumberFieldProps & RefAttributes<HTMLDivElement>
>;
// @public
export const NumberFieldDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-NumberField';
readonly inputWrapper: 'bui-InputWrapper';
readonly input: 'bui-Input';
readonly inputIcon: 'bui-InputIcon';
readonly stepperButtons: 'bui-StepperButtons';
readonly stepperButton: 'bui-StepperButton';
};
readonly bg: 'consumer';
readonly propDefs: {
readonly size: {
readonly dataAttribute: true;
readonly default: 'small';
};
readonly className: {};
readonly icon: {};
readonly placeholder: {};
readonly label: {};
readonly description: {};
readonly secondaryLabel: {};
};
};
// @public (undocumented)
export type NumberFieldOwnProps = {
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
className?: string;
icon?: ReactNode;
placeholder?: string;
label?: FieldLabelProps['label'];
description?: FieldLabelProps['description'];
secondaryLabel?: FieldLabelProps['secondaryLabel'];
};
// @public (undocumented)
export interface NumberFieldProps
extends Omit<NumberFieldProps_2, 'className' | 'description'>,
NumberFieldOwnProps {}
// @public (undocumented)
export interface OffsetParams<TFilter> {
// (undocumented)
@@ -0,0 +1,140 @@
/*
* Copyright 2026 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.
*/
@layer tokens, base, components, utilities;
@layer components {
.bui-NumberField {
display: flex;
flex-direction: column;
font-family: var(--bui-font-regular);
width: 100%;
flex-shrink: 0;
&[data-on-bg='neutral-1'] .bui-Input {
background-color: var(--bui-bg-neutral-2);
}
&[data-on-bg='neutral-2'] .bui-Input {
background-color: var(--bui-bg-neutral-3);
}
&[data-on-bg='neutral-3'] .bui-Input {
background-color: var(--bui-bg-neutral-4);
}
}
.bui-InputWrapper {
position: relative;
&[data-size='small'] .bui-Input {
height: 2rem;
padding-right: 3.5rem;
}
&[data-size='medium'] .bui-Input {
height: 2.5rem;
padding-right: 4.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);
}
&[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
}
.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: none;
background-color: var(--bui-bg-neutral-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: box-shadow 0.2s ease-in-out;
width: 100%;
height: 100%;
cursor: inherit;
&[data-focused] {
outline: none;
box-shadow: inset 0 0 0 1px var(--bui-ring);
}
&::placeholder {
color: var(--bui-fg-secondary);
}
&[data-invalid] {
box-shadow: inset 0 0 0 1px var(--bui-border-danger);
}
}
.bui-StepperButtons {
position: absolute;
right: var(--bui-space-1);
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
}
.bui-NumberField .bui-StepperButton {
width: 1.5rem;
height: 1.5rem;
}
.bui-NumberField[data-size='medium'] .bui-StepperButton {
width: 2rem;
height: 2rem;
}
}
@@ -0,0 +1,218 @@
/*
* Copyright 2026 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 preview from '../../../../../.storybook/preview';
import { NumberField } from './NumberField';
import { Form } from 'react-aria-components';
import { Flex } from '../Flex';
import { Box } from '../Box';
import { Text } from '../Text';
import { FieldLabel } from '../FieldLabel';
import { RiTimeLine, RiSparklingLine } from '@remixicon/react';
const meta = preview.meta({
title: 'Backstage UI/NumberField',
component: NumberField,
argTypes: {
isRequired: {
control: 'boolean',
},
icon: {
control: 'object',
},
},
});
export const Default = meta.story({
args: {
name: 'quantity',
placeholder: 'Enter a number',
style: {
maxWidth: '300px',
},
},
});
export const Sizes = meta.story({
args: {
...Default.input.args,
},
render: args => (
<Flex direction="row" gap="4" style={{ width: '100%', maxWidth: '600px' }}>
<NumberField {...args} size="small" icon={<RiSparklingLine />} />
<NumberField {...args} size="medium" icon={<RiSparklingLine />} />
</Flex>
),
});
export const DefaultValue = meta.story({
args: {
...Default.input.args,
defaultValue: 42,
},
});
export const WithLabel = meta.story({
args: {
...Default.input.args,
label: 'Label',
},
});
export const WithDescription = meta.story({
args: {
...WithLabel.input.args,
description: 'Description',
},
});
export const Required = meta.story({
args: {
...WithLabel.input.args,
isRequired: true,
},
});
export const Disabled = meta.story({
args: {
...Default.input.args,
isDisabled: true,
},
});
export const WithIcon = meta.story({
args: {
...Default.input.args,
},
render: args => <NumberField {...args} size="small" icon={<RiTimeLine />} />,
});
export const DisabledWithIcon = WithIcon.extend({
args: {
isDisabled: true,
},
});
export const ShowError = meta.story({
args: {
...WithLabel.input.args,
},
render: args => (
<Form validationErrors={{ quantity: 'Value is out of range' }}>
<NumberField {...args} />
</Form>
),
});
export const Validation = meta.story({
args: {
...WithLabel.input.args,
validate: (value: number) => (value < 0 ? 'Must be positive' : null),
},
});
export const MinMaxStep = meta.story({
args: {
...Default.input.args,
label: 'Minutes',
minValue: 0,
maxValue: 59,
step: 1,
},
render: args => (
<NumberField
{...args}
icon={<RiTimeLine />}
style={{ maxWidth: '200px' }}
/>
),
});
export const CustomField = meta.story({
render: () => (
<>
<FieldLabel
htmlFor="custom-field"
id="custom-field-label"
label="Custom Field"
/>
<NumberField
id="custom-field"
aria-labelledby="custom-field-label"
name="custom-field"
defaultValue={10}
/>
</>
),
});
export const StepIncrement = meta.story({
args: {
...WithLabel.input.args,
label: 'Quantity',
defaultValue: 5,
minValue: 0,
maxValue: 20,
step: 5,
},
render: args => <NumberField {...args} style={{ maxWidth: '200px' }} />,
});
export const AutoBg = meta.story({
render: () => (
<Flex direction="column" gap="4">
<div style={{ maxWidth: '600px' }}>
NumberField automatically detects its parent bg context and increments
the neutral level by 1. No prop is needed it's fully automatic.
</div>
<Box bg="neutral" p="4">
<Text>Neutral 1 container</Text>
<Flex mt="2" style={{ maxWidth: '300px' }}>
<NumberField
aria-label="Number"
placeholder="Enter number"
size="small"
/>
</Flex>
</Box>
<Box bg="neutral">
<Box bg="neutral" p="4">
<Text>Neutral 2 container</Text>
<Flex mt="2" style={{ maxWidth: '300px' }}>
<NumberField
aria-label="Number"
placeholder="Enter number"
size="small"
/>
</Flex>
</Box>
</Box>
<Box bg="neutral">
<Box bg="neutral">
<Box bg="neutral" p="4">
<Text>Neutral 3 container</Text>
<Flex mt="2" style={{ maxWidth: '300px' }}>
<NumberField
aria-label="Number"
placeholder="Enter number"
size="small"
/>
</Flex>
</Box>
</Box>
</Box>
</Flex>
),
});
@@ -0,0 +1,121 @@
/*
* Copyright 2026 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, useMemo } from 'react';
import {
Group,
Input,
NumberField as AriaNumberField,
} from 'react-aria-components';
import { RiAddLine, RiSubtractLine } from '@remixicon/react';
import { FieldLabel } from '../FieldLabel';
import { FieldError } from '../FieldError';
import { ButtonIcon } from '../ButtonIcon';
import type { NumberFieldProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { NumberFieldDefinition } from './definition';
/**
* A numeric input with an integrated label, optional icon, and inline error display.
*
* @public
*/
export const NumberField = forwardRef<HTMLDivElement, NumberFieldProps>(
(props, ref) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
NumberFieldDefinition,
props,
);
const { classes, label, icon, secondaryLabel, placeholder, description } =
ownProps;
useEffect(() => {
if (!label && !restProps['aria-label'] && !restProps['aria-labelledby']) {
console.warn(
'NumberField requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, restProps['aria-label'], restProps['aria-labelledby']]);
const secondaryLabelText =
secondaryLabel || (restProps.isRequired ? 'Required' : null);
const formatOptions = useMemo(
() => ({
useGrouping: false,
...restProps.formatOptions,
}),
[restProps.formatOptions],
);
return (
<AriaNumberField
className={classes.root}
{...dataAttributes}
{...restProps}
formatOptions={formatOptions}
ref={ref}
>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<Group
className={classes.inputWrapper}
data-size={dataAttributes['data-size']}
>
{icon && (
<div
className={classes.inputIcon}
data-size={dataAttributes['data-size']}
aria-hidden="true"
>
{icon}
</div>
)}
<Input
className={classes.input}
{...(icon && { 'data-icon': true })}
placeholder={placeholder}
/>
<div className={classes.stepperButtons}>
<ButtonIcon
slot="decrement"
variant="tertiary"
size={ownProps.size}
className={classes.stepperButton}
icon={<RiSubtractLine />}
aria-label="Decrease"
/>
<ButtonIcon
slot="increment"
variant="tertiary"
size={ownProps.size}
className={classes.stepperButton}
icon={<RiAddLine />}
aria-label="Increase"
/>
</div>
</Group>
<FieldError />
</AriaNumberField>
);
},
);
NumberField.displayName = 'NumberField';
@@ -0,0 +1,45 @@
/*
* Copyright 2026 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 { defineComponent } from '../../hooks/useDefinition';
import type { NumberFieldOwnProps } from './types';
import styles from './NumberField.module.css';
/**
* Component definition for NumberField
* @public
*/
export const NumberFieldDefinition = defineComponent<NumberFieldOwnProps>()({
styles,
classNames: {
root: 'bui-NumberField',
inputWrapper: 'bui-InputWrapper',
input: 'bui-Input',
inputIcon: 'bui-InputIcon',
stepperButtons: 'bui-StepperButtons',
stepperButton: 'bui-StepperButton',
},
bg: 'consumer',
propDefs: {
size: { dataAttribute: true, default: 'small' },
className: {},
icon: {},
placeholder: {},
label: {},
description: {},
secondaryLabel: {},
},
});
@@ -0,0 +1,19 @@
/*
* Copyright 2026 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 './NumberField';
export * from './types';
export { NumberFieldDefinition } from './definition';
@@ -0,0 +1,50 @@
/*
* Copyright 2026 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 { NumberFieldProps as AriaNumberFieldProps } from 'react-aria-components';
import type { ReactNode } from 'react';
import type { Breakpoint } from '../../types';
import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export type NumberFieldOwnProps = {
/**
* The size of the number field
* @defaultValue 'small'
*/
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
className?: string;
/**
* An icon to render before the input
*/
icon?: ReactNode;
/**
* Text to display in the input when it has no value
*/
placeholder?: string;
label?: FieldLabelProps['label'];
description?: FieldLabelProps['description'];
secondaryLabel?: FieldLabelProps['secondaryLabel'];
};
/** @public */
export interface NumberFieldProps
extends Omit<AriaNumberFieldProps, 'className' | 'description'>,
NumberFieldOwnProps {}
+1
View File
@@ -62,6 +62,7 @@ export {
ListRowDefinition,
} from './components/List/definition';
export { MenuDefinition } from './components/Menu/definition';
export { NumberFieldDefinition } from './components/NumberField/definition';
export { PasswordFieldDefinition } from './components/PasswordField/definition';
export { PopoverDefinition } from './components/Popover/definition';
export { RadioGroupDefinition } from './components/RadioGroup/definition';
+1
View File
@@ -53,6 +53,7 @@ export * from './components/Tabs';
export * from './components/TagGroup';
export * from './components/Text';
export * from './components/TextField';
export * from './components/NumberField';
export * from './components/PasswordField';
export * from './components/Tooltip';
export * from './components/Menu';