Merge branch 'feat-virtual-list' into next

pull/2930/head^2
tanjinzhou 2020-09-30 16:47:14 +08:00
commit a892a8908b
31 changed files with 2588 additions and 5 deletions

@ -1 +1 @@
Subproject commit b2bd75fdaad216ac1bb4e19a38ab5cf801116baf Subproject commit 79d49c0ff31a4f505ccd5bc3ad238c08f9925212

View File

@ -0,0 +1,8 @@
function createRef() {
const func = function setRef(node) {
func.current = node;
};
return func;
}
export default createRef;

View File

@ -0,0 +1,69 @@
const attributes = `accept acceptCharset accessKey action allowFullScreen allowTransparency
alt async autoComplete autoFocus autoPlay capture cellPadding cellSpacing challenge
charSet checked classID className colSpan cols content contentEditable contextMenu
controls coords crossOrigin data dateTime default defer dir disabled download draggable
encType form formAction formEncType formMethod formNoValidate formTarget frameBorder
headers height hidden high href hrefLang htmlFor httpEquiv icon id inputMode integrity
is keyParams keyType kind label lang list loop low manifest marginHeight marginWidth max maxLength media
mediaGroup method min minLength multiple muted name noValidate nonce open
optimum pattern placeholder poster preload radioGroup readOnly rel required
reversed role rowSpan rows sandbox scope scoped scrolling seamless selected
shape size sizes span spellCheck src srcDoc srcLang srcSet start step style
summary tabIndex target title type useMap value width wmode wrap`;
const eventsName = `onCopy onCut onPaste onCompositionEnd onCompositionStart onCompositionUpdate onKeyDown
onKeyPress onKeyUp onFocus onBlur onChange onInput onSubmit onClick onContextMenu onDoubleClick
onDrag onDragEnd onDragEnter onDragExit onDragLeave onDragOver onDragStart onDrop onMouseDown
onMouseEnter onMouseLeave onMouseMove onMouseOut onMouseOver onMouseUp onSelect onTouchCancel
onTouchEnd onTouchMove onTouchStart onScroll onWheel onAbort onCanPlay onCanPlayThrough
onDurationChange onEmptied onEncrypted onEnded onError onLoadedData onLoadedMetadata
onLoadStart onPause onPlay onPlaying onProgress onRateChange onSeeked onSeeking onStalled onSuspend onTimeUpdate onVolumeChange onWaiting onLoad onError`;
const propList = `${attributes} ${eventsName}`.split(/[\s\n]+/);
/* eslint-enable max-len */
const ariaPrefix = 'aria-';
const dataPrefix = 'data-';
function match(key, prefix) {
return key.indexOf(prefix) === 0;
}
/**
* Picker props from exist props with filter
* @param props Passed props
* @param ariaOnly boolean | { aria?: boolean; data?: boolean; attr?: boolean; } filter config
*/
export default function pickAttrs(props, ariaOnly = false) {
let mergedConfig;
if (ariaOnly === false) {
mergedConfig = {
aria: true,
data: true,
attr: true,
};
} else if (ariaOnly === true) {
mergedConfig = {
aria: true,
};
} else {
mergedConfig = {
...ariaOnly,
};
}
const attrs = {};
Object.keys(props).forEach(key => {
if (
// Aria
(mergedConfig.aria && (key === 'role' || match(key, ariaPrefix))) ||
// Data
(mergedConfig.data && match(key, dataPrefix)) ||
// Attr
(mergedConfig.attr && propList.includes(key))
) {
attrs[key] = props[key];
}
});
return attrs;
}

View File

