diff --git a/frontend/documentation/components/SelectField.stories.tsx b/frontend/documentation/components/SelectField.stories.tsx new file mode 100644 index 000000000000..4823e4696372 --- /dev/null +++ b/frontend/documentation/components/SelectField.stories.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import SelectField from 'components/base/forms/SelectField' + +const meta: Meta = { + component: SelectField, + parameters: { layout: 'padded' }, + title: 'Components/Forms/SelectField', +} +export default meta + +type Story = StoryObj + +const options = [ + { label: 'Production', value: 'production' }, + { label: 'Staging', value: 'staging' }, + { label: 'Development', value: 'development' }, +] + +export const Default: Story = { + render: () => ( + + ), +} + +export const Required: Story = { + render: () => ( + + ), +} + +export const WithTooltip: Story = { + render: () => ( + + ), +} + +export const WithError: Story = { + render: () => ( + + ), +} + +export const Disabled: Story = { + render: () => ( + + ), +} diff --git a/frontend/web/components/base/forms/SelectField.tsx b/frontend/web/components/base/forms/SelectField.tsx new file mode 100644 index 000000000000..54f34cb0d95a --- /dev/null +++ b/frontend/web/components/base/forms/SelectField.tsx @@ -0,0 +1,90 @@ +import React, { ReactNode, useId } from 'react' +import cn from 'classnames' +import { GroupBase, Props as ReactSelectProps } from 'react-select' +import { TooltipProps } from 'components/Tooltip' +import FieldError from './FieldError' +import FieldLabel from './FieldLabel' + +// Props consumed by the field wrapper. Everything else is forwarded to Select. +interface OwnProps { + title?: ReactNode + // Base id for the field. The inner combobox input uses it (so the label's + // htmlFor resolves) and the error derives `${id}-error` from it. + id?: string + error?: ReactNode + required?: boolean + tooltip?: string + tooltipPlace?: TooltipProps['place'] + // Wrapper class; defaults to a form-group for consistent spacing. + className?: string + noMargin?: boolean + 'data-test'?: string +} + +// Custom props our global Select adds on top of react-select. +interface BaseSelectExtras { + size?: string + autoSelect?: boolean +} + +export type SelectFieldProps< + Option = unknown, + IsMulti extends boolean = false, +> = OwnProps & + BaseSelectExtras & + Omit< + ReactSelectProps>, + | keyof OwnProps + | keyof BaseSelectExtras + | 'inputId' + | 'aria-invalid' + | 'aria-errormessage' + > + +// A labelled, accessible Select. The DS counterpart to InputGroup for a +// react-select control: it wires the label, the inline error, and the aria +// relationships off one id, so consumers stop wrapping a bare Select in +// InputGroup's `component` slot (which renders the control with no a11y +// wiring at all). Prefer this over `} />`. +function SelectField