11import { Button , Gridicon , SegmentedControl } from '@automattic/components' ;
22import { throttle } from '@wordpress/compose' ;
33import clsx from 'clsx' ;
4- import { useEffect , useRef } from 'react' ;
4+ import { useRtl } from 'i18n-calypso' ;
5+ import { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
56
67import './styles.scss' ;
78
89const SHOW_SCROLL_THRESHOLD = 10 ;
9- const showElement = ( element : Element | null ) => element ?. classList . remove ( 'display-none' ) ;
10- const hideElement = ( element : Element | null ) => element ?. classList . add ( 'display-none' ) ;
1110
1211type BaseTab = {
1312 slug : string ;
@@ -32,68 +31,96 @@ const ScrollableHorizontalNavigation = < T extends object >( {
3231 titleField = 'title' ,
3332} : Props < T > ) => {
3433 const scrollRef = useRef < HTMLDivElement > ( null ) ;
34+ const isRtl = useRtl ( ) ;
35+ const [ showLeftArrow , setShowLeftArrow ] = useState ( false ) ;
36+ const [ showRightArrow , setShowRightArrow ] = useState ( false ) ;
37+
38+ // Arrow visibility is tied to physical button position, not reading direction:
39+ // the left button shows once scrolled past the physical left edge, the right
40+ // button while content remains past the physical right edge. `Math.abs`
41+ // normalizes `scrollLeft`, which is negative in RTL containers. A small
42+ // threshold hides arrows when nearly at an edge. Reading direction only
43+ // affects scroll direction on click (handled below); chevrons stay physical.
44+ const updateScrollButtonVisibility = useCallback ( ( ) => {
45+ const container = scrollRef . current ;
46+
47+ if ( ! container ) {
48+ setShowLeftArrow ( false ) ;
49+ setShowRightArrow ( false ) ;
50+ return ;
51+ }
52+
53+ const { scrollLeft, scrollWidth, clientWidth } = container ;
54+ const scrollLeftAbs = Math . abs ( Math . floor ( scrollLeft ) ) ;
55+ const maxScrollLeft = scrollWidth - clientWidth ;
56+
57+ setShowLeftArrow ( scrollLeftAbs > SHOW_SCROLL_THRESHOLD ) ;
58+ setShowRightArrow ( scrollLeftAbs + SHOW_SCROLL_THRESHOLD < maxScrollLeft ) ;
59+ } , [ ] ) ;
60+
61+ useLayoutEffect ( ( ) => {
62+ updateScrollButtonVisibility ( ) ;
63+ } , [ tabs , selectedTab , updateScrollButtonVisibility ] ) ;
64+
65+ useEffect ( ( ) => {
66+ const container = scrollRef . current ;
67+
68+ if ( ! container ) {
69+ return ;
70+ }
71+
72+ const observer = new ResizeObserver ( updateScrollButtonVisibility ) ;
73+ observer . observe ( container ) ;
74+ updateScrollButtonVisibility ( ) ;
75+
76+ return ( ) => observer . disconnect ( ) ;
77+ } , [ tabs , updateScrollButtonVisibility ] ) ;
3578
36- // Scroll the selected tab into view on initial render and whenever it changes.
3779 useEffect ( ( ) => {
3880 const selectedTabElement = scrollRef . current ?. querySelector ( '.is-selected' ) ;
3981 selectedTabElement ?. scrollIntoView ( {
4082 behavior : 'smooth' ,
4183 block : 'nearest' ,
4284 inline : 'center' ,
4385 } ) ;
44- } , [ selectedTab ] ) ;
45-
46- const bumpScrollX = ( shouldScrollLeft = false ) => {
47- if ( scrollRef . current ) {
48- const directionMultiplier = shouldScrollLeft ? - 1 : 1 ;
49- const finalPositionX =
50- scrollRef . current . scrollLeft +
51- // 2/3 reflects the fraction of visible width that will scroll.
52- directionMultiplier * scrollRef . current . clientWidth * ( 2 / 3 ) ;
53- scrollRef . current . scrollTo ( { top : 0 , left : finalPositionX , behavior : 'smooth' } ) ;
54- }
55- } ;
56-
57- const shouldHideLeftScrollButton = ( ) =>
58- scrollRef . current && scrollRef . current . scrollLeft < SHOW_SCROLL_THRESHOLD ;
59- const shouldHideRightScrollButton = ( ) =>
60- scrollRef . current &&
61- scrollRef . current . scrollLeft >
62- scrollRef . current . scrollWidth - scrollRef . current . clientWidth - SHOW_SCROLL_THRESHOLD ;
63-
64- // To keep track of the navigation tabs scroll position and keep it from appearing to reset
65- // after child render.
66- const handleScroll = throttle ( ( ) => {
67- // Determine and set visibility classes on scroll button wrappers.
68- const leftScrollButton = document . querySelector (
69- '.scrollable-horizontal-navigation__left-button-wrapper'
70- ) ;
71- const rightScrollButton = document . querySelector (
72- '.scrollable-horizontal-navigation__right-button-wrapper'
73- ) ;
74- if ( shouldHideLeftScrollButton ( ) ) {
75- hideElement ( leftScrollButton ) ;
76- } else {
77- showElement ( leftScrollButton ) ;
78- }
79- if ( shouldHideRightScrollButton ( ) ) {
80- hideElement ( rightScrollButton ) ;
81- } else {
82- showElement ( rightScrollButton ) ;
86+
87+ const rafId = requestAnimationFrame ( updateScrollButtonVisibility ) ;
88+
89+ return ( ) => cancelAnimationFrame ( rafId ) ;
90+ } , [ selectedTab , updateScrollButtonVisibility ] ) ;
91+
92+ const handleScroll = useMemo (
93+ ( ) => throttle ( updateScrollButtonVisibility , 50 ) ,
94+ [ updateScrollButtonVisibility ]
95+ ) ;
96+
97+ // `scrollBy` moves the viewport physically: a negative delta scrolls toward
98+ // the physical left, a positive delta toward the physical right, in both LTR
99+ // and RTL.
100+ const scrollByDirection = useCallback ( ( scrollLeftwards : boolean ) => {
101+ if ( ! scrollRef . current ) {
102+ return ;
83103 }
84- } , 50 ) ;
104+
105+ const scrollAmount = scrollRef . current . clientWidth * ( 2 / 3 ) ;
106+ const left = scrollLeftwards ? - scrollAmount : scrollAmount ;
107+
108+ scrollRef . current . scrollBy ( { left, behavior : 'smooth' } ) ;
109+ } , [ ] ) ;
85110
86111 return (
87112 < div className = { clsx ( 'scrollable-horizontal-navigation' , className ) } >
88113 < div
89114 className = { clsx ( 'scrollable-horizontal-navigation__left-button-wrapper' , {
90- 'display-none' : shouldHideLeftScrollButton ( ) ,
115+ 'display-none' : ! showLeftArrow ,
91116 } ) }
92117 aria-hidden
93118 >
94119 < Button
95120 className = "scrollable-horizontal-navigation__left-button"
96- onClick = { ( ) => bumpScrollX ( true ) }
121+ // The physical-left button scrolls toward the start in LTR (physically
122+ // left) and toward the start in RTL (physically right).
123+ onClick = { ( ) => scrollByDirection ( ! isRtl ) }
97124 tabIndex = { - 1 }
98125 >
99126 < Gridicon icon = "chevron-left" />
@@ -102,13 +129,15 @@ const ScrollableHorizontalNavigation = < T extends object >( {
102129
103130 < div
104131 className = { clsx ( 'scrollable-horizontal-navigation__right-button-wrapper' , {
105- 'display-none' : shouldHideRightScrollButton ( ) ,
132+ 'display-none' : ! showRightArrow ,
106133 } ) }
107134 aria-hidden
108135 >
109136 < Button
110137 className = "scrollable-horizontal-navigation__right-button"
111- onClick = { ( ) => bumpScrollX ( ) }
138+ // The physical-right button scrolls toward the end in LTR (physically
139+ // right) and toward the end in RTL (physically left).
140+ onClick = { ( ) => scrollByDirection ( isRtl ) }
112141 tabIndex = { - 1 }
113142 >
114143 < Gridicon icon = "chevron-right" />
0 commit comments