Add doscs + FieldError

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-19 21:32:43 +01:00
parent 6910892548
commit 4f1b4ad6c0
14 changed files with 351 additions and 19 deletions
@@ -0,0 +1,98 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { RadioGroupSnippet } from '@/snippets/stories-snippets';
import {
radioGroupPropDefs,
radioGroupUsageSnippet,
radioGroupDefaultSnippet,
radioGroupDescriptionSnippet,
radioGroupHorizontalSnippet,
radioGroupDisabledSnippet,
radioGroupDisabledSingleSnippet,
radioGroupValidationSnippet,
radioGroupReadOnlySnippet,
} from './radio-group.props';
import { ComponentInfos } from '@/components/ComponentInfos';
# RadioGroup
A radio group allows a user to select a single item from a list of mutually exclusive options.
<Snippet
align="center"
py={4}
preview={<RadioGroupSnippet story="Default" />}
code={radioGroupDefaultSnippet}
/>
<ComponentInfos
component="radio-group"
classNames={['canon-RadioGroup', 'canon-RadioGroupContent']}
usageCode={radioGroupUsageSnippet}
/>
## API reference
<PropsTable data={radioGroupPropDefs} />
## Examples
### Horizontal
Here's a simple TextField with a description.
<Snippet
align="center"
py={4}
open
preview={<RadioGroupSnippet story="Horizontal" />}
code={radioGroupHorizontalSnippet}
/>
### Disabled
You can disable the entire radio group by adding the `isDisabled` prop to the `RadioGroup` component.
<Snippet
align="center"
py={4}
open
preview={<RadioGroupSnippet story="Disabled" />}
code={radioGroupDisabledSnippet}
/>
### Disabled Single radio
You can disable a single radio by adding the `isDisabled` prop to the `Radio` component.
<Snippet
align="center"
py={4}
open
preview={<RadioGroupSnippet story="DisabledSingle" />}
code={radioGroupDisabledSingleSnippet}
/>
### Validation
Here's an example of a radio group with errors.
<Snippet
align="center"
py={4}
open
preview={<RadioGroupSnippet story="Validation" />}
code={radioGroupValidationSnippet}
/>
### Read only
You can make the radio group read only by adding the `isReadOnly` prop to the `RadioGroup` component.
<Snippet
align="center"
py={4}
open
preview={<RadioGroupSnippet story="ReadOnly" />}
code={radioGroupReadOnlySnippet}
/>
@@ -0,0 +1,64 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
export const radioGroupPropDefs: 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 radioGroupUsageSnippet = `import { RadioGroup } from '@backstage/canon';
<RadioGroup />`;
export const radioGroupDefaultSnippet = `<RadioGroup label="Label" />`;
export const radioGroupDescriptionSnippet = `<TextField label="Label" description="Description" placeholder="Enter a URL" />`;
export const radioGroupHorizontalSnippet = `<RadioGroup label="Label" orientation="horizontal" />`;
export const radioGroupDisabledSnippet = `<RadioGroup label="Label" isDisabled>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>`;
export const radioGroupDisabledSingleSnippet = `<RadioGroup label="Label">
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander" isDisabled>Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>`;
export const radioGroupValidationSnippet = `<RadioGroup validate: value => (value === \'charmander\' ? \'Nice try!\' : null)>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>`;
export const radioGroupReadOnlySnippet = `<RadioGroup label="Label" isReadOnly>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>`;
@@ -18,6 +18,7 @@ import * as MenuStories from '../../../packages/canon/src/components/Menu/Menu.s
import * as LinkStories from '../../../packages/canon/src/components/Link/Link.stories';
import * as AvatarStories from '../../../packages/canon/src/components/Avatar/Avatar.stories';
import * as CollapsibleStories from '../../../packages/canon/src/components/Collapsible/Collapsible.stories';
import * as RadioGroupStories from '../../../packages/canon/src/components/RadioGroup/RadioGroup.stories';
import * as TabsStories from '../../../packages/canon/src/components/Tabs/Tabs.stories';
import * as SwitchStories from '../../../packages/canon/src/components/Switch/Switch.stories';
@@ -197,3 +198,14 @@ export const SwitchSnippet = ({
return StoryComponent ? <StoryComponent /> : null;
};
export const RadioGroupSnippet = ({
story,
}: {
story: keyof typeof RadioGroupStories;
}) => {
const stories = composeStories(RadioGroupStories);
const StoryComponent = stories[story as keyof typeof stories];
return StoryComponent ? <StoryComponent /> : null;
};
+5
View File
@@ -116,6 +116,11 @@ export const components: Page[] = [
slug: 'menu',
status: 'alpha',
},
{
title: 'RadioGroup',
slug: 'radio-group',
status: 'alpha',
},
{
title: 'Select',
slug: 'select',
@@ -0,0 +1,87 @@
/*
* 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';
import { TextField, Input, Form } from 'react-aria-components';
import { FieldError } from './FieldError';
const meta = {
title: 'Forms/FieldError',
component: FieldError,
} satisfies Meta<typeof FieldError>;
export default meta;
type Story = StoryObj<typeof meta>;
// Show error with server validation using Form component
export const WithServerValidation: Story = {
render: () => (
<Form validationErrors={{ demo: 'This is a server validation error.' }}>
<TextField
name="demo"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Input />
<FieldError />
</TextField>
</Form>
),
};
// Show error using children
export const WithCustomMessage: Story = {
render: () => (
<TextField
isInvalid
validationBehavior="aria"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Input />
<FieldError>This is a custom error message.</FieldError>
</TextField>
),
};
// Show error with render prop function
export const WithRenderProp: Story = {
render: () => (
<TextField
isInvalid
validationBehavior="aria"
validate={() => 'This field is invalid'}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Input />
<FieldError>
{({ validationErrors }) =>
validationErrors.length > 0 ? validationErrors[0] : 'Field is invalid'
}
</FieldError>
</TextField>
),
};
@@ -0,0 +1,6 @@
.canon-FieldError {
color: var(--canon-fg-danger);
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
margin-top: var(--canon-space-2);
}
@@ -0,0 +1,39 @@
/*
* 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 { forwardRef } from 'react';
import {
FieldError as AriaFieldError,
type FieldErrorProps,
} from 'react-aria-components';
import clsx from 'clsx';
/** @public */
export const FieldError = forwardRef<HTMLDivElement, FieldErrorProps>(
(props: FieldErrorProps, ref) => {
const { className, ...rest } = props;
return (
<AriaFieldError
className={clsx('canon-FieldError', className)}
ref={ref}
{...rest}
/>
);
},
);
FieldError.displayName = 'FieldError';
@@ -0,0 +1,17 @@
/*
* 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 './FieldError';
@@ -18,10 +18,10 @@ import { forwardRef, useEffect } from 'react';
import {
RadioGroup as AriaRadioGroup,
Radio as AriaRadio,
FieldError,
} from 'react-aria-components';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
import { FieldError } from '../FieldError';
import type { RadioGroupProps, RadioProps } from './types';
@@ -66,7 +66,7 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
description={description}
/>
<div className="canon-RadioGroupContent">{children}</div>
<FieldError className="canon-TextFieldError" />
<FieldError />
</AriaRadioGroup>
);
},
@@ -97,16 +97,28 @@ export const Disabled: Story = {
export const WithIcon: Story = {
args: {
...Default.args,
defaultValue: 'Search...',
icon: <Icon name="search" />,
},
render: args => (
<TextField
{...args}
icon={<Icon name="search" />}
defaultValue="Search..."
/>
),
};
export const DisabledWithIcon: Story = {
args: {
...WithIcon.args,
isDisabled: true,
...Default.args,
},
render: args => (
<TextField
{...args}
icon={<Icon name="search" />}
defaultValue="Search..."
isDisabled
/>
),
};
export const ShowError: Story = {
@@ -104,10 +104,3 @@
cursor: not-allowed;
border: 1px solid var(--canon-border-disabled);
}
.canon-TextFieldError {
color: var(--canon-fg-danger);
font-size: var(--canon-font-size-2);
font-weight: var(--canon-font-weight-regular);
margin-top: var(--canon-space-2);
}
@@ -15,14 +15,11 @@
*/
import { forwardRef, useEffect } from 'react';
import {
Input,
TextField as AriaTextField,
FieldError,
} from 'react-aria-components';
import { Input, TextField as AriaTextField } from 'react-aria-components';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
import { FieldError } from '../FieldError';
import type { TextFieldProps } from './types';
@@ -86,7 +83,7 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
{...(icon && { 'data-icon': true })}
/>
</div>
<FieldError className="canon-TextFieldError" />
<FieldError />
</AriaTextField>
);
},
+1
View File
@@ -23,6 +23,7 @@
@import '../components/Container/styles.css';
@import '../components/DataTable/Root/DataTableRoot.styles.css';
@import '../components/DataTable/Pagination/DataTablePagination.styles.css';
@import '../components/FieldError/FieldError.styles.css';
@import '../components/FieldLabel/FieldLabel.styles.css';
@import '../components/Flex/styles.css';
@import '../components/Grid/styles.css';
+1
View File
@@ -38,6 +38,7 @@ export * from './components/Checkbox';
export * from './components/Collapsible';
export * from './components/DataTable';
export * from './components/FieldLabel';
export * from './components/FieldError';
export * from './components/Heading';
export * from './components/Icon';
export * from './components/Link';