diff --git a/.changeset/proud-dingos-occur.md b/.changeset/proud-dingos-occur.md new file mode 100644 index 00000000000..a9c0a86f44e --- /dev/null +++ b/.changeset/proud-dingos-occur.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Make resize vertical splitter keyboard accessible diff --git a/src/PageLayout/PageLayout.test.tsx b/src/PageLayout/PageLayout.test.tsx index ab96d4beaed..243ba9eef93 100644 --- a/src/PageLayout/PageLayout.test.tsx +++ b/src/PageLayout/PageLayout.test.tsx @@ -191,7 +191,7 @@ describe('PageLayout', () => { const pane = placeholder.parentNode const initialWidth = (pane as HTMLElement).style.getPropertyValue('--pane-width') - const divider = await screen.findByRole('separator') + const divider = await screen.findByRole('slider') // Moving divider should resize pane. fireEvent.mouseDown(divider) fireEvent.mouseMove(divider) diff --git a/src/PageLayout/PageLayout.tsx b/src/PageLayout/PageLayout.tsx index e516be2189f..a2321b06b40 100644 --- a/src/PageLayout/PageLayout.tsx +++ b/src/PageLayout/PageLayout.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useRef} from 'react' import {createGlobalStyle} from 'styled-components' import Box from '../Box' import {useId} from '../hooks/useId' @@ -10,7 +10,6 @@ import {Theme} from '../ThemeProvider' import {canUseDOM} from '../utils/environment' import {useOverflow} from '../internal/hooks/useOverflow' import {warning} from '../utils/warning' -import VisuallyHidden from '../_VisuallyHidden' import {useStickyPaneHeight} from './useStickyPaneHeight' const REGION_ORDER = { @@ -31,6 +30,7 @@ const PageLayoutContext = React.createContext<{ padding: keyof typeof SPACING_MAP rowGap: keyof typeof SPACING_MAP columnGap: keyof typeof SPACING_MAP + paneRef: React.RefObject enableStickyPane?: (top: number | string) => void disableStickyPane?: () => void contentTopRef?: (node?: Element | null | undefined) => void @@ -39,6 +39,7 @@ const PageLayoutContext = React.createContext<{ padding: 'normal', rowGap: 'normal', columnGap: 'normal', + paneRef: {current: null}, }) // ---------------------------------------------------------------------------- @@ -76,6 +77,8 @@ const Root: React.FC> = ({ const {rootRef, enableStickyPane, disableStickyPane, contentTopRef, contentBottomRef, stickyPaneHeight} = useStickyPaneHeight() + const paneRef = useRef(null) + const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer}) return ( @@ -88,6 +91,7 @@ const Root: React.FC> = ({ disableStickyPane, contentTopRef, contentBottomRef, + paneRef, }} > void - onDrag?: (delta: number) => void + onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void onDoubleClick?: () => void } @@ -224,11 +228,34 @@ const VerticalDivider: React.FC { const [isDragging, setIsDragging] = React.useState(false) + const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) const responsiveVariant = useResponsiveValue(variant, 'none') const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) + const {paneRef} = React.useContext(PageLayoutContext) + + const [minWidth, setMinWidth] = React.useState(0) + const [maxWidth, setMaxWidth] = React.useState(0) + const [currentWidth, setCurrentWidth] = React.useState(0) + + React.useEffect(() => { + if (paneRef.current !== null) { + const paneStyles = getComputedStyle(paneRef.current as Element) + const maxPaneWidthDiffPixels = paneStyles.getPropertyValue('--pane-max-width-diff') + const minWidthPixels = paneStyles.getPropertyValue('--pane-min-width') + const paneWidth = paneRef.current.getBoundingClientRect().width + const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0]) + const minPaneWidth = Number(minWidthPixels.split('px')[0]) + const viewportWidth = window.innerWidth + const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth + setMinWidth(minPaneWidth) + setMaxWidth(maxPaneWidth) + setCurrentWidth(paneWidth || 0) + } + }, [paneRef, isKeyboardDrag, isDragging]) + React.useEffect(() => { stableOnDrag.current = onDrag }, [onDrag]) @@ -239,7 +266,7 @@ const VerticalDivider: React.FC { function handleDrag(event: MouseEvent) { - stableOnDrag.current?.(event.movementX) + stableOnDrag.current?.(event.movementX, false) event.preventDefault() } @@ -249,23 +276,49 @@ const VerticalDivider: React.FC minWidth) { + delta = -3 + } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) { + delta = 3 + } else { + return + } + setCurrentWidth(currentWidth + delta) + stableOnDrag.current?.(delta, true) + event.preventDefault() + } + + function handleKeyDragEnd(event: KeyboardEvent) { + setIsKeyboardDrag(false) + stableOnDragEnd.current?.() + event.preventDefault() + } // TODO: Support touch events - if (isDragging) { + if (isDragging || isKeyboardDrag) { window.addEventListener('mousemove', handleDrag) + window.addEventListener('keydown', handleKeyDrag) window.addEventListener('mouseup', handleDragEnd) + window.addEventListener('keyup', handleKeyDragEnd) document.body.setAttribute('data-page-layout-dragging', 'true') } else { window.removeEventListener('mousemove', handleDrag) window.removeEventListener('mouseup', handleDragEnd) + window.removeEventListener('keydown', handleKeyDrag) + window.removeEventListener('keyup', handleKeyDragEnd) document.body.removeAttribute('data-page-layout-dragging') } return () => { window.removeEventListener('mousemove', handleDrag) window.removeEventListener('mouseup', handleDragEnd) + window.removeEventListener('keydown', handleKeyDrag) + window.removeEventListener('keyup', handleKeyDragEnd) document.body.removeAttribute('data-page-layout-dragging') } - }, [isDragging]) + }, [isDragging, isKeyboardDrag, currentWidth, minWidth, maxWidth]) return ( { setIsDragging(true) onDragStart?.() }} + onKeyDown={event => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown' + ) { + setIsKeyboardDrag(true) + onDragStart?.() + } + }} onDoubleClick={onDoubleClick} /> @@ -592,7 +662,7 @@ const Pane = React.forwardRef { if (sticky) { @@ -637,55 +707,10 @@ const Pane = React.forwardRef(null) useRefObjectAsForwardedRef(forwardRef, paneRef) - const [minPercent, setMinPercent] = React.useState(0) - const [maxPercent, setMaxPercent] = React.useState(0) const hasOverflow = useOverflow(paneRef) - const measuredRef = React.useCallback(() => { - if (paneRef.current !== null) { - const maxPaneWidthDiffPixels = getComputedStyle(paneRef.current as Element).getPropertyValue( - '--pane-max-width-diff', - ) - const paneWidth = paneRef.current.getBoundingClientRect().width - const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0]) - const viewportWidth = window.innerWidth - const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth - - const minPercent = Math.round((100 * minWidth) / viewportWidth) - setMinPercent(minPercent) - - const maxPercent = Math.round((100 * maxPaneWidth) / viewportWidth) - setMaxPercent(maxPercent) - - const widthPercent = Math.round((100 * paneWidth) / viewportWidth) - setWidthPercent(widthPercent.toString()) - } - }, [paneRef, minWidth]) - - const [widthPercent, setWidthPercent] = React.useState('') - const [prevPercent, setPrevPercent] = React.useState('') - - const handleWidthFormSubmit = (event: React.FormEvent) => { - event.preventDefault() - let percent = Number(widthPercent) - if (Number.isNaN(percent)) { - percent = Number(prevPercent) || minPercent - } else if (percent > maxPercent) { - percent = maxPercent - } else if (percent < minPercent) { - percent = minPercent - } - - setWidthPercent(percent.toString()) - // Cache previous valid percent. - setPrevPercent(percent.toString()) - - updatePaneWidth((percent / 100) * window.innerWidth) - } - const paneId = useId(id) const labelProp: {'aria-labelledby'?: string; 'aria-label'?: string} = {} @@ -706,7 +731,6 @@ const Pane = React.forwardRef merge( @@ -756,9 +780,14 @@ const Pane = React.forwardRef { + onDrag={(delta, isKeyboard = false) => { // Get the number of pixels the divider was dragged - const deltaWithDirection = position === 'end' ? -delta : delta + let deltaWithDirection + if (isKeyboard) { + deltaWithDirection = delta + } else { + deltaWithDirection = position === 'end' ? -delta : delta + } updatePaneWidth(paneWidth + deltaWithDirection) }} // Ensure `paneWidth` state and actual pane width are in sync when the drag ends @@ -798,32 +827,6 @@ const Pane = React.forwardRef {children} - {resizable && ( - // eslint-disable-next-line github/a11y-no-visually-hidden-interactive-element - -
- -

- Use a value between {minPercent}% and {maxPercent}% -

- { - setWidthPercent(event.target.value) - }} - /> - -
-
- )}
)