diff --git a/packages/canon/src/components/SearchField/SearchField.stories.tsx b/packages/canon/src/components/SearchField/SearchField.stories.tsx new file mode 100644 index 0000000000..fc2e72aff1 --- /dev/null +++ b/packages/canon/src/components/SearchField/SearchField.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + name: 'url', + style: { + maxWidth: '300px', + }, + }, +}; + +export const Sizes: Story = { + args: { + ...Default.args, + }, + render: args => ( + + + + + ), +}; + +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: , + }, +}; + +export const DisabledWithIcon: Story = { + args: { + ...WithIcon.args, + isDisabled: true, + }, +}; + +export const ShowError: Story = { + args: { + ...WithLabel.args, + }, + render: args => ( +
+ + + ), +}; + +export const Validation: Story = { + args: { + ...WithLabel.args, + validate: value => (value === 'admin' ? 'Nice try!' : null), + }, +}; + +export const CustomField: Story = { + render: () => ( + <> + + + + ), +}; diff --git a/packages/canon/src/components/SearchField/SearchField.styles.css b/packages/canon/src/components/SearchField/SearchField.styles.css new file mode 100644 index 0000000000..70bd20e2ac --- /dev/null +++ b/packages/canon/src/components/SearchField/SearchField.styles.css @@ -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; +} diff --git a/packages/canon/src/components/SearchField/SearchField.tsx b/packages/canon/src/components/SearchField/SearchField.tsx new file mode 100644 index 0000000000..c04c99571e --- /dev/null +++ b/packages/canon/src/components/SearchField/SearchField.tsx @@ -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( + (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 ( + // + // + ); + }, +); + +SearchField.displayName = 'searchField'; diff --git a/packages/canon/src/components/SearchField/index.ts b/packages/canon/src/components/SearchField/index.ts new file mode 100644 index 0000000000..288c3fe44a --- /dev/null +++ b/packages/canon/src/components/SearchField/index.ts @@ -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'; diff --git a/packages/canon/src/components/SearchField/types.ts b/packages/canon/src/components/SearchField/types.ts new file mode 100644 index 0000000000..b561292684 --- /dev/null +++ b/packages/canon/src/components/SearchField/types.ts @@ -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 { + /** + * An icon to render before the input + */ + icon?: ReactNode | false; + + /** + * The size of the text field + * @defaultValue 'medium' + */ + size?: 'small' | 'medium' | Partial>; + + /** + * The placeholder text for the input + */ + placeholder?: string; +} diff --git a/packages/canon/src/components/TextField/TextField.styles.css b/packages/canon/src/components/TextField/TextField.styles.css index 9f2039b3af..58e1cba403 100644 --- a/packages/canon/src/components/TextField/TextField.styles.css +++ b/packages/canon/src/components/TextField/TextField.styles.css @@ -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); diff --git a/packages/canon/src/components/TextField/TextField.tsx b/packages/canon/src/components/TextField/TextField.tsx index 444e76e983..5e53614302 100644 --- a/packages/canon/src/components/TextField/TextField.tsx +++ b/packages/canon/src/components/TextField/TextField.tsx @@ -71,20 +71,17 @@ export const TextField = forwardRef( secondaryLabel={secondaryLabelText} description={description} /> -
+
{icon && ( )} - +
diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index aa453f7314..c1a9b7cc0d 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -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';