Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions frontend/documentation/components/SelectField.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from 'react'
import type { Meta, StoryObj } from 'storybook'

import SelectField from 'components/base/forms/SelectField'

const meta: Meta<typeof SelectField> = {
component: SelectField,
parameters: { layout: 'padded' },
title: 'Components/Forms/SelectField',
}
export default meta

type Story = StoryObj<typeof SelectField>

const options = [
{ label: 'Production', value: 'production' },
{ label: 'Staging', value: 'staging' },
{ label: 'Development', value: 'development' },
]

export const Default: Story = {
render: () => (
<SelectField title='Environment' options={options} value={options[0]} />
),
}

export const Required: Story = {
render: () => (
<SelectField
title='Environment'
required
options={options}
placeholder='Select an environment'
/>
),
}

export const WithTooltip: Story = {
render: () => (
<SelectField
title='Environment'
tooltip='Where this change is applied.'
options={options}
value={options[0]}
/>
),
}

export const WithError: Story = {
render: () => (
<SelectField
title='Environment'
options={options}
error='Select an environment.'
/>
),
}

export const Disabled: Story = {
render: () => (
<SelectField
title='Environment'
options={options}
value={options[0]}
isDisabled
/>
),
}
90 changes: 90 additions & 0 deletions frontend/web/components/base/forms/SelectField.tsx
Original file line number Diff line number Diff line change
@@ -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<Option, IsMulti, GroupBase<Option>>,
| 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 `<InputGroup component={<Select />} />`.
function SelectField<Option = unknown, IsMulti extends boolean = false>({
className,
'data-test': dataTest,
error,
id,
noMargin,
required,
title,
tooltip,
tooltipPlace,
...selectProps
}: SelectFieldProps<Option, IsMulti>) {
const generatedId = useId()
const inputId = id || generatedId
const errorId = `${inputId}-error`
const hasError = !!error

return (
<div className={cn(className, { 'form-group': !noMargin })}>
{(!!title || !!tooltip) && (
<FieldLabel
htmlFor={inputId}
required={required}
tooltip={tooltip}
tooltipPlace={tooltipPlace}
>
{title}
</FieldLabel>
)}
<Select
{...selectProps}
inputId={inputId}
data-test={dataTest}
aria-invalid={hasError || undefined}
aria-errormessage={hasError ? errorId : undefined}
/>
<FieldError id={errorId} error={error} />
</div>
)
}

export default SelectField
Loading