Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(indexes): 组件对齐 mobile-vue #513

Merged
merged 11 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion site/mobile/mobile.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export default {
{
title: 'Indexes 索引',
name: 'indexes',
component: () => import('tdesign-mobile-react/indexes/_example/index.jsx'),
component: () => import('tdesign-mobile-react/indexes/_example/index.tsx'),
},
{
title: 'Picker 选择器',
Expand Down
298 changes: 196 additions & 102 deletions src/indexes/Indexes.tsx
Original file line number Diff line number Diff line change
@@ -1,146 +1,240 @@
import React, { useState, MouseEvent, TouchEvent, useRef, useEffect } from 'react';
import isFunction from 'lodash/isFunction';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import throttle from 'lodash/throttle';
import { Cell, CellGroup } from '../cell';
import { TdIndexesProps, ListItem } from './type';
import cls from 'classnames';
import { TdIndexesProps } from './type';
import { StyledProps } from '../common';
import useConfig from '../_util/useConfig';

const topOffset = 40; // 滑动选中高亮的顶部偏移(px)
import useDefaultProps from '../hooks/useDefaultProps';
import parseTNode from '../_util/parseTNode';
import { usePrefixClass } from '../hooks/useClass';
import { indexesDefaultProps } from './defaultProps';
import { IndexesProrvider } from './IndexesContext';

export interface IndexesProps extends TdIndexesProps, StyledProps {}

interface GroupTop {
height: number;
top: number;
anchor: string | number;
totalHeight: number;
}

interface ChildNodes {
ele: HTMLElement;
anchor: string | number;
}

const Indexes: React.FC<IndexesProps> = (props) => {
const { height, list, onSelect, style } = props;
const { indexList, className, style, sticky, stickyOffset, children, onChange, onSelect } = useDefaultProps(
props,
indexesDefaultProps,
);

const indexesClass = usePrefixClass('indexes');

const { classPrefix } = useConfig();
const prefix = classPrefix;
// 当前高亮index
const [currentGroup, setCurrentGroup] = useState<ListItem | null>(null);
const [activeSidebar, setActiveSidebar] = useState<string | number>(null);
// 是否展示index高亮提示
const [showScrollTip, setShowScrollTip] = useState<boolean>(false);
const [showSidebarTip, setShowSidebarTip] = useState<boolean>(false);

// 索引主列表的ref
const indexesRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
// 存放tip消失的定时器
const tipTimer = useRef(null);
// 存放index组的scrollTop
const groupTop = useRef([]);

const handleSelect = (argv: { groupIndex: string; childrenIndex: number }) => {
if (!isFunction(onSelect)) {
return;
// 存放 anchor 组的scrollTop
const groupTop = useRef<GroupTop[]>([]);

const childNodes = useRef<ChildNodes[]>([]);
const parentRect = useRef({ top: 0 });

const indexListMemo = useMemo(() => {
if (!indexList) {
const start = 'A'.charCodeAt(0);
const alphabet = [];
for (let i = start, end = start + 26; i < end; i += 1) {
alphabet.push(String.fromCharCode(i));
}
return alphabet;
}
return indexList;
}, [indexList]);

const setAnchorOnScroll = (top: number) => {
let scrollTop = top;
if (!groupTop.current.length) return;
const stickyTop = stickyOffset + parentRect.current.top;
scrollTop += stickyTop;
const curIndex = groupTop.current.findIndex(
(group) => scrollTop >= group.top - group.height && scrollTop <= group.top + group.totalHeight - group.height,
);
setActiveSidebar(groupTop.current[0].anchor);
if (curIndex === -1) return;
const curGroup = groupTop.current[curIndex];
setActiveSidebar(curGroup.anchor);
if (sticky) {
const offset = curGroup.top - scrollTop;
const betwixt = offset < curGroup.height && offset > 0 && scrollTop > stickyTop;
childNodes.current.forEach((child, index) => {
const { ele } = child;
const wrapperClass = `${indexesClass}-anchor__wrapper`;
const headerClass = `${indexesClass}-anchor__header`;
const wrapper = ele.querySelector<HTMLElement>(`.${wrapperClass}`);
const header = ele.querySelector<HTMLElement>(`.${headerClass}`);
if (index === curIndex) {
if (scrollTop - parentRect.current.top > stickyOffset) {
wrapper.classList.add(`${wrapperClass}--sticky`);
} else {
wrapper.classList.remove(`${wrapperClass}--sticky`);
}
wrapper.classList.add(`${wrapperClass}--active`);
header.classList.add(`${headerClass}--active`);
wrapper.style.cssText = `transform: translate3d(0, ${betwixt ? offset : 0}px, 0); top: ${stickyTop}px`;
} else if (index + 1 === curIndex) {
wrapper.classList.add(`${wrapperClass}--sticky`);
wrapper.classList.add(`${wrapperClass}--active`);
header.classList.add(`${headerClass}--active`);
wrapper.style.cssText = `transform: translate3d(0, ${betwixt ? offset - groupTop.current[index].height : 0}px, 0); top: ${stickyTop}px;`;
} else {
wrapper.classList.remove(`${wrapperClass}--sticky`);
wrapper.classList.remove(`${wrapperClass}--active`);
header.classList.remove(`${headerClass}--active`);
wrapper.style.cssText = '';
}
});
}
};

onSelect(argv);
const scrollToByIndex = (index: number | string) => {
const curGroup = groupTop.current.find((item) => item.anchor === index);
if (indexesRef.current) {
indexesRef.current.scrollTo?.(0, curGroup.top ?? 0);
}
};

const showTips = () => {
setShowScrollTip(true);
clearInterval(tipTimer.current);
tipTimer.current = null;
tipTimer.current = setTimeout(() => {
setShowScrollTip(false);
}, 2000);
const setActiveSidebarAndTip = (index: string | number) => {
setActiveSidebar(index);
setShowSidebarTip(true);
};

const getCurrentTitleNode = (current?: ListItem) =>
Array.from(document.getElementsByClassName(`${prefix}-indexes__index-${(current || currentGroup)?.index}`)).find(
(x): x is HTMLElement => x instanceof HTMLElement,
);
const handleSidebarItemClick = (index: string | number) => {
onSelect?.(index);
setActiveSidebarAndTip(index);
scrollToByIndex(index);
};

const handleSideBarItemClick = (e: MouseEvent<HTMLDivElement>, listItem: ListItem) => {
setCurrentGroup(listItem);
showTips();
getCurrentTitleNode(listItem).scrollIntoView();
const handleRootScroll = () => {
const scrollTop = indexesRef.current?.scrollTop ?? 0;
setAnchorOnScroll(scrollTop);
};

const handleSideBarTouchMove = (e: TouchEvent<HTMLDivElement>) => {
const { touches } = e;
const getAnchorsRect = () => {
childNodes.current.map((child) => {
const { ele, anchor } = child;
// const { index } = dataset;
const rect = ele.getBoundingClientRect();
groupTop.current.push({
height: rect.height,
top: rect.top - parentRect.current.top,
anchor,
totalHeight: 0,
});
return child;
});
};
const handleSidebarTouchmove = (event: TouchEvent) => {
event.preventDefault();
const { touches } = event;
const { clientX, clientY } = touches[0];
const target = document.elementFromPoint(clientX, clientY);

// 触摸侧边index sidebar
if (target && target.className.match(`${prefix}-indexes__sidebar-item`) && target instanceof HTMLElement) {
if (target && target.className === `${indexesClass}__sidebar-item` && target instanceof HTMLElement) {
const { index } = target.dataset;
const listItem = list.find((element) => element.index === index);
if (index !== undefined && currentGroup?.index !== index) {
setCurrentGroup(listItem);
showTips();
getCurrentTitleNode(listItem).scrollIntoView();
const curIndex = indexListMemo.find((idx) => String(idx) === index);
if (curIndex !== undefined && activeSidebar !== index) {
setActiveSidebarAndTip(curIndex);
scrollToByIndex(curIndex);
}
}
};

const handleScroll = () => {
// 滑动列表
const { scrollTop } = indexesRef.current;

const curIndex = groupTop.current.findIndex((element) => element - topOffset > scrollTop);

if (curIndex > -1) {
setCurrentGroup(list[curIndex]);
}
const relation = (ele: HTMLElement, anchor: string | number) => {
ele && childNodes.current.push({ ele, anchor });
};

const getDomInfo = () => {
const groupItemDom = document.querySelectorAll('.t-indexes .t-cell-group__container');
groupTop.current = Array.from(groupItemDom, (element) => element.clientHeight);
groupTop.current.reduce((acc, cur, index) => {
const amount = acc + cur;
groupTop.current[index] = amount;
useEffect(() => {
const clearSidebarTip = (): void => {
if (showSidebarTip && activeSidebar !== null) {
tipTimer.current && clearTimeout(tipTimer.current);
tipTimer.current = window.setTimeout(() => {
setShowSidebarTip(false);
}, 1000);
}
};
if (showSidebarTip) {
clearSidebarTip();
}
}, [showSidebarTip, activeSidebar]);

return amount;
});
};
useEffect(() => {
onChange?.(activeSidebar);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSidebar]);

useEffect(() => {
getDomInfo();
parentRect.current = indexesRef.current?.getBoundingClientRect() || { top: 0 };
getAnchorsRect();
groupTop.current.forEach((item, index) => {
const next = groupTop.current[index + 1];
// eslint-disable-next-line no-param-reassign
item.totalHeight = (next?.top || Infinity) - item.top;
});
setAnchorOnScroll(0);

// https://github.com/facebook/react/pull/19654
// react 中 onTouchMove 等事件默认使用 passive: true,导致无法在listener 中使用 preventDefault()
const sideBar = sidebarRef.current;
sideBar && sideBar.addEventListener('touchmove', handleSidebarTouchmove, { passive: false });

return () => {
tipTimer.current && clearTimeout(tipTimer.current);
sideBar && sideBar.removeEventListener('touchmove', handleSidebarTouchmove);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div
className={`${prefix}-indexes`}
onScroll={throttle(handleScroll, 100)}
style={{ height: height || window.innerHeight, ...style }}
ref={indexesRef}
>
{list.map((listItem) => (
<div className={`${prefix}-indexes__anchor ${prefix}-indexes__index-${listItem.index}`} key={listItem.index}>
<div className={`${prefix}-indexes__title`}>{listItem.title}</div>
<CellGroup>
{listItem.children.map((element, index) => (
<Cell
title={element.title}
key={element.title}
onClick={() => handleSelect({ groupIndex: listItem.index, childrenIndex: index })}
bordered={false}
></Cell>
))}
</CellGroup>
<IndexesProrvider value={{ relation }}>
<div
className={cls(indexesClass, className)}
onScroll={throttle(handleRootScroll, 1000 / 30)}
style={{ ...style }}
ref={indexesRef}
>
<div ref={sidebarRef} className={`${indexesClass}__sidebar`}>
{indexListMemo.map((listItem) => (
<div
className={cls(`${indexesClass}__sidebar-item`, {
[`${indexesClass}__sidebar-item--active`]: activeSidebar === listItem,
})}
key={listItem}
data-index={listItem}
onClick={(e) => {
e.preventDefault();
handleSidebarItemClick(listItem);
}}
>
{listItem}
{showSidebarTip && activeSidebar === listItem && (
<div className={`${indexesClass}__sidebar-tips`}>{activeSidebar}</div>
)}
</div>
))}
</div>
))}
<div className={`${prefix}-indexes__sidebar`} onTouchMove={throttle(handleSideBarTouchMove, 100)}>
{list.map((listItem) => (
<div
className={`${prefix}-indexes__sidebar-item ${
currentGroup?.index === listItem.index ? `${prefix}-indexes__sidebar-item--active` : ''
}`}
data-index={listItem.index}
key={listItem.index}
onClick={(e) => handleSideBarItemClick(e, listItem)}
>
{listItem.index}
{showScrollTip && currentGroup?.index === listItem.index && (
<div className={`${prefix}-indexes__sidebar-tip`}>
<div className={`${prefix}-indexes__sidebar-tip-text`}>{currentGroup?.index}</div>
</div>
)}
</div>
))}
{parseTNode(children)}
</div>
</div>
</IndexesProrvider>
);
};

Indexes.displayName = 'Link';

export default Indexes;
34 changes: 34 additions & 0 deletions src/indexes/IndexesAnchor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { FC, useContext, useEffect, useRef } from 'react';
import cls from 'classnames';
import { StyledProps } from '../common';
import parseTNode from '../_util/parseTNode';
import { usePrefixClass } from '../hooks/useClass';
import { TdIndexesAnchorProps } from './type';
import { IndexesContext } from './IndexesContext';

export interface IndexesAnchorProps extends TdIndexesAnchorProps, StyledProps {}

const IndexesAnchor: FC<IndexesAnchorProps> = (props) => {
const { children, index, className, style } = props;
const indexesAnchorClass = usePrefixClass('indexes-anchor');
const indexesAnchorRef = useRef<HTMLDivElement>(null);
const { relation } = useContext(IndexesContext);

useEffect(() => {
relation(indexesAnchorRef.current, index);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div className={cls(indexesAnchorClass, className)} style={style} ref={indexesAnchorRef} data-index={index}>
<div className={`${indexesAnchorClass}__wrapper`}>
<div className={`${indexesAnchorClass}__slot`}>{parseTNode(children)}</div>
<div className={`${indexesAnchorClass}__header`}>{index}</div>
</div>
</div>
);
};

IndexesAnchor.displayName = 'IndexesAnchor';

export default IndexesAnchor;
11 changes: 11 additions & 0 deletions src/indexes/IndexesContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React, { createContext, useMemo } from 'react';

export interface IndexesContextValue {
relation: (ele: HTMLElement, index: string | number) => void;
}
export const IndexesContext = createContext<IndexesContextValue | null>(null);

export function IndexesProrvider({ value, children }) {
const memoValue = useMemo(() => value, [value]);
return <IndexesContext.Provider value={memoValue}>{children}</IndexesContext.Provider>;
}
Loading
Loading