chore: scrollable tabs

This commit is contained in:
jialin
2026-01-14 16:01:47 +08:00
parent fc4d71139a
commit 94fec52329
3 changed files with 210 additions and 131 deletions
@@ -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 };
}