Skip to content

Commit b44a517

Browse files
authored
Merge branch 'main' into mp/select-focus-outline-fix
2 parents 25f5698 + 384ae6b commit b44a517

15 files changed

+232
-37
lines changed

.changeset/chilly-frogs-act.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
Support issues closed as not planned, and correct the standard issue closed backgroud colour

.changeset/cool-wolves-float.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/sweet-elephants-end.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/tough-coats-allow.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# @primer/components
22

3+
## 35.5.0
4+
5+
### Minor Changes
6+
7+
- [#2180](https://github.com/primer/react/pull/2180) [`300025d1`](https://github.com/primer/react/commit/300025d11a010eb076f2327897744d06539bb355) Thanks [@mattcosta7](https://github.com/mattcosta7)! - Update the primer/primitives dependency
8+
9+
### Patch Changes
10+
11+
- [#2150](https://github.com/primer/react/pull/2150) [`63a2de51`](https://github.com/primer/react/commit/63a2de51c0d7de0d2640cc80435b1cead071f1f6) Thanks [@dgreif](https://github.com/dgreif)! - Ensure all files in `lib-esm` are in fact esm and not CommonJS
12+
13+
* [#2145](https://github.com/primer/react/pull/2145) [`a2950ac4`](https://github.com/primer/react/commit/a2950ac40b9caeeb2b3d9883508d2d6a84980252) Thanks [@mperrotti](https://github.com/mperrotti)! - Updates SegmentedControl component's keyboard navigation to align with the recommendations of GitHub's accessibility team.
14+
15+
- [#2143](https://github.com/primer/react/pull/2143) [`d9b161a0`](https://github.com/primer/react/commit/d9b161a0bde13a20a5972159a3f71e7be7475d7a) Thanks [@mperrotti](https://github.com/mperrotti)! - Fixes bugs in form components discovered while fixing/improving Storybook and docs.
16+
317
## 35.4.0
418

519
### Minor Changes

docs/content/StateLabel.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Use StateLabel components to show the status of an issue or pull request.
1212
<>
1313
<StateLabel status="issueOpened">Open</StateLabel>
1414
<StateLabel status="issueClosed">Closed</StateLabel>
15+
<StateLabel status="issueClosedNotPlanned">Closed</StateLabel>
1516
<StateLabel status="pullOpened">Open</StateLabel>
1617
<StateLabel status="pullClosed">Closed</StateLabel>
1718
<StateLabel status="pullMerged">Merged</StateLabel>
@@ -22,8 +23,8 @@ Use StateLabel components to show the status of an issue or pull request.
2223

2324
## Component props
2425

25-
| Name | Type | Default | Description |
26-
| :------ | :---------------- | :------: | :------------------------------------------------------------------------------------------------------------- |
27-
| variant | String | 'normal' | a value of `small` or `normal` results in a smaller or larger version of the StateLabel. |
28-
| status | String | | Can be one of `issueOpened`, `issueClosed`, `pullOpened`, `pullClosed`, `pullMerged`, `draft` or `issueDraft`. |
29-
| sx | SystemStyleObject | {} | Style to be applied to the component |
26+
| Name | Type | Default | Description |
27+
| :------ | :---------------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------- |
28+
| variant | String | 'normal' | a value of `small` or `normal` results in a smaller or larger version of the StateLabel. |
29+
| status | String | | Can be one of `issueOpened`, `issueClosed`, `issueClosedNotPlanned`, `pullOpened`, `pullClosed`, `pullMerged`, `draft` or `issueDraft`. |
30+
| sx | SystemStyleObject | {} | Style to be applied to the component |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@primer/react",
3-
"version": "35.4.0",
3+
"version": "35.5.0",
44
"description": "An implementation of GitHub's Primer Design System using React",
55
"main": "lib/index.js",
66
"module": "lib-esm/index.js",

src/SegmentedControl/SegmentedControl.test.tsx

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
import React from 'react'
22
import '@testing-library/jest-dom/extend-expect'
3-
import {render} from '@testing-library/react'
3+
import {fireEvent, render} from '@testing-library/react'
44
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
55
import userEvent from '@testing-library/user-event'
66
import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing'
77
import {SegmentedControl} from '.' // TODO: update import when we move this to the global index
88

99
const segmentData = [
10-
{label: 'Preview', iconLabel: 'EyeIcon', icon: () => <EyeIcon aria-label="EyeIcon" />},
11-
{label: 'Raw', iconLabel: 'FileCodeIcon', icon: () => <FileCodeIcon aria-label="FileCodeIcon" />},
12-
{label: 'Blame', iconLabel: 'PeopleIcon', icon: () => <PeopleIcon aria-label="PeopleIcon" />}
10+
{label: 'Preview', id: 'preview', iconLabel: 'EyeIcon', icon: () => <EyeIcon aria-label="EyeIcon" />},
11+
{label: 'Raw', id: 'raw', iconLabel: 'FileCodeIcon', icon: () => <FileCodeIcon aria-label="FileCodeIcon" />},
12+
{label: 'Blame', id: 'blame', iconLabel: 'PeopleIcon', icon: () => <PeopleIcon aria-label="PeopleIcon" />}
1313
]
1414

1515
// TODO: improve test coverage
1616
describe('SegmentedControl', () => {
17+
const mockWarningFn = jest.fn()
18+
19+
beforeAll(() => {
20+
jest.spyOn(global.console, 'warn').mockImplementation(mockWarningFn)
21+
})
22+
1723
behavesAsComponent({
1824
Component: SegmentedControl,
1925
toRender: () => (
2026
<SegmentedControl aria-label="File view">
21-
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
22-
<SegmentedControl.Button>Raw</SegmentedControl.Button>
23-
<SegmentedControl.Button>Blame</SegmentedControl.Button>
27+
{segmentData.map(({label}, index) => (
28+
<SegmentedControl.Button selected={index === 0} key={label}>
29+
{label}
30+
</SegmentedControl.Button>
31+
))}
2432
</SegmentedControl>
2533
)
2634
})
@@ -133,6 +141,68 @@ describe('SegmentedControl', () => {
133141
}
134142
expect(handleClick).toHaveBeenCalled()
135143
})
144+
145+
it('focuses the selected button first', () => {
146+
const {getByRole} = render(
147+
<>
148+
<button>Before</button>
149+
<SegmentedControl aria-label="File view">
150+
{segmentData.map(({label, id}, index) => (
151+
<SegmentedControl.Button selected={index === 1} key={label} id={id}>
152+
{label}
153+
</SegmentedControl.Button>
154+
))}
155+
</SegmentedControl>
156+
</>
157+
)
158+
const initialFocusButtonNode = getByRole('button', {name: segmentData[1].label})
159+
160+
expect(document.activeElement?.id).not.toEqual(initialFocusButtonNode.id)
161+
162+
userEvent.tab() // focus the button before the segmented control
163+
userEvent.tab() // move focus into the segmented control
164+
165+
expect(document.activeElement?.id).toEqual(initialFocusButtonNode.id)
166+
})
167+
168+
it('focuses the previous button when keying ArrowLeft, and the next button when keying ArrowRight', () => {
169+
const {getByRole} = render(
170+
<SegmentedControl aria-label="File view">
171+
{segmentData.map(({label, id}, index) => (
172+
<SegmentedControl.Button selected={index === 1} key={label} id={id}>
173+
{label}
174+
</SegmentedControl.Button>
175+
))}
176+
</SegmentedControl>
177+
)
178+
const initialFocusButtonNode = getByRole('button', {name: segmentData[1].label})
179+
const nextFocusButtonNode = getByRole('button', {name: segmentData[0].label})
180+
181+
expect(document.activeElement?.id).not.toEqual(nextFocusButtonNode.id)
182+
183+
fireEvent.focus(initialFocusButtonNode)
184+
fireEvent.keyDown(initialFocusButtonNode, {key: 'ArrowLeft'})
185+
186+
expect(document.activeElement?.id).toEqual(nextFocusButtonNode.id)
187+
188+
fireEvent.keyDown(initialFocusButtonNode, {key: 'ArrowRight'})
189+
190+
expect(document.activeElement?.id).toEqual(initialFocusButtonNode.id)
191+
})
192+
193+
it('should warn the user if they neglect to specify a label for the segmented control', () => {
194+
render(
195+
<SegmentedControl>
196+
{segmentData.map(({label, id}) => (
197+
<SegmentedControl.Button id={id} key={label}>
198+
{label}
199+
</SegmentedControl.Button>
200+
))}
201+
</SegmentedControl>
202+
)
203+
204+
expect(mockWarningFn).toHaveBeenCalled()
205+
})
136206
})
137207

138208
checkStoriesForAxeViolations('examples', '../SegmentedControl/')

src/SegmentedControl/SegmentedControl.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import React from 'react'
1+
import React, {RefObject, useRef} from 'react'
22
import Button, {SegmentedControlButtonProps} from './SegmentedControlButton'
33
import SegmentedControlIconButton, {SegmentedControlIconButtonProps} from './SegmentedControlIconButton'
44
import {Box, useTheme} from '..'
55
import {merge, SxProp} from '../sx'
6+
import {FocusKeys, FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
67

78
type SegmentedControlProps = {
89
'aria-label'?: string
@@ -28,10 +29,16 @@ const getSegmentedControlStyles = (props?: SegmentedControlProps) => ({
2829
})
2930

3031
// TODO: implement `variant` prop for responsive behavior
31-
// TODO: implement `loading` prop
32-
// TODO: log a warning if no `ariaLabel` or `ariaLabelledBy` prop is passed
33-
// TODO: implement keyboard behavior to move focus using the arrow keys
34-
const Root: React.FC<SegmentedControlProps> = ({children, fullWidth, onChange, sx: sxProp = {}, ...rest}) => {
32+
const Root: React.FC<SegmentedControlProps> = ({
33+
'aria-label': ariaLabel,
34+
'aria-labelledby': ariaLabelledby,
35+
children,
36+
fullWidth,
37+
onChange,
38+
sx: sxProp = {},
39+
...rest
40+
}) => {
41+
const segmentedControlContainerRef = useRef<HTMLSpanElement>(null)
3542
const {theme} = useTheme()
3643
const selectedChildren = React.Children.toArray(children).map(
3744
child =>
@@ -46,8 +53,41 @@ const Root: React.FC<SegmentedControlProps> = ({children, fullWidth, onChange, s
4653
sxProp as SxProp
4754
)
4855

56+
const focusInStrategy: FocusZoneHookSettings['focusInStrategy'] = () => {
57+
if (segmentedControlContainerRef.current) {
58+
// we need to use type assertion because querySelector returns "Element", not "HTMLElement"
59+
type SelectedButton = HTMLButtonElement | undefined
60+
61+
const selectedButton = segmentedControlContainerRef.current.querySelector(
62+
'button[aria-current="true"]'
63+
) as SelectedButton
64+
65+
return selectedButton
66+
}
67+
}
68+
69+
useFocusZone({
70+
containerRef: segmentedControlContainerRef,
71+
bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd,
72+
focusInStrategy
73+
})
74+
75+
if (!ariaLabel && !ariaLabelledby) {
76+
// eslint-disable-next-line no-console
77+
console.warn(
78+
'Use the `aria-label` or `aria-labelledby` prop to provide an accessible label for assistive technology'
79+
)
80+
}
81+
4982
return (
50-
<Box role="toolbar" sx={sx} {...rest}>
83+
<Box
84+
role="toolbar"
85+
sx={sx}
86+
aria-label={ariaLabel}
87+
aria-labelledby={ariaLabelledby}
88+
ref={segmentedControlContainerRef as RefObject<HTMLDivElement>}
89+
{...rest}
90+
>
5191
{React.Children.map(children, (child, i) => {
5292
if (React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child)) {
5393
return React.cloneElement(child, {

src/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ exports[`SegmentedControl renders consistently 1`] = `
8686
width: 1px;
8787
}
8888
89+
.c1:focus:focus-visible:not(:last-child):after {
90+
width: 0;
91+
}
92+
8993
.c1 .segmentedControl-text:after {
9094
content: "Preview";
9195
display: block;
@@ -180,6 +184,10 @@ exports[`SegmentedControl renders consistently 1`] = `
180184
width: 1px;
181185
}
182186
187+
.c2:focus:focus-visible:not(:last-child):after {
188+
width: 0;
189+
}
190+
183191
.c2 .segmentedControl-text:after {
184192
content: "Raw";
185193
display: block;
@@ -274,6 +282,10 @@ exports[`SegmentedControl renders consistently 1`] = `
274282
width: 1px;
275283
}
276284
285+
.c3:focus:focus-visible:not(:last-child):after {
286+
width: 0;
287+
}
288+
277289
.c3 .segmentedControl-text:after {
278290
content: "Blame";
279291
display: block;

0 commit comments

Comments
 (0)