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 { toType, getType, isFunction, validateType, isInteger, isArray, warn } from './utils';
|
||||
|
||||
const VuePropTypes = {
|
||||
const PropTypes = {
|
||||
get any() {
|
||||
return toType('any', {
|
||||
type: null,
|
||||
|
@ -251,7 +251,7 @@ const typeDefaults = () => ({
|
|||
|
||||
let currentDefaults = typeDefaults();
|
||||
|
||||
Object.defineProperty(VuePropTypes, 'sensibleDefaults', {
|
||||
Object.defineProperty(PropTypes, 'sensibleDefaults', {
|
||||
enumerable: false,
|
||||
set(value) {
|
||||
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>
|
||||
</template>
|
||||
<script>
|
||||
import demo from '../antdv-demo/docs/input/demo/basic.md';
|
||||
import demo from '../components/vc-virtual-list/examples/basic.jsx';
|
||||
export default {
|
||||
components: {
|
||||
demo,
|
||||
|
|
Loading…
Reference in New Issue