{ sung.codes }

by dance2die
Blog
← Go Back

Scrolling with Page Up/Down Keys in React-Window

Broken Post?Let me know

Photo by Ruthie on Unsplash

React-Window is a React library by Brian Vaughn for rendering a massive amount of items in a list (or a grid but I will use "list" to keep the sentences simple as principle is the same for both) efficiently.

By rendering only visible items

But the problem is that when you click on an item in a list, you can't scroll up/down using keys.

such as Page Up/Down, Arrow Up/Down, Home, or End keys.

Let's see how we can support scrolling in react-window with Page Up/Down.

Replicating the issue

Go to an (any example) react-window example and scroll up/down with keyboard without selecting an item in the list.

You should be able to scroll with any keys.

And then click on any item in the list and try to scroll with keyboard.

And you will see that it will move just once and stop responding.

What happened?

The behavior isn't implemented according to this GitHub issue, Support scrolling with Page Up / Page Down keys (, which is NOT created by me but by Steve Randy Tantra).

And you are responsible to add a support for yourself.

Thankfully, Brian has provided a way to implement it in the same thread.

Let's Make that example list scrollable with Page Up/Down, Home and End keys.

Implementation

You can see the working implementation here and follow along.

Unfortunately, keyboards will scroll this current page up/down thus you'd have to open the editor in new window....

Wrap the list with a container element

First you need to wrap the list within a container element such as div/section/main etc.

chrome 2019 05 07 21 54 17

And then add the tab index to capture the onKeyDown event.

const Example = () => {
  //.. rest hidden for brevity

  return (
+    <div onKeyDown={handleKeyDown} tabIndex="0" style={{ width: '151px' }}>
      <List
        className="List"
        height={listHeight}
        itemCount={1000}
        itemSize={35}
        useIsScrolling
        width={300}
      >
        {Row}
      </List>
+    </div>
  )
}
view raw Example.div.md hosted with ❤ by GitHub

View this gist on GitHub

Add references to the list

Next, we need to refer to the list to scroll so create two (you can create one but it's more readable with two, I will show you why later) references to the List.

const Example = () => {
+  const outerListRef = useRef(undefined)
+  const innerListRef = useRef(undefined)

  //.. rest hidden for brevity

  return (
    <div onKeyDown={handleKeyDown} tabIndex="0" style={{ width: '151px' }}>
      <List
+        outerRef={outerListRef}
+        innerRef={innerListRef}
        className="List"
        height={listHeight}
        itemCount={1000}
        itemSize={35}
        useIsScrolling
        width={300}
      >
        {Row}
      </List>
    </div>
  )
}
view raw Example.refs.md hosted with ❤ by GitHub

View this gist on GitHub

outerListRef is an outerRef refers to the List itself (the container property) while innerListRef is the dynamic container which updates as you scroll and contains the maximum content height.

You can refer to the documentation on inner/outerRefs but found it a bit hard to grasp without looking at the code. So let's take a look at what those two references actually refer to in rendered HTML.

explorer 2019 05 07 22 04 16 4

The outerRef is the element we need to use scrollTo (scroll is the same)API with and the innerRef is the element we need to extract the maximum height from.

Without _innerRef_, you refer to it as _outerRef.current.firstElementChild_ so _innerRef_ improves readability.

Handling onKeyDown event

Let's add the onKeyDown event handler, which is fired whenever you hold down any keys.

const Example = () => {
  const outerListRef = useRef(undefined)
  const innerListRef = useRef(undefined)
+  const [scrollOffset, setScrollOffset] = useState(0)
+  const listHeight = 150

+  const [pageUp, pageDown, home, end] = [33, 34, 36, 35]
   // The magic number "5" is an arbitrary value from trial & error...
+  const pageOffset = listHeight * 5
+  const maxHeight =
+    (innerListRef.current &&
+      innerListRef.current.style.height.replace('px', '')) ||
+    listHeight

+  const minHeight = 0.1

+  const keys = {
+    [pageUp]: Math.max(minHeight, scrollOffset - pageOffset),
+    [pageDown]: Math.min(scrollOffset + pageOffset, maxHeight),
+    [end]: maxHeight,
+    [home]: minHeight,
+  }

+  const handleKeyDown = ({ keyCode }) => {
+    keys[keyCode] && setScrollOffset(keys[keyCode])
+  }

  return (
    <div onKeyDown={handleKeyDown} tabIndex="0" style={{ width: '151px' }}>
      <List
        outerRef={outerListRef}
        innerRef={innerListRef}
        className="List"
        height={listHeight}
        itemCount={1000}
        itemSize={35}
        useIsScrolling
        width={300}
      >
        {Row}
      </List>
    </div>
  )
}

View this gist on GitHub

handleKeyDown is given a keyboard event with a keyCode property, which is destructured from the argument.
And when the matching key is found from the keys then we set the scroll offset (where we are currently in the list).

keys object(an essentially a map) holds a list of keys to be handled where

  • pageUp has keyCode value of 33
  • pageDown has keyCode value of 34
  • end has keyCode value of 36
  • home has keyCode value of 35

So whenever pageUp/Down, end, or home keys are pressed, we are updating the current position (scroll offset).

maxHeight is retrieved using the innerRef's style height for convenience without using outerRef.

minHeight is set to oddly 0.1 instead of 0. I really have no idea why setting it to 0 would not work with scroll API.

Would anyone let me know why it is so?

And let's rock and scroll~

As react-window mutates the DOM while scrolling, we need to add it to the useLayoutEffect because we need to scrolling to happen after it.

useLayoutEffect documentation says "it fires synchronously after all DOM mutations."

Would anyone let me know if it's a good approach? (because useEffect still worked fine.)

Scrolling mutating the DOM

Refer to Kent C. Dodds' post useEffect vs useLayoutEffect for difference between them.

const Example = () => {
  // code above removed for brevity...
  
  const handleKeyDown = ({ keyCode }) => {
    keys[keyCode] && setScrollOffset(keys[keyCode])
  }

+  useLayoutEffect(() => {
+    outerListRef.current &&
+      outerListRef.current.scrollTo({
+        left: 0,
+        top: scrollOffset,
+        behavior: 'auto',
+      })
+  })

  return (
    <div onKeyDown={handleKeyDown} tabIndex="0" style={{ width: '151px' }}>
      <List
        outerRef={outerListRef}
        innerRef={innerListRef}
        className="List"
        height={listHeight}
        itemCount={1000}
        itemSize={35}
        useIsScrolling
        width={300}
      >
        {Row}
      </List>
    </div>
  )
}

View this gist on GitHub

In the effect, we are basically calling scrollTo to update the current scroll position in the list.

When the behavior is set to smooth it was gliding, which I am not sure how to prevent from happening... 😅

Yet, again, I shameless ask why it's happening and how to get around the issue 🙏

Roulette?

Result

Now you can scroll using Page Up/Down, Home, and End keys.

Here is the link to the code again on CodeSandbox.

Edit react-window: scrolling with page up/down

Additional Context

I've had the problem implementing it for one of the pet projects and that GitHub issue and seemingly simple but yet helpful reply by Brian saved the day. So I give thanks to Steve & Brian.

I'd appreciate it if anyone can provide me with feedbacks for questions I've asked above 🙂