Skip to content

Commit 1d4d263

Browse files
committed
feat(key-press): add keypress management solution
Introduce "onKeyPress" prop on Focusable components
1 parent 57234fa commit 1d4d263

24 files changed

+1427
-1004
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ dist
44

55
.cache
66
.rts2_cache_*
7+
8+
yarn-error.log

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@
4545
"repository": "git+https://github.com/vovaguguiev/react-sunbeam.git",
4646
"author": "Vova Guguiev <vladimir.guguiev@gmail.com>",
4747
"devDependencies": {
48-
"@commitlint/cli": "^8.1.0",
49-
"@commitlint/config-conventional": "^8.1.0",
48+
"@commitlint/cli": "^8.2.0",
49+
"@commitlint/config-conventional": "^8.2.0",
5050
"commitizen": "^4.0.3",
5151
"cz-conventional-changelog": "^3.0.2",
52-
"husky": "^3.0.5",
53-
"lerna": "^3.16.4",
52+
"husky": "^3.0.8",
53+
"lerna": "^3.17.0",
5454
"prettier": "^1.18.2",
5555
"pretty-quick": "^1.11.1",
5656
"rimraf": "^3.0.0"

packages/demo/FocusableItem.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import * as React from "react"
22
import { useRef, useCallback } from "react"
3-
import { useFocusable, useSunbeam, FocusEvent } from "react-sunbeam"
3+
import { useFocusable, useSunbeam, FocusEvent, KeyPressListener } from "react-sunbeam"
44

55
type Props = {
66
children?: React.ReactNode
77
style?: React.CSSProperties | ((focused: boolean) => React.CSSProperties)
88
focusKey: string
9+
onKeyPress?: KeyPressListener
910
onFocus?: (event: FocusEvent) => void
1011
onBlur?: (event: FocusEvent) => void
1112
}
@@ -16,13 +17,20 @@ type Props = {
1617
* to the app. For example `FocusableItem` uses "tap-to-focus" functionality because the app requires it
1718
* even though `react-sunbeam` doesn't implement it out-of-the-box
1819
*/
19-
export function FocusableItem({ children, style, focusKey, onFocus, onBlur }: Props) {
20+
export function FocusableItem({ children, style, focusKey, onKeyPress, onFocus, onBlur }: Props) {
2021
const elementRef = useRef<HTMLDivElement>(null)
21-
const { focused, path } = useFocusable({ focusKey, elementRef, onFocus, onBlur })
22-
const { setFocus } = useSunbeam()
22+
const { focused, path } = useFocusable({
23+
focusKey,
24+
elementRef,
25+
onFocus,
26+
onBlur,
27+
onKeyPress,
28+
})
29+
const sunbeamContextValue = useSunbeam()
30+
const setFocus = sunbeamContextValue ? sunbeamContextValue.setFocus : undefined
2331

2432
const handleClick = useCallback(() => {
25-
setFocus(path)
33+
if (setFocus) setFocus(path)
2634
}, [path])
2735

2836
return (

packages/demo/GamesGallery.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const GamesGallery = memo(function GamesGallery({ onFocus, onBlur, onItem
1717
const handleItemFocus = useCallback(
1818
(event: FocusEvent) => {
1919
const viewport = viewportRef.current
20+
if (!viewport) throw new Error("Unexpected, viewportRef.current is undefined")
2021
const { width: viewportWidth, left: viewportLeft } = viewport.getBoundingClientRect()
2122

2223
const { left: elementLeft, width: elementWidth } = event.getBoundingClientRect()
@@ -32,6 +33,7 @@ export const GamesGallery = memo(function GamesGallery({ onFocus, onBlur, onItem
3233

3334
const newScrollX = ensureScrollXWithinBounds(scrollX + deltaScrollX)
3435
function ensureScrollXWithinBounds(value: number): number {
36+
if (!trackRef.current) throw new Error("Unexpected, trackRef.current is undefined")
3537
const minScrollX = 0
3638
const maxScrollX = trackRef.current.scrollWidth - viewportWidth
3739
if (value < minScrollX) return minScrollX
@@ -45,13 +47,23 @@ export const GamesGallery = memo(function GamesGallery({ onFocus, onBlur, onItem
4547
[scrollX]
4648
)
4749

48-
const { setFocus } = useSunbeam()
50+
const sunbeamContextValue = useSunbeam()
51+
const setFocus = sunbeamContextValue ? sunbeamContextValue.setFocus : undefined
4952
const handleItemClick = useCallback((itemFocusPath: ReadonlyArray<string>) => {
50-
setFocus(itemFocusPath)
53+
if (setFocus) setFocus(itemFocusPath)
5154
}, [])
5255

5356
return (
54-
<Focusable onFocus={onFocus} onBlur={onBlur} focusKey="gamesGallery">
57+
<Focusable
58+
onFocus={onFocus}
59+
onBlur={onBlur}
60+
focusKey="gamesGallery"
61+
onKeyPress={event => {
62+
if (event.key !== "g") return
63+
console.log("Handling a `g` key press in GamesGallery")
64+
event.stopPropagation()
65+
}}
66+
>
5567
<div ref={viewportRef} style={{ width: "1078px" }}>
5668
<div
5769
ref={trackRef}
@@ -140,6 +152,12 @@ function GameTile({
140152
borderRadius: "2px",
141153
transition: "border-color 100ms ease-out",
142154
})}
155+
onKeyPress={event => {
156+
if (event.key !== "Enter") return
157+
event.preventDefault()
158+
event.stopPropagation()
159+
console.log('Handling "Enter" key in GameTile', focusKey)
160+
}}
143161
onFocus={onFocus}
144162
onBlur={onBlur}
145163
>

packages/demo/index.tsx

Lines changed: 117 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from "react"
2-
import { useCallback, useEffect, useState } from "react"
2+
import { useCallback, useState } from "react"
33
import { render } from "react-dom"
44
import {
55
Direction,
@@ -9,61 +9,78 @@ import {
99
FocusManager,
1010
SunbeamProvider,
1111
unstable_defaultGetPreferredChildOnFocusReceive,
12-
useSunbeam,
12+
KeyPressManager,
1313
} from "react-sunbeam"
1414

1515
import { ProfilesMenu } from "./ProfilesMenu"
1616
import { GamesGallery } from "./GamesGallery"
1717
import { NavigationMenu } from "./NavigationMenu"
1818

19-
export function App() {
20-
const [selectedItem, setSelectedItem] = useState<string | null>(null)
21-
const [screen, setScreen] = useState("home")
22-
23-
const { moveFocusLeft, moveFocusRight, moveFocusUp, moveFocusDown } = useSunbeam()
24-
const onKeyDown = useCallback(
25-
(event: Event) => {
26-
if (!(event instanceof KeyboardEvent)) return
27-
28-
switch (event.key) {
29-
case "ArrowRight":
30-
event.preventDefault()
31-
moveFocusRight()
32-
return
33-
34-
case "ArrowLeft":
35-
event.preventDefault()
36-
moveFocusLeft()
37-
return
38-
39-
case "ArrowUp":
40-
event.preventDefault()
41-
moveFocusUp()
42-
return
43-
44-
case "ArrowDown":
45-
event.preventDefault()
46-
moveFocusDown()
47-
return
19+
const focusManager = new FocusManager({
20+
initialFocusPath: ["gallery", "1"],
21+
})
22+
const keyPressManager = new KeyPressManager()
23+
keyPressManager.addListener(event => {
24+
switch (event.key) {
25+
case "ArrowRight":
26+
event.preventDefault()
27+
focusManager.moveRight()
28+
return
29+
30+
case "ArrowLeft":
31+
event.preventDefault()
32+
focusManager.moveLeft()
33+
return
34+
35+
case "ArrowUp":
36+
event.preventDefault()
37+
focusManager.moveUp()
38+
return
39+
40+
case "ArrowDown":
41+
event.preventDefault()
42+
focusManager.moveDown()
43+
return
44+
}
45+
})
4846

49-
case " ":
50-
case "Enter":
51-
event.preventDefault()
52-
if (screen !== "detail") setScreen("detail")
53-
return
54-
case "Backspace":
55-
event.preventDefault()
56-
if (screen !== "home") setScreen("home")
57-
return
47+
render(
48+
<SunbeamProvider
49+
focusManager={focusManager}
50+
keyPressManager={keyPressManager}
51+
onFocusUpdate={handleFocusUpdate}
52+
// unstable_passFocusBetweenChildren={({ focusableChildren, focusOrigin, direction }) => {
53+
// if (direction === "LEFT" || direction === "RIGHT") {
54+
// return "KEEP_FOCUS_UNCHANGED"
55+
// }
56+
//
57+
// return unstable_defaultPassFocusBetweenChildren({focusableChildren, focusOrigin, direction})
58+
// }}
59+
unstable_getPreferredChildOnFocusReceive={({
60+
focusableChildren,
61+
focusOrigin,
62+
direction,
63+
}: {
64+
focusableChildren: Map<string, FocusableTreeNode>
65+
focusOrigin?: FocusableTreeNode
66+
direction?: Direction
67+
}) => {
68+
if (!focusOrigin || !direction) {
69+
// focus the gallery initially
70+
if (focusableChildren.has("gamesGallery")) return focusableChildren.get("gamesGallery")
5871
}
59-
},
60-
[focusManager, selectedItem, screen]
61-
)
62-
useEffect(() => {
63-
document.addEventListener("keydown", onKeyDown)
6472

65-
return () => document.removeEventListener("keydown", onKeyDown)
66-
}, [onKeyDown])
73+
return unstable_defaultGetPreferredChildOnFocusReceive({ focusableChildren, focusOrigin, direction })
74+
}}
75+
>
76+
<App />
77+
</SunbeamProvider>,
78+
document.getElementById("app")
79+
)
80+
81+
function App() {
82+
const [selectedItem, setSelectedItem] = useState<string | null>(null)
83+
const [screen, setScreen] = useState("home")
6784

6885
const handleItemFocus = useCallback(
6986
(event: FocusEvent) => {
@@ -87,7 +104,15 @@ export function App() {
87104
// TODO: implement Detail screen
88105
return (
89106
<div>
90-
<Focusable focusKey="detail-focusable" style={{ display: "flex" }}>
107+
<Focusable
108+
focusKey="detail-focusable"
109+
style={{ display: "flex" }}
110+
onKeyPress={event => {
111+
if (event.key !== "Backspace") return
112+
event.preventDefault()
113+
setScreen("home")
114+
}}
115+
>
91116
{({ focused }) => (
92117
<div>
93118
<h1>Detail page for {selectedItem}</h1>
@@ -100,82 +125,56 @@ export function App() {
100125
}
101126

102127
return (
103-
<div
104-
style={{
105-
backgroundColor: "#2D2D2D",
106-
display: "flex",
107-
flexDirection: "column",
108-
height: "720px",
109-
width: "1280px",
110-
overflow: "hidden",
128+
<Focusable
129+
onKeyPress={event => {
130+
if (event.key === " " || event.key === "Enter") {
131+
event.preventDefault()
132+
event.stopPropagation()
133+
console.log('Handling "Enter" key in Home Screen')
134+
setScreen("detail")
135+
}
111136
}}
112137
>
113-
<div style={{ marginTop: "32px", marginLeft: "60px" }}>
114-
<ProfilesMenu
115-
onFocus={handleContainerFocus}
116-
onBlur={handleContainerBlur}
117-
onItemFocus={handleItemFocus}
118-
onItemBlur={handleItemBlur}
119-
/>
120-
</div>
121-
<div style={{ marginTop: "94px", alignSelf: "center" }}>
122-
<GamesGallery
123-
onFocus={handleContainerFocus}
124-
onBlur={handleContainerBlur}
125-
onItemFocus={handleItemFocus}
126-
onItemBlur={handleItemBlur}
127-
/>
128-
</div>
129-
<div style={{ marginTop: "94px", alignSelf: "center" }}>
130-
<NavigationMenu
131-
onFocus={handleContainerFocus}
132-
onBlur={handleContainerBlur}
133-
onItemFocus={handleItemFocus}
134-
onItemBlur={handleItemBlur}
135-
/>
138+
<div
139+
style={{
140+
backgroundColor: "#2D2D2D",
141+
display: "flex",
142+
flexDirection: "column",
143+
height: "720px",
144+
width: "1280px",
145+
overflow: "hidden",
146+
}}
147+
>
148+
<div style={{ marginTop: "32px", marginLeft: "60px" }}>
149+
<ProfilesMenu
150+
onFocus={handleContainerFocus}
151+
onBlur={handleContainerBlur}
152+
onItemFocus={handleItemFocus}
153+
onItemBlur={handleItemBlur}
154+
/>
155+
</div>
156+
<div style={{ marginTop: "94px", alignSelf: "center" }}>
157+
<GamesGallery
158+
onFocus={handleContainerFocus}
159+
onBlur={handleContainerBlur}
160+
onItemFocus={handleItemFocus}
161+
onItemBlur={handleItemBlur}
162+
/>
163+
</div>
164+
<div style={{ marginTop: "94px", alignSelf: "center" }}>
165+
<NavigationMenu
166+
onFocus={handleContainerFocus}
167+
onBlur={handleContainerBlur}
168+
onItemFocus={handleItemFocus}
169+
onItemBlur={handleItemBlur}
170+
/>
171+
</div>
136172
</div>
137-
</div>
173+
</Focusable>
138174
)
139175
}
140176

141-
const focusManager = new FocusManager({
142-
initialFocusPath: ["gallery", "1"],
143-
})
144-
145-
function handleFocusUpdate({ focusPath }) {
177+
function handleFocusUpdate({ focusPath }: { focusPath: readonly string[] }) {
146178
// e.g. report an analytics event
147179
// console.log(`focus is updated, the new focusPath is: ${focusPath}`)
148180
}
149-
150-
render(
151-
<SunbeamProvider
152-
focusManager={focusManager}
153-
onFocusUpdate={handleFocusUpdate}
154-
// unstable_passFocusBetweenChildren={({ focusableChildren, focusOrigin, direction }) => {
155-
// if (direction === "LEFT" || direction === "RIGHT") {
156-
// return "KEEP_FOCUS_UNCHANGED"
157-
// }
158-
//
159-
// return unstable_defaultPassFocusBetweenChildren({focusableChildren, focusOrigin, direction})
160-
// }}
161-
unstable_getPreferredChildOnFocusReceive={({
162-
focusableChildren,
163-
focusOrigin,
164-
direction,
165-
}: {
166-
focusableChildren: Map<string, FocusableTreeNode>
167-
focusOrigin?: FocusableTreeNode
168-
direction?: Direction
169-
}) => {
170-
if (!focusOrigin || !direction) {
171-
// focus the gallery initially
172-
if (focusableChildren.has("gamesGallery")) return focusableChildren.get("gamesGallery")
173-
}
174-
175-
return unstable_defaultGetPreferredChildOnFocusReceive({ focusableChildren, focusOrigin, direction })
176-
}}
177-
>
178-
<App />
179-
</SunbeamProvider>,
180-
document.getElementById("app")
181-
)

0 commit comments

Comments
 (0)