Skip to content

Commit af46902

Browse files
RSS1102tdesign-bot
andauthored
refactor(tag-input): extracts the common logic of the scroll (#4210)
* refactor(tag-input): 提取滚动逻辑到公共工具函数 * fix(tag-input): 修正 common 子模块为远程可拉取提交 * refactor(tag-input): 滚动逻辑内置至组件 utils * fix: 修复布局错误 * chore: stash changelog [ci skip] --------- Co-authored-by: tdesign-bot <tdesign@tencent.com>
1 parent 4c6bc71 commit af46902

8 files changed

Lines changed: 371 additions & 77 deletions

File tree

packages/components/tag-input/TagInput.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import useControlled from '../hooks/useControlled';
88
import useDefaultProps from '../hooks/useDefaultProps';
99
import useDragSorter from '../hooks/useDragSorter';
1010
import useGlobalIcon from '../hooks/useGlobalIcon';
11+
import useUpdateLayoutEffect from '../hooks/useUpdateLayoutEffect';
1112
import TInput from '../input';
1213
import { tagInputDefaultProps } from './defaultProps';
1314
import useHover from './useHover';
@@ -80,16 +81,24 @@ const TagInput = forwardRef<InputRef, TagInputProps>((originalProps, ref) => {
8081
getDragProps,
8182
});
8283

84+
useUpdateLayoutEffect(() => {
85+
if (excessTagsDisplayType === 'scroll') {
86+
scrollToRight();
87+
}
88+
}, [tagValue]);
89+
8390
const NAME_CLASS = `${prefix}-tag-input`;
8491
const WITH_SUFFIX_ICON_CLASS = `${prefix}-tag-input__with-suffix-icon`;
8592
const CLEAR_CLASS = `${prefix}-tag-input__suffix-clear`;
8693
const BREAK_LINE_CLASS = `${prefix}-tag-input--break-line`;
8794

8895
const tagInputPlaceholder = !tagValue?.length ? placeholder : '';
8996

90-
const showClearIcon = Boolean(!readOnly && !disabled && clearable && isHover && tagValue?.length);
97+
const showClearIcon = Boolean(!readOnly && !disabled && clearable && isHover && (tagValue?.length || tInputValue));
9198

92-
useImperativeHandle(ref as InputRef, () => ({ ...(tagInputRef.current || {}) }));
99+
useImperativeHandle(ref as InputRef, () => ({
100+
...(tagInputRef.current || {}),
101+
}));
93102

94103
const updateSuffixWidth = (selector: string, cssVar: string, widthRef: React.MutableRefObject<number>) => {
95104
const wrapperEl = tagInputRef.current?.currentElement as HTMLElement;
@@ -140,9 +149,11 @@ const TagInput = forwardRef<InputRef, TagInputProps>((originalProps, ref) => {
140149
};
141150

142151
const onInputEnter = (value: InputValue, context: { e: KeyboardEvent<HTMLInputElement> }) => {
152+
context.e?.preventDefault?.();
143153
setTInputValue('', { e: context.e, trigger: 'enter' });
144-
!isCompositionRef.current && onInnerEnter(value, context);
145-
scrollToRight();
154+
if (!isCompositionRef.current) {
155+
onInnerEnter(value, context);
156+
}
146157
};
147158

148159
const onInnerClick = (context: { e: MouseEvent<HTMLDivElement> }) => {

packages/components/tag-input/_example/custom-tag.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ export default function CustomTagExample() {
1818
<span>
1919
<img
2020
src="https://tdesign.gtimg.com/site/avatar.jpg"
21-
style={{ maxWidth: '18px', maxHeight: '18px', borderRadius: '50%', verticalAlign: 'text-top' }}
21+
style={{
22+
maxWidth: '18px',
23+
maxHeight: '18px',
24+
borderRadius: '50%',
25+
verticalAlign: 'text-top',
26+
}}
2227
/>
2328
{value}
2429
</span>
@@ -38,7 +43,12 @@ export default function CustomTagExample() {
3843
<div>
3944
<img
4045
src="https://tdesign.gtimg.com/site/avatar.jpg"
41-
style={{ maxWidth: '18px', maxHeight: '18px', borderRadius: '50%', verticalAlign: 'text-top' }}
46+
style={{
47+
maxWidth: '18px',
48+
maxHeight: '18px',
49+
borderRadius: '50%',
50+
verticalAlign: 'text-top',
51+
}}
4252
/>
4353
{item}
4454
</div>

packages/components/tag-input/_example/excess.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Space, TagInput } from 'tdesign-react';
44
import type { TagInputValue } from 'tdesign-react';
55

66
export default function TagInputExcessExample() {
7-
const [tags, setTags] = useState<TagInputValue>(['Vue', 'React']);
7+
const [tags, setTags] = useState<TagInputValue>(['Vue', 'React', 'Angular', 'Miniprogram', 'Uniapp', 'Flutter']);
88
return (
99
<Space direction="vertical" style={{ width: '80%' }}>
1010
{/* <!-- 标签数量超出时,滚动显示 --> */}

packages/components/tag-input/useTagScroll.ts

Lines changed: 52 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,90 @@
11
/**
22
* 当标签数量过多时,输入框显示不下,则需要滚动查看,以下为滚动逻辑
3-
* 如果标签过多时的处理方式,是标签省略,则不需要此功能
43
*/
5-
import { useEffect, useRef, useState } from 'react';
6-
import { isFunction } from 'lodash-es';
4+
import { useEffect, useRef } from 'react';
5+
6+
import useConfig from '../hooks/useConfig';
7+
import {
8+
getScrollContainer,
9+
handleWheelScroll,
10+
scrollToLeft as scrollToLeftBase,
11+
scrollToRight as scrollToRightBase,
12+
} from './utils/tagInputScroll';
713

814
import type { WheelEvent } from 'react';
915
import type { InputRef } from '../input';
1016
import type { TdTagInputProps } from './type';
1117

12-
let mouseEnterTimer = null;
13-
1418
export default function useTagScroll(props: TdTagInputProps) {
15-
const { excessTagsDisplayType = 'scroll', disabled } = props;
19+
const { excessTagsDisplayType, disabled } = props;
1620
const readOnly = props.readOnly || props.readonly;
21+
const { classPrefix: prefix } = useConfig();
1722

23+
/** 标签输入框组件 ref */
1824
const tagInputRef = useRef<InputRef>(null);
19-
20-
// 允许向右滚动的最大距离
21-
const [scrollDistance, setScrollDistance] = useState(0);
22-
const [scrollElement, setScrollElement] = useState<HTMLDivElement>();
23-
24-
const updateScrollElement = (element: HTMLDivElement) => {
25-
const scrollElement = element.children[0] as HTMLDivElement;
26-
setScrollElement(scrollElement);
27-
};
28-
29-
const updateScrollDistance = () => {
30-
if (!scrollElement) return;
31-
setScrollDistance(scrollElement.scrollWidth - scrollElement.clientWidth);
32-
};
33-
34-
const scrollTo = (distance: number) => {
35-
if (isFunction(scrollElement?.scroll)) {
36-
scrollElement.scroll({ left: distance, behavior: 'smooth' });
25+
/** 滚动容器元素缓存(.input__prefix) */
26+
const scrollElementRef = useRef<HTMLElement>();
27+
/** 进入防抖定时器 */
28+
const mouseEnterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29+
30+
/** 是否为可交互的 scroll 模式 */
31+
const isScrollMode = excessTagsDisplayType === 'scroll' && !readOnly && !disabled;
32+
33+
/** 获取滚动容器(带缓存) */
34+
const getScrollElement = () => {
35+
const root = tagInputRef.current?.currentElement as HTMLElement;
36+
if (!root) return scrollElementRef.current;
37+
if (scrollElementRef.current?.parentElement !== root) {
38+
const found = getScrollContainer(root, prefix);
39+
if (found) scrollElementRef.current = found;
3740
}
41+
return scrollElementRef.current;
3842
};
3943

44+
/** 滚动到最右侧并开启 scrollable,用于:悬浮进入、tag 变化后定位末尾 */
4045
const scrollToRight = () => {
41-
updateScrollDistance();
42-
scrollTo(scrollDistance);
46+
const el = getScrollElement();
47+
if (el) scrollToRightBase(el, prefix);
4348
};
4449

45-
const scrollToLeft = () => {
46-
scrollTo(0);
50+
/** 处理滚轮事件,绑定到 TInput 的 onWheel */
51+
const onWheel = ({ e }: { e: WheelEvent<HTMLDivElement> }) => {
52+
if (!isScrollMode) return;
53+
const el = getScrollElement();
54+
if (el) handleWheelScroll(el, e);
4755
};
4856

49-
// TODO:MAC 电脑横向滚动,Windows 纵向滚动。当前只处理了横向滚动
50-
const onWheel = ({ e }: { e: WheelEvent<HTMLDivElement> }) => {
51-
if (readOnly || disabled) return;
52-
if (!scrollElement) return;
53-
if (e.deltaX > 0) {
54-
const distance = Math.min(scrollElement.scrollLeft + 120, scrollDistance);
55-
scrollTo(distance);
56-
} else {
57-
const distance = Math.max(scrollElement.scrollLeft - 120, 0);
58-
scrollTo(distance);
57+
/** 清除防抖定时器 */
58+
const clearEnterTimer = () => {
59+
if (mouseEnterTimerRef.current) {
60+
clearTimeout(mouseEnterTimerRef.current);
61+
mouseEnterTimerRef.current = null;
5962
}
6063
};
6164

62-
// 鼠标 hover,自动滑动到最右侧,以便输入新标签
65+
/** 鼠标悬浮进入:延迟 100ms 后滚动到最右侧 */
6366
const scrollToRightOnEnter = () => {
64-
if (excessTagsDisplayType !== 'scroll') return;
65-
// 一闪而过的 mousenter 不需要执行
66-
mouseEnterTimer = setTimeout(() => {
67+
if (!isScrollMode) return;
68+
clearEnterTimer();
69+
mouseEnterTimerRef.current = setTimeout(() => {
6770
scrollToRight();
68-
clearTimeout(mouseEnterTimer);
71+
mouseEnterTimerRef.current = null;
6972
}, 100);
7073
};
7174

75+
/** 鼠标离开:滚回最左并关闭 scrollable */
7276
const scrollToLeftOnLeave = () => {
73-
if (excessTagsDisplayType !== 'scroll') return;
74-
scrollTo(0);
75-
clearTimeout(mouseEnterTimer);
76-
};
77-
78-
const clearScroll = () => {
79-
clearTimeout(mouseEnterTimer);
80-
};
81-
82-
const initScroll = (element: HTMLDivElement) => {
83-
if (!element) return;
84-
updateScrollElement(element);
77+
if (!isScrollMode) return;
78+
clearEnterTimer();
79+
const el = getScrollElement();
80+
if (el) scrollToLeftBase(el, prefix);
8581
};
8682

87-
useEffect(() => {
88-
initScroll(tagInputRef?.current?.currentElement);
89-
return clearScroll;
90-
// eslint-disable-next-line react-hooks/exhaustive-deps
91-
}, []);
83+
useEffect(() => clearEnterTimer, []);
9284

9385
return {
94-
initScroll,
95-
clearScroll,
9686
tagInputRef,
97-
scrollElement,
98-
scrollDistance,
99-
scrollTo,
10087
scrollToRight,
101-
scrollToLeft,
102-
updateScrollElement,
103-
updateScrollDistance,
10488
onWheel,
10589
scrollToRightOnEnter,
10690
scrollToLeftOnLeave,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* TagInput 滚动相关逻辑
3+
* 当标签数量过多时,输入框显示不下则需要滚动查看
4+
*/
5+
6+
const SCROLL_CONTAINER_CLASS = 'input__prefix';
7+
const SCROLLABLE_CLASS = 'input__prefix--scrollable';
8+
9+
/** 根据根元素查找滚动容器 .{classPrefix}-input__prefix */
10+
export function getScrollContainer(root: HTMLElement, classPrefix: string): HTMLElement | null {
11+
return root.querySelector(`.${classPrefix}-${SCROLL_CONTAINER_CLASS}`);
12+
}
13+
14+
/** 计算元素的最大可滚动距离 scrollWidth - clientWidth */
15+
export function getScrollDistance(el: HTMLElement): number {
16+
return el.scrollWidth - el.clientWidth;
17+
}
18+
19+
/** 设置/移除 scrollable 状态类,控制 overflow 是否开放 */
20+
export function setScrollableClass(el: HTMLElement, classPrefix: string, scrollable: boolean): void {
21+
const className = `${classPrefix}-${SCROLLABLE_CLASS}`;
22+
if (scrollable) {
23+
el.classList.add(className);
24+
} else {
25+
el.classList.remove(className);
26+
}
27+
}
28+
29+
/** 处理滚轮事件:MAC deltaX / Windows deltaY 兼容 */
30+
export function handleWheelScroll(el: HTMLElement, e: { deltaX: number; deltaY: number }): void {
31+
const delta = Math.abs(e.deltaX) >= Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
32+
if (delta === 0) return;
33+
const max = getScrollDistance(el);
34+
// eslint-disable-next-line no-param-reassign
35+
el.scrollLeft = Math.max(0, Math.min(el.scrollLeft + delta, max));
36+
}
37+
38+
/** 滚动到最左侧并移除 scrollable class(鼠标离开时) */
39+
export function scrollToLeft(el: HTMLElement, classPrefix: string): void {
40+
// eslint-disable-next-line no-param-reassign
41+
el.scrollLeft = 0;
42+
setScrollableClass(el, classPrefix, false);
43+
}
44+
45+
/** 滚动到最右侧并开启 scrollable class */
46+
export function scrollToRight(el: HTMLElement, classPrefix: string): void {
47+
setScrollableClass(el, classPrefix, true);
48+
// eslint-disable-next-line no-param-reassign
49+
el.scrollLeft = getScrollDistance(el);
50+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
pr_number: 4210
3+
contributor: RSS1102
4+
---
5+
6+
- fix(TagInput): 修复 `excessTagsDisplayType="scroll` 时,没有显示横向滚动条等交互问题 @RSS1102 ([#4210](https://github.com/Tencent/tdesign-react/pull/4210))
7+
- fix(TagInput): 在启用 `clearable` 的前提下,当仅输入文字时也可以显示清除按钮 @RSS1102 ([#4210](https://github.com/Tencent/tdesign-react/pull/4210))

0 commit comments

Comments
 (0)