Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): add ScrollableRegion and useOverflow #4719

Merged
merged 5 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading