Add SearchField

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-23 12:13:20 +01:00
parent cfd2aef5ac
commit 47b3f582cc
8 changed files with 400 additions and 22 deletions
@@ -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';
import { SearchField } from './SearchField';
import { Form } from 'react-aria-components';
import { Icon } from '../Icon';
import { Flex } from '../Flex';
import { FieldLabel } from '../FieldLabel';
const meta = {
title: 'Forms/SearchField',
component: SearchField,
argTypes: {
isRequired: {
control: 'boolean',
},
icon: {
control: 'object',
},
placeholder: {
control: 'text',
},
},
} satisfies Meta<typeof SearchField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
name: 'url',
style: {
maxWidth: '300px',
},
},
};
export const Sizes: Story = {
args: {
...Default.args,
},
render: args => (
<Flex direction="row" gap="4" style={{ width: '100%', maxWidth: '600px' }}>
<SearchField {...args} size="small" />
<SearchField {...args} size="medium" />
</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,
defaultValue: 'Search...',
icon: <Icon name="search" />,
},
};
export const DisabledWithIcon: Story = {
args: {
...WithIcon.args,
isDisabled: true,
},
};
export const ShowError: Story = {
args: {
...WithLabel.args,
},
render: args => (
<Form validationErrors={{ url: 'Invalid URL' }}>
<SearchField {...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"
/>
<SearchField
id="custom-field"
aria-labelledby="custom-field-label"
name="custom-field"
defaultValue="Custom Field"
/>
</>
),
};
@@ -0,0 +1,55 @@
/*
* 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.
*/
.canon-SearchField[data-empty] .canon-InputClear {
display: none;
}
.canon-InputClear {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: var(--canon-fg-secondary);
transition: color 0.2s ease-in-out;
}
.canon-InputClear:hover {
color: var(--canon-fg-primary);
}
.canon-InputClear[data-size='small'] {
width: 2rem;
height: 2rem;
}
.canon-InputClear[data-size='medium'] {
width: 2.5rem;
height: 2.5rem;
}
.canon-InputClear svg {
width: 1rem;
height: 1rem;
}
@@ -0,0 +1,113 @@
/*
* 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 } from 'react';
import {
Input,
SearchField as AriaSearchField,
FieldError,
Button,
Label,
Text,
} from 'react-aria-components';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
import { RiSearch2Line, RiCloseCircleLine } from '@remixicon/react';
import type { SearchFieldProps } from './types';
/** @public */
export const SearchField = forwardRef<HTMLDivElement, SearchFieldProps>(
(props, ref) => {
const {
className,
icon,
size = 'small',
label,
secondaryLabel,
description,
isRequired,
placeholder = 'Search',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
...rest
} = props;
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'SearchField requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
// Get the responsive value for the variant
const responsiveSize = useResponsiveValue(size);
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
const secondaryLabelText =
secondaryLabel || (isRequired ? 'Required' : null);
// return (
// <AriaSearchField>
// <Label />
// <Input />
// <Button />
// <Text slot="description" />
// <FieldError />
// </AriaSearchField>
// );
return (
<AriaSearchField
className={clsx('canon-TextField', 'canon-SearchField', className)}
data-size={responsiveSize}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
{...rest}
ref={ref}
>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
<div className="canon-Input" data-size={responsiveSize}>
{icon !== false && (
<div
className="canon-InputIcon"
data-size={responsiveSize}
aria-hidden="true"
>
{icon || <RiSearch2Line />}
</div>
)}
<Input
{...(icon !== false && { 'data-icon': true })}
placeholder={placeholder}
/>
<Button className="canon-InputClear" data-size={responsiveSize}>
<RiCloseCircleLine />
</Button>
</div>
<FieldError className="canon-TextFieldError" />
</AriaSearchField>
);
},
);
SearchField.displayName = 'searchField';
@@ -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 './SearchField';
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 { SearchFieldProps as AriaSearchFieldProps } from 'react-aria-components';
import { ReactNode } from 'react';
import type { Breakpoint } from '../../types';
import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export interface SearchFieldProps
extends AriaSearchFieldProps,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
/**
* An icon to render before the input
*/
icon?: ReactNode | false;
/**
* The size of the text field
* @defaultValue 'medium'
*/
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
/**
* The placeholder text for the input
*/
placeholder?: string;
}
@@ -21,19 +21,19 @@
width: 100%;
}
.canon-TextFieldInputWrapper {
.canon-Input {
position: relative;
}
.canon-TextFieldInputWrapper[data-size='small'] {
.canon-Input[data-size='small'] input {
height: 2rem;
}
.canon-TextFieldInputWrapper[data-size='medium'] {
.canon-Input[data-size='medium'] input {
height: 2.5rem;
}
.canon-TextFieldIcon {
.canon-InputIcon {
position: absolute;
left: var(--canon-space-3);
top: 50%;
@@ -43,19 +43,19 @@
flex-shrink: 0;
}
.canon-TextFieldIcon[data-size='small'],
.canon-TextFieldIcon[data-size='small'] svg {
.canon-InputIcon[data-size='small'],
.canon-InputIcon[data-size='small'] svg {
width: 1rem;
height: 1rem;
}
.canon-TextFieldIcon[data-size='medium'],
.canon-TextFieldIcon[data-size='medium'] svg {
.canon-InputIcon[data-size='medium'],
.canon-InputIcon[data-size='medium'] svg {
width: 1.25rem;
height: 1.25rem;
}
.canon-TextFieldInput {
.canon-Input input {
display: flex;
align-items: center;
padding: 0 var(--canon-space-3);
@@ -71,35 +71,40 @@
width: 100%;
height: 100%;
cursor: inherit;
&::-webkit-search-cancel-button,
&::-webkit-search-decoration {
-webkit-appearance: none;
}
}
.canon-TextFieldInput::placeholder {
.canon-Input input::placeholder {
color: var(--canon-fg-secondary);
}
.canon-TextFieldInput[data-icon] {
.canon-Input input[data-icon] {
padding-left: var(--canon-space-8);
}
.canon-TextFieldInput[data-focused] {
.canon-Input input[data-focused] {
outline-color: var(--canon-border-pressed);
outline-width: 0px;
}
.canon-TextFieldInput[data-hovered] {
.canon-Input input[data-hovered] {
border-color: var(--canon-border-hover);
}
.canon-TextFieldInput[data-focused] {
.canon-Input input[data-focused] {
border-color: var(--canon-border-pressed);
outline-width: 0px;
}
.canon-TextFieldInput[data-invalid] {
.canon-Input input[data-invalid] {
border-color: var(--canon-fg-danger);
}
.canon-TextFieldInput[data-disabled] {
.canon-Input input[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
border: 1px solid var(--canon-border-disabled);
@@ -71,20 +71,17 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
secondaryLabel={secondaryLabelText}
description={description}
/>
<div className="canon-TextFieldInputWrapper" data-size={responsiveSize}>
<div className="canon-Input" data-size={responsiveSize}>
{icon && (
<div
className="canon-TextFieldIcon"
className="canon-InputIcon"
data-size={responsiveSize}
aria-hidden="true"
>
{icon}
</div>
)}
<Input
className="canon-TextFieldInput"
{...(icon && { 'data-icon': true })}
/>
<Input {...(icon && { 'data-icon': true })} />
</div>
<FieldError className="canon-TextFieldError" />
</AriaTextField>
+1
View File
@@ -38,6 +38,7 @@
@import '../components/Tabs/Tabs.styles.css';
@import '../components/Text/styles.css';
@import '../components/TextField/TextField.styles.css';
@import '../components/SearchField/SearchField.styles.css';
@import '../components/Tooltip/Tooltip.styles.css';
@import '../components/ScrollArea/ScrollArea.styles.css';
@import '../components/Select/Select.styles.css';