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
+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';