Skip to content

Commit

Permalink
feat(ScrollView): add interactive=auto to observe the content (#1984)
Browse files Browse the repository at this point in the history
  • Loading branch information
tujoworker committed Feb 23, 2023
1 parent 88ae6f7 commit 6bca0fe
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ showTabs: true

## Properties

| Properties | Description |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `interactive` | _(optional)_ set to `true` to make the content accessible to keyboard navigation. Defaults to `false`. |
| [Space](/uilib/components/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. |
| Properties | Description |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `interactive` | _(optional)_ to make the content accessible to keyboard navigation. Use `true` or `auto`. Auto will detect if a scrollbar is visible and make the ScrollView accessible for keyboard navigation. Defaults to `false`. |
| [Space](/uilib/components/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. |
9 changes: 6 additions & 3 deletions packages/dnb-eufemia/src/components/table/TableScrollView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from 'react'
import classnames from 'classnames'
import ScrollView from '../../fragments/scroll-view/ScrollView'
import ScrollView, {
ScrollViewAllProps,
} from '../../fragments/scroll-view/ScrollView'

import type { SpacingProps } from '../../shared/types'

Expand All @@ -13,15 +15,16 @@ export type TableScrollViewProps = {

export type TableScrollViewAllProps = TableScrollViewProps &
Omit<React.TableHTMLAttributes<HTMLDivElement>, 'children'> &
SpacingProps
SpacingProps &
ScrollViewAllProps

export default function TableScrollView(props: TableScrollViewAllProps) {
const { className, children, ...rest } = props

return (
<ScrollView
className={classnames('dnb-table__scroll-view', className)}
interactive
interactive="auto"
{...rest}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React from 'react'
import { render } from '@testing-library/react'
import { act, render } from '@testing-library/react'
import Table from '../Table'
import ScrollView from '../TableScrollView'
import { BasicTable } from './TableMocks'
import { setResizeObserver } from '../../../fragments/scroll-view/__tests__/__mocks__/ResizeObserver'

describe('Table.ScrollView', () => {
it('should support spacing props', () => {
Expand All @@ -24,20 +25,42 @@ describe('Table.ScrollView', () => {
})

it('should have tabindex="0"', () => {
let renderResizeObserver = null

const observe = jest.fn()
const init = jest.fn((callback) => {
renderResizeObserver = callback
})
setResizeObserver({ init, observe })

const ref = React.createRef<HTMLDivElement>()

render(
<ScrollView>
<ScrollView innerRef={ref}>
<Table>
<BasicTable />
</Table>
</ScrollView>
)

const element = document.querySelector('.dnb-table__scroll-view')
const attributes = Array.from(element.attributes).map(
(attr) => attr.name
)

expect(attributes).toEqual(['class', 'tabindex'])
act(() => {
jest.spyOn(ref.current, 'scrollWidth', 'get').mockReturnValue(102)
jest.spyOn(ref.current, 'offsetWidth', 'get').mockReturnValue(100)

renderResizeObserver()
})

expect(element.getAttribute('tabindex')).toBe('0')

act(() => {
jest.spyOn(ref.current, 'scrollWidth', 'get').mockReturnValue(101)
jest.spyOn(ref.current, 'offsetWidth', 'get').mockReturnValue(100)

renderResizeObserver()
})

expect(element.hasAttribute('tabindex')).toBeFalsy()
})
})
64 changes: 55 additions & 9 deletions packages/dnb-eufemia/src/fragments/scroll-view/ScrollView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import { SpacingProps } from '../../shared/types'

export type ScrollViewProps = {
/**
* Set to `true` to make the content accessible to keyboard navigation
* To make the content accessible to keyboard navigation. Use `true` or `auto`. Auto will detect if a scrollbar is visible and make the ScrollView accessible for keyboard navigation.
* Default: false
*/
interactive?: boolean
interactive?: boolean | 'auto'
}

export type ScrollViewAllProps = ScrollViewProps &
Expand Down Expand Up @@ -61,19 +61,65 @@ function ScrollView(localProps: ScrollViewAllProps) {
...(attributes as React.HTMLAttributes<unknown>),
}

if (innerRef) {
mainParams.ref = innerRef as React.RefObject<HTMLDivElement>
}
const ref = React.useRef<HTMLDivElement>()
mainParams.ref = innerRef
? (innerRef as React.RefObject<HTMLDivElement>)
: ref

if (interactive) {
mainParams.tabIndex = 0 // Ensure that scrollable region has keyboard access
}
mainParams.tabIndex = useInteractive({
interactive,
children,
ref: mainParams.ref,
})

validateDOMAttributes(props, mainParams)

return <div {...mainParams}>{children}</div>
}

function useInteractive({ interactive, children, ref }) {
const [isInteractive, setAsInteractive] = React.useState(
Boolean(interactive)
)

React.useLayoutEffect(() => {
if (interactive === 'auto') {
setAsInteractive(hasScrollbar())
}
}, [interactive, children]) // eslint-disable-line react-hooks/exhaustive-deps

React.useLayoutEffect(() => {
if (interactive === 'auto' && typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(() => {
setAsInteractive(hasScrollbar())
})
observer.observe(ref.current)
return () => observer?.disconnect()
}
}, [interactive, ref]) // eslint-disable-line react-hooks/exhaustive-deps

if (isInteractive) {
return 0 // Ensure that scrollable region has keyboard access
}

return undefined

function hasScrollbar() {
if (!ref.current) {
return true // fallback and assume, there is a scrollbar
}

/**
* Safari Desktop adds one pixel "on zoom" level 1
* therefore we just remove it here
*/
return (
ref.current.scrollWidth - 1 > ref.current.offsetWidth ||
ref.current.scrollHeight - 1 > ref.current.offsetHeight
)
}
}

export default React.forwardRef((props: ScrollViewAllProps, ref) => {
return <ScrollView {...props} innerRef={ref} />
return <ScrollView {...props} innerRef={props.innerRef || ref} />
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react'
import { render } from '@testing-library/react'
import { act, render } from '@testing-library/react'
import ScrollView from '../ScrollView'
import { setResizeObserver } from './__mocks__/ResizeObserver'

describe('ScrollView', () => {
it('should contain children content', () => {
Expand All @@ -15,11 +16,90 @@ describe('ScrollView', () => {
render(<ScrollView interactive>overflow content</ScrollView>)

const element = document.querySelector('.dnb-scroll-view')
const attributes = Array.from(element.attributes).map(
(attr) => attr.name

expect(element.getAttribute('tabindex')).toBe('0')
})

it('should set tabindex based on children when interactive is set to auto', () => {
setResizeObserver()

const ref = React.createRef<HTMLDivElement>()
const { rerender } = render(
<ScrollView ref={ref} interactive="auto">
overflow content
</ScrollView>
)

const element = document.querySelector('.dnb-scroll-view')
expect(element.hasAttribute('tabindex')).toBeFalsy()

act(() => {
jest.spyOn(ref.current, 'scrollWidth', 'get').mockReturnValue(102)
jest.spyOn(ref.current, 'offsetWidth', 'get').mockReturnValue(100)

rerender(
<ScrollView ref={ref} interactive="auto">
new content to force hook re-render
</ScrollView>
)
})

expect(element.getAttribute('tabindex')).toBe('0')

act(() => {
jest.spyOn(ref.current, 'scrollWidth', 'get').mockReturnValue(101)
jest.spyOn(ref.current, 'offsetWidth', 'get').mockReturnValue(100)

rerender(
<ScrollView ref={ref} interactive="auto">
again, new content to force hook re-render
</ScrollView>
)
})

expect(element.hasAttribute('tabindex')).toBeFalsy()
})

it('should set tabindex based on ResizeObserver when interactive is set to auto', () => {
let renderResizeObserver = null

const observe = jest.fn()
const init = jest.fn((callback) => {
renderResizeObserver = callback
})
setResizeObserver({ init, observe })

const ref = React.createRef<HTMLDivElement>()
render(
<ScrollView ref={ref} interactive="auto">
overflow content
</ScrollView>
)

expect(attributes).toEqual(['class', 'tabindex'])
const element = document.querySelector('.dnb-scroll-view')
expect(element.hasAttribute('tabindex')).toBeFalsy()

act(() => {
jest.spyOn(ref.current, 'scrollWidth', 'get').mockReturnValue(102)
jest.spyOn(ref.current, 'offsetWidth', 'get').mockReturnValue(100)

renderResizeObserver()
})

expect(element.getAttribute('tabindex')).toBe('0')

act(() => {
jest.spyOn(ref.current, 'scrollWidth', 'get').mockReturnValue(101)
jest.spyOn(ref.current, 'offsetWidth', 'get').mockReturnValue(100)

renderResizeObserver()
})

expect(element.hasAttribute('tabindex')).toBeFalsy()

expect(init).toHaveBeenCalledTimes(1)
expect(observe).toHaveBeenCalledTimes(1)
expect(observe).toHaveBeenCalledWith(ref.current)
})

it('should include custom classes', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
type ObserverOptions = {
init?: (callback: ResizeObserverCallback) => void
observe?: (elem: HTMLElement) => void
}

export const setResizeObserver = ({
observe,
init,
}: ObserverOptions = {}) => {
class ResizeObserver {
constructor(callback: ResizeObserverCallback) {
init?.(callback)
}
observe(elem: HTMLElement) {
return observe?.(elem)
}
unobserve() {
// do nothing
}
disconnect() {
// do nothing
}
}

globalThis.ResizeObserver = ResizeObserver
}

0 comments on commit 6bca0fe

Please sign in to comment.