Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
878ce35
allows FormControl.Label to render as a span or legend and updates th…
mperrotti Jan 25, 2023
656821a
changes SegmentedControl.IconButton to use aria-current instead of ar…
mperrotti Jan 25, 2023
ef9b17c
nicer small screen experience for the SegmentedControl story that sho…
mperrotti Jan 25, 2023
4bf5381
allows referencing a label for the ActionMenu
mperrotti Jan 25, 2023
163440f
fixes a bug where 'defaultSelected' prop would not work
mperrotti Jan 25, 2023
00e55fd
updates docs
mperrotti Jan 25, 2023
b2d52f7
Update generated/components.json
mperrotti Jan 25, 2023
b76a462
adds changeset
mperrotti Jan 25, 2023
d55d34a
Merge branch 'mp/segmented-control-a11y-eng-fixes' of github.com:prim…
mperrotti Jan 25, 2023
1f53c75
Merge github.com:primer/react into mp/segmented-control-a11y-eng-fixes
mperrotti Jan 27, 2023
ebe5623
fixes bug that broke selected segment test
mperrotti Jan 27, 2023
6c29939
fixes styling issue where segment separation border was appearing on …
mperrotti Jan 27, 2023
677efa9
Merge branch 'main' into mp/segmented-control-a11y-eng-fixes
mperrotti Jan 31, 2023
e67d273
Merge branch 'main' into mp/segmented-control-a11y-eng-fixes
mperrotti Feb 4, 2023
f01f437
fixes linting issue
mperrotti Feb 4, 2023
a51662f
Merge branch 'main' into mp/segmented-control-a11y-eng-fixes
mperrotti Feb 16, 2023
bc069d9
Merge branch 'main' into mp/segmented-control-a11y-eng-fixes
mperrotti Feb 17, 2023
4e71402
Merge branch 'main' into mp/segmented-control-a11y-eng-fixes
mperrotti Feb 17, 2023
5b74f4f
Merge branch 'main' into mp/segmented-control-a11y-eng-fixes
mperrotti Feb 17, 2023
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
5 changes: 5 additions & 0 deletions .changeset/eleven-jokes-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Addresses feedback from the accessibility team about our SegmentedControl component. These changes include an update to ActionMenu that allows u to specify the ID of the DOM node that labels the menu.
78 changes: 38 additions & 40 deletions docs/content/SegmentedControl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,48 @@ storybook: '/react/storybook/?path=/story/components-segmentedcontrol'

import data from '../../src/SegmentedControl/SegmentedControl.docs.json'

<Note variant="warning">

