Skip to content

Commit a687958

Browse files
authored
feat: 新增RangeInputPopup组件 (#320)
1 parent 48971dd commit a687958

File tree

7 files changed

+287
-11
lines changed

7 files changed

+287
-11
lines changed

src/popup/popup.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ export default class Popup extends Component<PopupProps> {
153153
};
154154

155155
handleDocumentClick = (ev?: MouseEvent) => {
156-
const target = ev.composedPath()[0];
156+
const path = ev && ev.composedPath ? ev.composedPath() : [];
157+
const target = path[0] || (ev && ev.target);
157158
setTimeout(() => {
158159
if (this.contentClicked) {
159160
setTimeout(() => {
@@ -163,9 +164,14 @@ export default class Popup extends Component<PopupProps> {
163164
}
164165

165166
const triggerEl = this.triggerRef.current as HTMLElement;
166-
if (domContains(triggerEl, target as HTMLElement)) return;
167167
const popperEl = this.popperRef.current as HTMLElement;
168+
169+
// 兼容 Shadow DOM,若事件路径包含触发元素或浮层,则视为内部点击
170+
if (path && path.includes && (path.includes(triggerEl) || path.includes(popperEl))) return;
171+
172+
if (domContains(triggerEl, target as HTMLElement)) return;
168173
if (domContains(popperEl, target as HTMLElement)) return;
174+
169175
this.handlePopVisible(false, { trigger: 'document', e: ev });
170176
});
171177
};
@@ -184,10 +190,13 @@ export default class Popup extends Component<PopupProps> {
184190
};
185191

186192
clickHandle = (e: MouseEvent) => {
187-
if (this.visible === true) {
188-
const triggerEl = this.triggerRef.current as HTMLElement;
189-
const target = e.composedPath()[0];
190-
if (domContains(triggerEl, target as HTMLElement)) {
193+
const path = e.composedPath ? e.composedPath() : [];
194+
const triggerEl = this.triggerRef.current as HTMLElement;
195+
196+
// 使用受控可见性判断(兼容受控场景)
197+
if (this.getVisible() === true) {
198+
const target = path[0] || (e && e.target);
199+
if (domContains(triggerEl, target as HTMLElement) || (path && path.includes && path.includes(triggerEl))) {
191200
this.handlePopVisible(false, { trigger: 'trigger-element-click', e });
192201
return;
193202
}
@@ -270,10 +279,16 @@ export default class Popup extends Component<PopupProps> {
270279
}
271280

272281
getOverlayStyle(overlayStyle: PopupProps['overlayStyle']) {
273-
if (this.triggerRef.current && this.popperRef.current && typeof overlayStyle === 'function') {
274-
return { ...overlayStyle(this.triggerRef.current as HTMLElement, this.popperRef.current as HTMLElement) };
282+
if (typeof overlayStyle === 'function') {
283+
const triggerElement = this.triggerRef.current as HTMLElement;
284+
const popupElement = this.popperRef.current as HTMLElement;
285+
const style = overlayStyle(triggerElement, popupElement);
286+
return style ? { ...style } : {};
287+
}
288+
if (overlayStyle && typeof overlayStyle === 'object') {
289+
return { ...overlayStyle };
275290
}
276-
return { ...overlayStyle };
291+
return {};
277292
}
278293

279294
updatePopper = () => {
@@ -365,6 +380,8 @@ export default class Popup extends Component<PopupProps> {
365380
attach={props.attach}
366381
onDOMReady={(h) => {
367382
this.popperRef.current = h;
383+
// 首次挂载popup panel时,保证overlayInnerStyle样式计算生效
384+
this.update();
368385
setTimeout(() => {
369386
this.updatePopper();
370387
});

src/range-input/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ spline: base
2222

2323
{{ status }}
2424

25+
### 与 Popup 组件搭配使用
26+
27+
{{ popup }}
28+
2529
## API
2630
### RangeInput Props
2731

@@ -45,3 +49,20 @@ onChange | Function | | TS 类型:`(ev: CustomEvent<{value: RangeInputValue,
4549
onFocus | Function | | TS 类型:`(ev: CustomEvent<{value: RangeInputValue, context?: { e?: FocusEvent; position?: RangeInputPosition }}>) => void`<br/>范围输入框获得焦点时触发 | N
4650
onMouseenter | Function | | TS 类型:`(ev: CustomEvent<{context: { e: MouseEvent }}>) => void`<br/>进入输入框时触发 | N
4751
onMouseleave | Function | | TS 类型:`(ev: CustomEvent<context: { e: MouseEvent }>) => void`<br/>离开输入框时触发 | N
52+
53+
### RangeInputPopup Props
54+
55+
名称 | 类型 | 默认值 | 说明 | 必传
56+
-- | -- | -- | -- | --
57+
disabled | Boolean | false | 是否禁用范围输入框 | N
58+
inputValue | Array | [] | 受控值,TS 类型:`RangeInputValue`。传入后组件进入受控模式 | N
59+
defaultInputValue | Array | [] | 输入框的默认值(非受控初始值),TS 类型:`RangeInputValue`。仅在未传 `inputValue` 时生效,示例:`defaultInputValue={[ '2025-01-01', '2025-01-07' ]}` | N
60+
panel | TNode | - | 下拉面板内容,可完全自定义该组件,示例:`panel={<div>这是一个浮窗</div>}` | N
61+
popupProps | PopupProps | - | 透传 Popup 浮层全部属性 | N
62+
popupVisible | Boolean | - | 弹层显隐(受控)。不传则该状态由组件内部管理 | N
63+
rangeInputProps | RangeInputProps | - | 透传 RangeInput 组件全部属性 | N
64+
readonly | Boolean | false | 输入框的只读状态。和`disabled`相比,`readonly`仍然可以打开 popup,但是无法输入内容 | N
65+
status | String | default | 输入框状态,枚举值:`default/success/warning/error`,可控制边框颜色 | N
66+
tips | TNode | - | 输入框下方提示文本,会根据 `status` 呈现不同样式 | N
67+
onInputChange | Function | - | 输入框值变化回调。签名:`(value: RangeInputValue, context?: any) => void`。受控时仅透传;非受控时组件内部会同时更新本地值 | N
68+
onPopupVisibleChange | Function | - | 弹层显隐变化回调。签名:`(visible: boolean, context: any) => void` | N
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import 'tdesign-web-components/range-input';
2+
import 'tdesign-web-components/popup';
3+
4+
import { classNames, Component, createRef, tag } from 'omi';
5+
6+
import { getClassPrefix } from '../_util/classname';
7+
import { StyledProps } from '../common';
8+
import useOverlayInnerStyle from '../select-input/useOverlayInnerStyle';
9+
import { TdRangeInputPopupProps } from './type';
10+
11+
export interface RangeInputPopupProps extends TdRangeInputPopupProps, StyledProps {}
12+
13+
@tag('t-range-input-popup')
14+
export default class RangeInputPopup extends Component<RangeInputPopupProps> {
15+
classPrefix = getClassPrefix();
16+
17+
inputRef = createRef();
18+
19+
innerInputValue = undefined;
20+
21+
cachedOverlayWidth?: string;
22+
23+
lockedTriggerElement?: HTMLElement;
24+
25+
lockedTriggerOriginalWidth?: string;
26+
27+
install() {
28+
const { inputValue, defaultInputValue } = this.props;
29+
this.innerInputValue = inputValue !== undefined ? inputValue : defaultInputValue;
30+
}
31+
32+
receiveProps(nextProps: { inputValue: any; visible?: boolean }) {
33+
// 受控场景下,同步外部值到内部缓存
34+
if (nextProps.inputValue !== undefined) {
35+
this.innerInputValue = nextProps.inputValue;
36+
}
37+
}
38+
39+
private handleRangeInputChange = (evt: CustomEvent) => {
40+
const detail = (evt && evt.detail) || {};
41+
const { value: nextValue, context } = detail;
42+
43+
// 未受控则更新内部值
44+
if (this.props.inputValue === undefined) {
45+
this.innerInputValue = nextValue;
46+
this.update();
47+
}
48+
// 透传给外部
49+
this.props.onInputChange?.(nextValue, context);
50+
};
51+
52+
render(props: RangeInputPopupProps) {
53+
const {
54+
panel,
55+
popupVisible,
56+
disabled,
57+
popupProps,
58+
status,
59+
tips,
60+
inputValue,
61+
rangeInputProps,
62+
readonly,
63+
className,
64+
style,
65+
innerStyle,
66+
} = props;
67+
68+
const { tOverlayInnerStyle, innerPopupVisible, onInnerPopupVisibleChange } = useOverlayInnerStyle(
69+
{ ...props, allowInput: false },
70+
undefined,
71+
this,
72+
);
73+
74+
const isVisible = popupVisible ?? innerPopupVisible;
75+
76+
if (!isVisible) {
77+
this.cachedOverlayWidth = undefined;
78+
if (this.lockedTriggerElement && this.lockedTriggerOriginalWidth !== undefined) {
79+
this.lockedTriggerElement.style.width = this.lockedTriggerOriginalWidth;
80+
}
81+
this.lockedTriggerElement = undefined;
82+
this.lockedTriggerOriginalWidth = undefined;
83+
}
84+
85+
// 计算 panel 宽度,支持自定义或和输入框宽度保持一致
86+
const overlayInnerStyle = (triggerEl: HTMLElement, popupEl: HTMLElement) => {
87+
if (!this.cachedOverlayWidth && triggerEl) {
88+
const { width } = triggerEl.getBoundingClientRect();
89+
if (Number.isFinite(width) && width > 0) {
90+
this.cachedOverlayWidth = `${Math.round(width)}px`;
91+
}
92+
}
93+
94+
if (triggerEl && !this.lockedTriggerElement) {
95+
this.lockedTriggerElement = triggerEl;
96+
this.lockedTriggerOriginalWidth = triggerEl.style.width;
97+
}
98+
99+
const baseStyle = tOverlayInnerStyle?.() || {};
100+
const resolvedBase = typeof baseStyle === 'function' ? baseStyle(triggerEl, popupEl) || {} : baseStyle;
101+
const resolvedExternal =
102+
typeof popupProps?.overlayInnerStyle === 'function'
103+
? popupProps.overlayInnerStyle(triggerEl, popupEl) || {}
104+
: popupProps?.overlayInnerStyle || {};
105+
106+
const merged = {
107+
...resolvedBase,
108+
...resolvedExternal,
109+
marginTop: '16px',
110+
} as Record<string, any>;
111+
112+
if (this.cachedOverlayWidth) {
113+
merged.width = this.cachedOverlayWidth;
114+
}
115+
116+
if (this.lockedTriggerElement && this.cachedOverlayWidth) {
117+
this.lockedTriggerElement.style.width = this.cachedOverlayWidth;
118+
}
119+
120+
return merged;
121+
};
122+
123+
const popupConfig = popupProps ?? {};
124+
const rangeInputConfig = rangeInputProps ?? {};
125+
const value = inputValue !== undefined ? inputValue : this.innerInputValue ?? [];
126+
const name = `${this.classPrefix}-range-input-popup`;
127+
const wrapperClassName = classNames(name, className, {
128+
[`${name}--visible`]: isVisible,
129+
});
130+
const wrapperStyle = { ...(style || {}), ...(innerStyle || {}) };
131+
132+
return (
133+
<div className={wrapperClassName} style={wrapperStyle} ref={this.inputRef}>
134+
<t-popup
135+
hideEmptyPopup
136+
content={panel}
137+
trigger="click"
138+
placement="bottom-left"
139+
showArrow={false}
140+
visible={isVisible}
141+
onVisibleChange={onInnerPopupVisibleChange}
142+
disabled={disabled}
143+
{...popupConfig}
144+
overlayInnerStyle={overlayInnerStyle}
145+
>
146+
<t-range-input
147+
disabled={disabled}
148+
status={status}
149+
tips={tips}
150+
value={value}
151+
onChange={this.handleRangeInputChange}
152+
readonly={readonly}
153+
{...rangeInputConfig}
154+
/>
155+
</t-popup>
156+
</div>
157+
);
158+
}
159+
}

src/range-input/_example/popup.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import 'tdesign-web-components/range-input';
2+
import 'tdesign-web-components/popup';
3+
import 'tdesign-web-components/space';
4+
import 'tdesign-icons-web-components/esm/components/calendar';
5+
6+
export default function RangeInputPopupExample() {
7+
return (
8+
<t-space direction="vertical">
9+
<t-range-input-popup panel={<div>这是一个浮层</div>} defaultInputValue={['2025-01-01', '默认输入']} />
10+
<t-range-input-popup panel={<div>这是一个浮层</div>} disabled />
11+
</t-space>
12+
);
13+
}

src/range-input/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import './style/index.js';
22

33
import _RangeInput from './RangeInput.jsx';
4+
import _RangeInputPopup from './RangeInputPopup';
45

56
export type { RangeInputProps } from './RangeInput.jsx';
67
export * from './type';
78

89
export const RangeInput = _RangeInput;
10+
export const RangeInputPopup = _RangeInputPopup;
911
export default RangeInput;

src/range-input/type.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { TNode } from '../common';
22
import { InputFormatType, InputValue } from '../input';
3+
import { PopupProps } from '../popup';
4+
import { RangeInputProps } from './RangeInput';
35

46
export interface TdRangeInputProps {
57
/**
@@ -112,6 +114,68 @@ export interface TdRangeInputProps {
112114
) => void;
113115
}
114116

117+
export interface TdRangeInputPopupProps {
118+
/**
119+
* 宽度随内容自适应
120+
* @default false
121+
*/
122+
autoWidth?: boolean;
123+
/**
124+
* 是否禁用范围输入框,值为数组表示可分别控制某一个输入框是否禁用
125+
*/
126+
disabled?: boolean;
127+
/**
128+
* 输入框的值
129+
*/
130+
inputValue?: RangeInputValue;
131+
/**
132+
* 输入框的值,非受控属性
133+
*/
134+
defaultInputValue?: RangeInputValue;
135+
/**
136+
* 左侧文本
137+
*/
138+
label?: TNode;
139+
/**
140+
* 下拉框内容,可完全自定义
141+
*/
142+
panel?: TNode;
143+
/**
144+
* 透传 Popup 浮层组件全部属性
145+
*/
146+
popupProps?: PopupProps;
147+
/**
148+
* 是否显示下拉框
149+
*/
150+
popupVisible?: boolean;
151+
/**
152+
* 透传 RangeInput 组件全部属性
153+
*/
154+
rangeInputProps?: RangeInputProps;
155+
/**
156+
* 只读状态,值为真会隐藏输入框,且无法打开下拉框
157+
* @default false
158+
*/
159+
readonly?: boolean;
160+
/**
161+
* 输入框状态
162+
* @default default
163+
*/
164+
status?: 'default' | 'success' | 'warning' | 'error';
165+
/**
166+
* 输入框下方提示文本,会根据不同的 `status` 呈现不同的样式
167+
*/
168+
tips?: TNode;
169+
/**
170+
* 输入框值发生变化时触发
171+
*/
172+
onInputChange?: (value: RangeInputValue, context?: any) => void;
173+
/**
174+
* 下拉框显示或隐藏时触发
175+
*/
176+
onPopupVisibleChange?: (visible: boolean, context: any) => void;
177+
}
178+
115179
export type RangeInputValue = Array<InputValue>;
116180

117181
export type RangeInputPosition = 'first' | 'second' | 'all';

src/select-input/useOverlayInnerStyle.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isFunction, isObject } from 'lodash-es';
1+
import { isFunction } from 'lodash-es';
22
import { Component } from 'omi';
33

44
import useControlled from '../_util/useControlled';
@@ -82,7 +82,7 @@ export default function useOverlayInnerStyle(
8282
const tOverlayInnerStyle = () => {
8383
let result: TdPopupProps['overlayInnerStyle'] = {};
8484
const overlayInnerStyle = popupProps?.overlayInnerStyle || {};
85-
if (isFunction(overlayInnerStyle) || (isObject(overlayInnerStyle) && overlayInnerStyle.width)) {
85+
if (isFunction(overlayInnerStyle)) {
8686
result = overlayInnerStyle;
8787
} else if (!autoWidth) {
8888
result = matchWidthFunc;

0 commit comments

Comments
 (0)