Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0c9e573
feat(Select): optimize custom node rendering
RylanBot Oct 14, 2025
a95f2db
refactor: optimize value display logic
RylanBot Oct 15, 2025
8ec8e7d
fix(useSingle): update placeholder logic
RylanBot Oct 15, 2025
259ed00
chore(Option): update deps import orders
RylanBot Oct 15, 2025
ba42885
fix(useSingle): improve label handling and typing state management
RylanBot Oct 16, 2025
5766fce
chore(useSingle): rename variables for clarity
RylanBot Oct 16, 2025
105862d
fix(Cascader): improve title handling
RylanBot Oct 23, 2025
58394d5
fix: `autoWidth`
RylanBot Nov 27, 2025
fb71e06
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot Nov 27, 2025
f784914
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot Dec 18, 2025
5e3edce
chore: update snapshots
RylanBot Dec 19, 2025
8ecbc38
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot May 12, 2026
817211b
fix: calc width
RylanBot May 12, 2026
7abbf3a
chore: update snap
RylanBot May 12, 2026
7179cb0
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot May 20, 2026
0ab0a21
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot Jun 1, 2026
a3a6c55
refactor: use `min-height` instead of `height` for custom element
RylanBot Jun 1, 2026
f362de1
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot Jun 10, 2026
bf2c5d3
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot Jun 16, 2026
2b9024e
fix: avoid `Cannot read properties of null`
RylanBot Jun 16, 2026
7e9bc75
fix: auto width
RylanBot Jun 25, 2026
59e8937
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot Jun 25, 2026
77f5605
fix: revert lint
RylanBot Jun 25, 2026
c7a6af5
fix: min-width
RylanBot Jun 25, 2026
4c00288
fix: calc width
RylanBot Jun 26, 2026
dd9f8a0
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot Jun 26, 2026
cdb87dd
chore: remove comments
RylanBot Jun 26, 2026
8fb5fbc
chore: optimize code
RylanBot Jun 26, 2026
66f3a46
fix: simplify logic
RylanBot Jun 26, 2026
7056f2d
Merge remote-tracking branch 'origin' into rylan/feat/select/filterable
RylanBot Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/components/_util/parseTNode.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { ReactElement, ReactNode } from 'react';
import React, { type ReactElement, type ReactNode } from 'react';
import { isFunction } from 'lodash-es';
import log from '@tdesign/common-js/log/index';
import { TNode } from '../common';
import type { TNode } from '../common';

// 解析 TNode 数据结构
export default function parseTNode(
Expand Down Expand Up @@ -37,3 +37,17 @@ export function parseContentTNode<T>(tnode: TNode<T>, props: T) {
return null;
}
}

export function extractTextFromTNode(node: TNode): string {
if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') return String(node);
if (React.isValidElement(node)) {
const { children } = node.props || {};
if (children) return extractTextFromTNode(children);
}
if (Array.isArray(node)) {
return node.map(extractTextFromTNode).join('');
}

// todo:兼容 ((props: T) => ReactNode) 函数类型
Comment thread
RylanBot marked this conversation as resolved.
return '';
}
89 changes: 71 additions & 18 deletions packages/components/select-input/useSingle.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';

import classNames from 'classnames';
import { isObject, pick } from 'lodash-es';
Expand Down Expand Up @@ -38,19 +38,25 @@ const DEFAULT_KEYS: TdSelectInputProps['keys'] = {
value: 'value',
};