@ -2,7 +2,7 @@ import { PropType } from 'vue';
import isPlainObject from 'lodash-es/isPlainObject'; import isPlainObject from 'lodash-es/isPlainObject';
import { toType, getType, isFunction, validateType, isInteger, isArray, warn } from './utils'; import { toType, getType, isFunction, validateType, isInteger, isArray, warn } from './utils';
const VuePropTypes = { const PropTypes = {
get any() { get any() {
return toType('any', { return toType('any', {
type: null, type: null,
@ -251,7 +251,7 @@ const typeDefaults = () => ({
let currentDefaults = typeDefaults(); let currentDefaults = typeDefaults();
Object.defineProperty(VuePropTypes, 'sensibleDefaults', { Object.defineProperty(PropTypes, 'sensibleDefaults', {
enumerable: false, enumerable: false,
set(value) { set(value) {
if (value === false) { if (value === false) {
@ -267,4 +267,4 @@ Object.defineProperty(VuePropTypes, 'sensibleDefaults', {
}, },
}); });
export default VuePropTypes; export default PropTypes;

View File

@ -0,0 +1,11 @@
import PropTypes from '../_util/vue-types';
export default {
props: {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
},
isSelectOptGroup: true,
render() {
return null;
},
};

View File

@ -0,0 +1,14 @@
import PropTypes from '../_util/vue-types';
export default {
props: {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
disabled: PropTypes.bool,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
},
isSelectOption: true,
render() {
return null;
},
};

View File

@ -0,0 +1,348 @@
import TransBtn from './TransBtn';
import PropTypes from '../_util/vue-types';
import KeyCode from '../_util/KeyCode';
import classNames from '../_util/classNames';
import pickAttrs from '../_util/pickAttrs';
import { isValidElement } from '../_util/props-util';
import createRef from '../_util/createRef';
import { computed, reactive, watch } from 'vue';
import List from '../vc-virtual-list/List';
const OptionListProps = {
prefixCls: PropTypes.string,
id: PropTypes.string,
options: PropTypes.array,
flattenOptions: PropTypes.array,
height: PropTypes.number,
itemHeight: PropTypes.number,
values: PropTypes.any,
multiple: PropTypes.bool,
open: PropTypes.bool,
defaultActiveFirstOption: PropTypes.bool,
notFoundContent: PropTypes.any,
menuItemSelectedIcon: PropTypes.any,
childrenAsData: PropTypes.bool,
searchValue: PropTypes.string,
virtual: PropTypes.bool,
onSelect: PropTypes.func,
onToggleOpen: PropTypes.func,
/** Tell Select that some value is now active to make accessibility work */
onActiveValue: PropTypes.func,
onScroll: PropTypes.func,
/** Tell Select that mouse enter the popup to force re-render */
onMouseenter: PropTypes.func,
};
/**
* Using virtual list of option display.
* Will fallback to dom if use customize render.
*/
const OptionList = {
props: OptionListProps,
name: 'OptionList',
inheritAttrs: false,
setup(props) {
const itemPrefixCls = computed(() => `${props.prefixCls}-item`);
// =========================== List ===========================
const listRef = createRef();
const onListMouseDown = event => {
event.preventDefault();
};
const scrollIntoView = index => {
if (listRef.current) {
listRef.current.scrollTo({ index });
}
};
// ========================== Active ==========================
const getEnabledActiveIndex = (index, offset = 1) => {
const len = props.flattenOptions.length;
for (let i = 0; i < len; i += 1) {
const current = (index + i * offset + len) % len;
const { group, data } = props.flattenOptions[current];
if (!group && !data.disabled) {
return current;
}
}
return -1;
};
const state = reactive({
activeIndex: getEnabledActiveIndex(0),
});
const setActive = (index, fromKeyboard = false) => {
state.activeIndex = index;
const info = { source: fromKeyboard ? 'keyboard' : 'mouse' };
// Trigger active event
const flattenItem = props.flattenOptions[index];
if (!flattenItem) {
props.onActiveValue(null, -1, info);
return;
}
props.onActiveValue(flattenItem.data.value, index, info);
};
// Auto active first item when list length or searchValue changed
watch([props.flattenOptions.length, props.searchValue], () => {
setActive(props.defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1);
});
// Auto scroll to item position in single mode
watch(props.open, () => {
/**
* React will skip `onChange` when component update.
* `setActive` function will call root accessibility state update which makes re-render.
* So we need to delay to let Input component trigger onChange first.
*/
const timeoutId = setTimeout(() => {
if (!props.multiple && props.open && props.values.size === 1) {
const value = Array.from(props.values)[0];
const index = props.flattenOptions.findIndex(({ data }) => data.value === value);
setActive(index);
scrollIntoView(index);
}
});
return () => clearTimeout(timeoutId);
});
// ========================== Values ==========================
const onSelectValue = value => {
if (value !== undefined) {
props.onSelect(value, { selected: !props.values.has(value) });
}
// Single mode should always close by select
if (!props.multiple) {
props.onToggleOpen(false);
}
};
function renderItem(index) {
const item = props.flattenOptions[index];
if (!item) return null;
const itemData = item.data || {};
const { value, label, children } = itemData;
const attrs = pickAttrs(itemData, true);
const mergedLabel = props.childrenAsData ? children : label;
return item ? (
<div
aria-label={typeof mergedLabel === 'string' ? mergedLabel : null}
{...attrs}
key={index}
role="option"
id={`${props.id}_list_${index}`}
aria-selected={props.values.has(value)}
>
{value}
</div>
) : null;
}
return {
renderItem,
listRef,
state,
onListMouseDown,
itemPrefixCls,
setActive,
onSelectValue,
onKeydown: event => {
const { which } = event;
switch (which) {
// >>> Arrow keys
case KeyCode.UP:
case KeyCode.DOWN: {
let offset = 0;
if (which === KeyCode.UP) {
offset = -1;
} else if (which === KeyCode.DOWN) {
offset = 1;
}
if (offset !== 0) {
const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset);
scrollIntoView(nextActiveIndex);
setActive(nextActiveIndex, true);
}
break;
}
// >>> Select
case KeyCode.ENTER: {
// value
const item = props.flattenOptions[state.activeIndex];
if (item && !item.data.disabled) {
onSelectValue(item.data.value);
} else {
onSelectValue(undefined);
}
if (props.open) {
event.preventDefault();
}
break;
}
// >>> Close
case KeyCode.ESC: {
props.onToggleOpen(false);
}
}
},
onKeyup: () => {},
scrollTo: index => {
scrollIntoView(index);
},
};
},
render() {
const { renderItem, listRef, onListMouseDown, itemPrefixCls, setActive, onSelectValue } = this;
const {
id,
childrenAsData,
values,
height,
itemHeight,
flattenOptions,
menuItemSelectedIcon,
notFoundContent,
virtual,
onScroll,
onMouseenter,
} = this.$props;
const { activeIndex } = this.state;
// ========================== Render ==========================
if (flattenOptions.length === 0) {
return (
<div
role="listbox"
id={`${id}_list`}
class={`${itemPrefixCls}-empty`}
onMousedown={onListMouseDown}
>
{notFoundContent}
</div>
);
}
return (
<>
<div role="listbox" id={`${id}_list`} style={{ height: 0, width: 0, overflow: 'hidden' }}>
{renderItem(activeIndex - 1)}
{renderItem(activeIndex)}
{renderItem(activeIndex + 1)}
</div>
<List
itemKey="key"
ref={listRef}
data={flattenOptions}
height={height}
itemHeight={itemHeight}
fullHeight={false}
onMousedown={onListMouseDown}
onScroll={onScroll}
virtual={virtual}
onMouseenter={onMouseenter}
>
{({ group, groupOption, data }, itemIndex) => {
const { label, key } = data;
// Group
if (group) {
return (
<div class={classNames(itemPrefixCls, `${itemPrefixCls}-group`)}>
{label !== undefined ? label : key}
</div>
);
}
const {
disabled,
value,
title,
children,
style,
class: cls,
className,
...otherProps
} = data;
// Option
const selected = values.has(value);
const optionPrefixCls = `${itemPrefixCls}-option`;
const optionClassName = classNames(itemPrefixCls, optionPrefixCls, cls, className, {
[`${optionPrefixCls}-grouped`]: groupOption,
[`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled,
[`${optionPrefixCls}-disabled`]: disabled,
[`${optionPrefixCls}-selected`]: selected,
});
const mergedLabel = childrenAsData ? children : label;
const iconVisible =
!menuItemSelectedIcon || typeof menuItemSelectedIcon === 'function' || selected;
const content = mergedLabel || value;
// https://github.com/ant-design/ant-design/issues/26717
let optionTitle =
typeof content === 'string' || typeof content === 'number'
? content.toString()
: undefined;
if (title !== undefined) {
optionTitle = title;
}
return (
<div
{...otherProps}
aria-selected={selected}
class={optionClassName}
title={optionTitle}
onMousemove={() => {
if (activeIndex === itemIndex || disabled) {
return;
}
setActive(itemIndex);
}}
onClick={() => {
if (!disabled) {
onSelectValue(value);
}
}}
style={style}
>
<div class={`${optionPrefixCls}-content`}>{content}</div>
{isValidElement(menuItemSelectedIcon) || selected}
{iconVisible && (
<TransBtn
class={`${itemPrefixCls}-option-state`}
customizeIcon={menuItemSelectedIcon}
customizeIconProps={{ isSelected: selected }}
>
{selected ? '✓' : null}
</TransBtn>
)}
</div>
);
}}
</List>
</>
);
},
};
export default OptionList;

View File

@ -0,0 +1,144 @@
import Trigger from '../vc-trigger';
import PropTypes from '../_util/vue-types';
import { getSlot } from '../_util/props-util';
import classNames from '../_util/classNames';
import createRef from '../_util/createRef';
const getBuiltInPlacements = dropdownMatchSelectWidth => {
// Enable horizontal overflow auto-adjustment when a custom dropdown width is provided
const adjustX = typeof dropdownMatchSelectWidth !== 'number' ? 0 : 1;
return {
bottomLeft: {
points: ['tl', 'bl'],
offset: [0, 4],
overflow: {
adjustX,
adjustY: 1,
},
},
bottomRight: {
points: ['tr', 'br'],
offset: [0, 4],
overflow: {
adjustX,
adjustY: 1,
},
},
topLeft: {
points: ['bl', 'tl'],
offset: [0, -4],
overflow: {
adjustX,
adjustY: 1,
},
},
topRight: {
points: ['br', 'tr'],
offset: [0, -4],
overflow: {
adjustX,
adjustY: 1,
},
},
};
};
export default {
name: 'SelectTrigger',
inheritAttrs: false,
props: {
// onPopupFocus: PropTypes.func,
// onPopupScroll: PropTypes.func,
dropdownAlign: PropTypes.object,
visible: PropTypes.bool,
disabled: PropTypes.bool,
dropdownClassName: PropTypes.string,
dropdownStyle: PropTypes.object,
empty: PropTypes.bool,
prefixCls: PropTypes.string,
popupClassName: PropTypes.string,
// children: PropTypes.any,
animation: PropTypes.string,
transitionName: PropTypes.string,
getPopupContainer: PropTypes.func,
dropdownRender: PropTypes.func,
containerWidth: PropTypes.number,
dropdownMatchSelectWidth: PropTypes.oneOfType([Number, Boolean]).def(true),
popupElement: PropTypes.any,
direction: PropTypes.string,
getTriggerDOMNode: PropTypes.func,
},
created() {
this.popupRef = createRef();
},
methods: {
getDropdownTransitionName() {
const props = this.$props;
let transitionName = props.transitionName;
if (!transitionName && props.animation) {
transitionName = `${this.getDropdownPrefixCls()}-${props.animation}`;
}
return transitionName;
},
getPopupElement() {
return this.popupRef.current;
},
},
render() {
const { empty, ...props } = { ...this.$props, ...this.$attrs };
const {
visible,
dropdownAlign,
prefixCls,
popupElement,
dropdownClassName,
dropdownStyle,
dropdownMatchSelectWidth,
containerWidth,
dropdownRender,
} = props;
const dropdownPrefixCls = `${prefixCls}-dropdown`;
let popupNode = popupElement;
if (dropdownRender) {
popupNode = dropdownRender({ menuNode: popupElement, props });
}
const builtInPlacements = getBuiltInPlacements(dropdownMatchSelectWidth);
const popupStyle = { minWidth: containerWidth, ...dropdownStyle };
if (typeof dropdownMatchSelectWidth === 'number') {
popupStyle.width = `${dropdownMatchSelectWidth}px`;
} else if (dropdownMatchSelectWidth) {
popupStyle.width = `${containerWidth}px`;
}
return (
<Trigger
{...props}
showAction={[]}
hideAction={[]}
popupPlacement={this.direction === 'rtl' ? 'bottomRight' : 'bottomLeft'}
popupPlacement="bottomLeft"
builtinPlacements={builtInPlacements}
prefixCls={dropdownPrefixCls}
popupTransitionName={this.getDropdownTransitionName()}
onPopupVisibleChange={props.onDropdownVisibleChange}
popup={<div ref={this.popupRef}>{popupNode}</div>}
popupAlign={dropdownAlign}
popupVisible={visible}
getPopupContainer={props.getPopupContainer}
popupClassName={classNames(dropdownClassName, {
[`${dropdownPrefixCls}-empty`]: empty,
})}
popupStyle={popupStyle}
getTriggerDOMNode={this.getTriggerDOMNode}
>
{getSlot(this)[0]}
</Trigger>
);
},
};

View File

@ -0,0 +1,41 @@
const TransBtn = (
_,
{ attrs: { class: className, customizeIcon, customizeIconProps, onMousedown, onClick }, slots },
) => {
let icon;
if (typeof customizeIcon === 'function') {
icon = customizeIcon(customizeIconProps);
} else {
icon = customizeIcon;
}
return (
<span
class={className}
onMousedown={event => {
event.preventDefault();
if (onMousedown) {
onMousedown(event);
}
}}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
}}
unselectable="on"
onClick={onClick}
aria-hidden
>
{icon !== undefined ? (
icon
) : (
<span class={className.split(/\s+/).map(cls => `${cls}-icon`)}>{slots?.default()}</span>
)}
</span>
);
};
TransBtn.inheritAttrs = false;
export default TransBtn;

View File

@ -0,0 +1,9 @@
// based on vc-select 9.2.2
import Select from './Select';
import Option from './Option';
import { SelectPropTypes } from './PropTypes';
import OptGroup from './OptGroup';
Select.Option = Option;
Select.OptGroup = OptGroup;
export { Select, Option, OptGroup, SelectPropTypes };
export default Select;

View File

@ -0,0 +1,57 @@
import classNames from '../_util/classNames';
import ResizeObserver from '../vc-resize-observer';
const Filter = ({ height, offset, prefixCls, onInnerResize }, { slots }) => {
let outerStyle = {};
let innerStyle = {
display: 'flex',
flexDirection: 'column',
};
if (offset !== undefined) {
outerStyle = { height: `${height}px`, position: 'relative', overflow: 'hidden' };
innerStyle = {
...innerStyle,
transform: `translateY(${offset}px)`,
position: 'absolute',
left: 0,
right: 0,
top: 0,
};
}
return (
<div style={outerStyle}>
<ResizeObserver
onResize={({ offsetHeight }) => {
if (offsetHeight && onInnerResize) {
onInnerResize();
}
}}
>
<div
style={innerStyle}
class={classNames({
[`${prefixCls}-holder-inner`]: prefixCls,
})}
>
{slots.default?.()}
</div>
</ResizeObserver>
</div>
);
};
Filter.displayName = 'Filter';
Filter.inheritAttrs = false;
Filter.props = {
prefixCls: String,
/** Virtual filler height. Should be `count * itemMinHeight` */
height: Number,
/** Set offset of visible items. Should be the top of start item position */
offset: Number,
onInnerResize: Function,
};
export default Filter;

View File

@ -0,0 +1,17 @@
import { cloneVNode } from 'vue';
function Item({ setRef }, { slots }) {
const children = slots?.default();
return children && children.length
? cloneVNode(children[0], {
ref: setRef,
})
: children;
}
Item.props = {
setRef: {
type: Function,
default: () => {},
},
};
export default Item;

View File

@ -0,0 +1,369 @@
import Filler from './Filler';
import Item from './Item';
import ScrollBar from './ScrollBar';
import useHeights from './hooks/useHeights';
import useScrollTo from './hooks/useScrollTo';
import useFrameWheel from './hooks/useFrameWheel';
import useMobileTouchMove from './hooks/useMobileTouchMove';
import useOriginScroll from './hooks/useOriginScroll';
import PropTypes from '../_util/vue-types';
import { computed, nextTick, onBeforeUnmount, reactive, watchEffect } from 'vue';
import classNames from '../_util/classNames';
import createRef from '../_util/createRef';
const EMPTY_DATA = [];
const ScrollStyle = {
overflowY: 'auto',
overflowAnchor: 'none',
};
function renderChildren(list, startIndex, endIndex, setNodeRef, renderFunc, { getKey }) {
return list.slice(startIndex, endIndex + 1).map((item, index) => {
const eleIndex = startIndex + index;
const node = renderFunc(item, eleIndex, {
// style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {},
});
const key = getKey(item);
return (
<Item key={key} setRef={ele => setNodeRef(item, ele)}>
{node}
</Item>
);
});
}
const ListProps = {
prefixCls: PropTypes.string,
data: PropTypes.array,
height: PropTypes.number,
itemHeight: PropTypes.number,
/** If not match virtual scroll condition, Set List still use height of container. */
fullHeight: PropTypes.bool.def(true),
itemKey: PropTypes.any,
component: PropTypes.any,
/** Set `false` will always use real scroll instead of virtual one */
virtual: PropTypes.bool,
children: PropTypes.func,
onScroll: PropTypes.func,
};
const List = {
props: ListProps,
inheritAttrs: false,
name: 'List',
setup(props) {
// ================================= MISC =================================
const inVirtual = computed(() => {
const { height, itemHeight, data, virtual } = props;
return virtual !== false && height && itemHeight && data && itemHeight * data.length > height;
});
const state = reactive({
scrollTop: 0,
scrollMoving: false,
mergedData: computed(() => props.data || EMPTY_DATA),
});
const componentRef = createRef();
// =============================== Item Key ===============================
const getKey = item => {
if (typeof props.itemKey === 'function') {
return props.itemKey(item);
}
return item[props.itemKey];
};
const sharedConfig = {
getKey,
};
// ================================ Scroll ================================
function syncScrollTop(newTop) {
let value;
if (typeof newTop === 'function') {
value = newTop(state.scrollTop);
} else {
value = newTop;
}
const alignedTop = keepInRange(value);
if (componentRef.current) {
componentRef.current.scrollTop = alignedTop;
}
state.scrollTop = alignedTop;
}
// ================================ Height ================================
const [setInstance, collectHeight, heights] = useHeights(getKey, null, null);
// ========================== Visible Calculation =========================
const calRes = computed(() => {
if (!inVirtual.value) {
return {
scrollHeight: undefined,
start: 0,
end: state.mergedData.length - 1,
offset: undefined,
};
}
let itemTop = 0;
let startIndex;
let startOffset;
let endIndex;
const dataLen = state.mergedData.length;
for (let i = 0; i < dataLen; i += 1) {
const item = state.mergedData[i];
const key = getKey(item);
const cacheHeight = heights[key];
const currentItemBottom =
itemTop + (cacheHeight === undefined ? props.itemHeight : cacheHeight);
if (currentItemBottom >= state.scrollTop && startIndex === undefined) {
startIndex = i;
startOffset = itemTop;
}
// Check item bottom in the range. We will render additional one item for motion usage
if (currentItemBottom > state.scrollTop + props.height && endIndex === undefined) {
endIndex = i;
}
itemTop = currentItemBottom;
}
// Fallback to normal if not match. This code should never reach
/* istanbul ignore next */
if (startIndex === undefined) {
startIndex = 0;
startOffset = 0;
}
if (endIndex === undefined) {
endIndex = state.mergedData.length - 1;
}
// Give cache to improve scroll experience
endIndex = Math.min(endIndex + 1, state.mergedData.length);
return {
scrollHeight: itemTop,
start: startIndex,
end: endIndex,
offset: startOffset,
};
});
// =============================== In Range ===============================
const maxScrollHeight = computed(() => calRes.value.scrollHeight - props.height);
function keepInRange(newScrollTop) {
let newTop = Math.max(newScrollTop, 0);
if (!Number.isNaN(maxScrollHeight.value)) {
newTop = Math.min(newTop, maxScrollHeight.value);
}
return newTop;
}
const isScrollAtTop = computed(() => state.scrollTop <= 0);
const isScrollAtBottom = computed(() => state.scrollTop >= maxScrollHeight.value);
const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom);
// ================================ Scroll ================================
function onScrollBar(newScrollTop) {
const newTop = newScrollTop;
syncScrollTop(newTop);
}
// This code may only trigger in test case.
// But we still need a sync if some special escape
function onFallbackScroll(e) {
const { scrollTop: newScrollTop } = e.currentTarget;
if (newScrollTop !== state.scrollTop) {
syncScrollTop(newScrollTop);
}
// Trigger origin onScroll
props.onScroll?.(e);
}
// Since this added in global,should use ref to keep update
const [onRawWheel, onFireFoxScroll] = useFrameWheel(
inVirtual,
isScrollAtTop,
isScrollAtBottom,
offsetY => {
syncScrollTop(top => {
const newTop = top + offsetY;
return newTop;
});
},
);
// Mobile touch move
useMobileTouchMove(inVirtual, componentRef, (deltaY, smoothOffset) => {
if (originScroll(deltaY, smoothOffset)) {
return false;
}
onRawWheel({ preventDefault() {}, deltaY });
return true;
});
// Firefox only
function onMozMousePixelScroll(e) {
if (inVirtual.value) {
e.preventDefault();
}
}
const removeEventListener = () => {
if (componentRef.current) {
componentRef.current.removeEventListener('wheel', onRawWheel);
componentRef.current.removeEventListener('DOMMouseScroll', onFireFoxScroll);
componentRef.current.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll);
}
};
watchEffect(() => {
nextTick(() => {
if (componentRef.current) {
removeEventListener();
componentRef.current.addEventListener('wheel', onRawWheel);
componentRef.current.addEventListener('DOMMouseScroll', onFireFoxScroll);
componentRef.current.addEventListener('MozMousePixelScroll', onMozMousePixelScroll);
}
});
});
onBeforeUnmount(() => {
removeEventListener();
});
// ================================= Ref ==================================
const scrollTo = useScrollTo(
componentRef,
state,
heights,
props,
getKey,
collectHeight,
syncScrollTop,
);
const componentStyle = computed(() => {
let cs = null;
if (props.height) {
cs = { [props.fullHeight ? 'height' : 'maxHeight']: props.height + 'px', ...ScrollStyle };
if (inVirtual.value) {
cs.overflowY = 'hidden';
if (state.scrollMoving) {
cs.pointerEvents = 'none';
}
}
}
return cs;
});
return {
state,
componentStyle,
scrollTo,
onFallbackScroll,
onScrollBar,
componentRef,
inVirtual,
calRes,
collectHeight,
setInstance,
sharedConfig,
};
},
render() {
const {
prefixCls = 'rc-virtual-list',
height,
itemHeight,
// eslint-disable-next-line no-unused-vars
fullHeight,
data,
itemKey,
virtual,
component: Component = 'div',
onScroll,
children,
style,
class: className,
...restProps
} = { ...this.$props, ...this.$attrs };
const mergedClassName = classNames(prefixCls, className);
const { scrollTop, mergedData } = this.state;
const { scrollHeight, offset, start, end } = this.calRes;
const {
componentStyle,
onFallbackScroll,
onScrollBar,
componentRef,
inVirtual,
collectHeight,
sharedConfig,
setInstance,
} = this;
const listChildren = renderChildren(
mergedData,
start,
end,
setInstance,
children,
sharedConfig,
);
return (
<div
style={{
...style,
position: 'relative',
}}
class={mergedClassName}
{...restProps}
>
<Component
class={`${prefixCls}-holder`}
style={componentStyle}
ref={componentRef}
onScroll={onFallbackScroll}
>
<Filler
prefixCls={prefixCls}
height={scrollHeight}
offset={offset}
onInnerResize={collectHeight}
>
{listChildren}
</Filler>
</Component>
{inVirtual && (
<ScrollBar
prefixCls={prefixCls}
scrollTop={scrollTop}
height={height}
scrollHeight={scrollHeight}
count={mergedData.length}
onScroll={onScrollBar}
onStartMove={() => {
this.state.scrollMoving = true;
}}
onStopMove={() => {
this.state.scrollMoving = false;
}}
/>
)}
</div>
);
},
};
export default List;

View File

@ -0,0 +1,232 @@
import { reactive } from 'vue';
import classNames from '../_util/classNames';
import createRef from '../_util/createRef';
import raf from '../_util/raf';
import PropTypes from '../_util/vue-types';
const MIN_SIZE = 20;
// export interface ScrollBarProps {
// prefixCls: string;
// scrollTop: number;
// scrollHeight: number;
// height: number;
// count: number;
// onScroll: (scrollTop: number) => void;
// onStartMove: () => void;
// onStopMove: () => void;
// }
// interface ScrollBarState {
// dragging: boolean;
// pageY: number;
// startTop: number;
// visible: boolean;
// }
function getPageY(e) {
return 'touches' in e ? e.touches[0].pageY : e.pageY;
}
export default {
name: 'ScrollBar',
inheritAttrs: false,
props: {
prefixCls: PropTypes.string,
scrollTop: PropTypes.number,
scrollHeight: PropTypes.number,
height: PropTypes.number,
count: PropTypes.number,
onScroll: PropTypes.func,
onStartMove: PropTypes.func,
onStopMove: PropTypes.func,
},
setup() {
return {
moveRaf: null,
scrollbarRef: createRef(),
thumbRef: createRef(),
visibleTimeout: null,
state: reactive({
dragging: false,
pageY: null,
startTop: null,
visible: false,
}),
};
},
watch: {
scrollTop: {
handler() {
this.delayHidden();
},
flush: 'post',
},
},
mounted() {
this.scrollbarRef.current.addEventListener('touchstart', this.onScrollbarTouchStart);
this.thumbRef.current.addEventListener('touchstart', this.onMouseDown);
},
beforeUnmount() {
this.removeEvents();
clearTimeout(this.visibleTimeout);
},
methods: {
delayHidden() {
clearTimeout(this.visibleTimeout);
this.state.visible = true;
this.visibleTimeout = setTimeout(() => {
this.state.visible = false;
}, 2000);
},
onScrollbarTouchStart(e) {
e.preventDefault();
},
onContainerMouseDown(e) {
e.stopPropagation();
e.preventDefault();
},
// ======================= Clean =======================
patchEvents() {
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp);
this.thumbRef.current.addEventListener('touchmove', this.onMouseMove);
this.thumbRef.current.addEventListener('touchend', this.onMouseUp);
},
removeEvents() {
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
this.scrollbarRef.current.removeEventListener('touchstart', this.onScrollbarTouchStart);
this.thumbRef.current.removeEventListener('touchstart', this.onMouseDown);
this.thumbRef.current.removeEventListener('touchmove', this.onMouseMove);
this.thumbRef.current.removeEventListener('touchend', this.onMouseUp);
raf.cancel(this.moveRaf);
},
// ======================= Thumb =======================
onMouseDown(e) {
const { onStartMove } = this.$props;
Object.assign(this.state, {
dragging: true,
pageY: getPageY(e),
startTop: this.getTop(),
});
onStartMove();
this.patchEvents();
e.stopPropagation();
e.preventDefault();
},
onMouseMove(e) {
const { dragging, pageY, startTop } = this.state;
const { onScroll } = this.$props;
raf.cancel(this.moveRaf);
if (dragging) {
const offsetY = getPageY(e) - pageY;
const newTop = startTop + offsetY;
const enableScrollRange = this.getEnableScrollRange();
const enableHeightRange = this.getEnableHeightRange();
const ptg = newTop / enableHeightRange;
const newScrollTop = Math.ceil(ptg * enableScrollRange);
this.moveRaf = raf(() => {
onScroll(newScrollTop);
});
}
},
onMouseUp() {
const { onStopMove } = this.$props;
this.state.dragging = false;
onStopMove();
this.removeEvents();
},
// ===================== Calculate =====================
getSpinHeight() {
const { height, count } = this.$props;
let baseHeight = (height / count) * 10;
baseHeight = Math.max(baseHeight, MIN_SIZE);
baseHeight = Math.min(baseHeight, height / 2);
return Math.floor(baseHeight);
},
getEnableScrollRange() {
const { scrollHeight, height } = this.$props;
return scrollHeight - height;
},
getEnableHeightRange() {
const { height } = this.$props;
const spinHeight = this.getSpinHeight();
return height - spinHeight;
},
getTop() {
const { scrollTop } = this.$props;
const enableScrollRange = this.getEnableScrollRange();
const enableHeightRange = this.getEnableHeightRange();
const ptg = scrollTop / enableScrollRange;
return ptg * enableHeightRange;
},
},
render() {
// eslint-disable-next-line no-unused-vars
const { visible, dragging } = this.state;
const { prefixCls } = this.$props;
const spinHeight = this.getSpinHeight() + 'px';
const top = this.getTop() + 'px';
return (
<div
ref={this.scrollbarRef}
class={`${prefixCls}-scrollbar`}
style={{
width: '8px',
top: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: visible ? null : 'none',
}}
onMousedown={this.onContainerMouseDown}
onMousemove={this.delayHidden}
>
<div
ref={this.thumbRef}
class={classNames(`${prefixCls}-scrollbar-thumb`, {
[`${prefixCls}-scrollbar-thumb-moving`]: dragging,
})}
style={{
width: '100%',
height: spinHeight,
top,
left: 0,
position: 'absolute',
background: 'rgba(0, 0, 0, 0.5)',
borderRadius: '99px',
cursor: 'pointer',
userSelect: 'none',
}}
onMousedown={this.onMouseDown}
/>
</div>
);
},
};

View File

@ -0,0 +1,31 @@
.motion {
transition: all 0.3s;
}
.item {
display: inline-block;
box-sizing: border-box;
margin: 0;
padding: 0 16px;
overflow: hidden;
line-height: 31px;
position: relative;
&:hover {
background: rgba(255, 0, 0, 0.1);
}
&::after {
content: '';
border-bottom: 1px solid gray;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
button {
vertical-align: text-top;
margin-right: 8px;
}
}

View File

@ -0,0 +1,214 @@
/* eslint-disable arrow-body-style */
import * as React from 'react';
// @ts-ignore
import CSSMotion from 'rc-animate/lib/CSSMotion';
import classNames from 'classnames';
import List, { ListRef } from '../src/List';
import './animate.less';
let uuid = 0;
function genItem() {
const item = {
id: `key_${uuid}`,
uuid,
};
uuid += 1;
return item;
}
const originData: Item[] = [];
for (let i = 0; i < 1000; i += 1) {
originData.push(genItem());
}
interface Item {
id: string;
uuid: number;
}
interface MyItemProps extends Item {
visible: boolean;
motionAppear: boolean;
onClose: (id: string) => void;
onLeave: (id: string) => void;
onAppear: (...args: any[]) => void;
onInsertBefore: (id: string) => void;
onInsertAfter: (id: string) => void;
}
const getCurrentHeight = (node: HTMLElement) => ({ height: node.offsetHeight });
const getMaxHeight = (node: HTMLElement) => {
return { height: node.scrollHeight };
};
const getCollapsedHeight = () => ({ height: 0, opacity: 0 });
const MyItem: React.ForwardRefRenderFunction<any, MyItemProps> = (
{
id,
uuid: itemUuid,
visible,
onClose,
onLeave,
onAppear,
onInsertBefore,
onInsertAfter,
motionAppear,
},
ref,
) => {
const motionRef = React.useRef(false);
React.useEffect(() => {
return () => {
if (motionRef.current) {
onAppear();
}
};
}, []);
return (
<CSSMotion
visible={visible}
ref={ref}
motionName="motion"
motionAppear={motionAppear}
onAppearStart={getCollapsedHeight}
onAppearActive={node => {
motionRef.current = true;
return getMaxHeight(node);
}}
onAppearEnd={onAppear}
onLeaveStart={getCurrentHeight}
onLeaveActive={getCollapsedHeight}
onLeaveEnd={() => {
onLeave(id);
}}
>
{({ className, style }, passedMotionRef) => {
return (
<div
ref={passedMotionRef}
className={classNames('item', className)}
style={style}
data-id={id}
>
<div style={{ height: itemUuid % 2 ? 100 : undefined }}>
<button
type="button"
onClick={() => {
onClose(id);
}}
>
Close
</button>
<button
type="button"
onClick={() => {
onInsertBefore(id);
}}
>
Insert Before
</button>
<button
type="button"
onClick={() => {
onInsertAfter(id);
}}
>
Insert After
</button>
{id}
</div>
</div>
);
}}
</CSSMotion>
);
};
const ForwardMyItem = React.forwardRef(MyItem);
const Demo = () => {
const [data, setData] = React.useState(originData);
const [closeMap, setCloseMap] = React.useState<{ [id: number]: boolean }>({});
const [animating, setAnimating] = React.useState(false);
const [insertIndex, setInsertIndex] = React.useState<number>();
const listRef = React.useRef<ListRef>();
const onClose = (id: string) => {
setCloseMap({
...closeMap,
[id]: true,
});
};
const onLeave = (id: string) => {
const newData = data.filter(item => item.id !== id);
setData(newData);
};
const onAppear = (...args: any[]) => {
console.log('Appear:', args);
setAnimating(false);
};
function lockForAnimation() {
setAnimating(true);
}
const onInsertBefore = (id: string) => {
const index = data.findIndex(item => item.id === id);
const newData = [...data.slice(0, index), genItem(), ...data.slice(index)];
setInsertIndex(index);
setData(newData);
lockForAnimation();
};
const onInsertAfter = (id: string) => {
const index = data.findIndex(item => item.id === id) + 1;
const newData = [...data.slice(0, index), genItem(), ...data.slice(index)];
setInsertIndex(index);
setData(newData);
lockForAnimation();
};
return (
<React.StrictMode>
<div>
<h2>Animate</h2>
<p>Current: {data.length} records</p>
<List<Item>
data={data}
data-id="list"
height={200}
itemHeight={20}
itemKey="id"
// disabled={animating}
ref={listRef}
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
// onSkipRender={onAppear}
// onItemRemove={onAppear}
>
{(item, index) => (
<ForwardMyItem
{...item}
motionAppear={animating && insertIndex === index}
visible={!closeMap[item.id]}
onClose={onClose}
onLeave={onLeave}
onAppear={onAppear}
onInsertBefore={onInsertBefore}
onInsertAfter={onInsertAfter}
/>
)}
</List>
</div>
</React.StrictMode>
);
};
export default Demo;

View File

@ -0,0 +1,216 @@
/* eslint-disable no-console */
import { reactive, ref } from 'vue';
import List from '../List';
import './basic.less';
const MyItem = (_, { attrs: { id } }) => (
<span
// style={{
// // height: 30 + (id % 2 ? 0 : 10),
// }}
class="fixed-item"
onClick={() => {
console.log('Click:', id);
}}
>
{id}
</span>
);
const TestItem = {
render() {
return <div style={{ lineHeight: '30px' }}>{this.$attrs.id}</div>;
},
};
const data = [];
for (let i = 0; i < 1000; i += 1) {
data.push({
id: String(i),
});
}
const TYPES = [
{ name: 'ref real dom element', type: 'dom' },
{ name: 'ref vue node', type: 'vue' },
];
const onScroll = e => {
console.log('scroll:', e.currentTarget.scrollTop);
};
const state = reactive({
destroy: false,
visible: true,
type: 'dom',
});
const listRef = ref(null);
const Demo = () => {
const { destroy, visible, type } = state;
return (
<div style={{ height: '200vh' }}>
<h2>Basic</h2>
{TYPES.map(({ name, type: nType }) => (
<label key={nType}>
<input
name="type"
type="radio"
checked={type === nType}
onChange={() => {
state.type = nType;
}}
/>
{name}
</label>
))}
<button
type="button"
onClick={() => {
listRef.value.scrollTo(100);
}}
>
Scroll To 100px
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'top',
});
}}
>
Scroll To 50 (top)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'bottom',
});
}}
>
Scroll To 50 (bottom)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'auto',
});
}}
>
Scroll To 50 (auto)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'top',
offset: 15,
});
}}
>
Scroll To 50 (top) + 15 offset
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'bottom',
offset: 15,
});
}}
>
Scroll To 50 (bottom) + 15 offset
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
key: '50',
align: 'auto',
});
}}
>
Scroll To key 50 (auto)
</button>
<button
type="button"
onClick={() => {
state.visible = !state.visible;
}}
>
visible
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: data.length - 2,
align: 'top',
});
}}
>
Scroll To Last (top)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 0,
align: 'bottom',
});
}}
>
Scroll To First (bottom)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'top',
});
state.destroy = true;
}}
>
Scroll To remove
</button>
{!destroy && (
<List
id="list"
ref={listRef}
data={data}
height={200}
itemHeight={20}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
display: visible ? null : 'none',
}}
onScroll={onScroll}
children={(item, _, props) =>
type === 'dom' ? <MyItem {...item} {...props} /> : <TestItem {...item} {...props} />
}
></List>
)}
</div>
);
};
export default Demo;
/* eslint-enable */

View File

@ -0,0 +1,8 @@
.fixed-item {
border: 1px solid gray;
padding: 0 16px;
height: 32px;
line-height: 30px;
box-sizing: border-box;
display: inline-block;
}

View File

@ -0,0 +1,60 @@
import * as React from 'react';
import List from '../src/List';
interface Item {
id: number;
height: number;
}
const MyItem: React.FC<Item> = ({ id, height }, ref) => {
return (
<span
ref={ref}
style={{
border: '1px solid gray',
padding: '0 16px',
height,
lineHeight: '30px',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
{id}
</span>
);
};
const ForwardMyItem = React.forwardRef(MyItem);
const data: Item[] = [];
for (let i = 0; i < 100; i += 1) {
data.push({
id: i,
height: 30 + (i % 2 ? 70 : 0),
});
}
const Demo = () => {
return (
<React.StrictMode>
<div>
<h2>Dynamic Height</h2>
<List
data={data}
height={500}
itemHeight={30}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{item => <ForwardMyItem {...item} />}
</List>
</div>
</React.StrictMode>
);
};
export default Demo;

View File

@ -0,0 +1,86 @@
import * as React from 'react';
import List from '../src/List';
interface Item {
id: number;
height: number;
}
const MyItem: React.FC<Item> = ({ id, height }, ref) => {
return (
<span
ref={ref}
style={{
border: '1px solid gray',
padding: '0 16px',
height,
lineHeight: '30px',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
{id}
</span>
);
};
const ForwardMyItem = React.forwardRef(MyItem);
const data: Item[] = [];
for (let i = 0; i < 100; i += 1) {
data.push({
id: i,
height: 30 + (i % 2 ? 20 : 0),
});
}
const Demo = () => {
return (
<React.StrictMode>
<div>
<h2>Less Count</h2>
<List
data={data.slice(0, 1)}
itemHeight={30}
height={100}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{item => <ForwardMyItem {...item} />}
</List>
<h2>Less Item Height</h2>
<List
data={data.slice(0, 10)}
itemHeight={1}
height={100}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{item => <ForwardMyItem {...item} />}
</List>
<h2>Without Height</h2>
<List
data={data}
itemHeight={30}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{item => <ForwardMyItem {...item} />}
</List>
</div>
</React.StrictMode>
);
};
export default Demo;

View File

@ -0,0 +1,106 @@
/* eslint-disable jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */
import * as React from 'react';
import List from '../src/List';
interface Item {
id: number;
}
const MyItem: React.FC<Item> = ({ id }, ref) => (
<span
ref={ref}
style={{
border: '1px solid gray',
padding: '0 16px',
height: 30,
lineHeight: '30px',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
{id}
</span>
);
const ForwardMyItem = React.forwardRef(MyItem);
function getData(count: number) {
const data: Item[] = [];
for (let i = 0; i < count; i += 1) {
data.push({
id: i,
});
}
return data;
}
const Demo = () => {
const [height, setHeight] = React.useState(100);
const [data, setData] = React.useState(getData(20));
return (
<React.StrictMode>
<div style={{ height: '150vh' }}>
<h2>Switch</h2>
<span
onChange={(e: any) => {
setData(getData(Number(e.target.value)));
}}
>
Data
<label>
<input type="radio" name="switch" value={0} />0
</label>
<label>
<input type="radio" name="switch" value={2} />2
</label>
<label>
<input type="radio" name="switch" value={100} />
100
</label>
<label>
<input type="radio" name="switch" value={200} />
200
</label>
<label>
<input type="radio" name="switch" value={1000} />
1000
</label>
</span>
<span
onChange={(e: any) => {
setHeight(Number(e.target.value));
}}
>
| Height
<label>
<input type="radio" name="switch" value={0} />0
</label>
<label>
<input type="radio" name="switch" value={100} />
100
</label>
<label>
<input type="radio" name="switch" value={200} />
200
</label>
</span>
<List
data={data}
height={height}
itemHeight={30}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{(item, _, props) => <ForwardMyItem {...item} {...props} />}
</List>
</div>
</React.StrictMode>
);
};
export default Demo;

View File

@ -0,0 +1,50 @@
import raf from '../../_util/raf';
import isFF from '../utils/isFirefox';
import useOriginScroll from './useOriginScroll';
export default function useFrameWheel(inVirtual, isScrollAtTop, isScrollAtBottom, onWheelDelta) {
let offsetRef = 0;
let nextFrame = null;
// Firefox patch
let wheelValue = null;
let isMouseScroll = false;
// Scroll status sync
const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom);
function onWheel(event) {
if (!inVirtual.value) return;
raf.cancel(nextFrame);
const { deltaY } = event;
offsetRef += deltaY;
wheelValue = deltaY;
// Do nothing when scroll at the edge, Skip check when is in scroll
if (originScroll(deltaY)) return;
// Proxy of scroll events
if (!isFF) {
event.preventDefault();
}
nextFrame = raf(() => {
// Patch a multiple for Firefox to fix wheel number too small
// ref: https://github.com/ant-design/ant-design/issues/26372#issuecomment-679460266
const patchMultiple = isMouseScroll ? 10 : 1;
onWheelDelta(offsetRef * patchMultiple);
offsetRef = 0;
});
}
// A patch for firefox
function onFireFoxScroll(event) {
if (!inVirtual.value) return;
isMouseScroll = event.detail === wheelValue;
}
return [onWheel, onFireFoxScroll];
}

View File

@ -0,0 +1,54 @@
import { reactive, ref } from 'vue';
import { findDOMNode } from '../../_util/props-util';
export default function useHeights(getKey, onItemAdd, onItemRemove) {
const instance = new Map();
const heights = reactive({});
let updatedMark = ref(0);
let heightUpdateId = 0;
function collectHeight() {
heightUpdateId += 1;
const currentId = heightUpdateId;
Promise.resolve().then(() => {
// Only collect when it's latest call
if (currentId !== heightUpdateId) return;
let changed = false;
instance.forEach((element, key) => {
if (element && element.offsetParent) {
const htmlElement = findDOMNode(element);
const { offsetHeight } = htmlElement;
if (heights[key] !== offsetHeight) {
changed = true;
heights[key] = htmlElement.offsetHeight;
}
}
});
if (changed) {
updatedMark.value++;
}
});
}
function setInstance(item, ins) {
const key = getKey(item);
const origin = instance.get(key);
if (ins) {
instance.set(key, ins);
collectHeight();
} else {
instance.delete(key);
}
// Instance changed
if (!origin !== !ins) {
if (ins) {
onItemAdd?.(item);
} else {
onItemRemove?.(item);
}
}
}
return [setInstance, collectHeight, heights, updatedMark];
}

View File

@ -0,0 +1,71 @@
import { watch } from 'vue';
const SMOOTH_PTG = 14 / 15;
export default function useMobileTouchMove(inVirtual, listRef, callback) {
let touched = false;
let touchY = 0;
let element = null;
// Smooth scroll
let interval = null;
let cleanUpEvents;
const onTouchMove = e => {
if (touched) {
const currentY = Math.ceil(e.touches[0].pageY);
let offsetY = touchY - currentY;
touchY = currentY;
if (callback(offsetY)) {
e.preventDefault();
}
// Smooth interval
clearInterval(interval);
interval = setInterval(() => {
offsetY *= SMOOTH_PTG;
if (!callback(offsetY, true) || Math.abs(offsetY) <= 0.1) {
clearInterval(interval);
}
}, 16);
}
};
const onTouchEnd = () => {
touched = false;
cleanUpEvents();
};
const onTouchStart = e => {
cleanUpEvents();
if (e.touches.length === 1 && !touched) {
touched = true;
touchY = Math.ceil(e.touches[0].pageY);
element = e.target;
element.addEventListener('touchmove', onTouchMove);
element.addEventListener('touchend', onTouchEnd);
}
};
cleanUpEvents = () => {
if (element) {
element.removeEventListener('touchmove', onTouchMove);
element.removeEventListener('touchend', onTouchEnd);
}
};
watch(inVirtual, val => {
listRef.current.removeEventListener('touchstart', onTouchStart);
cleanUpEvents();
clearInterval(interval);
if (val.value) {
listRef.current.addEventListener('touchstart', onTouchStart);
}
});
}

View File

@ -0,0 +1,31 @@
export default (isScrollAtTop, isScrollAtBottom) => {
// Do lock for a wheel when scrolling
let lock = false;
let lockTimeout = null;
function lockScroll() {
clearTimeout(lockTimeout);
lock = true;
lockTimeout = setTimeout(() => {
lock = false;
}, 50);
}
return (deltaY, smoothOffset = false) => {
const originScroll =
// Pass origin wheel when on the top
(deltaY < 0 && isScrollAtTop.value) ||
// Pass origin wheel when on the bottom
(deltaY > 0 && isScrollAtBottom.value);
if (smoothOffset && originScroll) {
// No need lock anymore when it's smooth offset from touchMove interval
clearTimeout(lockTimeout);
lock = false;
} else if (!originScroll || lock) {
lockScroll();
}
return !lock && originScroll;
};
};

View File

@ -0,0 +1,103 @@
/* eslint-disable no-param-reassign */
import raf from '../../_util/raf';
export default function useScrollTo(
containerRef,
state,
heights,
props,
getKey,
collectHeight,
syncScrollTop,
) {
let scroll = null;
return arg => {
raf.cancel(scroll);
const data = state.mergedData;
const itemHeight = props.itemHeight;
if (typeof arg === 'number') {
syncScrollTop(arg);
} else if (arg && typeof arg === 'object') {
let index;
const { align } = arg;
if ('index' in arg) {
({ index } = arg);
} else {
index = data.findIndex(item => getKey(item) === arg.key);
}
const { offset = 0 } = arg;
// We will retry 3 times in case dynamic height shaking
const syncScroll = (times, targetAlign) => {
if (times < 0 || !containerRef.current) return;
const height = containerRef.current.clientHeight;
let needCollectHeight = false;
let newTargetAlign = targetAlign;
// Go to next frame if height not exist
if (height) {
const mergedAlign = targetAlign || align;
// Get top & bottom
let stackTop = 0;
let itemTop = 0;
let itemBottom = 0;
for (let i = 0; i <= index; i += 1) {
const key = getKey(data[i]);
itemTop = stackTop;
const cacheHeight = heights[key];
itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
stackTop = itemBottom;
if (i === index && cacheHeight === undefined) {
needCollectHeight = true;
}
}
// Scroll to
let targetTop = null;
switch (mergedAlign) {
case 'top':
targetTop = itemTop - offset;
break;
case 'bottom':
targetTop = itemBottom - height + offset;
break;
default: {
const { scrollTop } = containerRef.current;
const scrollBottom = scrollTop + height;
if (itemTop < scrollTop) {
newTargetAlign = 'top';
} else if (itemBottom > scrollBottom) {
newTargetAlign = 'bottom';
}
}
}
if (targetTop !== null && targetTop !== containerRef.current.scrollTop) {
syncScrollTop(targetTop);
}
}
// We will retry since element may not sync height as it described
scroll = raf(() => {
if (needCollectHeight) {
collectHeight();
}
syncScroll(times - 1, newTargetAlign);
});
};
syncScroll(3);
}
};
}

View File

@ -0,0 +1,3 @@
import List from './List';
export default List;

View File

@ -0,0 +1,81 @@
/**
* Get index with specific start index one by one. e.g.
* min: 3, max: 9, start: 6
*
* Return index is:
* [0]: 6
* [1]: 7
* [2]: 5
* [3]: 8
* [4]: 4
* [5]: 9
* [6]: 3
*/
export function getIndexByStartLoc(min, max, start, index) {
const beforeCount = start - min;
const afterCount = max - start;
const balanceCount = Math.min(beforeCount, afterCount) * 2;
// Balance
if (index <= balanceCount) {
const stepIndex = Math.floor(index / 2);
if (index % 2) {
return start + stepIndex + 1;
}
return start - stepIndex;
}
// One is out of range
if (beforeCount > afterCount) {
return start - (index - afterCount);
}
return start + (index - beforeCount);
}
/**
* We assume that 2 list has only 1 item diff and others keeping the order.
* So we can use dichotomy algorithm to find changed one.
*/
export function findListDiffIndex(originList, targetList, getKey) {
const originLen = originList.length;
const targetLen = targetList.length;
let shortList;
let longList;
if (originLen === 0 && targetLen === 0) {
return null;
}
if (originLen < targetLen) {
shortList = originList;
longList = targetList;
} else {
shortList = targetList;
longList = originList;
}
const notExistKey = { __EMPTY_ITEM__: true };
function getItemKey(item) {
if (item !== undefined) {
return getKey(item);
}
return notExistKey;
}
// Loop to find diff one
let diffIndex = null;
let multiple = Math.abs(originLen - targetLen) !== 1;
for (let i = 0; i < longList.length; i += 1) {
const shortKey = getItemKey(shortList[i]);
const longKey = getItemKey(longList[i]);
if (shortKey !== longKey) {
diffIndex = i;
multiple = multiple || shortKey !== getItemKey(longList[i + 1]);
break;
}
}
return diffIndex === null ? null : { index: diffIndex, multiple };
}

View File

@ -0,0 +1,3 @@
const isFF = typeof navigator === 'object' && /Firefox/i.test(navigator.userAgent);
export default isFF;

View File

@ -0,0 +1,147 @@
/**
* Our algorithm have additional one ghost item
* whose index as `data.length` to simplify the calculation
*/
export const GHOST_ITEM_KEY = '__vc_ghost_item__';
/**
* Safari has the elasticity effect which provides negative `scrollTop` value.
* We should ignore it since will make scroll animation shake.
*/
export function alignScrollTop(scrollTop, scrollRange) {
if (scrollTop < 0) {
return 0;
}
if (scrollTop >= scrollRange) {
return scrollRange;
}
return scrollTop;
}
/**
* Get node `offsetHeight`. We prefer node is a dom element directly.
* But if not provided, downgrade to `findDOMNode` to get the real dom element.
*/
export function getNodeHeight(node) {
return node ? node.offsetHeight : 0;
}
/**
* Calculate the located item absolute top with whole scroll height
*/
export function getItemAbsoluteTop({ scrollTop, ...rest }) {
return scrollTop + getItemRelativeTop(rest);
}
/**
* Calculate the located item related top with current window height
*/
export function getItemRelativeTop({
itemIndex,
itemOffsetPtg,
itemElementHeights,
scrollPtg,
clientHeight,
getItemKey,
}) {
const locatedItemHeight = itemElementHeights[getItemKey(itemIndex)] || 0;
const locatedItemTop = scrollPtg * clientHeight;
const locatedItemOffset = itemOffsetPtg * locatedItemHeight;
return Math.floor(locatedItemTop - locatedItemOffset);
}
export function getCompareItemRelativeTop({
locatedItemRelativeTop,
locatedItemIndex,
compareItemIndex,
startIndex,
endIndex,
getItemKey,
itemElementHeights,
}) {
let originCompareItemTop = locatedItemRelativeTop;
const compareItemKey = getItemKey(compareItemIndex);
if (compareItemIndex <= locatedItemIndex) {
for (let index = locatedItemIndex; index >= startIndex; index -= 1) {
const key = getItemKey(index);
if (key === compareItemKey) {
break;
}
const prevItemKey = getItemKey(index - 1);
originCompareItemTop -= itemElementHeights[prevItemKey] || 0;
}
} else {
for (let index = locatedItemIndex; index <= endIndex; index += 1) {
const key = getItemKey(index);
if (key === compareItemKey) {
break;
}
originCompareItemTop += itemElementHeights[key] || 0;
}
}
return originCompareItemTop;
}
export function getScrollPercentage({ scrollTop, scrollHeight, clientHeight }) {
if (scrollHeight <= clientHeight) {
return 0;
}
const scrollRange = scrollHeight - clientHeight;
const alignedScrollTop = alignScrollTop(scrollTop, scrollRange);
const scrollTopPtg = alignedScrollTop / scrollRange;
return scrollTopPtg;
}
export function getElementScrollPercentage(element) {
if (!element) {
return 0;
}
return getScrollPercentage(element);
}
/**
* Get location item and its align percentage with the scroll percentage.
* We should measure current scroll position to decide which item is the location item.
* And then fill the top count and bottom count with the base of location item.
*
* `total` should be the real count instead of `total - 1` in calculation.
*/
function getLocationItem(scrollPtg, total) {
const itemIndex = Math.floor(scrollPtg * total);
const itemTopPtg = itemIndex / total;
const itemBottomPtg = (itemIndex + 1) / total;
const itemOffsetPtg = (scrollPtg - itemTopPtg) / (itemBottomPtg - itemTopPtg);
return {
index: itemIndex,
offsetPtg: itemOffsetPtg,
};
}
/**
* Get display items start, end, located item index. This is pure math calculation
*/
export function getRangeIndex(scrollPtg, itemCount, visibleCount) {
const { index, offsetPtg } = getLocationItem(scrollPtg, itemCount);
const beforeCount = Math.ceil(scrollPtg * visibleCount);
const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);
return {
itemIndex: index,
itemOffsetPtg: offsetPtg,
startIndex: Math.max(0, index - beforeCount),
endIndex: Math.min(itemCount - 1, index + afterCount),
};
}
export function requireVirtual(height, itemHeight, count, virtual) {
return virtual !== false && typeof height === 'number' && count * itemHeight > height;
}

View File

@ -4,7 +4,7 @@
</div> </div>
</template> </template>
<script> <script>
import demo from '../antdv-demo/docs/input/demo/basic.md'; import demo from '../components/vc-virtual-list/examples/basic.jsx';
export default { export default {
components: { components: {
demo, demo,