chore: scrollable tabs
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import SegmentLine from '@/components/segment-line';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import _ from 'lodash';
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import useFieldScroll from './use-field-scroll';
|
||||
|
||||
const SegmentedHeader = styled.div<{ $top?: number }>`
|
||||
position: sticky;
|
||||
top: ${(props) => props.$top || 0}px;
|
||||
z-index: 10;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--ant-color-split);
|
||||
background-color: var(--ant-color-bg-elevated);
|
||||
`;
|
||||
|
||||
/**
|
||||
* ScrollSpyTabs component
|
||||
* defaultTarget: The default active target tab
|
||||
* segmentedTop: { // Mostlty, It's always a constants.
|
||||
* top: number; // The top offset for the sticky header
|
||||
* offsetTop: number; // The offset top for the target
|
||||
* }
|
||||
* getScrollElementScrollableHeight: function to get the scrollable height of the scroll element
|
||||
* segmentOptions.field: The target data-field={segmentOptions.field} to scroll to
|
||||
* activeKey: The current active keys for collapsible sections
|
||||
* setActiveKey: The function to set active keys for collapsible sections
|
||||
*/
|
||||
interface ScrollSpyTabsProps {
|
||||
ref?: any;
|
||||
children?: React.ReactNode;
|
||||
defaultTarget?: string;
|
||||
segmentedTop: {
|
||||
top: number;
|
||||
offsetTop: number;
|
||||
};
|
||||
activeKey: string[];
|
||||
setActiveKey: (keys: string[]) => void;
|
||||
getScrollElementScrollableHeight?: () => {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
};
|
||||
segmentOptions: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ReactNode;
|
||||
field: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const ScrollSpyTabs: React.FC<ScrollSpyTabsProps> = forwardRef(
|
||||
(
|
||||
{
|
||||
getScrollElementScrollableHeight,
|
||||
segmentedTop,
|
||||
segmentOptions,
|
||||
defaultTarget,
|
||||
activeKey,
|
||||
setActiveKey,
|
||||
children
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [target, setTarget] = React.useState<string>(
|
||||
defaultTarget || segmentOptions[0]?.value || ''
|
||||
);
|
||||
|
||||
const { scrollToSegment, holderHeight } = useFieldScroll({
|
||||
activeKey,
|
||||
setActiveKey,
|
||||
segmentOptions,
|
||||
segmentedTop: segmentedTop,
|
||||
getScrollElementScrollableHeight: getScrollElementScrollableHeight
|
||||
});
|
||||
|
||||
const throttleScrollToSegment = useMemoizedFn(
|
||||
_.throttle(
|
||||
async (val: string) => {
|
||||
setTarget(val);
|
||||
scrollToSegment(val, { offsetTop: segmentedTop.offsetTop });
|
||||
},
|
||||
500,
|
||||
{ trailing: true }
|
||||
)
|
||||
);
|
||||
|
||||
const handleTargetChange = async (val: any) => {
|
||||
throttleScrollToSegment(val);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleTargetChange
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SegmentedHeader $top={segmentedTop.top}>
|
||||
<SegmentLine
|
||||
theme={'light'}
|
||||
defaultValue={target}
|
||||
value={target}
|
||||
onChange={handleTargetChange}
|
||||
options={segmentOptions}
|
||||
/>
|
||||
</SegmentedHeader>
|
||||
{children}
|
||||
<div className="holder" style={{ height: holderHeight }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ScrollSpyTabs;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
interface ScrollOptions {
|
||||
wait?: number;
|
||||
behavior?: 'smooth' | 'auto';
|
||||
block?: 'start' | 'end' | 'center';
|
||||
offsetTop?: number;
|
||||
}
|
||||
|
||||
export default function useScrollAfterExpand({
|
||||
activeKey,
|
||||
setActiveKey,
|
||||
segmentOptions,
|
||||
defaultWait = 300,
|
||||
segmentedTop = { top: 0, offsetTop: 96 },
|
||||
getScrollElementScrollableHeight
|
||||
}: {
|
||||
activeKey: string[];
|
||||
setActiveKey: (keys: string[]) => void;
|
||||
segmentOptions: { value: string; field: string }[];
|
||||
getScrollElementScrollableHeight?: () => {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
};
|
||||
defaultWait?: number;
|
||||
segmentedTop: {
|
||||
top: number; // The top offset for the sticky header
|
||||
offsetTop: number; // The offset top for the target
|
||||
};
|
||||
}) {
|
||||
const [holderHeight, setHolderHeight] = useState<number>(0);
|
||||
const boxHeightRef = useRef<number>(0);
|
||||
|
||||
const scrollToElement = useCallback(
|
||||
(
|
||||
el: HTMLElement,
|
||||
{ behavior = 'smooth', offsetTop = 0 }: ScrollOptions = {}
|
||||
) => {
|
||||
// find the nearest scrollable parent
|
||||
const scrollParent = (() => {
|
||||
let node: HTMLElement | null = el;
|
||||
while (node) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return document.scrollingElement || document.documentElement;
|
||||
})();
|
||||
|
||||
const parentRect = scrollParent.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const top =
|
||||
elRect.top - parentRect.top + scrollParent.scrollTop - offsetTop;
|
||||
|
||||
scrollParent.scrollTo({ top, behavior });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* due to the scrollheight changes after expanding the segment and including the holder height.
|
||||
*
|
||||
*/
|
||||
const scrollToSegment = useMemoizedFn(
|
||||
async (val: string, options?: ScrollOptions) => {
|
||||
if (!activeKey.includes(val)) {
|
||||
setActiveKey([...activeKey, val]);
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, options?.wait ?? defaultWait);
|
||||
});
|
||||
}
|
||||
|
||||
const current = segmentOptions.find((item) => item.value === val);
|
||||
if (!current?.field) return;
|
||||
|
||||
await new Promise(requestAnimationFrame);
|
||||
|
||||
const el: HTMLElement | null = document.querySelector(
|
||||
`[data-field="${current.field}"]`
|
||||
) as HTMLElement | null;
|
||||
|
||||
const targetRectTop = el?.getBoundingClientRect().top || 0;
|
||||
|
||||
const scroller = getScrollElementScrollableHeight?.() || {
|
||||
scrollHeight: 0,
|
||||
scrollTop: 0
|
||||
};
|
||||
|
||||
// remaining scroll height
|
||||
const remainingScrollHeight = scroller.scrollHeight - scroller.scrollTop;
|
||||
|
||||
// total distance from the top of the scroller to the target element
|
||||
const offsetDistance =
|
||||
targetRectTop - segmentedTop.offsetTop - segmentedTop.top;
|
||||
|
||||
let boxHeight = 0;
|
||||
|
||||
// verify boxHeight is correct, if setting the boxHeight causes the element to be hidden, use the previous boxHeight
|
||||
if (offsetDistance <= 0) {
|
||||
boxHeight = boxHeightRef.current;
|
||||
} else {
|
||||
boxHeight =
|
||||
offsetDistance - remainingScrollHeight + boxHeightRef.current;
|
||||
}
|
||||
|
||||
// update boxHeightRef
|
||||
boxHeightRef.current = boxHeight;
|
||||
|
||||
setHolderHeight(boxHeight);
|
||||
await new Promise(requestAnimationFrame);
|
||||
|
||||
if (el) scrollToElement(el, options);
|
||||
}
|
||||
);
|
||||
|
||||
return { scrollToSegment, holderHeight };
|
||||
}
|
||||
Reference in New Issue
Block a user