Skip to content

Commit e869847

Browse files
committed
Make resize vertical splitter keyboard accessible
1 parent 24032c7 commit e869847

File tree

1 file changed

+81
-83
lines changed

1 file changed

+81
-83
lines changed

src/PageLayout/PageLayout.tsx

Lines changed: 81 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, {useRef} from 'react'
22
import {createGlobalStyle} from 'styled-components'
33
import Box from '../Box'
44
import {useId} from '../hooks/useId'
@@ -10,7 +10,6 @@ import {Theme} from '../ThemeProvider'
1010
import {canUseDOM} from '../utils/environment'
1111
import {useOverflow} from '../internal/hooks/useOverflow'
1212
import {warning} from '../utils/warning'
13-
import VisuallyHidden from '../_VisuallyHidden'
1413
import {useStickyPaneHeight} from './useStickyPaneHeight'
1514

1615
const REGION_ORDER = {
@@ -31,6 +30,7 @@ const PageLayoutContext = React.createContext<{
3130
padding: keyof typeof SPACING_MAP
3231
rowGap: keyof typeof SPACING_MAP
3332
columnGap: keyof typeof SPACING_MAP
33+
paneRef?: React.RefObject<HTMLDivElement>
3434
enableStickyPane?: (top: number | string) => void
3535
disableStickyPane?: () => void
3636
contentTopRef?: (node?: Element | null | undefined) => void
@@ -76,6 +76,8 @@ const Root: React.FC<React.PropsWithChildren<PageLayoutProps>> = ({
7676
const {rootRef, enableStickyPane, disableStickyPane, contentTopRef, contentBottomRef, stickyPaneHeight} =
7777
useStickyPaneHeight()
7878

79+
const paneRef = useRef<HTMLDivElement>(null)
80+
7981
const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer})
8082

8183
return (
@@ -88,6 +90,7 @@ const Root: React.FC<React.PropsWithChildren<PageLayoutProps>> = ({
8890
disableStickyPane,
8991
contentTopRef,
9092
contentBottomRef,
93+
paneRef,
9194
}}
9295
>
9396
<Box
@@ -197,7 +200,7 @@ const verticalDividerVariants = {
197200
type DraggableDividerProps = {
198201
draggable?: boolean
199202
onDragStart?: () => void
200-
onDrag?: (delta: number) => void
203+
onDrag?: (delta: number, isKeyboard: boolean) => void
201204
onDragEnd?: () => void
202205
onDoubleClick?: () => void
203206
}
@@ -224,11 +227,34 @@ const VerticalDivider: React.FC<React.PropsWithChildren<DividerProps & Draggable
224227
sx = {},
225228
}) => {
226229
const [isDragging, setIsDragging] = React.useState(false)
230+
const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false)
227231
const responsiveVariant = useResponsiveValue(variant, 'none')
228232

229233
const stableOnDrag = React.useRef(onDrag)
230234
const stableOnDragEnd = React.useRef(onDragEnd)
231235

236+
const {paneRef} = React.useContext(PageLayoutContext)
237+
238+
const [minWidth, setMinWidth] = React.useState(0)
239+
const [maxWidth, setMaxWidth] = React.useState(0)
240+
const [currentWidth, setCurrentWidth] = React.useState(0)
241+
242+
React.useEffect(() => {
243+
if (paneRef?.current !== null) {
244+
const paneStyles = getComputedStyle(paneRef?.current as Element)
245+
const maxPaneWidthDiffPixels = paneStyles.getPropertyValue('--pane-max-width-diff')
246+
const minWidthPixels = paneStyles.getPropertyValue('--pane-min-width')
247+
const paneWidth = paneRef?.current.getBoundingClientRect().width
248+
const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0])
249+
const minPaneWidth = Number(minWidthPixels.split('px')[0])
250+
const viewportWidth = window.innerWidth
251+
const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth
252+
setMinWidth(minPaneWidth)
253+
setMaxWidth(maxPaneWidth)
254+
setCurrentWidth(paneWidth || 0)
255+
}
256+
}, [paneRef, isKeyboardDrag, isDragging])
257+
232258
React.useEffect(() => {
233259
stableOnDrag.current = onDrag
234260
}, [onDrag])
@@ -239,7 +265,7 @@ const VerticalDivider: React.FC<React.PropsWithChildren<DividerProps & Draggable
239265

