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:
@@ -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"
|
||||
/>`;
|
||||
@@ -98,6 +98,10 @@ export const components: Page[] = [
|
||||
title: 'Menu',
|
||||
slug: 'menu',
|
||||
},
|
||||
{
|
||||
title: 'NumberField',
|
||||
slug: 'number-field',
|
||||
},
|
||||
{
|
||||
title: 'PasswordField',
|
||||
slug: 'password-field',
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user