Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/demos/scroll-to.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Scroll To
nav:
title: Demo
path: /demo
---

<code src="../examples/scroll-to.tsx"></code>
185 changes: 185 additions & 0 deletions docs/examples/scroll-to.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import React, { useRef, useState } from 'react';
import Listy, {
type ListyRef,
type ListyScrollToConfig,
type ScrollAlign,
} from '@rc-component/listy';
import '../../assets/index.less';

const GROUP_SIZE = 15;
const GROUP_COUNT = 6;

interface Row {
id: number;
name: string;
group: string;
}

const items: Row[] = Array.from(
{ length: GROUP_SIZE * GROUP_COUNT },
(_, index) => ({
id: index,
name: `Item ${index}`,
group: `Group ${Math.floor(index / GROUP_SIZE)}`,
}),
);

const GROUP_KEYS = Array.from({ length: GROUP_COUNT }, (_, i) => `Group ${i}`);

const controlRow: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 8,
};

export default () => {
const listRef = useRef<ListyRef>(null);
const [virtual, setVirtual] = useState(true);
const [align, setAlign] = useState<ScrollAlign>('top');
const [offset, setOffset] = useState(0);
const [itemKey, setItemKey] = useState(50);
const [groupKey, setGroupKey] = useState('Group 3');
const [lastConfig, setLastConfig] = useState<ListyScrollToConfig>();

// Single entry point so the demo can echo the exact config it passes in.
const run = (config: ListyScrollToConfig) => {
setLastConfig(config);
listRef.current?.scrollTo(config);
};

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={controlRow}>
<label>
<input
type="checkbox"
checked={virtual}
onChange={(e) => setVirtual(e.target.checked)}
/>{' '}
virtual
</label>

<label>
align{' '}
<select
value={align}
onChange={(e) => setAlign(e.target.value as ScrollAlign)}
>
<option value="top">top</option>
<option value="bottom">bottom</option>
<option value="auto">auto</option>
</select>
</label>

<label>
offset{' '}
<input
type="number"
value={offset}
style={{ width: 64 }}
onChange={(e) => setOffset(Number(e.target.value) || 0)}
/>
</label>
</div>

{/* number | { top } — absolute pixel scroll */}
<div style={controlRow}>
<button type="button" onClick={() => run(0)}>
scrollTo(0)
</button>
<button type="button" onClick={() => run(400)}>
scrollTo(400)
</button>
<button type="button" onClick={() => run({ top: 200 })}>
scrollTo({'{ top: 200 }'})
</button>
</div>

{/* { key, align, offset } — scroll to an item */}
<div style={controlRow}>
<label>
key{' '}
<input
type="number"
value={itemKey}
style={{ width: 64 }}
onChange={(e) => setItemKey(Number(e.target.value) || 0)}
/>
</label>
<button
type="button"
onClick={() => run({ key: itemKey, align, offset })}
>
scrollTo item
</button>

{/* { groupKey, align, offset } — scroll to a group header */}
<label>
group{' '}
<select value={groupKey} onChange={(e) => setGroupKey(e.target.value)}>
{GROUP_KEYS.map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => run({ groupKey, align, offset })}
>
scrollTo group
</button>
</div>

<div>
last config:{' '}
<code>{lastConfig === undefined ? '—' : JSON.stringify(lastConfig)}</code>
</div>

<Listy
// Remount on mode switch so both branches start from a clean scroll.
key={virtual ? 'virtual' : 'raw'}
ref={listRef}
virtual={virtual}
height={360}
itemHeight={40}
items={items}
rowKey="id"
sticky
itemRender={(item, index) => {
const height = 40 + (index % 3) * 16;
return (
<div
style={{
padding: '0 16px',
height,
lineHeight: `${height}px`,
borderBottom: '1px solid #f0f0f0',
background: '#fff',
}}
>
{item.name}
</div>
);
}}
group={{
key: (item) => item.group,
title: (key, groupItems) => (
<div
style={{
padding: '10px 16px',
fontWeight: 600,
background: '#e6f4ff',
borderBottom: '1px solid #91caff',
}}
>
{key} · {groupItems.length} items
</div>
),
}}
/>
</div>
);
};
32 changes: 10 additions & 22 deletions src/RawList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as React from 'react';
import clsx from 'clsx';
import { useEvent } from '@rc-component/util';
import GroupHeader from '../GroupHeader';
import useGroupSegments from '../hooks/useGroupSegments';
import useItemKey from '../hooks/useItemKey';
import useRawListScroll from './useRawListScroll';
import { toTaggedKey, type KeyType } from '../util';
import type { ListComponentProps, ListyRef } from '../List';

