Skip to content

Commit 902bcca

Browse files
authored
feat: Bubble.List support (#57)
* feat: init list * chore: trigger event * feat: scroll of it * test: update snapshot * fix: displayName * test: fix test case * test: coverage * test: coverage * test: coverage * test: coverage * docs: update api * chore: fix lint * chore: update ts * chore: update ts * chore: adjust scroll * test: coverage * chore: comment
1 parent d110632 commit 902bcca

21 files changed

+962
-119
lines changed

.prettierrc

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"singleQuote": true,
55
"tabWidth": 2,
66
"printWidth": 100,
7+
"proseWrap": "never",
78
"trailingComma": "all",
89
"jsxSingleQuote": false
910
}

components/bubble/Bubble.tsx

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import React from 'react';
2+
import classnames from 'classnames';
3+
4+
import type { BubbleProps } from './interface';
5+
import Loading from './loading';
6+
import useStyle from './style';
7+
import useTypedEffect from './hooks/useTypedEffect';
8+
import { Avatar } from 'antd';
9+
import useTypingConfig from './hooks/useTypingConfig';
10+
import useConfigContext from '../config-provider/useConfigContext';
11+
12+
export interface BubbleRef {
13+
nativeElement: HTMLElement;
14+
}
15+
16+
export interface BubbleContextProps {
17+
onUpdate?: VoidFunction;
18+
}
19+
20+
export const BubbleContext = React.createContext<BubbleContextProps>({});
21+
22+
const Bubble: React.ForwardRefRenderFunction<BubbleRef, BubbleProps> = (props, ref) => {
23+
const {
24+
prefixCls: customizePrefixCls,
25+
className,
26+
rootClassName,
27+
style,
28+
classNames,
29+
styles,
30+
avatar,
31+
placement = 'start',
32+
loading = false,
33+
typing,
34+
content = '',
35+
messageRender,
36+
...otherHtmlProps
37+
} = props;
38+
39+
const { onUpdate } = React.useContext(BubbleContext);
40+
41+
// ============================= Refs =============================
42+
const divRef = React.useRef<HTMLDivElement>(null);
43+
44+
React.useImperativeHandle(ref, () => ({
45+
nativeElement: divRef.current!,
46+
}));
47+
48+
// ============================ Prefix ============================
49+
const { direction, getPrefixCls } = useConfigContext();
50+
51+
const prefixCls = getPrefixCls('bubble', customizePrefixCls);
52+
53+
// ============================ Typing ============================
54+
const [typingEnabled, typingStep, typingInterval] = useTypingConfig(typing);
55+
56+
const [typedContent, isTyping] = useTypedEffect(
57+
content,
58+
typingEnabled,
59+
typingStep,
60+
typingInterval,
61+
);
62+
63+
React.useEffect(() => {
64+
onUpdate?.();
65+
}, [typedContent]);
66+
67+
// ============================ Styles ============================
68+
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);
69+
70+
const mergedCls = classnames(
71+
className,
72+
rootClassName,
73+
prefixCls,
74+
hashId,
75+
cssVarCls,
76+
`${prefixCls}-${placement}`,
77+
{
78+
[`${prefixCls}-rtl`]: direction === 'rtl',
79+
[`${prefixCls}-typing`]: isTyping && !loading && !messageRender,
80+
},
81+
);
82+
83+
// ============================ Avatar ============================
84+
const avatarNode = React.isValidElement(avatar) ? avatar : <Avatar {...avatar} />;
85+
86+
// =========================== Content ============================
87+
const mergedContent = messageRender ? messageRender(typedContent) : typedContent;
88+
89+
// ============================ Render ============================
90+
return wrapCSSVar(
91+
<div style={style} className={mergedCls} {...otherHtmlProps} ref={divRef}>
92+
{/* Avatar */}
93+
{avatar && (
94+
<div
95+
style={styles?.avatar}
96+
className={classnames(`${prefixCls}-avatar`, classNames?.avatar)}
97+
>
98+
{avatarNode}
99+
</div>
100+
)}
101+
102+
{/* Content */}
103+
<div
104+
style={styles?.content}
105+
className={classnames(`${prefixCls}-content`, classNames?.content)}
106+
>
107+
{loading ? <Loading prefixCls={prefixCls} /> : mergedContent}
108+
</div>
109+
</div>,
110+
);
111+
};
112+
113+
const ForwardBubble = React.forwardRef(Bubble);
114+
115+
if (process.env.NODE_ENV !== 'production') {
116+
ForwardBubble.displayName = 'Bubble';
117+
}
118+
119+
export default ForwardBubble;

components/bubble/BubbleList.tsx

