Skip to content

Commit cbf5dcc

Browse files
Reader discover tags - update scrollable horizontal nav component's button handling (#112438)
* update scrollable horizontal nav component button handling * update styles * fix styles on plugins page implementation * fix RTL handling * fix comment, keep threshold * udpate confusing test name
1 parent 0f34a0a commit cbf5dcc

4 files changed

Lines changed: 363 additions & 57 deletions

File tree

client/components/scrollable-horizontal-navigation/index.tsx

Lines changed: 77 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { Button, Gridicon, SegmentedControl } from '@automattic/components';
22
import { throttle } from '@wordpress/compose';
33
import 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

67
import './styles.scss';
78

89
const 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

1211
type 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" />

client/components/scrollable-horizontal-navigation/styles.scss

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
position: relative;
33
margin: auto;
44
overflow: hidden;
5+
padding-bottom: 15px;
6+
border-bottom: 1px solid var(--color-neutral-5);
7+
margin-bottom: 20px;
58

69
@media only screen and (max-width: 600px) {
7-
padding: 0 24px;
10+
padding: 0 24px 15px;
811
}
912

1013
@media only screen and (max-width: 660px) {
@@ -15,18 +18,21 @@
1518
.scrollable-horizontal-navigation__right-button-wrapper {
1619
position: absolute;
1720
z-index: 1;
21+
display: flex;
22+
align-items: center;
1823
pointer-events: none;
19-
height: 100%;
20-
margin-top: 5px;
24+
top: 5px;
2125

2226
&.display-none {
2327
display: none;
2428
}
2529

2630
.scrollable-horizontal-navigation__left-button,
2731
.scrollable-horizontal-navigation__right-button {
32+
background: var(--color-surface);
33+
border: 1px solid var(--color-neutral-10);
2834
border-radius: 50%;
29-
padding: 6px 10px;
35+
padding: 7px;
3036
pointer-events: auto;
3137
width: 40px;
3238
height: 40px;
@@ -38,22 +44,20 @@
3844
.scrollable-horizontal-navigation__right-button-wrapper {
3945
right: 0;
4046
padding-left: 30px;
41-
background: linear-gradient(270deg, color-mix(in srgb, var(--color-surface) 100%, transparent) 30%, color-mix(in srgb, var(--color-surface) 70%, transparent) 40%, color-mix(in srgb, var(--color-surface) 0%, transparent) 100%);
47+
background: linear-gradient(270deg, var(--color-surface) 30%, transparent 100%);
4248
}
49+
4350
.scrollable-horizontal-navigation__left-button-wrapper {
4451
left: 0;
4552
padding-right: 30px;
46-
background: linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 100%, transparent) 30%, color-mix(in srgb, var(--color-surface) 70%, transparent) 40%, color-mix(in srgb, var(--color-surface) 0%, transparent) 100%);
53+
background: linear-gradient(90deg, var(--color-surface) 30%, transparent 100%);
4754
}
4855

4956
.scrollable-horizontal-navigation__tabs {
5057
position: relative;
5158
overflow-x: scroll;
5259
left: -5px;
53-
margin-bottom: 20px;
5460
padding-top: 5px;
55-
padding-bottom: 15px;
56-
border-bottom: 1px solid var(--color-neutral-5);
5761
-ms-overflow-style: none; /* Internet Explorer 10+ */
5862
scrollbar-width: none; /* Firefox */
5963

@@ -76,6 +80,12 @@
7680
background: var(--color-surface);
7781
}
7882
}
83+
84+
&.is-selected {
85+
.segmented-control__link {
86+
border-color: var(--color-primary);
87+
}
88+
}
7989
}
8090
}
8191
}

0 commit comments

Comments
 (0)