feat: InputText多选模式下的tag自适应展示tooltip(OverflowTpl) (#9055)

This commit is contained in:
RUNZE LU 2023-12-14 19:36:37 +08:00 committed by GitHub
parent 4ccb9d8bf6
commit 0364aa57a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 396 additions and 119 deletions

View File

@ -230,7 +230,7 @@ order: 56
"value": "a"
},
{
"label": "OptionB",
"label": "OptionB (with long suffix for testing ellipsis)",
"value": "b"
},
{

View File

@ -46,11 +46,11 @@
"qs": "6.9.7"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^6.1.1",
"@babel/generator": "^7.22.9",
"@babel/parser": "^7.22.7",
"@babel/traverse": "^7.22.8",
"@babel/types": "^7.22.5",
"@fortawesome/fontawesome-free": "^6.1.1",
"@rollup/plugin-replace": "^5.0.1",
"@types/express": "^4.17.14",
"@types/jest": "^28.1.0",
@ -143,4 +143,4 @@
"printBasicPrototype": false
}
}
}
}

View File

@ -17,6 +17,9 @@ Object.defineProperty(window, 'DragEvent', {
value: class DragEvent {}
});
// Mock ResizeObserver in jest env
global.ResizeObserver = require('resize-observer-polyfill');
global.__buildVersion = '';
global.beforeAll(() => {

View File

@ -25,6 +25,7 @@
"moment-timezone": "^0.5.34",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"resize-observer-polyfill": "^1.5.1",
"rimraf": "^3.0.2",
"rollup": "^2.79.1",
"rollup-plugin-auto-external": "^2.0.0",

View File

@ -18,6 +18,11 @@ const pages: TreeArray = [
label: '按钮',
path: '/basic/button',
component: React.lazy(() => import('./basic/Button'))
},
{
label: '文字提示',
path: '/basic/overflow-tpl',
component: React.lazy(() => import('./basic/OverflowTpl'))
}
]
},

View File