+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import * as React from 'react';
2+
import pickAttrs from 'rc-util/lib/pickAttrs';
3+
import useConfigContext from '../config-provider/useConfigContext';
4+
import classNames from 'classnames';
5+
import type { BubbleProps } from './interface';
6+
import Bubble, { BubbleContext } from './Bubble';
7+
import type { BubbleRef } from './Bubble';
8+
import useStyle from './style';
9+
import { useEvent } from 'rc-util';
10+
import useListData from './hooks/useListData';
11+
12+
export interface BubbleListRef {
13+
nativeElement: HTMLDivElement;
14+
scrollTo: (info: {
15+
offset?: number;
16+
key?: string | number;
17+
behavior?: ScrollBehavior;
18+
block?: ScrollLogicalPosition;
19+
}) => void;
20+
}
21+
22+
export type BubbleDataType = BubbleProps & {
23+
key?: string | number;
24+
role?: string;
25+
};
26+
27+
export type RoleType = Partial<Omit<BubbleProps, 'content'>>;
28+
29+
export type RolesType = Record<string, RoleType> | ((bubbleDataP: BubbleDataType) => RoleType);
30+
31+
export interface BubbleListProps extends React.HTMLAttributes<HTMLDivElement> {
32+
prefixCls?: string;
33+
rootClassName?: string;
34+
data?: BubbleDataType[];
35+
autoScroll?: boolean;
36+
roles?: RolesType;
37+
}
38+
39+
const BubbleList: React.ForwardRefRenderFunction<BubbleListRef, BubbleListProps> = (props, ref) => {
40+
const {
41+
prefixCls: customizePrefixCls,
42+
rootClassName,
43+
className,
44+
data,
45+
autoScroll = true,
46+
roles,
47+
...restProps
48+
} = props;
49+
const domProps = pickAttrs(restProps, {
50+
attr: true,
51+
aria: true,
52+
});
53+
54+
// ============================= Refs =============================
55+
const listRef = React.useRef<HTMLDivElement>(null);
56+
57+
const bubbleRefs = React.useRef<Record<string, BubbleRef>>({});
58+
59+
// ============================ Prefix ============================
60+
const { getPrefixCls } = useConfigContext();
61+
62+
const prefixCls = getPrefixCls('bubble', customizePrefixCls);
63+
const listPrefixCls = `${prefixCls}-list`;
64+
65+
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);
66+
67+
// ============================= Data =============================
68+
const mergedData = useListData(data, roles);
69+
70+
// ============================ Scroll ============================
71+
// Is current scrollTop at the end. User scroll will make this false.
72+
const [scrollReachEnd, setScrollReachEnd] = React.useState(true);
73+
74+
const [updateCount, setUpdateCount] = React.useState(0);
75+
76+
const onInternalScroll: React.UIEventHandler<HTMLDivElement> = (e) => {
77+
const target = e.target as HTMLElement;
78+
79+
setScrollReachEnd(target.scrollTop + target.clientHeight === target.scrollHeight);
80+
};
81+
82+
React.useEffect(() => {
83+
if (autoScroll && listRef.current && scrollReachEnd) {
84+
listRef.current.scrollTo({
85+
top: listRef.current.scrollHeight,
86+
});
87+
}
88+
}, [updateCount]);
89+
90+
// Always scroll to bottom when data change
91+
React.useEffect(() => {
92+
if (autoScroll) {
93+
// New date come, the origin last one is the second last one
94+
const lastItemKey = mergedData[mergedData.length - 2]?.key;
95+
const bubbleInst = bubbleRefs.current[lastItemKey!];
96+
97+
// Auto scroll if last 2 item is visible
98+
if (bubbleInst) {
99+
const { nativeElement } = bubbleInst;
100+
const { top, bottom } = nativeElement.getBoundingClientRect();
101+
const { top: listTop, bottom: listBottom } = listRef.current!.getBoundingClientRect();
102+
103+
const isVisible = top < listBottom && bottom > listTop;
104+
if (isVisible) {
105+
setUpdateCount((c) => c + 1);
106+
setScrollReachEnd(true);
107+
}
108+
}
109+
}
110+
}, [mergedData.length]);
111+
112+
// ========================== Outer Ref ===========================
113+
React.useImperativeHandle(ref, () => ({
114+
nativeElement: listRef.current!,
115+
scrollTo: ({ key, offset, behavior = 'smooth', block }) => {
116+
if (typeof offset === 'number') {
117+
// Offset scroll
118+
listRef.current!.scrollTo({
119+
top: offset,
120+
behavior,
121+
});
122+
} else if (key !== undefined) {
123+
// Key scroll
124+
const bubbleInst = bubbleRefs.current[key];
125+
126+
if (bubbleInst) {
127+
// Block current auto scrolling
128+
const index = mergedData.findIndex((dataItem) => dataItem.key === key);
129+
setScrollReachEnd(index === mergedData.length - 1);
130+
131+
// Do native scroll
132+
bubbleInst.nativeElement.scrollIntoView({
133+
behavior,
134+
block,
135+
});
136+
}
137+
}
138+
},
139+
}));
140+
141+
// =========================== Context ============================
142+
// When bubble content update, we try to trigger `autoScroll` for sync
143+
const onBubbleUpdate = useEvent(() => {
144+
if (autoScroll) {
145+
setUpdateCount((c) => c + 1);
146+
}
147+
});
148+
149+
const context = React.useMemo(
150+
() => ({
151+
onUpdate: onBubbleUpdate,
152+
}),
153+
[],
154+
);
155+
156+
// ============================ Render ============================
157+
return wrapCSSVar(
158+
<BubbleContext.Provider value={context}>
159+
<div
160+
{...domProps}
161+
className={classNames(listPrefixCls, rootClassName, className, hashId, cssVarCls, {
162+
[`${listPrefixCls}-reach-end`]: scrollReachEnd,
163+
})}
164+
ref={listRef}
165+
onScroll={onInternalScroll}
166+
>
167+
{mergedData.map(({ key, ...bubble }) => (
168+
<Bubble
169+
{...bubble}
170+
key={key}
171+
ref={(node) => {
172+
if (node) {
173+
bubbleRefs.current[key] = node;
174+
} else {
175+
delete bubbleRefs.current[key];
176+
}
177+
}}
178+
/>
179+
))}
180+
</div>
181+
</BubbleContext.Provider>,
182+
);
183+
};
184+
185+
const ForwardBubbleList = React.forwardRef(BubbleList);
186+
187+
if (process.env.NODE_ENV !== 'production') {
188+
ForwardBubbleList.displayName = 'BubbleList';
189+
}
190+
191+
export default ForwardBubbleList;

0 commit comments

Comments
 (0)