Skip to content

Commit

Permalink
feat(react): add ScrollableRegion and useOverflow (#4719)
Browse files Browse the repository at this point in the history
* feat(react): add ScrollableRegion and useOverflow

* chore: address ts error in Table

* test: update snapshots

* chore: add changeset

---------

Co-authored-by: Josh Black <joshblack@users.noreply.github.com>
  • Loading branch information
joshblack and joshblack authored Jul 25, 2024
1 parent 991839c commit 801ca96
Show file tree
Hide file tree
Showing 13 changed files with 202 additions and 17 deletions.
5 changes: 5 additions & 0 deletions .changeset/thick-ants-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Add experimental ScrollableRegion component and useOverflow hook
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/react/src/DataTable/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {UniqueRow} from './row'
import {SortDirection} from './sorting'
import {useTableLayout} from './useTable'
import {SkeletonText} from '../drafts/Skeleton/SkeletonText'
import {ScrollableRegion} from '../internal/components/ScrollableRegion'
import {ScrollableRegion} from '../ScrollableRegion'

// ----------------------------------------------------------------------------
// Table
Expand Down Expand Up @@ -250,6 +250,8 @@ const Table = React.forwardRef<HTMLTableElement, TableProps>(function Table(
ref,
) {
return (
// TODO update type to be non-optional in next major release
// @ts-expect-error this type should be required in the next major version
<ScrollableRegion aria-labelledby={labelledby} className="TableOverflowWrapper">
<StyledTable
{...rest}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {FocusKeys} from '@primer/behaviors'
import Portal from '../Portal'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {useId} from '../hooks/useId'
import {ScrollableRegion} from '../internal/components/ScrollableRegion'
import {ScrollableRegion} from '../ScrollableRegion'
import type {ResponsiveValue} from '../hooks/useResponsiveValue'

/* Dialog Version 2 */
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {BetterSystemStyleObject, SxProp} from '../sx'
import {merge} from '../sx'
import type {Theme} from '../ThemeProvider'
import {canUseDOM} from '../utils/environment'
import {useOverflow} from '../internal/hooks/useOverflow'
import {useOverflow} from '../hooks/useOverflow'
import {warning} from '../utils/warning'
import {useStickyPaneHeight} from './useStickyPaneHeight'

Expand Down
58 changes: 58 additions & 0 deletions packages/react/src/ScrollableRegion/ScrollableRegion.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react'
import type {Meta, StoryObj} from '@storybook/react'
import {ScrollableRegion} from '../ScrollableRegion'

const meta = {
title: 'Drafts/Components/ScrollableRegion',
component: ScrollableRegion,
} satisfies Meta<typeof ScrollableRegion>

export default meta

export const Default = () => {
return (
<ScrollableRegion aria-label="Example scrollable region">
<p>Example content that triggers overflow.</p>
<p
style={{
whiteSpace: 'nowrap',
}}
>
The content here will not wrap at smaller screen sizes and will trigger the component to set the container as a
region, label it, make it focusable, and make it scrollable.
</p>
</ScrollableRegion>
)
}

export const Playground: StoryObj<typeof ScrollableRegion> = {
render: args => {
return (
<ScrollableRegion {...args}>
<p>Example content that triggers overflow.</p>
<p
style={{
whiteSpace: 'nowrap',
}}
>
The content here will not wrap at smaller screen sizes and will trigger the component to set the container as
a region, label it, make it focusable, and make it scrollable.
</p>
</ScrollableRegion>
)
},
args: {
'aria-label': 'Example scrollable region',
},
argTypes: {
'aria-label': {
control: 'text',
},
'aria-labelledby': {
control: 'text',
},
className: {
control: 'text',
},
},
}
88 changes: 88 additions & 0 deletions packages/react/src/ScrollableRegion/ScrollableRegion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {render, screen} from '@testing-library/react'
import React, {act} from 'react'
import {ScrollableRegion} from '../ScrollableRegion'

const originalResizeObserver = global.ResizeObserver

describe('ScrollableRegion', () => {
let mockResizeCallback: (entries: Array<ResizeObserverEntry>) => void

beforeEach(() => {
global.ResizeObserver = class ResizeObserver {
constructor(callback: ResizeObserverCallback) {
mockResizeCallback = (entries: Array<ResizeObserverEntry>) => {
return callback(entries, this)
}
}

observe() {}
disconnect() {}
unobserve() {}
}
})

afterEach(() => {
global.ResizeObserver = originalResizeObserver
})

test('does not render with region props by default', () => {
render(
<ScrollableRegion aria-label="Example label" data-testid="container">
Example content
</ScrollableRegion>,
)

expect(screen.getByTestId('container')).not.toHaveAttribute('role')
expect(screen.getByTestId('container')).not.toHaveAttribute('tabindex')
expect(screen.getByTestId('container')).not.toHaveAttribute('aria-labelledby')
expect(screen.getByTestId('container')).not.toHaveAttribute('aria-label')

expect(screen.getByTestId('container')).toHaveStyleRule('overflow', 'auto')
expect(screen.getByTestId('container')).toHaveStyleRule('position', 'relative')
})

test('does render with region props when overflow is present', () => {
render(
<ScrollableRegion aria-label="Example label" data-testid="container">
Example content
</ScrollableRegion>,
)

act(() => {
// Mock a resize occurring when the scroll height is greater than the
// client height
const target = document.createElement('div')
mockResizeCallback([
{
target: {
...target,
scrollHeight: 500,
clientHeight: 100,
},
borderBoxSize: [],
contentBoxSize: [],
contentRect: {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON() {
return {}
},
},
devicePixelContentBoxSize: [],
},
])
})

expect(screen.getByLabelText('Example label')).toBeVisible()

expect(screen.getByLabelText('Example label')).toHaveAttribute('role', 'region')
expect(screen.getByLabelText('Example label')).toHaveAttribute('tabindex', '0')
expect(screen.getByLabelText('Example label')).toHaveAttribute('aria-label')
})
})
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
import React from 'react'
import Box from '../../Box'
import Box from '../Box'
import {useOverflow} from '../hooks/useOverflow'

type ScrollableRegionProps = React.PropsWithChildren<{
'aria-labelledby'?: string
className?: string
}>
type Labelled =
| {
'aria-label': string
'aria-labelledby'?: never
}
| {
'aria-label'?: never
'aria-labelledby': string
}

type ScrollableRegionProps = React.ComponentPropsWithoutRef<'div'> & Labelled

const defaultStyles = {
// When setting overflow, we also set `position: relative` to avoid
// `position: absolute` items breaking out of the container and causing
// scrollabrs on the page. This can occur with common classes like `sr-only`
// scrollbars on the page. This can occur with common classes like `sr-only`
// and can cause difficult to track down layout issues
position: 'relative',
overflow: 'auto',
}

export function ScrollableRegion({'aria-labelledby': labelledby, children, ...rest}: ScrollableRegionProps) {
function ScrollableRegion({
'aria-label': label,
'aria-labelledby': labelledby,
children,
...rest
}: ScrollableRegionProps) {
const ref = React.useRef(null)
const hasOverflow = useOverflow(ref)
const regionProps = hasOverflow
? {
'aria-label': label,
'aria-labelledby': labelledby,
role: 'region',
tabIndex: 0,
Expand All @@ -33,3 +46,6 @@ export function ScrollableRegion({'aria-labelledby': labelledby, children, ...re
</Box>
)
}

export {ScrollableRegion}
export type {ScrollableRegionProps}
2 changes: 2 additions & 0 deletions packages/react/src/ScrollableRegion/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {ScrollableRegion} from './ScrollableRegion'
export type {ScrollableRegionProps} from './ScrollableRegion'
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ exports[`@primer/react/drafts should not update exports without a semver change
"type ParentLinkProps",
"type Reference",
"type SavedReply",
"ScrollableRegion",
"type ScrollableRegionProps",
"SelectPanel",
"type SelectPanelMessageProps",
"type SelectPanelProps",
Expand Down Expand Up @@ -345,6 +347,7 @@ exports[`@primer/react/drafts should not update exports without a semver change
"useCombobox",
"useDynamicTextareaHeight",
"useIgnoreKeyboardActionsWhileComposing",
"useOverflow",
"useSafeAsyncCallback",
"useSlots",
"useSyntheticChange",
Expand Down Expand Up @@ -414,6 +417,8 @@ exports[`@primer/react/experimental should not update exports without a semver c
"type ParentLinkProps",
"type Reference",
"type SavedReply",
"ScrollableRegion",
"type ScrollableRegionProps",
"SelectPanel",
"type SelectPanelMessageProps",
"type SelectPanelProps",
Expand Down Expand Up @@ -458,6 +463,7 @@ exports[`@primer/react/experimental should not update exports without a semver c
"useCombobox",
"useDynamicTextareaHeight",
"useIgnoreKeyboardActionsWhileComposing",
"useOverflow",
"useSafeAsyncCallback",
"useSlots",
"useSyntheticChange",
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/drafts/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './useIgnoreKeyboardActionsWhileComposing'
export * from './useSafeAsyncCallback'
export * from './useSyntheticChange'
export * from '../../hooks/useSlots'
export {useOverflow} from '../../hooks/useOverflow'
3 changes: 3 additions & 0 deletions packages/react/src/drafts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ export type {TabPanelsProps, TabPanelsTabProps, TabPanelsPanelProps} from './Tab
export * from '../TooltipV2'
export * from '../ActionBar'

export {ScrollableRegion} from '../ScrollableRegion'
export type {ScrollableRegionProps} from '../ScrollableRegion'

export {Stack} from '../Stack'
export type {StackProps, StackItemProps} from '../Stack'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ export function useOverflow<T extends HTMLElement>(ref: React.RefObject<T>) {

const observer = new ResizeObserver(entries => {
for (const entry of entries) {
setHasOverflow(
entry.target.scrollHeight > entry.target.clientHeight || entry.target.scrollWidth > entry.target.clientWidth,
)
if (
entry.target.scrollHeight > entry.target.clientHeight ||
entry.target.scrollWidth > entry.target.clientWidth
) {
setHasOverflow(true)
break
}
}
})

Expand Down

0 comments on commit 801ca96

Please sign in to comment.