A `SegmentedControl` should not be used in a form as a replacement for something like a [RadioGroup](/RadioGroup) or [Select](/Select). See the [Accessibility section](https://primer.style/design/components/segmented-control#accessibility) of the SegmentedControl interface guidelines for more details.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3


</Note>

## Examples

### Uncontrolled (default)
### With a label above and caption below

```jsx live
<SegmentedControl aria-label="File view">
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
<FormControl>
<FormControl.Label id="scLabel-horiz" as="span">
File view
</FormControl.Label>
<SegmentedControl aria-labelledby="scLabel-horiz" aria-describedby="scCaption-horiz">
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
<FormControl.Caption id="scCaption-horiz">Change the way the file is viewed</FormControl.Caption>
</FormControl>
```

### With a label and caption on the left

```jsx live
<Box display="flex">
<Box flexGrow={1}>
<Text fontSize={2} fontWeight="bold" id="scLabel-vert" display="block">
File view
</Text>
<Text color="fg.subtle" fontSize={1} id="scCaption-vert" display="block">
Change the way the file is viewed
</Text>
</Box>
<SegmentedControl aria-labelledby="scLabel-vert" aria-describedby="scCaption-vert">
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
</Box>
```

### Controlled
Expand Down Expand Up @@ -109,40 +141,6 @@ render(Controlled)
</SegmentedControl>
```

### With a label and caption on the left

```jsx live
<Box display="flex">
<Box flexGrow={1}>
<Text fontSize={2} fontWeight="bold" id="scLabel-vert" display="block">
File view
</Text>
<Text color="fg.subtle" fontSize={1} id="scCaption-vert" display="block">
Change the way the file is viewed
</Text>
</Box>
<SegmentedControl aria-labelledby="scLabel-vert" aria-describedby="scCaption-vert">
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
</Box>
```

### With a label above and caption below

```jsx live
<FormControl>
<FormControl.Label id="scLabel-horiz">File view</FormControl.Label>
<SegmentedControl aria-labelledby="scLabel-horiz" aria-describedby="scCaption-horiz">
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
<FormControl.Caption id="scCaption-horiz">Change the way the file is viewed</FormControl.Caption>
</FormControl>
```

### With something besides the first option selected

```jsx live
Expand Down
6 changes: 6 additions & 0 deletions generated/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -2692,6 +2692,12 @@
"defaultValue": "false",
"description": "Whether the label should be visually hidden"
},
{
"name": "as",
"type": "'label' | 'legend' | 'span'",
"defaultValue": "'label'",
"description": "The label element can be changed to a 'legend' when it's being used to label a fieldset, or a 'span' when it's being used to label an element that is not a form input. For example: when using a FormControl to render a labeled SegementedControl, the label should be a 'span'"
},
{
"name": "sx",
"type": "SystemStyleObject"
Expand Down
9 changes: 7 additions & 2 deletions src/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,12 @@ type MenuOverlayProps = Partial<OverlayProps> &
*/
children: React.ReactNode
}
const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({children, align = 'start', ...overlayProps}) => {
const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
children,
align = 'start',
'aria-labelledby': ariaLabelledby,
...overlayProps
}) => {
// we typecast anchorRef as required instead of optional
// because we know that we're setting it in context in Menu
const {anchorRef, renderAnchor, anchorId, open, onOpen, onClose} = React.useContext(MenuContext) as MandateProps<
Expand Down Expand Up @@ -117,7 +122,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({children,
value={{
container: 'ActionMenu',
listRole: 'menu',
listLabelledBy: anchorId,
listLabelledBy: ariaLabelledby || anchorId,
selectionAttribute: 'aria-checked', // Should this be here?
afterSelect: onClose,
}}
Expand Down
6 changes: 6 additions & 0 deletions src/FormControl/FormControl.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
"defaultValue": "false",
"description": "Whether the label should be visually hidden"
},
{
"name": "as",
"type": "'label' | 'legend' | 'span'",
"defaultValue": "'label'",
"description": "The label element can be changed to a 'legend' when it's being used to label a fieldset, or a 'span' when it's being used to label an element that is not a form input. For example: when using a FormControl to render a labeled SegementedControl, the label should be a 'span'"
},
{
"name": "sx",
"type": "SystemStyleObject"
Expand Down
13 changes: 5 additions & 8 deletions src/FormControl/_FormControlLabel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import {SxProp} from '../sx'
import InputLabel from '../_InputLabel'
import InputLabel, {LegendOrSpanProps, LabelProps} from '../_InputLabel'
import {FormControlContext} from './FormControl'
import {Slot} from './slots'

Expand All @@ -9,15 +9,12 @@ export type Props = {
* Whether the label should be visually hidden
*/
visuallyHidden?: boolean
id?: string
} & SxProp

const FormControlLabel: React.FC<React.PropsWithChildren<{htmlFor?: string; id?: string} & Props>> = ({
children,
htmlFor,
id,
visuallyHidden,
sx,
}) => (
const FormControlLabel: React.FC<
React.PropsWithChildren<{htmlFor?: string} & (LegendOrSpanProps | LabelProps) & Props>
> = ({children, htmlFor, id, visuallyHidden, sx}) => (
<Slot name="Label">
{({disabled, id: formControlId, required}: FormControlContext) => (
<InputLabel
Expand Down
2 changes: 1 addition & 1 deletion src/SegmentedControl/SegmentedControl.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export const Playground: Story<Args> = args => (
variant={parseVariantFromArgs(args)}
size={args.size}
>
<SegmentedControl.Button selected aria-label={'Preview'} leadingIcon={EyeIcon}>
<SegmentedControl.Button defaultSelected aria-label={'Preview'} leadingIcon={EyeIcon}>
Preview
</SegmentedControl.Button>
<SegmentedControl.Button aria-label={'Raw'} leadingIcon={FileCodeIcon}>
Expand Down
18 changes: 17 additions & 1 deletion src/SegmentedControl/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('SegmentedControl', () => {
SegmentedControl,
})

it('renders with a selected segment', () => {
it('renders with a selected segment - controlled', () => {
const {getByText} = render(
<SegmentedControl aria-label="File view">
{segmentData.map(({label}, index) => (
Expand All @@ -61,6 +61,22 @@ describe('SegmentedControl', () => {
expect(selectedButton?.getAttribute('aria-current')).toBe('true')
})

it('renders with a selected segment - uncontrolled', () => {
const {getByText} = render(
<SegmentedControl aria-label="File view">
{segmentData.map(({label}, index) => (
<SegmentedControl.Button defaultSelected={index === 1} key={label}>
{label}
</SegmentedControl.Button>
))}
</SegmentedControl>,
)

const selectedButton = getByText('Raw').closest('button')

expect(selectedButton?.getAttribute('aria-current')).toBe('true')
})

it('renders the dropdown variant', () => {
act(() => {
matchMedia.useMediaQuery(viewportRanges.narrow)
Expand Down
66 changes: 37 additions & 29 deletions src/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
const selectedSegments = React.Children.toArray(children).map(
child =>
React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child) &&
child.props.selected,
(child.props.defaultSelected || child.props.selected),
)
const hasSelectedButton = selectedSegments.some(isSelected => isSelected)
const selectedIndexExternal = hasSelectedButton ? selectedSegments.indexOf(true) : 0
Expand Down Expand Up @@ -102,40 +102,48 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
if (!ariaLabel && !ariaLabelledby) {
// eslint-disable-next-line no-console
console.warn(
'Use the `aria-label` or `aria-labelledby` prop to provide an accessible label for assistive technology',
'Use the `aria-label` or `aria-labelledby` prop to provide an accessible label for assistive technologies',
)
}

return responsiveVariant === 'dropdown' ? (
// Render the 'dropdown' variant of the SegmentedControlButton or SegmentedControlIconButton
<ActionMenu>
<ActionMenu.Button leadingIcon={getChildIcon(selectedChild)}>{getChildText(selectedChild)}</ActionMenu.Button>
<ActionMenu.Overlay>
<ActionList selectionVariant="single">
{React.Children.map(children, (child, index) => {
const ChildIcon = getChildIcon(child)
// Not a valid child element - skip rendering
if (!React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child)) {
return null
}
<>
<ActionMenu>
{/*
The aria-label is only provided as a backup when the designer or engineer neglects to show a label for the SegmentedControl.
The best thing to do is to have a visual label who's id is referenced using the `aria-labelledby` prop.
*/}
<ActionMenu.Button aria-label={ariaLabel} leadingIcon={getChildIcon(selectedChild)}>
{getChildText(selectedChild)}
</ActionMenu.Button>
<ActionMenu.Overlay aria-labelledby={ariaLabelledby}>
<ActionList selectionVariant="single">
{React.Children.map(children, (child, index) => {
const ChildIcon = getChildIcon(child)
// Not a valid child element - skip rendering
if (!React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child)) {
return null
}

return (
<ActionList.Item
key={`segmented-control-action-btn-${index}`}
selected={index === selectedIndex}
onSelect={(event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => {
isUncontrolled && setSelectedIndexInternalState(index)
onChange && onChange(index)
child.props.onClick && child.props.onClick(event as React.MouseEvent<HTMLLIElement>)
}}
>
{ChildIcon && <ChildIcon />} {getChildText(child)}
</ActionList.Item>
)
})}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
return (
<ActionList.Item
key={`segmented-control-action-btn-${index}`}
selected={index === selectedIndex}
onSelect={(event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => {
isUncontrolled && setSelectedIndexInternalState(index)
onChange && onChange(index)
child.props.onClick && child.props.onClick(event as React.MouseEvent<HTMLLIElement>)
}}
>
{ChildIcon && <ChildIcon />} {getChildText(child)}
</ActionList.Item>
)
})}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</>
) : (
// Render a segmented control
<SegmentedControlList
Expand Down
Loading