feat:移动端表头吸附效果
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -275,8 +276,10 @@ export default function MobileFundTable({
|
|||||||
|
|
||||||
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||||
const tableContainerRef = useRef(null);
|
const tableContainerRef = useRef(null);
|
||||||
|
const portalHeaderRef = useRef(null);
|
||||||
const [tableContainerWidth, setTableContainerWidth] = useState(0);
|
const [tableContainerWidth, setTableContainerWidth] = useState(0);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [showPortalHeader, setShowPortalHeader] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = tableContainerRef.current;
|
const el = tableContainerRef.current;
|
||||||
@@ -289,16 +292,63 @@ export default function MobileFundTable({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = tableContainerRef.current;
|
if (typeof window === 'undefined') return;
|
||||||
if (!el) return;
|
|
||||||
const handleScroll = () => {
|
const handleVerticalScroll = () => {
|
||||||
setIsScrolled(el.scrollLeft > 0);
|
console.log('scrollY', window.scrollY);
|
||||||
|
setShowPortalHeader(window.scrollY >= 100);
|
||||||
};
|
};
|
||||||
handleScroll();
|
|
||||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
handleVerticalScroll();
|
||||||
return () => el.removeEventListener('scroll', handleScroll);
|
window.addEventListener('scroll', handleVerticalScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', handleVerticalScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableEl = tableContainerRef.current;
|
||||||
|
if (!tableEl) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(tableEl.scrollLeft > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll();
|
||||||
|
tableEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tableEl.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableEl = tableContainerRef.current;
|
||||||
|
const portalEl = portalHeaderRef.current;
|
||||||
|
console.log('portalEl', portalEl)
|
||||||
|
if (!tableEl || !portalEl) return;
|
||||||
|
|
||||||
|
const syncScrollToPortal = () => {
|
||||||
|
console.log('tableEl.scrollLeft', tableEl.scrollLeft)
|
||||||
|
portalEl.scrollLeft = tableEl.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncScrollToTable = () => {
|
||||||
|
tableEl.scrollLeft = portalEl.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
syncScrollToPortal();
|
||||||
|
|
||||||
|
const handleTableScroll = () => syncScrollToPortal();
|
||||||
|
const handlePortalScroll = () => syncScrollToTable();
|
||||||
|
|
||||||
|
tableEl.addEventListener('scroll', handleTableScroll, { passive: true });
|
||||||
|
// portalEl.addEventListener('scroll', handlePortalScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tableEl.removeEventListener('scroll', handleTableScroll);
|
||||||
|
// portalEl.removeEventListener('scroll', handlePortalScroll);
|
||||||
|
};
|
||||||
|
}, [showPortalHeader]);
|
||||||
|
|
||||||
const NAME_CELL_WIDTH = 140;
|
const NAME_CELL_WIDTH = 140;
|
||||||
const GAP = 12;
|
const GAP = 12;
|
||||||
const LAST_COLUMN_EXTRA = 12;
|
const LAST_COLUMN_EXTRA = 12;
|
||||||
@@ -742,116 +792,149 @@ export default function MobileFundTable({
|
|||||||
return 'text-right';
|
return 'text-right';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const renderTableHeader = ()=>{
|
||||||
<div className="mobile-fund-table" ref={tableContainerRef}>
|
if(!headerGroup) return null;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className="mobile-fund-table-scroll"
|
className="table-header-row mobile-fund-table-header"
|
||||||
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
|
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
|
||||||
>
|
>
|
||||||
{headerGroup && (
|
{headerGroup.headers.map((header, headerIndex) => {
|
||||||
|
const columnId = header.column.id;
|
||||||
|
const pinClass = getPinClass(columnId, true);
|
||||||
|
const alignClass = getAlignClass(columnId);
|
||||||
|
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
className={`table-header-cell ${alignClass} ${pinClass}`}
|
||||||
|
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = (onlyShowHeader) => {
|
||||||
|
if (onlyShowHeader) {
|
||||||
|
return (
|
||||||
|
<div style={{position: 'fixed', top: 175}} className="mobile-fund-table mobile-fund-table-portal-header" ref={portalHeaderRef}>
|
||||||
<div
|
<div
|
||||||
className="table-header-row mobile-fund-table-header"
|
className="mobile-fund-table-scroll"
|
||||||
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
|
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
|
||||||
>
|
>
|
||||||
{headerGroup.headers.map((header, headerIndex) => {
|
{renderTableHeader()}
|
||||||
const columnId = header.column.id;
|
</div>
|
||||||
const pinClass = getPinClass(columnId, true);
|
</div>
|
||||||
const alignClass = getAlignClass(columnId);
|
);
|
||||||
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
|
}
|
||||||
return (
|
|
||||||
<div
|
return (
|
||||||
key={header.id}
|
<div className="mobile-fund-table" ref={tableContainerRef}>
|
||||||
className={`table-header-cell ${alignClass} ${pinClass}`}
|
<div
|
||||||
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
className="mobile-fund-table-scroll"
|
||||||
>
|
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
|
||||||
{header.isPlaceholder
|
>
|
||||||
? null
|
{renderTableHeader()}
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</div>
|
{!onlyShowHeader && (
|
||||||
);
|
<DndContext
|
||||||
})}
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={data.map((item) => item.code)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<SortableRow
|
||||||
|
key={row.original.code || row.id}
|
||||||
|
row={row}
|
||||||
|
isTableDragging={!!activeId}
|
||||||
|
disabled={sortBy !== 'default'}
|
||||||
|
>
|
||||||
|
{(setActivatorNodeRef, listeners) => (
|
||||||
|
<div
|
||||||
|
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
|
||||||
|
className="table-row"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg)',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
||||||
|
}}
|
||||||
|
{...(sortBy === 'default' ? listeners : {})}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||||
|
const columnId = cell.column.id;
|
||||||
|
const pinClass = getPinClass(columnId, false);
|
||||||
|
const alignClass = getAlignClass(columnId);
|
||||||
|
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
|
||||||
|
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
|
||||||
|
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableRow>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{table.getRowModel().rows.length === 0 && !onlyShowHeader && (
|
||||||
|
<div className="table-row empty-row">
|
||||||
|
<div className="table-cell" style={{ textAlign: 'center' }}>
|
||||||
|
<span className="muted">暂无数据</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DndContext
|
{!onlyShowHeader && (
|
||||||
sensors={sensors}
|
<MobileSettingModal
|
||||||
collisionDetection={closestCenter}
|
open={settingModalOpen}
|
||||||
onDragStart={handleDragStart}
|
onClose={() => setSettingModalOpen(false)}
|
||||||
onDragEnd={handleDragEnd}
|
columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
|
||||||
onDragCancel={handleDragCancel}
|
columnVisibility={mobileColumnVisibility}
|
||||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
onColumnReorder={(newOrder) => {
|
||||||
>
|
setMobileColumnOrder(newOrder);
|
||||||
<SortableContext
|
}}
|
||||||
items={data.map((item) => item.code)}
|
onToggleColumnVisibility={handleToggleMobileColumnVisibility}
|
||||||
strategy={verticalListSortingStrategy}
|
onResetColumnOrder={handleResetMobileColumnOrder}
|
||||||
>
|
onResetColumnVisibility={handleResetMobileColumnVisibility}
|
||||||
<AnimatePresence mode="popLayout">
|
showFullFundName={showFullFundName}
|
||||||
{table.getRowModel().rows.map((row) => (
|
onToggleShowFullFundName={handleToggleShowFullFundName}
|
||||||
<SortableRow
|
/>
|
||||||
key={row.original.code || row.id}
|
)}
|
||||||
row={row}
|
|
||||||
isTableDragging={!!activeId}
|
{!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
|
||||||
disabled={sortBy !== 'default'}
|
|
||||||
>
|
|
||||||
{(setActivatorNodeRef, listeners) => (
|
|
||||||
<div
|
|
||||||
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
|
|
||||||
className="table-row"
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg)',
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 1,
|
|
||||||
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
|
||||||
}}
|
|
||||||
{...(sortBy === 'default' ? listeners : {})}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
|
||||||
const columnId = cell.column.id;
|
|
||||||
const pinClass = getPinClass(columnId, false);
|
|
||||||
const alignClass = getAlignClass(columnId);
|
|
||||||
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
|
|
||||||
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={cell.id}
|
|
||||||
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
|
|
||||||
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SortableRow>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{table.getRowModel().rows.length === 0 && (
|
return (
|
||||||
<div className="table-row empty-row">
|
<>
|
||||||
<div className="table-cell" style={{ textAlign: 'center' }}>
|
{renderContent()}
|
||||||
<span className="muted">暂无数据</span>
|
</>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MobileSettingModal
|
|
||||||
open={settingModalOpen}
|
|
||||||
onClose={() => setSettingModalOpen(false)}
|
|
||||||
columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
|
|
||||||
columnVisibility={mobileColumnVisibility}
|
|
||||||
onColumnReorder={(newOrder) => {
|
|
||||||
setMobileColumnOrder(newOrder);
|
|
||||||
}}
|
|
||||||
onToggleColumnVisibility={handleToggleMobileColumnVisibility}
|
|
||||||
onResetColumnOrder={handleResetMobileColumnOrder}
|
|
||||||
onResetColumnVisibility={handleResetMobileColumnVisibility}
|
|
||||||
showFullFundName={showFullFundName}
|
|
||||||
onToggleShowFullFundName={handleToggleShowFullFundName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1535,6 +1535,22 @@ input[type="number"] {
|
|||||||
/* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */
|
/* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-fund-table-portal-header {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.mobile-fund-table-portal-header::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mobile-fund-table-portal-header .mobile-fund-table-scroll {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-fund-table .table-header-row {
|
.mobile-fund-table .table-header-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */
|
/* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ export default function HomePage() {
|
|||||||
// 动态计算 Navbar 和 FilterBar 高度
|
// 动态计算 Navbar 和 FilterBar 高度
|
||||||
const navbarRef = useRef(null);
|
const navbarRef = useRef(null);
|
||||||
const filterBarRef = useRef(null);
|
const filterBarRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
const [navbarHeight, setNavbarHeight] = useState(0);
|
const [navbarHeight, setNavbarHeight] = useState(0);
|
||||||
const [filterBarHeight, setFilterBarHeight] = useState(0);
|
const [filterBarHeight, setFilterBarHeight] = useState(0);
|
||||||
// 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
|
// 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
|
||||||
@@ -3598,7 +3599,7 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container content" style={{ width: containerWidth }}>
|
<div ref={containerRef} className="container content" style={{ width: containerWidth }}>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showThemeTransition && (
|
{showThemeTransition && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
Reference in New Issue
Block a user