Skip to content

Commit 801ca96

Browse files
authored
feat(react): add ScrollableRegion and useOverflow (#4719)
* feat(react): add ScrollableRegion and useOverflow * chore: address ts error in Table * test: update snapshots * chore: add changeset --------- Co-authored-by: Josh Black <[email protected]>
1 parent 991839c commit 801ca96

File tree

13 files changed

+202
-17
lines changed

13 files changed

+202
-17
lines changed

.changeset/thick-ants-try.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Add experimental ScrollableRegion component and useOverflow hook

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react/src/DataTable/Table.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {UniqueRow} from './row'
1313
import {SortDirection} from './sorting'
1414
import {useTableLayout} from './useTable'
1515
import {SkeletonText} from '../drafts/Skeleton/SkeletonText'
16-
import {ScrollableRegion} from '../internal/components/ScrollableRegion'
16+
import {ScrollableRegion} from '../ScrollableRegion'
1717

1818
// ----------------------------------------------------------------------------
1919
// Table
@@ -250,6 +250,8 @@ const Table = React.forwardRef<HTMLTableElement, TableProps>(function Table(
250250
ref,
251251
) {
252252
return (
253+
// TODO update type to be non-optional in next major release
254+
// @ts-expect-error this type should be required in the next major version
253255
<ScrollableRegion aria-labelledby={labelledby} className="TableOverflowWrapper">
254256
<StyledTable
255257
{...rest}

packages/react/src/Dialog/Dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {FocusKeys} from '@primer/behaviors'
1515
import Portal from '../Portal'
1616
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
1717
import {useId} from '../hooks/useId'
18-
import {ScrollableRegion} from '../internal/components/ScrollableRegion'
18+
import {ScrollableRegion} from '../ScrollableRegion'
1919
import type {ResponsiveValue} from '../hooks/useResponsiveValue'
2020

2121
/* Dialog Version 2 */

packages/react/src/PageLayout/PageLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {BetterSystemStyleObject, SxProp} from '../sx'
1010
import {merge} from '../sx'
1111
import type {Theme} from '../ThemeProvider'
1212
import {canUseDOM} from '../utils/environment'
13-
import {useOverflow} from '../internal/hooks/useOverflow'
13+
import {useOverflow} from '../hooks/useOverflow'
1414
import {warning} from '../utils/warning'
1515
import {useStickyPaneHeight} from './useStickyPaneHeight'
1616

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from 'react'
2+
import type {Meta, StoryObj} from '@storybook/react'
3+
import {ScrollableRegion} from '../ScrollableRegion'
4+
5+
const meta = {
6+
title: 'Drafts/Components/ScrollableRegion',
7+
component: ScrollableRegion,
8+
} satisfies Meta<typeof ScrollableRegion>
9+
10+
export default meta
11+
12+
export const Default = () => {
13+
return (
14+
<ScrollableRegion aria-label="Example scrollable region">
15+
<p>Example content that triggers overflow.</p>
16+
<p
17+
style={{
18+
whiteSpace: 'nowrap',
19+
}}
20+
>
21+
The content here will not wrap at smaller screen sizes and will trigger the component to set the container as a
22+
region, label it, make it focusable, and make it scrollable.
23+
</p>
24+
</ScrollableRegion>
25+
)
26+
}
27+
28+
export const Playground: StoryObj<typeof ScrollableRegion> = {
29+
render: args => {
30+
return (
31+
<ScrollableRegion {...args}>
32+
<p>Example content that triggers overflow.</p>
33+
<p
34+
style={{
35+
whiteSpace: 'nowrap',
36+
}}
37+
>
38+
The content here will not wrap at smaller screen sizes and will trigger the component to set the container as
39+
a region, label it, make it focusable, and make it scrollable.
40+
</p>
41+
</ScrollableRegion>
42+
)
43+
},
44+
args: {
45+
'aria-label': 'Example scrollable region',
46+
},
47+
argTypes: {
48+
'aria-label': {
49+
control: 'text',
50+
},
51+
'aria-labelledby': {
52+
control: 'text',
53+
},
54+
className: {
55+
control: 'text',
56+
},
57+
},
58+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {render, screen} from '@testing-library/react'
2+
import React, {act} from 'react'
3+
import {ScrollableRegion} from '../ScrollableRegion'
4+
5+
const originalResizeObserver = global.ResizeObserver
6+
7+
describe('ScrollableRegion', () => {
8+
let mockResizeCallback: (entries: Array<ResizeObserverEntry>) => void
9+
10+
beforeEach(() => {
11+
global.ResizeObserver = class ResizeObserver {
12+
constructor(callback: ResizeObserverCallback) {
13+
mockResizeCallback = (entries: Array<ResizeObserverEntry>) => {
14+
return callback(entries, this)
15+
}
16+
}
17+
18+
observe() {}
19+
disconnect() {}
20+
unobserve() {}
21+
}
22+
})
23+
24+
afterEach(() => {
25+
global.ResizeObserver = originalResizeObserver
26+
})
27+
28+
test('does not render with region props by default', () => {
29+
render(
30+
<ScrollableRegion aria-label="Example label" data-testid="container">
31+
Example content
32+
</ScrollableRegion>,
33+
)
34+
35+
expect(screen.getByTestId('container')).not.toHaveAttribute('role')
36+
expect(screen.getByTestId('container')).not.toHaveAttribute('tabindex')
37+
expect(screen.getByTestId('container')).not.toHaveAttribute('aria-labelledby')
38+
expect(screen.getByTestId('container')).not.toHaveAttribute('aria-label')
39+
40+
expect(screen.getByTestId('container')).toHaveStyleRule('overflow', 'auto')
41+
expect(screen.getByTestId('container')).toHaveStyleRule('position', 'relative')
42+
})
43+
44+
test('does render with region props when overflow is present', () => {
45+
render(
46+
<ScrollableRegion aria-label="Example label" data-testid="container">
47+
Example content
48+
</ScrollableRegion>,
49+
)
50+
51+
act(() => {
52+
// Mock a resize occurring when the scroll height is greater than the
53+
// client height
54+
const target = document.createElement('div')
55+
mockResizeCallback([
56+
{
57+
target: {
58+
...target,
59+
scrollHeight: 500,
60+
clientHeight: 100,
61+
},
62+
borderBoxSize: [],
63+
contentBoxSize: [],
64+
contentRect: {
65+
width: 0,
66+
height: 0,
67+
top: 0,
68+
right: 0,
69+
bottom: 0,
70+
left: 0,
71+
x: 0,
72+
y: 0,
73+
toJSON() {
74+
return {}
75+
},
76+
},
77+
devicePixelContentBoxSize: [],
78+
},
79+
])
80+
})
81+
82+
expect(screen.getByLabelText('Example label')).toBeVisible()
83+
84+
expect(screen.getByLabelText('Example label')).toHaveAttribute('role', 'region')
85+
expect(screen.getByLabelText('Example label')).toHaveAttribute('tabindex', '0')
86+
expect(screen.getByLabelText('Example label')).toHaveAttribute('aria-label')
87+
})
88+
})
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,39 @@
11
import React from 'react'
2-
import Box from '../../Box'
2+
import Box from '../Box'
33
import {useOverflow} from '../hooks/useOverflow'
44

5-
type ScrollableRegionProps = React.PropsWithChildren<{
6-
'aria-labelledby'?: string
7-
className?: string
8-
}>
5+
type Labelled =
6+
| {
7+
'aria-label': string
8+
'aria-labelledby'?: never
9+
}
10+
| {
11+
'aria-label'?: never
12+
'aria-labelledby': string
13+
}
14+
15+
type ScrollableRegionProps = React.ComponentPropsWithoutRef<'div'> & Labelled
916

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

19-
export function ScrollableRegion({'aria-labelledby': labelledby, children, ...rest}: ScrollableRegionProps) {
26+
function ScrollableRegion({
27+
'aria-label': label,
28+
'aria-labelledby': labelledby,
29+
children,
30+
...rest
31+
}: ScrollableRegionProps) {
2032
const ref = React.useRef(null)
2133
const hasOverflow = useOverflow(ref)
2234
const regionProps = hasOverflow
2335
? {
36+
'aria-label': label,
2437
'aria-labelledby': labelledby,
2538
role: 'region',
2639
tabIndex: 0,
@@ -33,3 +46,6 @@ export function ScrollableRegion({'aria-labelledby': labelledby, children, ...re
3346
</Box>
3447
)
3548
}
49+
50+
export {ScrollableRegion}
51+
export type {ScrollableRegionProps}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export {ScrollableRegion} from './ScrollableRegion'
2+
export type {ScrollableRegionProps} from './ScrollableRegion'

packages/react/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ exports[`@primer/react/drafts should not update exports without a semver change
301301
"type ParentLinkProps",
302302
"type Reference",
303303
"type SavedReply",
304+
"ScrollableRegion",
305+
"type ScrollableRegionProps",
304306
"SelectPanel",
305307
"type SelectPanelMessageProps",
306308
"type SelectPanelProps",
@@ -345,6 +347,7 @@ exports[`@primer/react/drafts should not update exports without a semver change
345347
"useCombobox",
346348
"useDynamicTextareaHeight",
347349
"useIgnoreKeyboardActionsWhileComposing",
350+
"useOverflow",
348351
"useSafeAsyncCallback",
349352
"useSlots",
350353
"useSyntheticChange",
@@ -414,6 +417,8 @@ exports[`@primer/react/experimental should not update exports without a semver c
414417
"type ParentLinkProps",
415418
"type Reference",
416419
"type SavedReply",
420+
"ScrollableRegion",
421+
"type ScrollableRegionProps",
417422
"SelectPanel",
418423
"type SelectPanelMessageProps",
419424
"type SelectPanelProps",
@@ -458,6 +463,7 @@ exports[`@primer/react/experimental should not update exports without a semver c
458463
"useCombobox",
459464
"useDynamicTextareaHeight",
460465
"useIgnoreKeyboardActionsWhileComposing",
466+
"useOverflow",
461467
"useSafeAsyncCallback",
462468
"useSlots",
463469
"useSyntheticChange",

packages/react/src/drafts/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './useIgnoreKeyboardActionsWhileComposing'
44
export * from './useSafeAsyncCallback'
55
export * from './useSyntheticChange'
66
export * from '../../hooks/useSlots'
7+
export {useOverflow} from '../../hooks/useOverflow'

packages/react/src/drafts/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export type {TabPanelsProps, TabPanelsTabProps, TabPanelsPanelProps} from './Tab
7272
export * from '../TooltipV2'
7373
export * from '../ActionBar'
7474

75+
export {ScrollableRegion} from '../ScrollableRegion'
76+
export type {ScrollableRegionProps} from '../ScrollableRegion'
77+
7578
export {Stack} from '../Stack'
7679
export type {StackProps, StackItemProps} from '../Stack'
7780

packages/react/src/internal/hooks/useOverflow.ts renamed to packages/react/src/hooks/useOverflow.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ export function useOverflow<T extends HTMLElement>(ref: React.RefObject<T>) {
1010

1111
const observer = new ResizeObserver(entries => {
1212
for (const entry of entries) {
13-
setHasOverflow(
14-
entry.target.scrollHeight > entry.target.clientHeight || entry.target.scrollWidth > entry.target.clientWidth,
15-
)
13+
if (
14+
entry.target.scrollHeight > entry.target.clientHeight ||
15+
entry.target.scrollWidth > entry.target.clientWidth
16+
) {
17+
setHasOverflow(true)
18+
break
19+
}
1620
}
1721
})
1822

0 commit comments

Comments
 (0)