-
Notifications
You must be signed in to change notification settings - Fork 553
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Select input component #1736
Select input component #1736
Changes from 21 commits
520a056
917423c
ffa2183
3bcebcb
0f77d9d
16b5f6a
bc06aba
7379950
9b81e50
8fe1d20
7329286
5303d0d
95c3be8
ccc3d8c
fde3e76
88a65c9
5ccc2f2
5cc9867
6e4df1a
6a04106
9ff8321
a90a58d
c6eb710
8b659c7
96909d7
e7c5d91
ee76cb3
4b84d8d
003e353
86ab778
77832b4
fa45ce6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@primer/react': minor | ||
--- | ||
|
||
Adds a component for a native select input |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
--- | ||
title: Select | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm thinking maybe "SelectInput" is a better name. I'd love to hear what other reviewers think. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep! I think so too. It kinda cements it as a Form component and not any fancy menu/overlay interface There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We only recently decided to avoid using |
||
description: Use a select input when a user needs to select one option from a long list | ||
status: Alpha | ||
source: https://github.com/primer/react/blob/main/src/Select.tsx | ||
storybook: '/react/storybook?path=/story/forms-select--default' | ||
--- | ||
|
||
import {Select, Text} from '@primer/react' | ||
import {ComponentChecklist} from '../src/component-checklist' | ||
import PropsTable from '../src/props-table' | ||
|
||
## Examples | ||
|
||
### Basic | ||
|
||
```jsx live | ||
<> | ||
<Text fontWeight="bold" fontSize={1} as="label" display="block" htmlFor="basic"> | ||
Preferred Primer component interface | ||
</Text> | ||
<Select id="basic"> | ||
<Select.Option value="figma">Figma</Select.Option> | ||
<Select.Option value="css">Primer CSS</Select.Option> | ||
<Select.Option value="prc">Primer React components</Select.Option> | ||
<Select.Option value="pvc">Primer ViewComponents</Select.Option> | ||
</Select> | ||
</> | ||
``` | ||
|
||
### With grouped options | ||
|
||
```jsx live | ||
<> | ||
<Text fontWeight="bold" fontSize={1} as="label" display="block" htmlFor="grouped"> | ||
Preferred Primer component interface | ||
</Text> | ||
<Select id="grouped"> | ||
<Select.OptGroup label="GUI"> | ||
<Select.Option value="figma">Figma</Select.Option> | ||
</Select.OptGroup> | ||
<Select.OptGroup label="Code"> | ||
<Select.Option value="css">Primer CSS</Select.Option> | ||
<Select.Option value="prc">Primer React components</Select.Option> | ||
<Select.Option value="pvc">Primer ViewComponents</Select.Option> | ||
</Select.OptGroup> | ||
</Select> | ||
</> | ||
``` | ||
|
||
### With a placeholder | ||
|
||
```jsx live | ||
<> | ||
<Text fontWeight="bold" fontSize={1} as="label" display="block" htmlFor="withPlaceholder"> | ||
Preferred Primer component interface | ||
</Text> | ||
<Select id="withPlaceholder" placeholder="Pick an interface"> | ||
<Select.Option value="figma">Figma</Select.Option> | ||
<Select.Option value="css">Primer CSS</Select.Option> | ||
<Select.Option value="prc">Primer React components</Select.Option> | ||
<Select.Option value="pvc">Primer ViewComponents</Select.Option> | ||
</Select> | ||
</> | ||
``` | ||
|
||
## Props | ||
|
||
### Select | ||
|
||
<PropsTable> | ||
<PropsTableRow | ||
name="block" | ||
type="boolean" | ||
defaultValue="false" | ||
description={<>Creates a full width input element</>} | ||
/> | ||
<PropsTableRow | ||
name="contrast" | ||
type="boolean" | ||
defaultValue="false" | ||
description="Changes background color to a higher contrast color" | ||
/> | ||
<PropsTableRow | ||
name="size" | ||
type="'small' | 'medium' | 'large'" | ||
description="Creates a smaller or larger input than the default." | ||
/> | ||
<PropsTableRow name="validationStatus" type="'warning' | 'error'" description="Style the input to match the status" /> | ||
<tr> | ||
|
||
<Box as="td" colSpan={4} fontSize={1} verticalAlign="top"> | ||
Additional props are passed to the <InlineCode><select></InlineCode> element. See{' '} | ||
<Link href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select#attributes">MDN</Link> for a list of | ||
props accepted by the <InlineCode><select></InlineCode> element. | ||
|
||
<br /> The <InlineCode>multiple</InlineCode> prop is not accepted. | ||
</Box> | ||
|
||
</tr> | ||
</PropsTable> | ||
|
||
### Select.OptGroup | ||
|
||
The `Select.OptGroup` component accepts the same props as a native HTML [`<optgroup>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/optgroup). | ||
|
||
### Select.Option | ||
|
||
The `Select.Option` component accepts the same props as a native HTML [`<option>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option). | ||
|
||
## Status | ||
|
||
<ComponentChecklist | ||
items={{ | ||
propsDocumented: true, | ||
noUnnecessaryDeps: true, | ||
adaptsToThemes: true, | ||
adaptsToScreenSizes: true, | ||
fullTestCoverage: true, | ||
usedInProduction: false, | ||
usageExamplesDocumented: false, | ||
designReviewed: false, | ||
a11yReviewed: false, | ||
stableApi: false, | ||
addressedApiFeedback: false, | ||
hasDesignGuidelines: false, | ||
hasFigmaComponent: false | ||
}} | ||
/> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import React from 'react' | ||
import styled from 'styled-components' | ||
import {get} from './constants' | ||
import TextInputWrapper, {StyledWrapperProps} from './_TextInputWrapper' | ||
|
||
type SelectProps = Omit< | ||
Omit<React.HTMLProps<HTMLSelectElement>, 'size'> & StyledWrapperProps, | ||
'multiple' | 'hasLeadingVisual' | 'hasTrailingVisual' | 'as' | ||
> | ||
|
||
const StyledSelect = styled.select` | ||
appearance: none; | ||
background-color: transparent; | ||
border: 0; | ||
color: currentColor; | ||
outline: none; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. worth setting the background-color here too? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That should be handled in the |
||
/* colors the select input's placeholder text */ | ||
&:invalid { | ||
color: ${get('colors.fg.subtle')}; | ||
} | ||
|
||
/* For Firefox: reverts color of non-placeholder options in the dropdown */ | ||
&:invalid option:not(:first-child) { | ||
color: ${get('colors.fg.default')}; | ||
} | ||
` | ||
|
||
const ArrowIndicatorSVG: React.FC<{className?: string}> = ({className}) => ( | ||
<svg width="16" height="16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" className={className}> | ||
<path d="m4.074 9.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.043 9H4.251a.25.25 0 0 0-.177.427ZM4.074 7.47 7.47 4.073a.25.25 0 0 1 .354 0L11.22 7.47a.25.25 0 0 1-.177.426H4.251a.25.25 0 0 1-.177-.426Z" /> | ||
</svg> | ||
) | ||
|
||
const ArrowIndicator = styled(ArrowIndicatorSVG)` | ||
pointer-events: none; | ||
position: absolute; | ||
right: 4px; | ||
top: 50%; | ||
transform: translateY(-50%); | ||
` | ||
|
||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>( | ||
({children, disabled, placeholder, size, required, ref: _propsRef, ...rest}: SelectProps, ref) => ( | ||
<TextInputWrapper | ||
sx={{ | ||
position: 'relative' | ||
}} | ||
size={size} | ||
> | ||
<StyledSelect | ||
ref={ref} | ||
required={required || Boolean(placeholder)} | ||
disabled={disabled} | ||
aria-required={required} | ||
aria-disabled={disabled} | ||
{...rest} | ||
> | ||
{placeholder ? ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Feels like an unnecessary ternary when you can use an AND / && instead? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great point. I'll switch it |
||
<option value="" disabled={required} selected hidden={required}> | ||
{placeholder} | ||
</option> | ||
) : null} | ||
{children} | ||
</StyledSelect> | ||
<ArrowIndicator /> | ||
</TextInputWrapper> | ||
) | ||
) | ||
|
||
const Option: React.FC<React.HTMLProps<HTMLOptionElement> & {value: string}> = props => <option {...props} /> | ||
|
||
const OptGroup: React.FC<React.HTMLProps<HTMLOptGroupElement>> = props => <optgroup {...props} /> | ||
|
||
export default Object.assign(Select, { | ||
Option, | ||
OptGroup | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
import React from 'react' | ||
import {Select} from '..' | ||
import {render} from '@testing-library/react' | ||
import {toHaveNoViolations} from 'jest-axe' | ||
import 'babel-polyfill' | ||
import '@testing-library/jest-dom' | ||
|
||
expect.extend(toHaveNoViolations) | ||
|
||
describe('Select', () => { | ||
it('renders a select input', () => { | ||
const {getByLabelText} = render( | ||
<> | ||
{/* eslint-disable-next-line jsx-a11y/label-has-for */} | ||
<label htmlFor="default">Choice</label> | ||
<Select id="default"> | ||
<Select.Option value="one">Choice one</Select.Option> | ||
<Select.Option value="two">Choice two</Select.Option> | ||
<Select.Option value="three">Choice three</Select.Option> | ||
<Select.Option value="four">Choice four</Select.Option> | ||
<Select.Option value="five">Choice five</Select.Option> | ||
<Select.Option value="six">Choice six</Select.Option> | ||
</Select> | ||
</> | ||
) | ||
|
||
const select = getByLabelText('Choice') | ||
|
||
expect(select).toBeDefined() | ||
}) | ||
|
||
it('renders a select input with grouped options', () => { | ||
const {getByLabelText} = render( | ||
<> | ||
{/* eslint-disable-next-line jsx-a11y/label-has-for */} | ||
<label htmlFor="grouped">Choice</label> | ||
<Select id="grouped"> | ||
<Select.OptGroup label="Group one"> | ||
<Select.Option value="one">Choice one</Select.Option> | ||
<Select.Option value="two">Choice two</Select.Option> | ||
<Select.Option value="three">Choice three</Select.Option> | ||
</Select.OptGroup> | ||
<Select.OptGroup label="Group two"> | ||
<Select.Option value="four">Choice four</Select.Option> | ||
<Select.Option value="five">Choice five</Select.Option> | ||
<Select.Option value="six">Choice six</Select.Option> | ||
</Select.OptGroup> | ||
</Select> | ||
</> | ||
) | ||
|
||
const select = getByLabelText('Choice') | ||
|
||
expect(select.querySelectorAll('optgroup')).toHaveLength(2) | ||
}) | ||
|
||
it('renders a select input with a placeholder', () => { | ||
const {getByText, getByLabelText} = render( | ||
<> | ||
{/* eslint-disable-next-line jsx-a11y/label-has-for */} | ||
<label htmlFor="placeholder">Choice</label> | ||
<Select id="placeholder" placeholder="Pick a choice"> | ||
<Select.Option value="one">Choice one</Select.Option> | ||
<Select.Option value="two">Choice two</Select.Option> | ||
<Select.Option value="three">Choice three</Select.Option> | ||
<Select.Option value="four">Choice four</Select.Option> | ||
<Select.Option value="five">Choice five</Select.Option> | ||
<Select.Option value="six">Choice six</Select.Option> | ||
</Select> | ||
</> | ||
) | ||
|
||
const placeholderOption = getByText('Pick a choice') | ||
const select = getByLabelText('Choice') | ||
|
||
expect(select).not.toHaveAttribute('aria-required') | ||
|
||
expect(placeholderOption).toBeDefined() | ||
expect(placeholderOption.tagName.toLowerCase()).toBe('option') | ||
// @ts-expect-error Property 'selected' does not exist on type 'HTMLElement' | ||
expect(placeholderOption.selected).not.toBeNull() | ||
expect(placeholderOption).not.toHaveAttribute('disabled') | ||
expect(placeholderOption).not.toHaveAttribute('hidden') | ||
}) | ||
|
||
it('renders a required select input with a placeholder', () => { | ||
const {getByText, getByLabelText} = render( | ||
<> | ||
{/* eslint-disable-next-line jsx-a11y/label-has-for */} | ||
<label htmlFor="reqWithPlaceholder">Choice</label> | ||
<Select id="reqWithPlaceholder" placeholder="Pick a choice" required> | ||
<Select.Option value="one">Choice one</Select.Option> | ||
<Select.Option value="two">Choice two</Select.Option> | ||
<Select.Option value="three">Choice three</Select.Option> | ||
<Select.Option value="four">Choice four</Select.Option> | ||
<Select.Option value="five">Choice five</Select.Option> | ||
<Select.Option value="six">Choice six</Select.Option> | ||
</Select> | ||
</> | ||
) | ||
|
||
const placeholderOption = getByText('Pick a choice') | ||
const select = getByLabelText('Choice') | ||
|
||
expect(select).toHaveAttribute('aria-required') | ||
|
||
expect(placeholderOption).toBeDefined() | ||
expect(placeholderOption.tagName.toLowerCase()).toBe('option') | ||
// @ts-expect-error Property 'selected' does not exist on type 'HTMLElement' | ||
expect(placeholderOption.selected).not.toBeNull() | ||
expect(placeholderOption).toHaveAttribute('disabled') | ||
expect(placeholderOption).toHaveAttribute('hidden') | ||
}) | ||
|
||
it('renders a disabled select input', () => { | ||
const {getByLabelText} = render( | ||
<> | ||
{/* eslint-disable-next-line jsx-a11y/label-has-for */} | ||
<label htmlFor="disabled">Choice</label> | ||
<Select id="disabled" disabled> | ||
<Select.Option value="one">Choice one</Select.Option> | ||
<Select.Option value="two">Choice two</Select.Option> | ||
<Select.Option value="three">Choice three</Select.Option> | ||
<Select.Option value="four">Choice four</Select.Option> | ||
<Select.Option value="five">Choice five</Select.Option> | ||
<Select.Option value="six">Choice six</Select.Option> | ||
</Select> | ||
</> | ||
) | ||
|
||
const select = getByLabelText('Choice') | ||
|
||
expect(select).toHaveAttribute('aria-disabled') | ||
expect(select).toHaveAttribute('disabled') | ||
}) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Select
wasn't added tonav.yml
and is therefore not present in the sidebar menu. Was this intentional @mperrotti? You can only find this doc page via the search atm.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not intentional. Adding now...