Skip to content

Commit

Permalink
feat(Scrollbar): add support for programmable scrolling with `scrollT…
Browse files Browse the repository at this point in the history
…op`, `scrollLeft`, and `scrollViewRef` props (#809)

* feat(Scrollbar): enable scrolling to specific positions using `scrollTop` or `scrollLeft` props

* feat(Scrollbar): add `scrollViewRef` prop

* feat: improve test coverage for the Scrollbar componennt

* docs: update docs
  • Loading branch information
cheton authored Nov 16, 2023
1 parent 29e733a commit e7c5b2d
Show file tree
Hide file tree
Showing 16 changed files with 334 additions and 33 deletions.
2 changes: 1 addition & 1 deletion packages/react-docs/pages/components/checkbox.page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ function Example() {
| id | string | | The `id` attribute of the input field. |
| indeterminate | boolean | | If `true`, the checkbox will be displayed in an indeterminate state. This only affects the icon shown inside the checkbox.
| inputProps | object | | Additional props to be applied to the input element. |
| inputRef | RefObject | | A ref object to access the input element. |
| inputRef | RefObject | | A `ref` to access the input element. |
| name | string | | The name of the input field in the checkbox. The name is useful for form submissions. |
| onBlur | function | | A callback function invoked when the checkbox loses focus. |
| onChange | function | | A callback function invoked when the state of the checkbox changes. |
Expand Down
8 changes: 5 additions & 3 deletions packages/react-docs/pages/components/icon/index.page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import { Icon } from '@tonic-ui/react';

## Usage

### Basic

Use an icon by passing the `icon` prop. This `icon` property value must match an icon key defined in `theme.icons`. By default, the icon inherits the font size and color of its parent.

{render('./usage')}
{render('./basic')}

### Animating icons

Expand All @@ -31,7 +33,7 @@ The `animation` prop can be used to override default animation, it is a shorthan
[animationFillMode](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-fill-mode)<br/>
[animationPlayState](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-play-state)<br/>

## Adding custom icons
### Adding custom icons

First, you have to add custom icons to the theme. Each icon must be an object containing `path` and optional style props passed to [SVGIcon](svgicon).

Expand Down Expand Up @@ -77,7 +79,7 @@ Pass the icon name as a prop to the `<Icon>` component to render the SVG icon:
<Icon icon="icon1" color="blue:50" />
```

### Search icons
## Search icons

{render('./search-icons')}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ function Example() {
| endAdornment | ReactNode | | End `InputAdornment` for this component. |
| inputComponent | ElementType | InputBase | The input component to render. |
| inputProps | object | | Additional props to be applied to the input element. |
| inputRef | RefObject | | A ref object to access the input element. |
| inputRef | RefObject | | A `ref` to access the input element. |
| size | string | 'md' | The visual size of the `input` element. One of: 'sm', 'md', 'lg' |
| variant | string | 'outline' | The variant of the input style to use. One of: 'outline', 'filled', 'flush', 'unstyled' |
| startAdornment | ReactNode | | Start `InputAdornment` for this component. |
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ The `Popover` component includes several accessibility features to ensure that i
| disabled | boolean | | If `true`, the popover will not display. |
| enterDelay | number | 100 | The number of milliseconds to wait before showing the popover if `trigger` is hover. |
| followCursor | boolean | | If `true`, the popover will follow the cursor. |
| initialFocusRef | RefObject | | The reference of the element that will be focused when the popover opens. |
| initialFocusRef | RefObject | | The `ref` of the element that will be focused when the popover opens. |
| isOpen | boolean | | If `true`, the popover will be open. |
| leaveDelay | number | 0 | The number of milliseconds to wait before hiding the popover if `trigger` is hover. |
| nextToCursor | boolean | | If `true`, the popover will be positioned next to the cursor. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The `add` method creates a new portal and returns a unique id for the portal.
<dd>`[options={}]` *(Object)*: An optional object that provides additional options for the portal.</dd>
<dd>`[options.id]` *(string)*: A unique identifier for the portal. If not provided, a unique ID will be generated.</dd>
<dd>`[options.appendToParentPortal=false]` *(boolean)*: If `true`, the portal will be appended to the parent portal. If `false`, the portal will be appended to the body. Defaults to `false`.</dd>
<dd>`[options.containerRef=null]` *(React.RefObject)*: A reference to the container element. If provided, the portal will be appended to the container element. Otherwise, the portal will be appended to the body. Defaults to `null`.</dd>
<dd>`[options.containerRef=null]` *(React.RefObject)*: A `ref` to the container element. If provided, the portal will be appended to the container element. Otherwise, the portal will be appended to the body. Defaults to `null`.</dd>
</dl>

#### Returns
Expand Down
2 changes: 1 addition & 1 deletion packages/react-docs/pages/components/portal/index.page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ It is also possible to nest portals inside a parent portal. To do this, pass `ap
| :--- | :--- | :------ | :---------- |
| appendToParentPortal | boolean | false | If `true`, the portal will check if it is within a parent portal and append itself to the parent's portal node. |
| children | ReactNode | | |
| containerRef | RefObject | | The `ref` to the component where the portal will be rendered. |
| containerRef | RefObject | | A `ref` to the container where the portal will be rendered. |
2 changes: 1 addition & 1 deletion packages/react-docs/pages/components/radio.page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ function Example() {
| disabled | boolean | | If `true`, disables the radio button and prevents user interaction. |
| id | string | | The `id` attribute of the input field. |
| inputProps | object | | Additional props to be applied to the input element. |
| inputRef | RefObject | | A ref object to access the input element. |
| inputRef | RefObject | | A `ref` to access the input element. |
| name | string | | The name of the input field in a series of radio buttons. The name is useful for form submissions. |
| onBlur | function | | A callback function invoked when the radio button loses focus. |
| onChange | function | | A callback function invoked when the state of the radio button changes. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Button, Divider, Scrollbar } from "@tonic-ui/react";
import { useToggle } from "@tonic-ui/react-hooks";
import React, { useRef } from "react";
import Lorem from "@/components/Lorem";

const App = () => {
const scrollTopRef = useRef();
const [on, toggle] = useToggle(true);

return (
<>
<Button onClick={toggle}>
Toggle Visibility
</Button>
<Divider my="4x" />
{on && (
<Scrollbar
height={200}
onUpdate={({ scrollTop }) => {
scrollTopRef.current = scrollTop;
}}
overflow="visible"
scrollTop={scrollTopRef.current}
>
<Lorem count={10} />
</Scrollbar>
)}
</>
);
};

export default App;
19 changes: 16 additions & 3 deletions packages/react-docs/pages/components/scrollbar/index.page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {

The `Scrollbar` is hidden by default, and it only appears when the user hovers over the scrollable content. You can set the height of the scrollbar using the `height` prop.

{render('./usage')}
{render('./basic')}

For more information on bidirectional scrolling, please refer to [Bidirectional scrolling: what's not to like?](https://adamsilver.io/blog/bidirectional-scrolling-whats-not-to-like/)

Expand All @@ -28,13 +28,13 @@ To enable vertical scrolling, set the scrollbar height to a value less than the

{render('./vertical-scrolling')}

### Horizontal scrolling
#### Horizontal scrolling

To enable horizontal scrolling, set the scrollbar width to a value less than the scrollable content width.

{render('./horizontal-scrolling')}

### Bidirectional scrolling
#### Bidirectional scrolling

{render('./bidirectional-scrolling')}

Expand Down Expand Up @@ -70,6 +70,16 @@ The scroll indicator can visually indicate the current scroll position of the sc

{render('./menu')}

## Commonly Asked Questions

### How to control the scroll position?

Utilize the `onUpdate`, `scrollLeft`, `scrollTop`, and `scrollViewRef` props to fetch or programmatically control the scroll position.

To see this in action, simply scroll through the content and click the toggle button to confirm the scroll position.

{render('./faq-scroll-position')}

## Props

### Scrollbar
Expand All @@ -90,3 +100,6 @@ The scroll indicator can visually indicate the current scroll position of the sc
| overflow | string | 'auto' | The overflow of the scrollable content. One of: 'auto', 'scroll', 'hidden'. |
| overflowX | string | | The horizontal overflow of the scrollable content. One of: 'auto', 'scroll', 'hidden'. |
| overflowY | string | | The vertical overflow of the scrollable content. One of: 'auto', 'scroll', 'hidden'. |
| scrollLeft | number | 0 | The horizontal scroll position of the scrollable content. |
| scrollTop | number | 0 | The vertical scroll position of the scrollable content. |
| scrollViewRef | RefObject | | A `ref` to the `ScrollView` component. |
2 changes: 1 addition & 1 deletion packages/react-docs/pages/components/switch/index.page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Here's an example of how you can utilize the `inputRef` prop to access the input
| disabled | boolean | | If `true`, disables the switch and prevents user interaction. |
| id | string | | The `id` attribute of the input field. |
| inputProps | object | | Additional props to be applied to the input element. |
| inputRef | RefObject | | A ref object to access the input element. |
| inputRef | RefObject | | A `ref` to access the input element. |
| name | string | | The name of the switch input when used within a form. |
| onBlur | function | | A callback function invoked when the switch loses focus. |
| onChange | function | | A callback function invoked when the state of the switch changes. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,5 @@ See the [useToastManager](./toast-manager/useToastManager) Hook for detailed usa
| Name | Type | Default | Description |
| :--- | :--- | :------ | :---------- |
| children | ReactNode \| `(context) => ReactNode` | | A function child can be used intead of a React element. This function is called with the context object. |
| containerRef | RefObject | | The `ref` to the component where the toasts will be rendered. |
| containerRef | RefObject | | A `ref` to the component where the toasts will be rendered. |
| placement | string | 'bottom-right' | The default placement to place toasts. One of: 'top', 'top-right', 'top-left', 'bottom', 'bottom-left', 'bottom-right' |
48 changes: 29 additions & 19 deletions packages/react/src/scrollbar/Scrollbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const Scrollbar = forwardRef((
overflow = 'auto',
overflowX,
overflowY,
scrollLeft: scrollLeftProp,
scrollTop: scrollTopProp,
scrollViewRef: scrollViewRefProp,
...rest
},
ref,
Expand Down Expand Up @@ -149,13 +152,11 @@ const Scrollbar = forwardRef((
}

const isHydrated = useHydrated();
const nodeRef = useRef(null);
const combinedRef = useMergeRefs(nodeRef, ref);

const viewScrollLeftRef = useRef(0);
const viewScrollTopRef = useRef(0);
const lastViewScrollLeftRef = useRef(0);
const lastViewScrollTopRef = useRef(0);
const currentScrollLeftRef = useRef(0);
const currentScrollTopRef = useRef(0);
const lastScrollLeftRef = useRef(0);
const lastScrollTopRef = useRef(0);

// For binding the `mousemove` and `mouseup` events to document, we use `useState` to store `startDragging` variable to trigger `useEffect`.
const [startDragging, setStartDragging] = useState(false);
Expand All @@ -168,11 +169,26 @@ const Scrollbar = forwardRef((
const prevPageXRef = useRef(0);
const prevPageYRef = useRef(0);

const nodeRef = useRef(null);
const scrollViewRef = useRef(null);
const horizontalTrackRef = useRef(null);
const verticalTrackRef = useRef(null);
const horizontalThumbRef = useRef(null);
const verticalThumbRef = useRef(null);
const combinedRef = useMergeRefs(nodeRef, ref);
const combinedScrollViewRef = useMergeRefs(scrollViewRef, scrollViewRefProp);

useEffect(() => {
if (scrollViewRef.current && scrollLeftProp !== undefined) {
scrollViewRef.current.scrollLeft = scrollLeftProp;
}
}, [scrollLeftProp]);

useEffect(() => {
if (scrollViewRef.current && scrollTopProp !== undefined) {
scrollViewRef.current.scrollTop = scrollTopProp;
}
}, [scrollTopProp]);

const getValues = () => {
const {
Expand Down Expand Up @@ -376,8 +392,8 @@ const Scrollbar = forwardRef((

const updateCallback = (values) => {
const { scrollLeft, scrollTop } = values;
viewScrollLeftRef.current = scrollLeft;
viewScrollTopRef.current = scrollTop;
currentScrollLeftRef.current = scrollLeft;
currentScrollTopRef.current = scrollTop;
};
update(updateCallback);

Expand All @@ -389,24 +405,18 @@ const Scrollbar = forwardRef((
// Start scrolling
isScrollingRef.current = true;

// Show tracks
showTracks();

const detectScrollingInterval = setInterval(() => {
if (lastViewScrollLeftRef.current === viewScrollLeftRef.current && lastViewScrollTopRef.current === viewScrollTopRef.current) {
if (lastScrollLeftRef.current === currentScrollLeftRef.current && lastScrollTopRef.current === currentScrollTopRef.current) {
clearInterval(detectScrollingInterval);

// Stop scrolling
isScrollingRef.current = false;

// Hide tracks
hideTracks();
}

lastViewScrollLeftRef.current = viewScrollLeftRef.current;
lastViewScrollTopRef.current = viewScrollTopRef.current;
lastScrollLeftRef.current = currentScrollLeftRef.current;
lastScrollTopRef.current = currentScrollTopRef.current;
}, 100);
}, [onScroll, update, showTracks, hideTracks]);
}, [onScroll, update]);
/* End Scrolling Events */

/* Start Dragging Events */
Expand Down Expand Up @@ -625,7 +635,7 @@ const Scrollbar = forwardRef((
const getScrollViewProps = () => {
return {
...scrollViewStyle,
ref: scrollViewRef,
ref: combinedScrollViewRef,
onScroll: handleScrollViewScroll,
onMouseEnter: handleScrollViewMouseEnter,
onMouseLeave: handleScrollViewMouseLeave,
Expand Down
Loading

0 comments on commit e7c5b2d

Please sign in to comment.