Skip to content

Commit 83b815c

Browse files
laraharrowLuke Bowerman
andauthored
Tabs keyboard shortcut (#1293)
* Tabs keyboard shortcut * update tests to account for keyboard accessibility Co-authored-by: Luke Bowerman <[email protected]>
1 parent 50f5e05 commit 83b815c

File tree

8 files changed

+132
-28
lines changed

8 files changed

+132
-28
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [UNRELEASED]
9+
10+
- `Tabs` updated for keyboard shortcut for accessibility
11+
812
## [0.9.10] - 2020-08-07
913

1014
### Added

packages/components/src/Menu/MenuList.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,10 @@ import {
5454
reset,
5555
omitStyledProps,
5656
} from '@looker/design-tokens'
57-
import { useForkedRef } from '../utils'
57+
import { useForkedRef, moveFocus } from '../utils'
5858
import { usePopover } from '../Popover'
5959
import { MenuContext, MenuItemContext } from './MenuContext'
6060
import { MenuGroup } from './MenuGroup'
61-
import { moveFocus } from './moveFocus'
6261

6362
export interface MenuListProps
6463
extends CompatibleHTMLProps<HTMLUListElement>,

packages/components/src/Tabs/Tab.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
2525
*/
2626

27-
import React, { forwardRef, Ref, useState } from 'react'
27+
import React, { forwardRef, Ref, useContext, useState } from 'react'
2828
import { rgba } from 'polished'
2929
import styled from 'styled-components'
3030
import {
@@ -37,6 +37,7 @@ import {
3737
typography,
3838
TypographyProps,
3939
} from '@looker/design-tokens'
40+
import { TabContext } from './TabContext'
4041

4142
export interface TabProps
4243
extends Omit<CompatibleHTMLProps<HTMLButtonElement>, 'type'>,
@@ -102,6 +103,7 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref<HTMLButtonElement>) => {
102103
disabled,
103104
index,
104105
onBlur,
106+
onKeyDown,
105107
onKeyUp,
106108
onSelect,
107109
selected,
@@ -110,11 +112,25 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref<HTMLButtonElement>) => {
110112

111113
const [isFocusVisible, setFocusVisible] = useState(false)
112114

115+
const { handleArrowLeft, handleArrowRight } = useContext(TabContext)
116+
113117
const handleOnKeyUp = (event: React.KeyboardEvent<HTMLButtonElement>) => {
114118
setFocusVisible(true)
115119
onKeyUp && onKeyUp(event)
116120
}
117121

122+
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
123+
switch (event.key) {
124+
case 'ArrowLeft':
125+
handleArrowLeft && handleArrowLeft(event)
126+
break
127+
case 'ArrowRight':
128+
handleArrowRight && handleArrowRight(event)
129+
break
130+
}
131+
onKeyDown && onKeyDown(event)
132+
}
133+
118134
const handleOnBlur = (event: React.FocusEvent<HTMLButtonElement>) => {
119135
setFocusVisible(false)
120136
onBlur && onBlur(event)
@@ -136,6 +152,7 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref<HTMLButtonElement>) => {
136152
focusVisible={isFocusVisible}
137153
id={`tab-${index}`}
138154
onBlur={handleOnBlur}
155+
onKeyDown={handleOnKeyDown}
139156
onClick={onClick}
140157
onKeyUp={handleOnKeyUp}
141158
ref={ref}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
3+
MIT License
4+
5+
Copyright (c) 2020 Looker Data Sciences, Inc.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
25+
*/
26+
27+
import { createContext, KeyboardEvent } from 'react'
28+
29+
export interface TabContextProps {
30+
handleArrowLeft?: (e: KeyboardEvent<HTMLButtonElement>) => void
31+
handleArrowRight?: (e: KeyboardEvent<HTMLButtonElement>) => void
32+
}
33+
34+
export const TabContext = createContext<TabContextProps>({})

packages/components/src/Tabs/TabList.tsx

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,17 @@
2424
2525
*/
2626

27-
import React, { Children, cloneElement, FC } from 'react'
27+
import React, {
28+
Children,
29+
cloneElement,
30+
forwardRef,
31+
KeyboardEvent,
32+
useRef,
33+
Ref,
34+
} from 'react'
2835
import styled, { css } from 'styled-components'
36+
import { moveFocus, useForkedRef } from '../utils'
37+
import { TabContext } from './TabContext'
2938
import { Tab } from '.'
3039

3140
export interface TabListProps {
@@ -36,29 +45,59 @@ export interface TabListProps {
3645
distribute?: boolean
3746
}
3847

39-
const TabListLayout: FC<TabListProps> = ({
40-
children,
41-
selectedIndex,
42-
onSelectTab,
43-
className,
44-
}) => {
45-
const clonedChildren = Children.map(
46-
children,
47-
(child: JSX.Element, index: number) => {
48-
return cloneElement(child, {
49-
index,
50-
onSelect: () => onSelectTab && onSelectTab(index),
51-
selected: index === selectedIndex,
52-
selectedIndex,
53-
})
48+
const TabListLayout = forwardRef(
49+
(
50+
{ children, selectedIndex, onSelectTab, className }: TabListProps,
51+
ref: Ref<HTMLDivElement>
52+
) => {
53+
const wrapperRef = useRef<HTMLDivElement>(null)
54+
const forkedRef = useForkedRef(wrapperRef, ref)
55+
56+
const clonedChildren = Children.map(
57+
children,
58+
(child: JSX.Element, index: number) => {
59+
return cloneElement(child, {
60+
index,
61+
onSelect: () => onSelectTab && onSelectTab(index),
62+
selected: index === selectedIndex,
63+
selectedIndex,
64+
})
65+
}
66+
)
67+
68+
function handleArrowKey(direction: number, initial: number) {
69+
moveFocus(direction, initial, wrapperRef)
5470
}
55-
)
56-
return (
57-
<div aria-label="Tabs" className={className} role="tablist">
58-
{clonedChildren}
59-
</div>
60-
)
61-
}
71+
72+
const context = {
73+
handleArrowLeft: (e: KeyboardEvent<HTMLButtonElement>) => {
74+
e.preventDefault()
75+
handleArrowKey(-1, -1)
76+
return false
77+
},
78+
handleArrowRight: (e: KeyboardEvent<HTMLButtonElement>) => {
79+
e.preventDefault()
80+
handleArrowKey(1, 0)
81+
return false
82+
},
83+
}
84+
85+
return (
86+
<TabContext.Provider value={context}>
87+
<div
88+
aria-label="Tabs"
89+
className={className}
90+
ref={forkedRef}
91+
role="tablist"
92+
>
93+
{clonedChildren}
94+
</div>
95+
</TabContext.Provider>
96+
)
97+
}
98+
)
99+
100+
TabListLayout.displayName = 'TabListLayout'
62101

63102
const defaultLayoutCSS = css`
64103
${Tab} {

packages/components/src/Tabs/Tabs.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
shallowWithTheme,
3434
} from '@looker/components-test-utils'
3535
import React from 'react'
36-
import { fireEvent } from '@testing-library/react'
36+
import { fireEvent, screen } from '@testing-library/react'
3737
import { Tab } from './Tab'
3838
import { TabList } from './TabList'
3939
import { TabPanel } from './TabPanel'
@@ -213,4 +213,14 @@ describe('focus behavior', () => {
213213
fireEvent.keyUp(getByText('tab2'), { charCode: 9, code: 9, key: 'Tab' })
214214
expect(getByText('tab2')).toMatchSnapshot()
215215
})
216+
217+
test('Tab keyboard navigation', () => {
218+
renderWithTheme(<TabTest />)
219+
220+
const tab1 = screen.getByText('tab1')
221+
tab1.focus()
222+
expect(tab1).toHaveFocus()
223+
fireEvent.keyDown(tab1, { code: 39, key: 'ArrowRight' })
224+
expect(screen.getByText('tab2')).toHaveFocus()
225+
})
216226
})

packages/components/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
export * from './getWindowedListBoundaries'
2828
export * from './HoverDisclosure'
29+
export * from './moveFocus'
2930
export * from './undefinedCoalesce'
3031
export * from './useControlWarn'
3132
export * from './useReadOnlyWarn'

packages/components/src/Menu/moveFocus.ts renamed to packages/components/src/utils/moveFocus.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import { MutableRefObject } from 'react'
2828

2929
const getTabStops = (ref: HTMLElement): HTMLElement[] =>
30-
Array.from(ref.querySelectorAll('a,button,[tabindex="0"]'))
30+
Array.from(ref.querySelectorAll('a,button:not(:disabled),[tabindex="0"]'))
3131

3232
export const moveFocus = (
3333
direction: number,

0 commit comments

Comments
 (0)