From 6bca0fe0a9273986eb9cf84871d96591e613f5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Fri, 10 Feb 2023 14:57:51 +0100 Subject: [PATCH] feat(ScrollView): add interactive=auto to observe the content (#1984) --- .../fragments/scroll-view/properties.md | 8 +- .../src/components/table/TableScrollView.tsx | 9 +- .../table/__tests__/TableScrollView.test.tsx | 35 ++++++-- .../src/fragments/scroll-view/ScrollView.tsx | 64 ++++++++++++-- .../scroll-view/__tests__/ScrollView.test.tsx | 88 ++++++++++++++++++- .../__tests__/__mocks__/ResizeObserver.ts | 26 ++++++ 6 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 packages/dnb-eufemia/src/fragments/scroll-view/__tests__/__mocks__/ResizeObserver.ts diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/scroll-view/properties.md b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/scroll-view/properties.md index 6e0ced5a8ed..3765494d1ce 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/scroll-view/properties.md +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/scroll-view/properties.md @@ -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. | diff --git a/packages/dnb-eufemia/src/components/table/TableScrollView.tsx b/packages/dnb-eufemia/src/components/table/TableScrollView.tsx index bb5d72877c2..450fcaee099 100644 --- a/packages/dnb-eufemia/src/components/table/TableScrollView.tsx +++ b/packages/dnb-eufemia/src/components/table/TableScrollView.tsx @@ -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' @@ -13,7 +15,8 @@ export type TableScrollViewProps = { export type TableScrollViewAllProps = TableScrollViewProps & Omit, 'children'> & - SpacingProps + SpacingProps & + ScrollViewAllProps export default function TableScrollView(props: TableScrollViewAllProps) { const { className, children, ...rest } = props @@ -21,7 +24,7 @@ export default function TableScrollView(props: TableScrollViewAllProps) { return ( {children} diff --git a/packages/dnb-eufemia/src/components/table/__tests__/TableScrollView.test.tsx b/packages/dnb-eufemia/src/components/table/__tests__/TableScrollView.test.tsx index 795151f6b91..39e5debde1c 100644 --- a/packages/dnb-eufemia/src/components/table/__tests__/TableScrollView.test.tsx +++ b/packages/dnb-eufemia/src/components/table/__tests__/TableScrollView.test.tsx @@ -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', () => { @@ -24,8 +25,18 @@ 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() + render( - +
@@ -33,11 +44,23 @@ describe('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() }) }) diff --git a/packages/dnb-eufemia/src/fragments/scroll-view/ScrollView.tsx b/packages/dnb-eufemia/src/fragments/scroll-view/ScrollView.tsx index b43c5ddf67b..3e01603b355 100644 --- a/packages/dnb-eufemia/src/fragments/scroll-view/ScrollView.tsx +++ b/packages/dnb-eufemia/src/fragments/scroll-view/ScrollView.tsx @@ -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 & @@ -61,19 +61,65 @@ function ScrollView(localProps: ScrollViewAllProps) { ...(attributes as React.HTMLAttributes), } - if (innerRef) { - mainParams.ref = innerRef as React.RefObject - } + const ref = React.useRef() + mainParams.ref = innerRef + ? (innerRef as React.RefObject) + : 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
{children}
} +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 + return }) diff --git a/packages/dnb-eufemia/src/fragments/scroll-view/__tests__/ScrollView.test.tsx b/packages/dnb-eufemia/src/fragments/scroll-view/__tests__/ScrollView.test.tsx index 600ed6c2da1..e38698635a3 100644 --- a/packages/dnb-eufemia/src/fragments/scroll-view/__tests__/ScrollView.test.tsx +++ b/packages/dnb-eufemia/src/fragments/scroll-view/__tests__/ScrollView.test.tsx @@ -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', () => { @@ -15,11 +16,90 @@ describe('ScrollView', () => { render(overflow content) 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() + const { rerender } = render( + + overflow content + + ) + + 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( + + new content to force hook re-render + + ) + }) + + expect(element.getAttribute('tabindex')).toBe('0') + + act(() => { + jest.spyOn(ref.current, 'scrollWidth', 'get').mockReturnValue(101) + jest.spyOn(ref.current, 'offsetWidth', 'get').mockReturnValue(100) + + rerender( + + again, new content to force hook re-render + + ) + }) + + 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() + render( + + overflow content + ) - 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', () => { diff --git a/packages/dnb-eufemia/src/fragments/scroll-view/__tests__/__mocks__/ResizeObserver.ts b/packages/dnb-eufemia/src/fragments/scroll-view/__tests__/__mocks__/ResizeObserver.ts new file mode 100644 index 00000000000..124fc044089 --- /dev/null +++ b/packages/dnb-eufemia/src/fragments/scroll-view/__tests__/__mocks__/ResizeObserver.ts @@ -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 +}