240266
React.useEffect(() => {
241267
function handleDrag(event: MouseEvent) {
242-
stableOnDrag.current?.(event.movementX)
268+
stableOnDrag.current?.(event.movementX, false)
243269
event.preventDefault()
244270
}
245271

@@ -249,14 +275,38 @@ const VerticalDivider: React.FC<React.PropsWithChildren<DividerProps & Draggable
249275
event.preventDefault()
250276
}
251277

278+
function handleKeyDrag(event: KeyboardEvent) {
279+
let delta = 0
280+
// Hardcoded a delta for every key press to move the splitter. Should I perhaps expose this as a prop?
281+
if (event.key === 'ArrowLeft' && currentWidth > minWidth) {
282+
delta = -3
283+
} else if (event.key === 'ArrowRight' && currentWidth < maxWidth) {
284+
delta = 3
285+
} else {
286+
return
287+
}
288+
setCurrentWidth(currentWidth + delta)
289+
stableOnDrag.current?.(delta, true)
290+
event.preventDefault()
291+
}
292+
293+
function handleKeyDragEnd(event: KeyboardEvent) {
294+
setIsKeyboardDrag(false)
295+
stableOnDragEnd.current?.()
296+
event.preventDefault()
297+
}
252298
// TODO: Support touch events
253-
if (isDragging) {
299+
if (isDragging || isKeyboardDrag) {
254300
window.addEventListener('mousemove', handleDrag)
301+
window.addEventListener('keydown', handleKeyDrag)
255302
window.addEventListener('mouseup', handleDragEnd)
303+
window.addEventListener('keyup', handleKeyDragEnd)
256304
document.body.setAttribute('data-page-layout-dragging', 'true')
257305
} else {
258306
window.removeEventListener('mousemove', handleDrag)
259307
window.removeEventListener('mouseup', handleDragEnd)
308+
window.removeEventListener('keydown', handleKeyDrag)
309+
window.removeEventListener('keyup', handleKeyDragEnd)
260310
document.body.removeAttribute('data-page-layout-dragging')
261311
}
262312

@@ -265,7 +315,7 @@ const VerticalDivider: React.FC<React.PropsWithChildren<DividerProps & Draggable
265315
window.removeEventListener('mouseup', handleDragEnd)
266316
document.body.removeAttribute('data-page-layout-dragging')
267317
}
268-
}, [isDragging])
318+
}, [isDragging, isKeyboardDrag])
269319

270320
return (
271321
<Box
@@ -286,17 +336,30 @@ const VerticalDivider: React.FC<React.PropsWithChildren<DividerProps & Draggable
286336
position: 'absolute',
287337
inset: '0 -2px',
288338
cursor: 'col-resize',
289-
bg: isDragging ? 'accent.fg' : 'transparent',
339+
bg: isDragging || isKeyboardDrag ? 'accent.fg' : 'transparent',
290340
transitionDelay: '0.1s',
291341
'&:hover': {
292-
bg: isDragging ? 'accent.fg' : 'neutral.muted',
342+
bg: isDragging || isKeyboardDrag ? 'accent.fg' : 'neutral.muted',
293343
},
294344
}}
295345
role="separator"
346+
aria-label="Draggable splitter"
347+
aria-valuemin={minWidth}
348+
aria-valuemax={maxWidth}
349+
aria-orientation="vertical"
350+
aria-valuenow={currentWidth}
351+
aria-valuetext={`Pane width ${currentWidth} pixels`}
352+
tabIndex={0}
296353
onMouseDown={() => {
297354
setIsDragging(true)
298355
onDragStart?.()
299356
}}
357+
onKeyDown={event => {
358+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
359+
setIsKeyboardDrag(true)
360+
onDragStart?.()
361+
}
362+
}}
300363
onDoubleClick={onDoubleClick}
301364
/>
302365
<DraggingGlobalStyles />
@@ -593,6 +656,8 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
593656
const isHidden = useResponsiveValue(responsiveHidden, false)
594657

595658
const {rowGap, columnGap, enableStickyPane, disableStickyPane} = React.useContext(PageLayoutContext)
659+
let {paneRef} = React.useContext(PageLayoutContext)
660+
paneRef = paneRef || useRef<HTMLDivElement>(null)
596661