@ -0,0 +1,29 @@
import React from 'react';
import {OverflowTpl} from 'amis-ui';
export default function ButtonExamples() {
return (
<div className="wrapper">
<div className="flex justify-items-start items-center">
{[
{text: 'Transforming business'},
{
text: "Innovating, creating, succeeding. Let's make a difference together."
},
{text: 'Bringing technology to the forefront'},
{text: 'Driving change in the industry'},
{text: 'Enriching the journey with'}
].map((item, index) => (
<div
className="text-xs inline-flex items-center font-bold leading-sm uppercase px-3 py-1 bg-blue-200 text-blue-700 rounded-full"
style={{maxWidth: '190px'}}
>
<OverflowTpl key={index} tooltip={item.text}>
{item.text}
</OverflowTpl>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
.#{$ns}OverflowTpl {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}

View File

@ -141,3 +141,4 @@
@import '../components/debug';
@import '../components/menu';
@import '../components/overflow-tpl';

View File

@ -1046,6 +1046,7 @@ export class DateRangePicker extends React.Component<
) {
newState.editState = 'end';
}
this.setState(newState);
}

View File

@ -0,0 +1,181 @@
/**
* @file OverflowTpl
* @desc ellipsistooltip提示, 使tooltip内容
*/
import React, {useState, useEffect, useRef, useCallback} from 'react';
import {findDOMNode} from 'react-dom';
import omit from 'lodash/omit';
import {themeable, isObject} from 'amis-core';
import TooltipWrapper from './TooltipWrapper';
import type {ThemeProps} from 'amis-core';
import type {TooltipWrapperProps} from './TooltipWrapper';
export interface OverflowTplProps extends ThemeProps {
className?: string;
/**
* tooltip相关配置
*/
tooltip?: TooltipWrapperProps['tooltip'];
/**
* 使 true使 span 使 div
*/
inline?: boolean;
/**
* selector
*/
targetSelector?: string;
/**
*
*/
children?: React.ReactNode | React.ReactNode[];
}
const OverflowTpl: React.FC<OverflowTplProps> = props => {
const {
classnames: cx,
children,
className,
targetSelector,
tooltip,
inline = true
} = props;
const [ellipsisAvtived, setEllipsisAvtived] = useState(false);
const [innerText, setInnerText] = useState('');
const innerRef = useRef<Element | React.ReactInstance>(null);
const defaultTooltip = tooltip
? typeof tooltip === 'string'
? {content: tooltip}
: isObject(tooltip)
? tooltip
: undefined
: typeof children === 'string'
? {content: children}
: undefined; /** 默认使用子节点文本 */
const normalizedTooltip =
innerText && (!defaultTooltip || defaultTooltip?.content == null)
? {...defaultTooltip, content: innerText}
: defaultTooltip;
const updateEllipsisActivation = useCallback(
(el?: Element) => {
setEllipsisAvtived(
el
? el.scrollWidth > el.clientWidth || el.scrollHeight > el.scrollHeight
: false
);
},
[innerRef.current]
);
const onMutation = useCallback(
(mutations: MutationRecord[]) => {
const dom = (
targetSelector
? document.querySelector(targetSelector)
: mutations?.[0].target
) as Element | undefined;
updateEllipsisActivation(dom);
if (
dom?.textContent &&
typeof dom.textContent === 'string' &&
(!defaultTooltip || defaultTooltip?.content == null)
) {
setInnerText(dom.textContent);
}
},
[targetSelector]
);
const onResize = useCallback(
(entries: ResizeObserverEntry[]) => {
const dom = (
targetSelector
? document.querySelector(targetSelector)
: entries?.[0].target
) as Element | undefined;
updateEllipsisActivation(dom);
if (
dom?.textContent &&
typeof dom.textContent === 'string' &&
(!defaultTooltip || !defaultTooltip?.content == null)
) {
setInnerText(dom.textContent);
}
},
[targetSelector]
);
/** 监听目标元素的DOM变化 */
useEffect(() => {
const element =
innerRef.current instanceof React.Component
? (findDOMNode(innerRef.current) as Element)
: innerRef.current;
if (element) {
const observer = new MutationObserver(onMutation);
observer.observe(element, {
childList: true,
subtree: true,
characterDataOldValue: true,
characterData: true
});
return () => observer.disconnect();
}
return;
}, [innerRef.current]);
/** 监听目标元素的尺寸变化 */
useEffect(() => {
const element =
innerRef.current instanceof React.Component
? (findDOMNode(innerRef.current) as Element)
: innerRef.current;
if (element) {
const observer = new ResizeObserver(onResize);
observer.observe(element);
return () => observer.disconnect();
}
return;
}, [innerRef.current]);
const WrapComponent = inline !== false ? 'span' : 'div';
const showTooltip = ellipsisAvtived && normalizedTooltip;
const Body = React.isValidElement(children) ? (
React.cloneElement(children as React.ReactElement, {ref: innerRef})
) : (
<WrapComponent
ref={innerRef as React.RefObject<HTMLDivElement>}
className={cx('OverflowTpl', className, {
'OverflowTpl--with-tooltip': showTooltip
})}
>
{children}
</WrapComponent>
);
return showTooltip ? (
<TooltipWrapper
{...omit(props, ['className', 'inline', 'targetSelector', 'children'])}
tooltip={normalizedTooltip}
>
{Body}
</TooltipWrapper>
) : (
Body
);
};
OverflowTpl.defaultProps = {
inline: true
};
export default themeable(OverflowTpl);

View File

@ -129,6 +129,7 @@ import Menu from './menu';
import InputBoxWithSuggestion from './InputBoxWithSuggestion';
import {CodeMirrorEditor} from './CodeMirror';
import type CodeMirror from 'codemirror';
import OverflowTpl from './OverflowTpl';
export {
NotFound,
@ -261,5 +262,6 @@ export {
DndContainer,
Menu,
CodeMirror,
CodeMirrorEditor
CodeMirrorEditor,
OverflowTpl
};

View File

@ -1493,7 +1493,7 @@ exports[`Renderer:text with options 1`] = `
</div>
`;
exports[`Renderer:text with options and multiple and delimiter: first option selected 1`] = `
exports[`Renderer:text with options and multiple Renderer:text with options and multiple and delimiter: first option selected 1`] = `
<div>
<div
class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -1556,7 +1556,7 @@ exports[`Renderer:text with options and multiple and delimiter: first option sel
class="cxd-TextControl-value"
>
<span
class="cxd-TextControl-valueLabel"
class="cxd-OverflowTpl cxd-TextControl-valueLabel"
>
OptionA
</span>
@ -1738,7 +1738,7 @@ exports[`Renderer:text with options and multiple and delimiter: first option sel
</div>
`;
exports[`Renderer:text with options and multiple and delimiter: options is opened 1`] = `
exports[`Renderer:text with options and multiple Renderer:text with options and multiple and delimiter: options is opened 1`] = `
<div>
<div
class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -1984,7 +1984,7 @@ exports[`Renderer:text with options and multiple and delimiter: options is opene
</div>
`;
exports[`Renderer:text with options and multiple and delimiter: options is opened again, and first option already selected 1`] = `
exports[`Renderer:text with options and multiple Renderer:text with options and multiple and delimiter: options is opened again, and first option already selected 1`] = `
<div>
<div
class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -2047,7 +2047,7 @@ exports[`Renderer:text with options and multiple and delimiter: options is opene
class="cxd-TextControl-value"
>
<span
class="cxd-TextControl-valueLabel"
class="cxd-OverflowTpl cxd-TextControl-valueLabel"
>
OptionA
</span>
@ -2229,7 +2229,7 @@ exports[`Renderer:text with options and multiple and delimiter: options is opene
</div>
`;
exports[`Renderer:text with options and multiple and delimiter: second option selected 1`] = `
exports[`Renderer:text with options and multiple Renderer:text with options and multiple and delimiter: second option selected 1`] = `
<div>
<div
class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -2292,7 +2292,7 @@ exports[`Renderer:text with options and multiple and delimiter: second option se
class="cxd-TextControl-value"
>
<span
class="cxd-TextControl-valueLabel"
class="cxd-OverflowTpl cxd-TextControl-valueLabel"
>
OptionA
</span>
@ -2305,7 +2305,7 @@ exports[`Renderer:text with options and multiple and delimiter: second option se
class="cxd-TextControl-value"
>
<span
class="cxd-TextControl-valueLabel"
class="cxd-OverflowTpl cxd-TextControl-valueLabel"
>
OptionB
</span>
@ -2477,7 +2477,7 @@ exports[`Renderer:text with options and multiple and delimiter: second option se
</div>
`;
exports[`Renderer:text with options and multiple and delimiter: thrid option create 1`] = `
exports[`Renderer:text with options and multiple Renderer:text with options and multiple and delimiter: thrid option create 1`] = `
<div>
<div
class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -2538,7 +2538,7 @@ exports[`Renderer:text with options and multiple and delimiter: thrid option cre
class="cxd-TextControl-value"
>
<span
class="cxd-TextControl-valueLabel"
class="cxd-OverflowTpl cxd-TextControl-valueLabel"
>
OptionA
</span>
@ -2551,7 +2551,7 @@ exports[`Renderer:text with options and multiple and delimiter: thrid option cre
class="cxd-TextControl-value"
>
<span
class="cxd-TextControl-valueLabel"
class="cxd-OverflowTpl cxd-TextControl-valueLabel"
>
OptionB
</span>
@ -2564,7 +2564,7 @@ exports[`Renderer:text with options and multiple and delimiter: thrid option cre
class="cxd-TextControl-value"
>
<span
class="cxd-TextControl-valueLabel"
class="cxd-OverflowTpl cxd-TextControl-valueLabel"
>
AbCd
</span>

View File

@ -1,5 +1,5 @@
import React = require('react');
import {render, cleanup, fireEvent, waitFor} from '@testing-library/react';
import {render, cleanup, fireEvent, waitFor, screen} from '@testing-library/react';
import '../../../src';
import {render as amisRender} from '../../../src';
import {makeEnv, replaceReactAriaIds, wait} from '../../helper';
@ -248,106 +248,153 @@ test('Renderer:text with options', async () => {
expect(container).toMatchSnapshot('select first option');
});
/**
*
*/
test('Renderer:text with options and multiple and delimiter', async () => {
const {container, input} = await setup(
{
multiple: true,
options: [
{
label: 'OptionA',
value: 'a'
},
{
label: 'OptionB',
value: 'b'
},
{
label: 'OptionC',
value: 'c'
},
{
label: 'OptionD',
value: 'd'
}
],
delimiter: '-',
joinValues: true,
creatable: true
},
{},
[
describe('Renderer:text with options and multiple', () => {
/**
*
*/
test('Renderer:text with options and multiple and delimiter', async () => {
const {container, input} = await setup(
{
type: 'static',
name: 'text'
}
]
);
multiple: true,
options: [
{
label: 'OptionA',
value: 'a'
},
{
label: 'OptionB',
value: 'b'
},
{
label: 'OptionC',
value: 'c'
},
{
label: 'OptionD',
value: 'd'
}
],
delimiter: '-',
joinValues: true,
creatable: true
},
{},
[
{
type: 'static',
name: 'text'
}
]
);
const textControl = container.querySelector(
'.cxd-TextControl-input'
) as HTMLElement;
const textControl = container.querySelector(
'.cxd-TextControl-input'
) as HTMLElement;
// 展开 options
fireEvent.click(textControl);
await wait(300);
// 展开 options
fireEvent.click(textControl);
await wait(300);
replaceReactAriaIds(container);
expect(container).toMatchSnapshot('options is opened');
replaceReactAriaIds(container);
expect(container).toMatchSnapshot('options is opened');
// 选中第一项
fireEvent.click(
container.querySelector(
'.cxd-TextControl-sugs .cxd-TextControl-sugItem'
) as HTMLElement
);
await wait(300);
// expect(input.value).toBe('a');
// 选中第一项
fireEvent.click(
container.querySelector(
'.cxd-TextControl-sugs .cxd-TextControl-sugItem'
) as HTMLElement
);
await wait(300);
// expect(input.value).toBe('a');
replaceReactAriaIds(container);
expect(container).toMatchSnapshot('first option selected');
replaceReactAriaIds(container);
expect(container).toMatchSnapshot('first option selected');
// 再次打开 options
fireEvent.click(textControl);
await wait(300);
// 再次打开 options
fireEvent.click(textControl);
await wait(300);
replaceReactAriaIds(container);
expect(container).toMatchSnapshot(
'options is opened again, and first option already selected'
);
replaceReactAriaIds(container);
expect(container).toMatchSnapshot(
'options is opened again, and first option already selected'
);
// 选中 options 中的第一项
fireEvent.click(
container.querySelector(
'.cxd-TextControl-sugs .cxd-TextControl-sugItem'
) as HTMLElement
);
await wait(300);
// 选中 options 中的第一项
fireEvent.click(
container.querySelector(
'.cxd-TextControl-sugs .cxd-TextControl-sugItem'
) as HTMLElement
);
await wait(300);
// 分隔符
expect(
(container.querySelector('.cxd-PlainField') as Element).innerHTML
).toBe('a-b');
// 分隔符
expect(
(container.querySelector('.cxd-PlainField') as Element).innerHTML
).toBe('a-b');
replaceReactAriaIds(container);
expect(container).toMatchSnapshot('second option selected');
replaceReactAriaIds(container);
expect(container).toMatchSnapshot('second option selected');
// 可创建
fireEvent.click(textControl);
await wait(300);
fireEvent.change(input, {target: {value: 'AbCd'}});
await wait(500);
fireEvent.keyDown(input, {key: 'Enter', code: 13});
await wait(500);
// 可创建
fireEvent.click(textControl);
await wait(300);
fireEvent.change(input, {target: {value: 'AbCd'}});
await wait(500);
fireEvent.keyDown(input, {key: 'Enter', code: 13});
await wait(500);
expect(
(container.querySelector('.cxd-PlainField') as Element).innerHTML
).toBe('a-b-AbCd');
expect(
(container.querySelector('.cxd-PlainField') as Element).innerHTML
).toBe('a-b-AbCd');
replaceReactAriaIds(container);
expect(container).toMatchSnapshot('thrid option create');
replaceReactAriaIds(container);
expect(container).toMatchSnapshot('thrid option create');
});
test('Renderer:text with options auto ellipsis', async () => {
const longText = 'OptionB (with long suffix for testing ellipsis)';
const {container} = await setup({
"name": "text",
"type": "input-text",
"label": "text",
"multiple": true,
"options": [
{
"label": "OptionA",
"value": "a"
},
{
"label": longText,
"value": "b"
},
{
"label": "OptionC",
"value": "c"
},
{
"label": "OptionD",
"value": "d"
}
]
});
const textControl = container.querySelector('.cxd-TextControl-input') as HTMLElement;
fireEvent.click(textControl);
await wait(300);
replaceReactAriaIds(container);
const listItems = container.querySelectorAll('.cxd-TextControl-sugs .cxd-TextControl-sugItem');
expect(listItems.length).toBe(4);
// 选中长文本项
fireEvent.click(listItems[1]);
await wait(300);
const valueLabel = screen.getByText(longText);
// FIXME: ResizeObserver的 polyfill 在 jest 环境中不好使,先这样测吧
expect(valueLabel).toBeInTheDocument();
expect(valueLabel.classList).toContain('cxd-OverflowTpl');
});
});
/**

View File

@ -1,28 +1,27 @@
import React from 'react';
import Downshift, {StateChangeOptions} from 'downshift';
import {matchSorter} from 'match-sorter';
import debouce from 'lodash/debounce';
import find from 'lodash/find';
import {
OptionsControl,
OptionsControlProps,
highlight,
FormOptionsControl,
resolveEventData,
CustomStyle,
getValueByPath,
PopOver,
Overlay,
formatInputThemeCss,
setThemeClassName
setThemeClassName,
ActionObject,
filter,
autobind,
createObject,
setVariable,
ucFirst,
isEffectiveApi
} from 'amis-core';
import {ActionObject} from 'amis-core';
import Downshift, {StateChangeOptions} from 'downshift';
import {matchSorter} from 'match-sorter';
import debouce from 'lodash/debounce';
import {filter} from 'amis-core';
import find from 'lodash/find';
import {Icon, SpinnerExtraProps} from 'amis-ui';
import {Input} from 'amis-ui';
import {autobind, createObject, setVariable, ucFirst} from 'amis-core';
import {isEffectiveApi} from 'amis-core';
import {Spinner} from 'amis-ui';
import {Icon, SpinnerExtraProps, Input, Spinner, OverflowTpl} from 'amis-ui';
import {ActionSchema} from '../Action';
import {FormOptionsSchema, SchemaApi} from '../../Schema';
import {supportStatic} from './StaticHoc';
@ -800,9 +799,12 @@ export default class TextControl extends React.PureComponent<
{selectedOptions.map((item, index) =>
multiple ? (
<div className={cx('TextControl-value')} key={index}>
<span className={cx('TextControl-valueLabel')}>
<OverflowTpl
className={cx('TextControl-valueLabel')}
tooltip={`${item[labelField || 'label']}`}
>
{`${item[labelField || 'label']}`}
</span>
</OverflowTpl>
<Icon
icon="close"
className={cx('TextControl-valueIcon', 'icon')}