// ============================== Types ===============================
Expand Down Expand Up @@ -36,38 +37,26 @@ function RawList<T, K extends React.Key = React.Key>(
const groupData = useGroupSegments<T, K>(data, group);

// ============================== Utils ===============================
const getItemKey = useEvent((item: T): React.Key => {
if (typeof rowKey === 'function') {
return rowKey(item);
}
return item[rowKey] as React.Key;
});
const getItemKey = useItemKey(rowKey);

const getScrollTargetProps = React.useCallback(
(key: React.Key) => ({
'data-key': String(key),
(key: React.Key, type: KeyType) => ({
'data-key': toTaggedKey(key, type),
}),
[],
);

// ============================ Render Item ===========================
const renderItem = React.useCallback(
(item: T, index: number, groupKey?: K) => {
(item: T, index: number) => {
const key = getItemKey(item);
const scrollTargetProps = getScrollTargetProps(key);
const scrollTargetProps = getScrollTargetProps(key, 'item');

return (
<div
key={key}
className={clsx(`${prefixCls}-item`, classNames?.item)}
style={{
...styles?.item,
...(sticky && groupKey !== undefined
? {
scrollMarginTop: `var(--${prefixCls}-item-scroll-margin-top, 0px)`,
}
: undefined),
}}
style={styles?.item}
{...scrollTargetProps}
>
{itemRender(item, index)}
Expand All @@ -80,7 +69,6 @@ function RawList<T, K extends React.Key = React.Key>(
getScrollTargetProps,
itemRender,
prefixCls,
sticky,
styles?.item,
],
);
Expand All @@ -94,7 +82,7 @@ function RawList<T, K extends React.Key = React.Key>(
<div
key={groupKey}
className={`${prefixCls}-group-section`}
{...getScrollTargetProps(groupKey)}
{...getScrollTargetProps(groupKey, 'group')}
>
<GroupHeader
group={group}
Expand All @@ -106,7 +94,7 @@ function RawList<T, K extends React.Key = React.Key>(
style={styles?.groupHeader}
/>
{groupItems.map(({ item, index }) => {
return renderItem(item, index, groupKey);
return renderItem(item, index);
})}
</div>
);
Expand Down
41 changes: 24 additions & 17 deletions src/RawList/useRawListScroll.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { toTaggedKey } from '../util';
import type { ListyRef, ScrollAlign } from '../List';

export default function useRawListScroll(
Expand Down Expand Up @@ -36,17 +37,20 @@ export default function useRawListScroll(
[prefixCls, stickyGroup],
);

const setTargetScrollMargin = React.useCallback(
(targetElement: HTMLElement, align: ScrollAlign) => {
const marginTop =
align === 'top' ? getStickyHeaderHeight(targetElement) : 0;

targetElement.style.setProperty(
`--${prefixCls}-item-scroll-margin-top`,
`${marginTop}px`,
);
const applyScrollMargin = React.useCallback(
(
targetElement: HTMLElement,
align: ScrollAlign,
offset: number,
isItem: boolean,
) => {
const headerOffset =
isItem && align !== 'bottom' ? getStickyHeaderHeight(targetElement) : 0;

targetElement.style.scrollMarginTop = `${headerOffset + offset}px`;
targetElement.style.scrollMarginBottom = `${offset}px`;
},
[getStickyHeaderHeight, prefixCls],
[getStickyHeaderHeight],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

// ============================== Scroll ==============================
Expand All @@ -63,16 +67,19 @@ export default function useRawListScroll(
}

if ('key' in config || 'groupKey' in config) {
const { align = 'top' } = config;
const targetKey = 'groupKey' in config ? config.groupKey : config.key;
const { align = 'top', offset = 0 } = config;
const isItem = 'key' in config;
const targetKey = isItem
? toTaggedKey(config.key, 'item')
: toTaggedKey(config.groupKey, 'group');
const targetElement = holder.querySelector<HTMLElement>(
`[data-key="${CSS.escape(String(targetKey))}"]`,
`[data-key="${CSS.escape(
targetKey
)}"]`,
);

if (targetElement) {
if ('key' in config) {
setTargetScrollMargin(targetElement, align);
}
applyScrollMargin(targetElement, align, offset, isItem);
Comment thread
aojunhao123 marked this conversation as resolved.

targetElement.scrollIntoView({
block:
Expand All @@ -95,7 +102,7 @@ export default function useRawListScroll(
holder.scrollTop = top;
}
},
[setTargetScrollMargin],
[applyScrollMargin],
);

// ============================ Imperative ============================
Expand Down
Loading
Loading