597662
React.useEffect(() => {
598663
if (sticky) {
@@ -637,55 +702,10 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
637702
}
638703
}
639704

640-
const paneRef = React.useRef<HTMLDivElement>(null)
641705
useRefObjectAsForwardedRef(forwardRef, paneRef)
642706

643-
const [minPercent, setMinPercent] = React.useState(0)
644-
const [maxPercent, setMaxPercent] = React.useState(0)
645707
const hasOverflow = useOverflow(paneRef)
646708

647-
const measuredRef = React.useCallback(() => {
648-
if (paneRef.current !== null) {
649-
const maxPaneWidthDiffPixels = getComputedStyle(paneRef.current as Element).getPropertyValue(
650-
'--pane-max-width-diff',
651-
)
652-
const paneWidth = paneRef.current.getBoundingClientRect().width
653-
const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0])
654-
const viewportWidth = window.innerWidth
655-
const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth
656-
657-
const minPercent = Math.round((100 * minWidth) / viewportWidth)
658-
setMinPercent(minPercent)
659-
660-
const maxPercent = Math.round((100 * maxPaneWidth) / viewportWidth)
661-
setMaxPercent(maxPercent)
662-
663-
const widthPercent = Math.round((100 * paneWidth) / viewportWidth)
664-
setWidthPercent(widthPercent.toString())
665-
}
666-
}, [paneRef, minWidth])
667-
668-
const [widthPercent, setWidthPercent] = React.useState('')
669-
const [prevPercent, setPrevPercent] = React.useState('')
670-
671-
const handleWidthFormSubmit = (event: React.FormEvent<HTMLElement>) => {
672-
event.preventDefault()
673-
let percent = Number(widthPercent)
674-
if (Number.isNaN(percent)) {
675-
percent = Number(prevPercent) || minPercent
676-
} else if (percent > maxPercent) {
677-
percent = maxPercent
678-
} else if (percent < minPercent) {
679-
percent = minPercent
680-
}
681-
682-
setWidthPercent(percent.toString())
683-
// Cache previous valid percent.
684-
setPrevPercent(percent.toString())
685-
686-
updatePaneWidth((percent / 100) * window.innerWidth)
687-
}
688-
689709
const paneId = useId(id)
690710

691711
const labelProp: {'aria-labelledby'?: string; 'aria-label'?: string} = {}
@@ -706,7 +726,6 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
706726

707727
return (
708728
<Box
709-
ref={measuredRef}
710729
// eslint-disable-next-line @typescript-eslint/no-explicit-any
711730
sx={(theme: any) =>
712731
merge<BetterSystemStyleObject>(
@@ -756,14 +775,19 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
756775
// If pane is resizable, the divider should be draggable
757776
draggable={resizable}
758777
sx={{[position === 'end' ? 'marginRight' : 'marginLeft']: SPACING_MAP[columnGap]}}
759-
onDrag={delta => {
778+
onDrag={(delta, isKeyboard = false) => {
760779
// Get the number of pixels the divider was dragged
761-
const deltaWithDirection = position === 'end' ? -delta : delta
780+
let deltaWithDirection
781+
if (isKeyboard) {
782+
deltaWithDirection = delta
783+
} else {
784+
deltaWithDirection = position === 'end' ? -delta : delta
785+
}
762786
updatePaneWidth(paneWidth + deltaWithDirection)
763787
}}
764788
// Ensure `paneWidth` state and actual pane width are in sync when the drag ends
765789
onDragEnd={() => {
766-
const paneRect = paneRef.current?.getBoundingClientRect()
790+
const paneRect = paneRef?.current?.getBoundingClientRect()
767791
if (!paneRect) return
768792
updatePaneWidth(paneRect.width)
769793
}}
@@ -797,32 +821,6 @@ const Pane = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageLayout
797821
{...labelProp}
798822
{...(id && {id: paneId})}
799823
>
800-
{resizable && (
801-
// eslint-disable-next-line github/a11y-no-visually-hidden-interactive-element
802-
<VisuallyHidden>
803-
<form onSubmit={handleWidthFormSubmit}>
804-
<label htmlFor={`${paneId}-width-input`}>Pane width</label>
805-
<p id={`${paneId}-input-hint`}>
806-
Use a value between {minPercent}% and {maxPercent}%
807-
</p>
808-
<input
809-
id={`${paneId}-width-input`}
810-
aria-describedby={`${paneId}-input-hint`}
811-
name="pane-width"
812-
inputMode="numeric"
813-
pattern="[0-9]*"
814-
value={widthPercent}
815-
autoCorrect="off"
816-
autoComplete="off"
817-
type="text"
818-
onChange={event => {
819-
setWidthPercent(event.target.value)
820-
}}
821-
/>
822-
<button type="submit">Change width</button>
823-
</form>
824-
</VisuallyHidden>
825-
)}
826824
{children}
827825
</Box>
828826
</Box>

0 commit comments

Comments
 (0)