-`;
diff --git a/packages/components/src/Tabs/Tab.tsx b/packages/components/src/Tabs/Tab.tsx
index c85da413a19..f5f7771878f 100644
--- a/packages/components/src/Tabs/Tab.tsx
+++ b/packages/components/src/Tabs/Tab.tsx
@@ -24,7 +24,7 @@
*/
-import React, { forwardRef, Ref, useContext, useState } from 'react'
+import React, { forwardRef, Ref, useState } from 'react'
import styled from 'styled-components'
import {
CompatibleHTMLProps,
@@ -38,7 +38,6 @@ import {
TypographyProps,
tabShadowColor,
} from '@looker/design-tokens'
-import { TabContext } from './TabContext'
export interface TabProps
extends Omit, 'type'>,
@@ -104,7 +103,6 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref) => {
disabled,
index,
onBlur,
- onKeyDown,
onKeyUp,
onSelect,
selected,
@@ -113,25 +111,11 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref) => {
const [isFocusVisible, setFocusVisible] = useState(false)
- const { handleArrowLeft, handleArrowRight } = useContext(TabContext)
-
const handleOnKeyUp = (event: React.KeyboardEvent) => {
setFocusVisible(true)
onKeyUp && onKeyUp(event)
}
- const handleOnKeyDown = (event: React.KeyboardEvent) => {
- switch (event.key) {
- case 'ArrowLeft':
- handleArrowLeft && handleArrowLeft(event)
- break
- case 'ArrowRight':
- handleArrowRight && handleArrowRight(event)
- break
- }
- onKeyDown && onKeyDown(event)
- }
-
const handleOnBlur = (event: React.FocusEvent) => {
setFocusVisible(false)
onBlur && onBlur(event)
@@ -153,13 +137,12 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref) => {
focusVisible={isFocusVisible}
id={`tab-${index}`}
onBlur={handleOnBlur}
- onKeyDown={handleOnKeyDown}
onClick={onClick}
onKeyUp={handleOnKeyUp}
ref={ref}
role="tab"
selected={selected}
- tabIndex={selected ? 0 : -1}
+ tabIndex={-1}
{...restProps}
>
{children}
diff --git a/packages/components/src/Tabs/TabContext.ts b/packages/components/src/Tabs/TabContext.ts
deleted file mode 100644
index 29c398298c2..00000000000
--- a/packages/components/src/Tabs/TabContext.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
-
- MIT License
-
- Copyright (c) 2020 Looker Data Sciences, Inc.
-
- Permission is hereby granted, free of charge, to any person obtaining a copy
- of this software and associated documentation files (the "Software"), to deal
- in the Software without restriction, including without limitation the rights
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- copies of the Software, and to permit persons to whom the Software is
- furnished to do so, subject to the following conditions:
-
- The above copyright notice and this permission notice shall be included in all
- copies or substantial portions of the Software.
-
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- SOFTWARE.
-
- */
-
-import { createContext, KeyboardEvent } from 'react'
-
-export interface TabContextProps {
- handleArrowLeft?: (e: KeyboardEvent) => void
- handleArrowRight?: (e: KeyboardEvent) => void
-}
-
-export const TabContext = createContext({})
diff --git a/packages/components/src/Tabs/TabList.tsx b/packages/components/src/Tabs/TabList.tsx
index bce9752097c..819c4efe6ae 100644
--- a/packages/components/src/Tabs/TabList.tsx
+++ b/packages/components/src/Tabs/TabList.tsx
@@ -24,14 +24,7 @@
*/
-import React, {
- Children,
- cloneElement,
- forwardRef,
- KeyboardEvent,
- useRef,
- Ref,
-} from 'react'
+import React, { Children, cloneElement, forwardRef, Ref } from 'react'
import {
fontSize,
FontSizeProps,
@@ -40,8 +33,7 @@ import {
reset,
} from '@looker/design-tokens'
import styled, { css } from 'styled-components'
-import { moveFocus, useForkedRef } from '../utils'
-import { TabContext } from './TabContext'
+import { useArrowKeyNav } from '../utils'
import { Tab } from '.'
export interface TabListProps extends PaddingProps, FontSizeProps {
@@ -57,9 +49,6 @@ const TabListLayout = forwardRef(
{ children, selectedIndex, onSelectTab, className }: TabListProps,
ref: Ref
) => {
- const wrapperRef = useRef(null)
- const forkedRef = useForkedRef(wrapperRef, ref)
-
const clonedChildren = Children.map(
children,
(child: JSX.Element, index: number) => {
@@ -72,34 +61,12 @@ const TabListLayout = forwardRef(
}
)
- function handleArrowKey(direction: number, initial: number) {
- moveFocus(direction, initial, wrapperRef.current)
- }
-
- const context = {
- handleArrowLeft: (e: KeyboardEvent) => {
- e.preventDefault()
- handleArrowKey(-1, -1)
- return false
- },
- handleArrowRight: (e: KeyboardEvent) => {
- e.preventDefault()
- handleArrowKey(1, 0)
- return false
- },
- }
+ const navProps = useArrowKeyNav({ axis: 'horizontal', ref })
return (
-
-
- {clonedChildren}
-
-
+
+ {clonedChildren}
+
)
}
)
diff --git a/packages/components/src/Tabs/Tabs.test.tsx b/packages/components/src/Tabs/Tabs.test.tsx
index 3727698c201..0a5b2f5d023 100644
--- a/packages/components/src/Tabs/Tabs.test.tsx
+++ b/packages/components/src/Tabs/Tabs.test.tsx
@@ -27,7 +27,6 @@
import 'jest-styled-components'
import '@testing-library/jest-dom/extend-expect'
import {
- assertSnapshotShallow,
mountWithTheme,
renderWithTheme,
shallowWithTheme,
@@ -40,23 +39,6 @@ import { TabPanel } from './TabPanel'
import { TabPanels } from './TabPanels'
import { Tabs, useTabs } from './Tabs'
-test('Tabs snapshot works as expected', () => {
- assertSnapshotShallow(
-
-
- tab1
- tab2
- tab3
-
-
- this is tab1 content
- this is tab2 content
- this is tab3 content
-
-
- )
-})
-
test('shows the correct number of navigation tabs', () => {
const tabs = shallowWithTheme(
diff --git a/packages/components/src/Tabs/__snapshots__/Tabs.test.tsx.snap b/packages/components/src/Tabs/__snapshots__/Tabs.test.tsx.snap
index a130cacc103..3248e17cb9d 100644
--- a/packages/components/src/Tabs/__snapshots__/Tabs.test.tsx.snap
+++ b/packages/components/src/Tabs/__snapshots__/Tabs.test.tsx.snap
@@ -1,7 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Tabs snapshot works as expected 1`] = `ShallowWrapper {}`;
-
exports[`focus behavior Tab Focus: does not render focus ring after click 1`] = `
.c0 {
font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif;
@@ -50,7 +48,7 @@ exports[`focus behavior Tab Focus: does not render focus ring after click 1`] =
class="c0 Tab-eojndt-1"
id="tab-0"
role="tab"
- tabindex="0"
+ tabindex="-1"
>
tab1
diff --git a/packages/components/src/utils/moveFocus.ts b/packages/components/src/utils/getNextFocus.ts
similarity index 58%
rename from packages/components/src/utils/moveFocus.ts
rename to packages/components/src/utils/getNextFocus.ts
index a6692487cac..45197d3ba2e 100644
--- a/packages/components/src/utils/moveFocus.ts
+++ b/packages/components/src/utils/getNextFocus.ts
@@ -24,30 +24,39 @@
*/
-const getTabStops = (ref: HTMLElement): HTMLElement[] =>
- Array.from(ref.querySelectorAll('a,button:not(:disabled),[tabindex="0"]'))
-
-export const moveFocus = (
- direction: number,
- initial: number,
- element?: HTMLElement | null
-) => {
- if (element) {
- const tabStops = getTabStops(element)
+export const getTabStops = (ref: HTMLElement): HTMLElement[] =>
+ Array.from(
+ ref.querySelectorAll(
+ 'a,button:not(:disabled),[tabindex="0"],[tabindex="-1"]:not(:disabled)'
+ )
+ )
+
+/**
+ * Returns the next focusable inside an element in a given direction
+ * @param direction 1 for forward -1 for reverse
+ * @param element the container element
+ */
+export const getNextFocus = (direction: 1 | -1, element: HTMLElement) => {
+ const tabStops = getTabStops(element)
+ if (tabStops.length > 0) {
+ const fallback =
+ direction === 1 ? tabStops[0] : tabStops[tabStops.length - 1]
if (
document.activeElement &&
tabStops.includes(document.activeElement as HTMLElement)
) {
const next =
- tabStops.findIndex((f) => f === document.activeElement) + direction
+ tabStops.findIndex((el) => el === document.activeElement) + direction
+
+ if (next === tabStops.length || !tabStops[next]) {
+ // Reached the end of tab stops for this direction
+ return fallback
+ }
- if (next === tabStops.length) return
- if (!tabStops[next]) return
- tabStops[next].focus()
- } else {
- tabStops.slice(initial)[0].focus()
+ return tabStops[next]
}
+ return fallback
}
- return false
+ return null
}
diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts
index 68fabde1f7e..2bc1e1ed13b 100644
--- a/packages/components/src/utils/index.ts
+++ b/packages/components/src/utils/index.ts
@@ -28,11 +28,12 @@ export * from './getNextFocusTarget'
export * from './getWindowedListBoundaries'
export * from './HoverDisclosure'
export * from './mergeHandlers'
-export * from './moveFocus'
+export * from './getNextFocus'
export * from './targetIsButton'
export * from './undefinedCoalesce'
export * from './useAnimationState'
export * from './useClickable'
+export * from './useArrowKeyNav'
export * from './useControlWarn'
export * from './useReadOnlyWarn'
export * from './useCallbackRef'
diff --git a/packages/components/src/utils/useArrowKeyNav.test.tsx b/packages/components/src/utils/useArrowKeyNav.test.tsx
new file mode 100644
index 00000000000..11f3a683be2
--- /dev/null
+++ b/packages/components/src/utils/useArrowKeyNav.test.tsx
@@ -0,0 +1,155 @@
+/*
+
+ MIT License
+
+ Copyright (c) 2020 Looker Data Sciences, Inc.
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+
+ */
+
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import React from 'react'
+import { useArrowKeyNav, UseArrowKeyNavProps } from './useArrowKeyNav'
+
+const ArrowKeyNavComponent = ({
+ axis,
+}: {
+ axis?: UseArrowKeyNavProps['axis']
+}) => {
+ const navProps = useArrowKeyNav({ axis })
+ return (
+
+
first
+
second
+
third
+
+ )
+}
+
+describe('useArrowKeyNav', () => {
+ test('tabbing', () => {
+ render(
+ <>
+
+
+
+ >
+ )
+ const before = screen.getByText('before')
+ const first = screen.getByText('first')
+
+ userEvent.click(before)
+ userEvent.tab()
+ expect(first).toHaveFocus()
+
+ // second and third are skipped due to tabIndex={-1}
+ userEvent.tab()
+ expect(screen.getByText('after')).toHaveFocus()
+
+ userEvent.tab({ shift: true })
+ expect(first).toHaveFocus()
+
+ userEvent.tab({ shift: true })
+ expect(before).toHaveFocus()
+ })
+
+ test('up/down arrow keys', () => {
+ render(
+ <>
+
+
+
+ >
+ )
+ const before = screen.getByText('before')
+ const first = screen.getByText('first')
+ const second = screen.getByText('second')
+ const third = screen.getByText('third')
+
+ userEvent.click(before)
+ userEvent.tab()
+
+ userEvent.type(first, '{arrowdown}')
+ expect(second).toHaveFocus()
+
+ userEvent.type(second, '{arrowdown}')
+ expect(third).toHaveFocus()
+
+ // circles back
+ userEvent.type(third, '{arrowdown}')
+ expect(first).toHaveFocus()
+
+ // circles back in reverse
+ userEvent.type(first, '{arrowup}')
+ expect(third).toHaveFocus()
+
+ userEvent.type(third, '{arrowup}')
+ expect(second).toHaveFocus()
+
+ userEvent.tab({ shift: true })
+ expect(before).toHaveFocus()
+
+ // Previous focus item is persisted
+ userEvent.tab()
+ expect(second).toHaveFocus()
+ })
+
+ test('left/right arrow keys', () => {
+ render(
+ <>
+
+
+
+ >
+ )
+ const before = screen.getByText('before')
+ const first = screen.getByText('first')
+ const second = screen.getByText('second')
+ const third = screen.getByText('third')
+
+ userEvent.click(before)
+ userEvent.tab()
+
+ userEvent.type(first, '{arrowright}')
+ expect(second).toHaveFocus()
+
+ userEvent.type(second, '{arrowright}')
+ expect(third).toHaveFocus()
+
+ // circles back
+ userEvent.type(third, '{arrowright}')
+ expect(first).toHaveFocus()
+
+ // circles back in reverse
+ userEvent.type(first, '{arrowleft}')
+ expect(third).toHaveFocus()
+
+ userEvent.type(third, '{arrowleft}')
+ expect(second).toHaveFocus()
+
+ userEvent.tab({ shift: true })
+ expect(before).toHaveFocus()
+
+ // Previous focus item is persisted
+ userEvent.tab()
+ expect(second).toHaveFocus()
+ })
+})
diff --git a/packages/components/src/utils/useArrowKeyNav.ts b/packages/components/src/utils/useArrowKeyNav.ts
new file mode 100644
index 00000000000..f8f2c8766c0
--- /dev/null
+++ b/packages/components/src/utils/useArrowKeyNav.ts
@@ -0,0 +1,146 @@
+/*
+
+ MIT License
+
+ Copyright (c) 2020 Looker Data Sciences, Inc.
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+
+ */
+
+import { FocusEvent, KeyboardEvent, Ref, useRef, useState } from 'react'
+import { getNextFocus as getNextFocusDefault } from './getNextFocus'
+import { useForkedRef } from './useForkedRef'
+import { useWrapEvent } from './useWrapEvent'
+
+export interface UseArrowKeyNavProps {
+ /**
+ * vertical for up/down arrow keys, horizontal for left/right, both for all (grid)
+ * @default vertical
+ */
+ axis?: 'vertical' | 'horizontal' | 'both'
+ /**
+ * A custom getter for the next item to focus
+ */
+ getNextFocus?: (
+ direction: 1 | -1,
+ element: E,
+ vertical?: boolean
+ ) => HTMLElement | null
+ /**
+ * will be merged with the ref in the return
+ */
+ ref?: Ref
+ /**
+ * will be merged with the onBlur in the return
+ */
+ onBlur?: (e: FocusEvent) => void
+ /**
+ * will be merged with the onFocus in the return
+ */
+ onFocus?: (e: FocusEvent) => void
+ /**
+ * will be merged with the onKeyDown in the return
+ */
+ onKeyDown?: (e: KeyboardEvent) => void
+}
+
+/**
+ * Returns props to spread onto container element for arrow key navigation.
+ * Add tabIndex={-1} to child elements.
+ */
+export const useArrowKeyNav = ({
+ axis = 'vertical',
+ getNextFocus = getNextFocusDefault,
+ ref,
+ onBlur,
+ onFocus,
+ onKeyDown,
+}: UseArrowKeyNavProps) => {
+ const internalRef = useRef(null)
+ const [focusedItem, setFocusedItem] = useState(null)
+ const [focusInside, setFocusInside] = useState(false)
+
+ const handleArrowKey = (
+ e: KeyboardEvent,
+ direction: 1 | -1,
+ vertical: boolean
+ ) => {
+ if (internalRef.current) {
+ const newFocusedItem = getNextFocus(
+ direction,
+ internalRef.current,
+ vertical
+ )
+ if (newFocusedItem) {
+ e.preventDefault()
+ newFocusedItem.focus()
+ setFocusedItem(newFocusedItem)
+ }
+ }
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ switch (e.key) {
+ case 'ArrowUp':
+ axis !== 'horizontal' && handleArrowKey(e, -1, true)
+ break
+ case 'ArrowDown':
+ axis !== 'horizontal' && handleArrowKey(e, 1, true)
+ break
+ case 'ArrowLeft':
+ axis !== 'vertical' && handleArrowKey(e, -1, false)
+ break
+ case 'ArrowRight':
+ axis !== 'vertical' && handleArrowKey(e, 1, false)
+ break
+ }
+ }
+
+ const handleFocus = (e: FocusEvent) => {
+ setFocusInside(true)
+ // When focus lands on the container
+ if (e.target === internalRef.current) {
+ // Check if there's a previously focused item that is still rendered
+ if (focusedItem && internalRef.current.contains(focusedItem)) {
+ focusedItem.focus()
+ } else {
+ const toFocus = getNextFocus(1, internalRef.current)
+ if (toFocus) {
+ // No need to update focusedItem with this since it's the default
+ toFocus.focus()
+ }
+ }
+ }
+ }
+
+ const handleBlur = () => {
+ setFocusInside(false)
+ }
+
+ return {
+ onBlur: useWrapEvent(handleBlur, onBlur),
+ onFocus: useWrapEvent(handleFocus, onFocus),
+ onKeyDown: useWrapEvent(handleKeyDown, onKeyDown),
+ ref: useForkedRef(internalRef, ref),
+ // Remove tabIndex from container if focus is inside to prevent focus from
+ // landing back on the container when shift-tabbing from the first item
+ tabIndex: focusInside ? undefined : 0,
+ }
+}
diff --git a/packages/components/src/utils/useWindow.tsx b/packages/components/src/utils/useWindow.tsx
index c6cebbfb33b..7847ae23dbc 100644
--- a/packages/components/src/utils/useWindow.tsx
+++ b/packages/components/src/utils/useWindow.tsx
@@ -28,6 +28,7 @@ import React, {
Children,
ReactChild,
Reducer,
+ Ref,
useEffect,
useMemo,
useReducer,
@@ -146,21 +147,23 @@ export type ChildHeightFunction = (child: ReactChild, index: number) => number
export type WindowSpacerTag = 'div' | 'li' | 'tr'
-export interface UseWindowProps {
+export interface UseWindowProps {
enabled?: boolean
children?: JSX.Element | JSX.Element[]
/** Derive the height of each child using props, type, etc. */
childHeight: number | ChildHeightFunction
/** Tagname to use for the spacers above and below the window */
spacerTag?: WindowSpacerTag
+ ref?: Ref
}
-export const useWindow = ({
+export const useWindow = ({
children,
enabled,
childHeight,
+ ref,
spacerTag = 'div',
-}: UseWindowProps) => {
+}: UseWindowProps) => {
const childArray = useMemo(() => Children.toArray(children), [children])
const [totalHeight, childHeightLadder] = useMemo(() => {
@@ -175,7 +178,7 @@ export const useWindow = ({
return [sum, ladder]
}, [childHeight, childArray])
- const [containerElement, ref] = useCallbackRef()
+ const [containerElement, callbackRef] = useCallbackRef(ref)
const { height } = useMeasuredElement(containerElement)
const scrollPosition = useScrollPosition(containerElement)
@@ -240,6 +243,6 @@ export const useWindow = ({
{after}
>
),
- ref,
+ ref: callbackRef,
}
}
diff --git a/playground/src/index.tsx b/playground/src/index.tsx
index a7e2a0bd1d2..e999d92f9b7 100644
--- a/playground/src/index.tsx
+++ b/playground/src/index.tsx
@@ -26,12 +26,12 @@
import React from 'react'
import { render } from 'react-dom'
import { ComponentsProvider } from '@looker/components'
-import { Basic } from '@looker/components/src/Menu/Menu.story'
+import { LongMenus } from '@looker/components/src/Menu/Menu.story'
const App = () => {
return (
-
+
)
}