Merge branch 'feat-virtual-list' into next
commit
a892a8908b
|
@ -1 +1 @@
|
||||||
Subproject commit b2bd75fdaad216ac1bb4e19a38ab5cf801116baf
|
Subproject commit 79d49c0ff31a4f505ccd5bc3ad238c08f9925212
|
|
@ -0,0 +1,8 @@
|
||||||
|
function createRef() {
|
||||||
|
const func = function setRef(node) {
|
||||||
|
func.current = node;
|
||||||
|
};
|
||||||
|
return func;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createRef;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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 */
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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];
|
||||||
|
}
|
|
@ -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];
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
import List from './List';
|
||||||
|
|
||||||
|
export default List;
|
|
@ -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 };
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
const isFF = typeof navigator === 'object' && /Firefox/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
export default isFF;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue