-
Notifications
You must be signed in to change notification settings - Fork 328
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
d6933f1
commit 36cc3c9
Showing
21 changed files
with
1,097 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; | ||
import classNames from 'classnames'; | ||
import isArray from 'lodash/isArray'; | ||
import { Icon } from 'tdesign-icons-react'; | ||
import useConfig from '../hooks/useConfig'; | ||
import ChatItem from './components/ChatItem'; | ||
import type { TdChatItemProps } from './components/ChatItem'; | ||
import Popconfirm from '../popconfirm'; | ||
import Divider from '../divider'; | ||
import parseTNode from '../_util/parseTNode'; | ||
|
||
const handleScrollToBottom = (target: HTMLDivElement, behavior?: 'auto' | 'smooth') => { | ||
const currentScrollHeight = target.scrollHeight; | ||
const currentClientHeight = target.clientHeight; | ||
|
||
const innerBehavior = behavior ?? 'auto'; | ||
if (innerBehavior === 'auto') { | ||
// eslint-disable-next-line no-param-reassign | ||
target.scrollTop = currentScrollHeight - currentClientHeight; | ||
} else { | ||
const startScrollTop = target.scrollTop; | ||
const endScrollTop = currentScrollHeight - currentClientHeight; | ||
const duration = 300; | ||
const step = (endScrollTop - startScrollTop) / duration; | ||
let startTime: number | undefined; | ||
// 平滑地修改scrollTop值 | ||
const animateScroll = (time: number) => { | ||
if (!startTime) { | ||
startTime = time; | ||
} | ||
const elapsed = time - startTime; | ||
const top = Math.min(endScrollTop, startScrollTop + elapsed * step); | ||
// eslint-disable-next-line no-param-reassign | ||
target.scrollTop = top; | ||
if (top < endScrollTop) { | ||
requestAnimationFrame(animateScroll); | ||
} | ||
}; | ||
|
||
requestAnimationFrame(animateScroll); | ||
} | ||
}; | ||
|
||
const Chat = forwardRef<any, any>((props, ref) => { | ||
const { | ||
data, | ||
layout, | ||
clearHistory = true, | ||
reverse, | ||
// isStreamLoad, | ||
textLoading, | ||
footer, | ||
children, | ||
onClear, | ||
onScroll, | ||
} = props; | ||
const { classPrefix } = useConfig(); | ||
const chatBoxRef = useRef<HTMLDivElement | null>(null); | ||
|
||
const COMPONENT_NAME = `${classPrefix}-chat`; | ||
|
||
const classes = useMemo( | ||
() => | ||
classNames([ | ||
COMPONENT_NAME, | ||
{ | ||
[`${COMPONENT_NAME}--normal`]: layout === 'both', | ||
}, | ||
]), | ||
[COMPONENT_NAME, layout], | ||
); | ||
|
||
const listClasses = useMemo( | ||
() => | ||
classNames([ | ||
`${COMPONENT_NAME}__list`, | ||
{ | ||
[`${COMPONENT_NAME}__list--reverse`]: reverse, | ||
}, | ||
]), | ||
[COMPONENT_NAME, reverse], | ||
); | ||
|
||
const clearConfirm = (context: { e: React.MouseEvent<HTMLElement, MouseEvent> }) => { | ||
onClear?.(context); | ||
}; | ||
|
||
const renderBody = () => { | ||
/** | ||
* 1. 两种方式获取要渲染的 list | ||
* a. props 传 data | ||
* b. slots t-chat-item | ||
* a 优先级更高 | ||
*/ | ||
if (isArray(data) && data.length > 0) { | ||
const isLoading = (index: number) => (reverse ? index === 0 : index === data.length - 1) && textLoading; | ||
return data.map((item: TdChatItemProps, index: number) => ( | ||
<ChatItem | ||
key={index} | ||
avatar={item.avatar} | ||
name={item.name} | ||
role={item.role} | ||
datetime={item.datetime} | ||
content={item.content} | ||
textLoading={isLoading(index)} | ||
itemIndex={index} | ||
/> | ||
)); | ||
} | ||
return children; | ||
}; | ||
|
||
const defaultClearHistory = ( | ||
<Popconfirm content="确定要清空所有的消息吗?" onConfirm={clearConfirm}> | ||
<Divider className="clear-btn"> | ||
<Icon name="clear" size="14px" /> | ||
<span className="clear-btn-text">清空历史记录</span> | ||
</Divider> | ||
</Popconfirm> | ||
); | ||
|
||
const renderClearHistory = clearHistory && parseTNode(clearHistory, null, defaultClearHistory); | ||
|
||
// 滚动到底部 | ||
// BackBottomParams | ||
const scrollToBottom = (data: any) => { | ||
if (!chatBoxRef.current) return; | ||
const { behavior = 'auto' } = data; | ||
handleScrollToBottom(chatBoxRef.current, behavior); | ||
}; | ||
|
||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { | ||
onScroll?.({ e }); | ||
}; | ||
|
||
useImperativeHandle(ref, () => ({ | ||
scrollToBottom, | ||
})); | ||
|
||
return ( | ||
<div className={classes}> | ||
<div className={listClasses} ref={chatBoxRef} onScroll={handleScroll}> | ||
{reverse && <div className="place-holder"></div>} | ||
{reverse && renderClearHistory} | ||
{renderBody()} | ||
{!reverse && renderClearHistory} | ||
</div> | ||
{!!footer && <div className={`${COMPONENT_NAME}__footer`}>{parseTNode(footer)}</div>} | ||
</div> | ||
); | ||
}); | ||
|
||
Chat.displayName = 'Chat'; | ||
|
||
export default Chat; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
import MockDate from 'mockdate'; | ||
import { fireEvent, render, waitFor, vi, mockDelay } from '@test/utils'; | ||
import React, { useState } from 'react'; | ||
import TimePicker from '../index'; | ||
|
||
// 固定时间,当使用 new Date() 时,返回固定时间,防止“当前时间”的副作用影响,导致 snapshot 变更,mockdate 插件见 https://github.com/boblauer/MockDate | ||
MockDate.set('2022-08-27'); | ||
|
||
// TODO | ||
describe('Timepicker 组件测试', () => { | ||
test('props.disabled works fine', () => { | ||
// disabled default value is | ||
const wrapper1 = render(<TimePicker></TimePicker>); | ||
const container1 = wrapper1.container.querySelector('.t-time-picker'); | ||
expect(container1.querySelector('.t-is-disabled')).toBeFalsy(); | ||
// disabled = true | ||
const wrapper2 = render(<TimePicker disabled={true}></TimePicker>); | ||
const container2 = wrapper2.container.querySelector('.t-time-picker .t-input'); | ||
expect(container2).toHaveClass('t-is-disabled'); | ||
// disabled = false | ||
const wrapper3 = render(<TimePicker disabled={false}></TimePicker>); | ||
const container3 = wrapper3.container.querySelector('.t-time-picker'); | ||
expect(container3.querySelector('.t-is-disabled')).toBeFalsy(); | ||
}); | ||
|
||
test('trigger panel works fine', async () => { | ||
const { container } = render(<TimePicker></TimePicker>); | ||
expect(container.querySelectorAll('input').length).toBe(1); | ||
fireEvent.click(document.querySelector('input')); | ||
await waitFor(() => { | ||
expect(document.querySelector('.t-time-picker__panel')).not.toBeNull(); | ||
expect(document.querySelector('.t-time-picker__panel')).toHaveStyle({ | ||
display: 'block', | ||
}); | ||
expect(document.querySelectorAll('.t-time-picker__panel-body-scroll').length).toBe(3); | ||
}); | ||
}); | ||
|
||
test('props.defaultValue works fine', async () => { | ||
const { container } = render(<TimePicker defaultValue="00:10:20"></TimePicker>); | ||
expect(container.querySelectorAll('input').length).toBe(1); | ||
expect(container.querySelectorAll('input').item(0)).toHaveValue('00:10:20'); | ||
fireEvent.click(document.querySelector('input')); | ||
await waitFor(() => { | ||
const scrollPanels = document.querySelectorAll('.t-time-picker__panel-body-scroll'); | ||
expect(scrollPanels.item(0).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('00'); | ||
expect(scrollPanels.item(1).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('10'); | ||
expect(scrollPanels.item(2).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('20'); | ||
}); | ||
}); | ||
|
||
test('props.defaultValue for TimePicker works fine', async () => { | ||
const { container } = render(<TimePicker defaultValue="00:10:20"></TimePicker>); | ||
expect(container.querySelectorAll('input').length).toBe(1); | ||
expect(container.querySelectorAll('input').item(0)).toHaveValue('00:10:20'); | ||
fireEvent.click(document.querySelector('input')); | ||
await waitFor(() => { | ||
const scrollPanels = document.querySelectorAll('.t-time-picker__panel-body-scroll'); | ||
expect(scrollPanels.item(0).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('00'); | ||
expect(scrollPanels.item(1).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('10'); | ||
expect(scrollPanels.item(2).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('20'); | ||
}); | ||
}); | ||
|
||
test('props.defaultValue for TimeRangePicker works fine', async () => { | ||
const { container } = render(<TimePicker.TimeRangePicker defaultValue={['00:00:00', '00:10:20']} />); | ||
const inputs = container.querySelectorAll('input'); | ||
expect(inputs.length).toBe(2); | ||
expect(inputs.item(0)).toHaveValue('00:00:00'); | ||
expect(inputs.item(1)).toHaveValue('00:10:20'); | ||
fireEvent.click(inputs.item(1)); | ||
await waitFor(() => { | ||
const scrollPanels = document.querySelectorAll('.t-time-picker__panel-body-scroll'); | ||
expect(scrollPanels.item(0).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('00'); | ||
expect(scrollPanels.item(1).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('10'); | ||
expect(scrollPanels.item(2).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('20'); | ||
}); | ||
}); | ||
|
||
test('props.value for TimePickerPanel works fine', () => { | ||
const onChange = vi.fn(); | ||
const { container } = render(<TimePicker.TimePickerPanel value={'00:10:20'} onChange={onChange} />); | ||
const scrollPanels = container.querySelectorAll('.t-time-picker__panel-body-scroll'); | ||
expect(scrollPanels.item(0).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('00'); | ||
expect(scrollPanels.item(1).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('10'); | ||
expect(scrollPanels.item(2).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('20'); | ||
}); | ||
|
||
test('props.allowInput works fine', async () => { | ||
const handleBlur = vi.fn(); | ||
const handleFocus = vi.fn(); | ||
const { container } = render(<TimePicker onBlur={handleBlur} onFocus={handleFocus} allowInput />); | ||
const InputDom = container.querySelector('.t-input__inner'); | ||
fireEvent.click(InputDom); | ||
expect(handleFocus).toBeCalledTimes(1); | ||
// input blur is not equal to TimePicker.blur | ||
fireEvent.mouseDown(document); | ||
expect(handleBlur).toBeCalledTimes(1); | ||
}); | ||
|
||
test('props.onInput&onBlur&onForus works fine', async () => { | ||
const handleBlur = vi.fn(); | ||
const handleInput = vi.fn(); | ||
const handleFocus = vi.fn(); | ||
const { container } = render( | ||
<TimePicker.TimeRangePicker allowInput onInput={handleInput} onBlur={handleBlur} onFocus={handleFocus} />, | ||
); | ||
const inputs = container.querySelectorAll('input'); | ||
fireEvent.focus(inputs[0]); | ||
expect(handleFocus).toBeCalledTimes(1); | ||
fireEvent.change(inputs[0], { target: { value: '00:10:20' } }); | ||
expect(handleInput).toBeCalledTimes(1); | ||
fireEvent.blur(inputs[0]); | ||
expect(handleBlur).toBeCalledTimes(1); | ||
}); | ||
|
||
test('click to pick', async () => { | ||
const handleChange = vi.fn(); | ||
const handleOpen = vi.fn(); | ||
const handlePick = vi.fn(); | ||
render( | ||
<TimePicker defaultValue="00:00:00" onChange={handleChange} onOpen={handleOpen} onPick={handlePick}></TimePicker>, | ||
); | ||
fireEvent.click(document.querySelector('input')); | ||
expect(handleOpen).toBeCalledTimes(1); | ||
await waitFor(async () => { | ||
const confirmBtn = document.querySelectorAll('.t-time-picker__panel button').item(0); | ||
expect(confirmBtn).toBeInTheDocument(); | ||
const panelItem1 = document.querySelectorAll('.t-time-picker__panel-body-scroll').item(0); | ||
fireEvent.click(panelItem1.querySelectorAll('.t-time-picker__panel-body-scroll-item').item(1)); | ||
|
||
expect(handlePick).toHaveBeenCalled(); | ||
fireEvent.click(confirmBtn); | ||
// expect(container.querySelectorAll('input').item(0)).toHaveValue('01:00:00'); | ||
// expect(handleChange).toHaveBeenCalled(1); | ||
}); | ||
}); | ||
|
||
test('TimePicker presets test', async () => { | ||
const date = '20:00:00'; | ||
const { container, getByText } = render( | ||
<TimePicker | ||
value={date} | ||
presets={{ | ||
此刻: '11:00:00', | ||
}} | ||
clearable | ||
/>, | ||
); | ||
|
||
fireEvent.click(document.querySelector('.t-input')); | ||
await mockDelay(); | ||
fireEvent.click(getByText('此刻')); | ||
fireEvent.click(getByText('确定')); | ||
|
||
const inputs = container.querySelectorAll('input'); | ||
expect(inputs.item(0).value).toBe(date); | ||
}); | ||
|
||
test('TimeRangePick presets test', async () => { | ||
const date = ['11:00:00', '12:00:00']; | ||
const defaultValue = ['00:00:01', '00:00:02']; | ||
|
||
const RangePicker = () => { | ||
const [value, setValue] = useState(defaultValue); | ||
const onChange = (value) => { | ||
setValue(value); | ||
}; | ||
|
||
return ( | ||
<TimePicker.TimeRangePicker | ||
value={value} | ||
onChange={onChange} | ||
allow-input | ||
presets={{ | ||
此刻: ['11:00:00', '12:00:00'], | ||
}} | ||
format="HH:mm:ss" | ||
clearable | ||
/> | ||
); | ||
}; | ||
const { getByText } = render(<RangePicker />); | ||
fireEvent.click(document.querySelectorAll('input').item(0)); | ||
await mockDelay(); | ||
fireEvent.click(getByText('此刻')); | ||
fireEvent.click(document.querySelectorAll('input').item(1)); | ||
await mockDelay(); | ||
fireEvent.click(getByText('此刻')); | ||
fireEvent.click(getByText('确定')); | ||
const inputs = document.querySelectorAll('input'); | ||
expect(inputs.item(0).value).toBe(date[0]); | ||
expect(inputs.item(1).value).toBe(date[1]); | ||
}); | ||
}); |
Oops, something went wrong.