function getInputValue(value: TdSelectInputProps['value'], keys: TdSelectInputProps['keys']) {
function getOptionLabel(value: TdSelectInputProps['value'], keys: TdSelectInputProps['keys']) {
const iKeys = keys || DEFAULT_KEYS;
return isObject(value) ? value[iKeys.label] : value;
}

export default function useSingle(props: TdSelectInputProps) {
const { value, keys, loading } = props;
const { value, loading } = props;

const optionLabel = getOptionLabel(value, props.keys);
const singleValueDisplay = props.valueDisplay ?? optionLabel;
const showLabelNode = React.isValidElement(singleValueDisplay);

const { classPrefix } = useConfig();
const [inputValue, setInputValue] = useControlled(props, 'inputValue', props.onInputChange);

const inputRef = useRef<InputRef>(null);
const blurTimeoutRef = useRef(null);

const [inputValue, setInputValue] = useControlled(props, 'inputValue', props.onInputChange);
const [labelWidth, setLabelWidth] = useState<number>(0);

const commonInputProps: SelectInputCommonProperties = {
...pick(props, COMMON_PROPERTIES),
Expand All @@ -69,14 +75,18 @@ export default function useSingle(props: TdSelectInputProps) {
}
};

useEffect(() => {
const labelEl = inputRef.current?.currentElement.querySelector(`.${classPrefix}-input__prefix`);
if (labelEl) {
const prefixWidth = labelEl.getBoundingClientRect().width;
setLabelWidth(prefixWidth);
}
}, [props.label, classPrefix]);

const renderSelectSingle = (
popupVisible: boolean,
onInnerBlur?: (context: { e: React.FocusEvent<HTMLInputElement> }) => void,
) => {
// 单选,值的呈现方式
const singleValueDisplay: any = !props.multiple ? props.valueDisplay : null;
const displayedValue = popupVisible && props.allowInput ? inputValue : getInputValue(value, keys);

const handleBlur = (value, ctx) => {
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
Expand Down Expand Up @@ -104,22 +114,65 @@ export default function useSingle(props: TdSelectInputProps) {
// !popupVisible && setInputValue(getInputValue(value, keys), { ...context, trigger: 'input' });
};

const displayedValue = () => {
if (popupVisible && inputValue) {
return inputValue;
}
if (props.allowInput && popupVisible && !showLabelNode) {
return '';
}
if (!showLabelNode) {
return singleValueDisplay;
}
return inputValue;
};

const displayedPlaceholder = () => {
if (popupVisible && singleValueDisplay && !showLabelNode) {
return singleValueDisplay;
}
if (showLabelNode) return '';
return props.placeholder;
};

const labelNode = showLabelNode ? (
<div
style={{
position: 'absolute',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这段新增的 div 以及样式...看是否要往 common 仓新增一个 class 统一管理 (?)

Vue 端在点击 Input 后,valueDisplay 会恢复为 value / label

React 端这次调整后采取保留原有的效果

left: `${labelWidth + 16}px`,
Comment thread
RylanBot marked this conversation as resolved.
Outdated
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'none',
textAlign: 'initial',
zIndex: 3,
// 输入状态,降低透明度,仿造 placeholder 效果
opacity: popupVisible && props.allowInput ? 0.5 : undefined,
}}
>
{singleValueDisplay}
</div>
) : null;

return (
<Input
ref={inputRef}
// 当 label 为 自定义节点时,input 为空,确保此时 clear icon 可见
showClearIconOnEmpty={props.clearable && showLabelNode}
{...commonInputProps}
autoWidth={props.autoWidth}
allowInput={props.allowInput}
placeholder={singleValueDisplay ? '' : props.placeholder}
value={singleValueDisplay ? ' ' : displayedValue}
label={
(props.label || singleValueDisplay) && (
suffix={

@RylanBot RylanBot Oct 16, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

原本的 displayValue 放在左侧 label
单选时光标位置在内容后面不太合理
现在移到 suffix,然后通过 style 绝对定位显示在左侧

labelNode ||
(commonInputProps.suffix && (
<>
{props.label}
{singleValueDisplay as React.ReactNode}
{labelNode}
{commonInputProps.suffix}
</>
)
))
}
autoWidth={props.autoWidth}
allowInput={props.allowInput}
label={props.label}
value={displayedValue()}
placeholder={displayedPlaceholder()}
onChange={onInnerInputChange}
onClear={onInnerClear}
// [Important Info]: SelectInput.blur is not equal to Input, example: click popup panel
Expand All @@ -130,7 +183,7 @@ export default function useSingle(props: TdSelectInputProps) {
// onBlur need to triggered by input when popup panel is null or when popupVisible is forced to false
onBlur={handleBlur}
{...props.inputProps}
inputClass={classNames(props.inputProps?.className, {
inputClass={classNames(props.inputProps?.inputClass, {
[`${classPrefix}-input--focused`]: popupVisible,
[`${classPrefix}-is-focused`]: popupVisible,
})}
Expand Down
104 changes: 61 additions & 43 deletions packages/components/select/_example/custom-options.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,73 @@
import React, { useState } from 'react';

import { Select } from 'tdesign-react';
import { Select, Space } from 'tdesign-react';

const { Option } = Select;

const options = [
{ label: '用户一', value: '1', description: '这是一段用户描述信息,可自定义内容' },
{ label: '用户二', value: '2', description: '这是一段用户描述信息,可自定义内容' },
{ label: '用户三', value: '3', description: '这是一段用户描述信息,可自定义内容' },
{ label: '用户四', value: '4', description: '这是一段用户描述信息,可自定义内容' },
{ label: '用户五', value: '5', description: '这是一段用户描述信息,可自定义内容' },
{ label: '用户六', value: '6', description: '这是一段用户描述信息,可自定义内容' },
{ label: '用户七', value: '7', description: '这是一段用户描述信息,可自定义内容' },
{ label: '用户八', value: '8', description: '这是一段用户描述信息,可自定义内容' },
{ label: '用户九', value: '9', description: '这是一段用户描述信息,可自定义内容' },
];

const avatarUrl = 'https://tdesign.gtimg.com/site/avatar.jpg';

export default function CustomOptions() {
const generateCustomContent = (index: number) => (
<div style={{ display: 'flex', padding: '8px 0' }}>
<img
src="https://tdesign.gtimg.com/site/avatar.jpg"
style={{
maxWidth: '40px',
borderRadius: '50%',
}}
/>
<div style={{ marginLeft: '16px' }}>
<div>用户{index}</div>
<div
style={{
fontSize: '13px',
color: 'var(--td-gray-color-9)',
}}
>
这是一段用户描述信息,可自定义内容
</div>
</div>
</div>
);

const createOption = (index: number) => {
const label = `用户${index}`;
return {
label,
value: index.toString(),
description: '这是一段用户描述信息,可自定义内容',
};
};

const options1 = Array.from({ length: 5 }, (_, index) => ({
...createOption(index + 1),
}));

const options2 = Array.from({ length: 5 }, (_, index) => ({
...createOption(index + 1),
content: generateCustomContent(index + 1),
}));

function CustomOptions() {
const [value, setValue] = useState('1');
const onChange = (value: string) => {
setValue(value);
};

return (
<Select value={value} onChange={onChange} style={{ width: '300px' }} clearable>
{options.map((option, idx) => (
<Option style={{ height: '60px' }} key={idx} value={option.value} label={option.label}>
<div style={{ display: 'flex' }}>
<img
src={avatarUrl}
style={{
maxWidth: '40px',
borderRadius: '50%',
}}
/>
<div style={{ marginLeft: '16px' }}>
<div>{option.label}</div>
<div
style={{
fontSize: '13px',
color: 'var(--td-gray-color-9)',
}}
>
{option.description}
</div>
</div>
</div>
</Option>
))}
</Select>
<Space size="150px">
<Space direction="vertical">
<strong>法一:使用插槽</strong>
<Select value={value} onChange={onChange} clearable>
{options1.map((option, idx) => (
<Option style={{ height: '60px' }} key={idx} value={option.value} label={option.label}>
{generateCustomContent(idx + 1)}
</Option>
))}
</Select>
</Space>
<Space direction="vertical">
<strong>法二:使用 `content` 属性</strong>
<Select options={options2} value={value} onChange={onChange} clearable style={{ width: 200 }} />
</Space>
</Space>
);
}

export default CustomOptions;
13 changes: 9 additions & 4 deletions packages/components/select/base/Option.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ const Option: React.FC<SelectOptionProps> = (props) => {
const label = propLabel || value;
const disabled = propDisabled || (multiple && Array.isArray(selectedValue) && max && selectedValue.length >= max);

const displayedContent = children || content || label;
const isCustomElement = React.isValidElement(displayedContent);

const titleContent = useMemo(() => {
// 外部设置 props,说明希望受控
const controlledTitle = Reflect.has(props, 'title');
Expand Down Expand Up @@ -121,7 +124,6 @@ const Option: React.FC<SelectOptionProps> = (props) => {
};

const renderItem = () => {
const displayContent = children || content || label;
if (multiple) {
return (
<label
Expand All @@ -143,11 +145,11 @@ const Option: React.FC<SelectOptionProps> = (props) => {
}}
/>
<span className={classNames(`${classPrefix}-checkbox__input`)}></span>
<span className={classNames(`${classPrefix}-checkbox__label`)}>{displayContent}</span>
<span className={classNames(`${classPrefix}-checkbox__label`)}>{displayedContent}</span>
</label>
);
}
return <span title={titleContent}>{displayContent}</span>;
return <span title={titleContent}>{displayedContent}</span>;
};

return (
Expand All @@ -161,7 +163,10 @@ const Option: React.FC<SelectOptionProps> = (props) => {
key={value}
onClick={handleSelect}
ref={setRefCurrent}
style={style}
style={{
...(isCustomElement ? { height: 'auto' } : {}),
Comment thread
RylanBot marked this conversation as resolved.
Outdated
...style,
}}
>
{renderItem()}
</li>
Expand Down
1 change: 0 additions & 1 deletion packages/components/select/base/OptionGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ const OptionGroup: React.FC<SelectGOptionGroupProps> = (props) => {
{children}
</li>
);
return;
};

export default OptionGroup;
Loading
Loading