@rainbow-oh/yee-c 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/cjs/Field/demo/standalone.d.ts +3 -0
  2. package/dist/cjs/Field/demo/standalone.js +56 -0
  3. package/dist/cjs/Field/field.js +20 -11
  4. package/dist/cjs/Field/interface.d.ts +7 -4
  5. package/dist/cjs/Field/style/index.less +1 -0
  6. package/dist/cjs/Table/demo/controlled-filter.d.ts +3 -0
  7. package/dist/cjs/Table/demo/controlled-filter.js +105 -0
  8. package/dist/cjs/Table/hooks/useFilter.d.ts +5 -1
  9. package/dist/cjs/Table/hooks/useFilter.js +80 -42
  10. package/dist/cjs/Table/index.d.ts +1 -1
  11. package/dist/cjs/Table/interface.d.ts +14 -4
  12. package/dist/cjs/Table/table.js +22 -25
  13. package/dist/cjs/Table/theader/cell.js +0 -3
  14. package/dist/cjs/Table/theader/filter.js +22 -5
  15. package/dist/cjs/style/themes.css +1 -1
  16. package/dist/cjs/style/variables.css +1 -1
  17. package/dist/cjs/version.d.ts +1 -1
  18. package/dist/cjs/version.js +1 -1
  19. package/dist/esm/Field/demo/standalone.d.ts +3 -0
  20. package/dist/esm/Field/demo/standalone.js +43 -0
  21. package/dist/esm/Field/field.js +24 -11
  22. package/dist/esm/Field/interface.d.ts +7 -4
  23. package/dist/esm/Field/style/index.less +1 -0
  24. package/dist/esm/Table/demo/controlled-filter.d.ts +3 -0
  25. package/dist/esm/Table/demo/controlled-filter.js +122 -0
  26. package/dist/esm/Table/hooks/useFilter.d.ts +5 -1
  27. package/dist/esm/Table/hooks/useFilter.js +112 -69
  28. package/dist/esm/Table/index.d.ts +1 -1
  29. package/dist/esm/Table/interface.d.ts +14 -4
  30. package/dist/esm/Table/table.js +34 -21
  31. package/dist/esm/Table/theader/cell.js +1 -3
  32. package/dist/esm/Table/theader/filter.js +22 -1
  33. package/dist/esm/style/themes.css +1 -1
  34. package/dist/esm/style/variables.css +1 -1
  35. package/dist/esm/version.d.ts +1 -1
  36. package/dist/esm/version.js +1 -1
  37. package/dist/umd/yee-c.min.css +1 -1
  38. package/dist/umd/yee-c.min.js +1 -1
  39. package/package.json +1 -1
  40. package/src/Field/demo/standalone.tsx +30 -0
  41. package/src/Field/field.tsx +38 -22
  42. package/src/Field/index.en-US.md +8 -4
  43. package/src/Field/index.zh-CN.md +8 -4
  44. package/src/Field/interface.ts +7 -4
  45. package/src/Field/style/index.less +1 -0
  46. package/src/Table/demo/controlled-filter.tsx +107 -0
  47. package/src/Table/hooks/useFilter.ts +107 -49
  48. package/src/Table/index.en-US.md +22 -1
  49. package/src/Table/index.ts +1 -0
  50. package/src/Table/index.zh-CN.md +22 -1
  51. package/src/Table/interface.ts +14 -2
  52. package/src/Table/table-filter.test.tsx +343 -0
  53. package/src/Table/table.tsx +29 -27
  54. package/src/Table/theader/cell.tsx +0 -2
  55. package/src/Table/theader/filter.tsx +26 -1
  56. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-oh/yee-c",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "description": "yee basic components",
5
5
  "keywords": [
6
6
  "react",
@@ -0,0 +1,30 @@
1
+ import { Field, Input, TextArea } from '@rainbow-oh/yee-c';
2
+ import React, { useState } from 'react';
3
+
4
+ export default () => {
5
+ const [name, setName] = useState('');
6
+ const [remark, setRemark] = useState('');
7
+
8
+ return (
9
+ <div style={{ maxWidth: 400 }}>
10
+ <Field label="Name" required>
11
+ <Input
12
+ value={name}
13
+ onChange={(v: string) => setName(v)}
14
+ placeholder="Standalone controlled Input"
15
+ />
16
+ </Field>
17
+ <Field label="Remark">
18
+ <TextArea
19
+ value={remark}
20
+ onChange={(v: string) => setRemark(v)}
21
+ placeholder="Standalone controlled TextArea"
22
+ rows={3}
23
+ />
24
+ </Field>
25
+ <Field>
26
+ <Input placeholder="Field without label" />
27
+ </Field>
28
+ </div>
29
+ );
30
+ };
@@ -28,7 +28,7 @@ const Field: FC<FieldProps> = (props) => {
28
28
 
29
29
  const [, forceUpdate] = useReducer((x) => x + 1, 0);
30
30
 
31
- const { form } = useVirtualField(formName, name, {
31
+ const { form } = useVirtualField(formName ?? '', name ?? '', {
32
32
  props,
33
33
  onStoreChange: forceUpdate,
34
34
  });
@@ -49,20 +49,33 @@ const Field: FC<FieldProps> = (props) => {
49
49
  };
50
50
 
51
51
  const getControlled = (children: React.ReactElement) => {
52
- const mergedDisabled =
53
- disabled || (children as React.ReactElement<any>).props.disabled;
52
+ const childProps = (children as React.ReactElement<any>).props;
53
+
54
+ // Pure layout mode (no form): children keep their own value/onChange.
55
+ // Only a Field-level disabled is merged down.
56
+ // Pure layout mode (no form or no field name): children keep their own
57
+ // value/onChange. Only a Field-level disabled is merged down.
58
+ if (!form || name === undefined) {
59
+ if (disabled === undefined) return children;
60
+ return React.cloneElement(children as React.ReactElement<any>, {
61
+ disabled: disabled || childProps.disabled,
62
+ });
63
+ }
64
+
65
+ const mergedDisabled = disabled || childProps.disabled;
66
+ const fieldName = name;
54
67
 
55
68
  const attr = {
56
- value: getFieldValue?.(name),
69
+ value: getFieldValue?.(fieldName),
57
70
  name: formName,
58
- id: name,
71
+ id: fieldName,
59
72
  disabled: mergedDisabled,
60
73
  onChange: (value: unknown, ...rest: Array<unknown>) => {
61
74
  const callbacks = getCallbacks?.();
62
75
  let _value = value;
63
76
  const fieldsValue = getFieldsValue?.();
64
77
  const feedback = callbacks?.onValuesBeforeChange?.(
65
- { [name]: _value },
78
+ { [fieldName]: _value },
66
79
  fieldsValue,
67
80
  );
68
81
 
@@ -70,20 +83,20 @@ const Field: FC<FieldProps> = (props) => {
70
83
  _value = feedback;
71
84
  }
72
85
 
73
- setFieldsValue?.({ [name]: _value }, 'onChange');
86
+ setFieldsValue?.({ [fieldName]: _value }, 'onChange');
74
87
 
75
88
  // Call child component's onChange
76
89
  if ((children.props as any)?.onChange) {
77
90
  (children.props as any).onChange(_value, ...rest);
78
91
  }
79
92
 
80
- callbacks?.['onValuesChange']?.({ [name]: _value }, fieldsValue);
93
+ callbacks?.['onValuesChange']?.({ [fieldName]: _value }, fieldsValue);
81
94
 
82
95
  forceUpdate();
83
96
  },
84
97
  onBlur: (event: unknown) => {
85
98
  // Fire-and-forget async validation; store notifies onStoreChange on completion.
86
- void validateField?.(name, 'onBlur');
99
+ void validateField?.(fieldName, 'onBlur');
87
100
 
88
101
  // Call child component's onBlur
89
102
  if ((children.props as any).onBlur) {
@@ -95,7 +108,8 @@ const Field: FC<FieldProps> = (props) => {
95
108
  return React.cloneElement(children, attr);
96
109
  };
97
110
 
98
- const validate = getFieldValidate?.(name);
111
+ const validate =
112
+ name !== undefined ? getFieldValidate?.(name) : undefined;
99
113
 
100
114
  return (
101
115
  <div
@@ -109,18 +123,20 @@ const Field: FC<FieldProps> = (props) => {
109
123
  )}
110
124
  style={style}
111
125
  >
112
- <label
113
- className={clsx(
114
- `${prefixCls}-label`,
115
- {
116
- [`${prefixCls}-required`]: isRequired(),
117
- },
118
- classNames?.label,
119
- )}
120
- style={styles?.label}
121
- >
122
- {label}
123
- </label>
126
+ {label ? (
127
+ <label
128
+ className={clsx(
129
+ `${prefixCls}-label`,
130
+ {
131
+ [`${prefixCls}-required`]: isRequired(),
132
+ },
133
+ classNames?.label,
134
+ )}
135
+ style={styles?.label}
136
+ >
137
+ {label}
138
+ </label>
139
+ ) : null}
124
140
  <div
125
141
  className={clsx(`${prefixCls}-content`, classNames?.content)}
126
142
  style={styles?.content}
@@ -10,11 +10,15 @@ toc: 'content'
10
10
 
11
11
  # Field
12
12
 
13
- A standalone form field component that works without a `<Form>` wrapper. Uses `useVirtualForm` to create a virtual form for value management and validation.
13
+ A standalone form field component that works without a `<Form>` wrapper. Two usage modes:
14
+
15
+ - **Virtual form mode**: create a virtual form with `useVirtualForm`; Field manages value and validation (pass `formName` + `name`)
16
+ - **Pure layout mode**: omit `formName` (or the form is not registered); Field only renders the label layout and required asterisk, while `value`/`onChange` pass straight through to the child — state is managed by the consumer
14
17
 
15
18
  ## Code Demo
16
19
 
17
20
  <code src="./demo/basic.tsx" title="Basic" description="Create a virtual form with useVirtualForm and bind Field to Input"></code>
21
+ <code src="./demo/standalone.tsx" title="Pure Layout" description="Without formName, state is managed by the consumer and Field only handles label layout"></code>
18
22
  <code src="./demo/validation.tsx" title="Validation" description="required, minLength, regexp, validator rules and validateTrigger"></code>
19
23
  <code src="./demo/layout.tsx" title="Layout & Style" description="Vertical/horizontal layout and semantic style customization"></code>
20
24
  <code src="./demo/disabled.tsx" title="Disabled" description="Disable the entire field via the disabled prop"></code>
@@ -25,14 +29,14 @@ A standalone form field component that works without a `<Form>` wrapper. Uses `u
25
29
 
26
30
  | Property | Type | Description | Default |
27
31
  | --- | --- | --- | --- |
28
- | formName | `string` | Virtual form name (required) | - |
29
- | name | `string` | Field name (required) | - |
32
+ | formName | `string` | Virtual form name; omit for pure layout mode | - |
33
+ | name | `string` | Field name (required in virtual form mode) | - |
30
34
  | label | `React.ReactNode` | Field label | - |
31
35
  | children | `React.ReactElement` | Form control (must be a single React element) | - |
32
36
  | rules | `Rule[]` | Validation rules | - |
33
37
  | required | `boolean` | Whether the field is required (shows asterisk) | `false` |
34
38
  | layout | `'vertical' \| 'horizontal'` | Layout direction | `'vertical'` |
35
- | disabled | `boolean` | Whether the field is disabled | `false` |
39
+ | disabled | `boolean` | Whether the field is disabled (merged onto the child, also works in pure layout mode) | `false` |
36
40
  | prefixCls | `string` | Class name prefix | `'yee-field'` |
37
41
  | className | `string` | Root element class name | - |
38
42
  | style | `React.CSSProperties` | Root element style | - |
@@ -10,11 +10,15 @@ toc: 'content'
10
10
 
11
11
  # Field 字段
12
12
 
13
- 脱离 `<Form>` 组件独立使用的表单字段组件,通过 `useVirtualForm` 创建虚拟表单来实现值管理和校验。
13
+ 脱离 `<Form>` 组件独立使用的表单字段组件。两种用法:
14
+
15
+ - **虚拟表单模式**:通过 `useVirtualForm` 创建虚拟表单,Field 负责值管理和校验(传 `formName` + `name`)
16
+ - **纯布局模式**:不传 `formName`(或该表单未注册),Field 只负责 label 布局与必填星号,`value`/`onChange` 由使用者自己控制并直接透传给子控件
14
17
 
15
18
  ## 代码演示
16
19
 
17
20
  <code src="./demo/basic.tsx" title="基础用法" description="使用 useVirtualForm 创建虚拟表单,配合 Field 绑定 Input"></code>
21
+ <code src="./demo/standalone.tsx" title="纯布局模式" description="不传 formName,受控状态自己管理,Field 只负责标签布局"></code>
18
22
  <code src="./demo/validation.tsx" title="校验规则" description="required、minLength、regexp、validator 等校验规则,以及 validateTrigger 控制触发时机"></code>
19
23
  <code src="./demo/layout.tsx" title="布局与样式" description="垂直/水平布局、语义化样式定制"></code>
20
24
  <code src="./demo/disabled.tsx" title="禁用状态" description="通过 disabled 属性禁用整个字段"></code>
@@ -25,14 +29,14 @@ toc: 'content'
25
29
 
26
30
  | 属性名 | 类型 | 描述 | 默认值 |
27
31
  | --- | --- | --- | --- |
28
- | formName | `string` | 所属虚拟表单名称(必填) | - |
29
- | name | `string` | 字段名称(必填) | - |
32
+ | formName | `string` | 所属虚拟表单名称;不传时 Field 为纯布局模式 | - |
33
+ | name | `string` | 字段名称(虚拟表单模式下必填) | - |
30
34
  | label | `React.ReactNode` | 字段标签 | - |
31
35
  | children | `React.ReactElement` | 表单控件(必须是单个 React 元素) | - |
32
36
  | rules | `Rule[]` | 校验规则 | - |
33
37
  | required | `boolean` | 是否必填(显示星号标记) | `false` |
34
38
  | layout | `'vertical' \| 'horizontal'` | 布局方式 | `'vertical'` |
35
- | disabled | `boolean` | 是否禁用 | `false` |
39
+ | disabled | `boolean` | 是否禁用(会合并到子控件上,纯布局模式下同样生效) | `false` |
36
40
  | prefixCls | `string` | 类名前缀 | `'yee-field'` |
37
41
  | className | `string` | 根元素类名 | - |
38
42
  | style | `React.CSSProperties` | 根元素样式 | - |
@@ -28,13 +28,16 @@ export interface FieldProps {
28
28
  */
29
29
  label?: React.ReactNode;
30
30
  /**
31
- * Field name
31
+ * Field name (required only when used with a registered virtual form)
32
32
  */
33
- name: string;
33
+ name?: string;
34
34
  /**
35
- * Name of the parent form
35
+ * Name of the parent form.
36
+ * When omitted (or no form is registered under this name), Field renders
37
+ * as a pure layout wrapper: the child keeps its own value/onChange and
38
+ * no validation runs.
36
39
  */
37
- formName: string;
40
+ formName?: string;
38
41
  /**
39
42
  * Whether disabled
40
43
  */
@@ -25,6 +25,7 @@
25
25
  &-content {
26
26
  position: relative;
27
27
  padding-bottom: var(--yee-field-content-padding-bottom);
28
+ width: stretch;
28
29
  }
29
30
 
30
31
  &-error {
@@ -0,0 +1,107 @@
1
+ import { Button, message, Space, Table } from '@rainbow-oh/yee-c';
2
+ import React, { useState } from 'react';
3
+
4
+ import type { ColumnProps } from '@rainbow-oh/yee-c';
5
+
6
+ interface RecordType {
7
+ key: string;
8
+ name: string;
9
+ age: number;
10
+ gender: string;
11
+ }
12
+
13
+ const allData: RecordType[] = [
14
+ { key: '1', name: 'John Brown', age: 32, gender: 'male' },
15
+ { key: '2', name: 'Jim Green', age: 42, gender: 'male' },
16
+ { key: '3', name: 'Joe Black', age: 32, gender: 'male' },
17
+ { key: '4', name: 'Jon Snow', age: 42, gender: 'female' },
18
+ { key: '5', name: 'Arya Stark', age: 16, gender: 'female' },
19
+ ];
20
+
21
+ export default () => {
22
+ const { messageApi, messageHolder } = message.useMessage();
23
+
24
+ // Server-side search: keep the keyword controlled, refetch in onChange.
25
+ const [nameKeyword, setNameKeyword] = useState<string>('');
26
+
27
+ // Controlled local filter: the tree selection lives in the parent state.
28
+ const [genders, setGenders] = useState<string[]>([]);
29
+
30
+ const columns: ColumnProps[] = [
31
+ {
32
+ title: 'Name',
33
+ dataIndex: 'name',
34
+ key: 'name',
35
+ filter: {
36
+ searchable: true,
37
+ // No onFilter: no local matching, the search hits the "server".
38
+ filteredValue: nameKeyword,
39
+ },
40
+ },
41
+ {
42
+ title: 'Age',
43
+ dataIndex: 'age',
44
+ key: 'age',
45
+ },
46
+ {
47
+ title: 'Gender',
48
+ dataIndex: 'gender',
49
+ key: 'gender',
50
+ filter: {
51
+ filterMode: 'tree',
52
+ items: [
53
+ { label: 'Male', key: 'male' },
54
+ { label: 'Female', key: 'female' },
55
+ ],
56
+ filteredValue: genders,
57
+ onFilter: (value, record) => value === record.gender,
58
+ },
59
+ },
60
+ ];
61
+
62
+ const handleChange: React.ComponentProps<typeof Table>['onChange'] = (
63
+ info,
64
+ ) => {
65
+ if (info.action !== 'filter') return;
66
+
67
+ const filters = (info.filters ?? {}) as {
68
+ name?: string;
69
+ gender?: string[];
70
+ };
71
+
72
+ // The committing column's key is always present in the payload — with its
73
+ // empty value on clear — so both setting and clearing are mirrored here.
74
+ const name = filters.name ?? '';
75
+ if (name !== nameKeyword) {
76
+ // Pretend to refetch with ?name=<keyword>.
77
+ messageApi.info(`Fetch: /api/users?name=${name}`);
78
+ setNameKeyword(name);
79
+ }
80
+
81
+ setGenders(filters.gender ?? []);
82
+ };
83
+
84
+ const data = allData.filter((item) => {
85
+ const matchName = nameKeyword
86
+ ? item.name.toLowerCase().includes(nameKeyword.toLowerCase())
87
+ : true;
88
+ const matchGender = genders.length ? genders.includes(item.gender) : true;
89
+ return matchName && matchGender;
90
+ });
91
+
92
+ return (
93
+ <>
94
+ {messageHolder}
95
+ <Space block style={{ marginBottom: 16 }}>
96
+ <Button onClick={() => setNameKeyword('')}>Reset name search</Button>
97
+ <Button onClick={() => setGenders([])}>Reset gender filter</Button>
98
+ </Space>
99
+ <Table
100
+ columns={columns}
101
+ dataSource={data}
102
+ onChange={handleChange}
103
+ pagination={false}
104
+ />
105
+ </>
106
+ );
107
+ };
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
2
  import type { ColumnProps, FilterValue } from '../interface';
3
3
 
4
4
  type FilterInput = FilterValue | FilterValue[];
@@ -16,9 +16,81 @@ type FilterRecord = {
16
16
  const isEmptyFilterValue = (value: FilterInput) =>
17
17
  value === '' || (Array.isArray(value) && value.length === 0);
18
18
 
19
+ export const isControlledFilter = (column?: ColumnProps) =>
20
+ column?.filter?.filteredValue !== undefined;
21
+
19
22
  const normalizeValue = (value: unknown) =>
20
23
  value === null || value === undefined ? '' : String(value).toLowerCase();
21
24
 
25
+ // Controlled columns (`filteredValue` defined) always take their value from
26
+ // the prop; internal records for them are ignored entirely — a commit on a
27
+ // controlled column must not leak into the merged state while the parent has
28
+ // not (yet) applied the new prop value.
29
+ const mergeRecords = (
30
+ internalRecords: Array<FilterRecord>,
31
+ columnMap: Map<string, ColumnProps>,
32
+ ): Array<FilterRecord> => {
33
+ const controlled: Array<FilterRecord> = [];
34
+ columnMap.forEach((column, dataIndex) => {
35
+ if (!isControlledFilter(column)) return;
36
+ const value = column.filter?.filteredValue as FilterInput;
37
+ if (!isEmptyFilterValue(value)) {
38
+ controlled.push({ dataIndex, value });
39
+ }
40
+ });
41
+ const merged = internalRecords.filter(
42
+ (record) => !isControlledFilter(columnMap.get(record.dataIndex)),
43
+ );
44
+ if (!controlled.length && merged.length === internalRecords.length) {
45
+ return internalRecords;
46
+ }
47
+ return [...merged, ...controlled];
48
+ };
49
+
50
+ const applyFilters = (
51
+ data: Array<Record<string, unknown>>,
52
+ records: Array<FilterRecord>,
53
+ columnMap: Map<string, ColumnProps>,
54
+ ) => {
55
+ const handled = records.filter(
56
+ ({ dataIndex, value }) =>
57
+ Boolean(dataIndex) &&
58
+ columnMap.has(dataIndex) &&
59
+ !isEmptyFilterValue(value),
60
+ );
61
+ if (!handled.length) {
62
+ return data;
63
+ }
64
+ return data.filter((item) => {
65
+ return handled.every(({ dataIndex, value }) => {
66
+ const column = columnMap.get(dataIndex);
67
+
68
+ // Controlled column without `onFilter` is server-side: the data the
69
+ // consumer renders is already filtered upstream, pass it through.
70
+ if (isControlledFilter(column) && !column?.filter?.onFilter) {
71
+ return true;
72
+ }
73
+
74
+ const customFilter = column?.filter?.onFilter;
75
+ if (customFilter) {
76
+ return Array.isArray(value)
77
+ ? value.some((filterValue) => customFilter(filterValue, item))
78
+ : customFilter(value, item);
79
+ }
80
+
81
+ const targetValue = normalizeValue(item[dataIndex]);
82
+
83
+ if (Array.isArray(value)) {
84
+ return value.some(
85
+ (filterValue) => targetValue === normalizeValue(filterValue),
86
+ );
87
+ }
88
+
89
+ return targetValue.includes(normalizeValue(value));
90
+ });
91
+ });
92
+ };
93
+
22
94
  export default function useFilter({
23
95
  data,
24
96
  columns,
@@ -26,7 +98,9 @@ export default function useFilter({
26
98
  data: Array<Record<string, unknown>>;
27
99
  columns: ColumnProps[];
28
100
  }) {
29
- const [filterRecords, setFilterRecords] = useState<Array<FilterRecord>>([]);
101
+ const [internalRecords, setInternalRecords] = useState<Array<FilterRecord>>(
102
+ [],
103
+ );
30
104
  const columnMap = useMemo(
31
105
  () =>
32
106
  new Map(
@@ -38,64 +112,48 @@ export default function useFilter({
38
112
  );
39
113
 
40
114
  useEffect(() => {
41
- setFilterRecords((current) => {
115
+ setInternalRecords((current) => {
42
116
  const next = current.filter(({ dataIndex }) => columnMap.has(dataIndex));
43
117
  return next.length === current.length ? current : next;
44
118
  });
45
119
  }, [columnMap]);
46
120
 
47
- const onFilter = ({ value, dataIndex }: FilterOptions) => {
48
- setFilterRecords((current) => {
49
- if (isEmptyFilterValue(value)) {
50
- return current.filter((item) => item.dataIndex !== dataIndex);
51
- }
121
+ const activeRecords = useMemo(
122
+ () => mergeRecords(internalRecords, columnMap),
123
+ [internalRecords, columnMap],
124
+ );
52
125
 
53
- const index = current.findIndex((item) => item.dataIndex === dataIndex);
54
- if (index === -1) {
55
- return [...current, { dataIndex, value }];
56
- }
126
+ const filtered = useMemo(
127
+ () => applyFilters(data, activeRecords, columnMap),
128
+ [columnMap, data, activeRecords],
129
+ );
57
130
 
58
- return current.map((item, itemIndex) =>
59
- itemIndex === index ? { ...item, value } : item,
60
- );
61
- });
62
- };
131
+ // Mirrors useSorter.onSort: computes the next state synchronously and
132
+ // returns it, so callers can emit onChange with the result in the same tick.
133
+ const onFilter = useCallback(
134
+ ({ value, dataIndex }: FilterOptions) => {
135
+ const nextInternal = isEmptyFilterValue(value)
136
+ ? internalRecords.filter((item) => item.dataIndex !== dataIndex)
137
+ : internalRecords.some((item) => item.dataIndex === dataIndex)
138
+ ? internalRecords.map((item) =>
139
+ item.dataIndex === dataIndex ? { ...item, value } : item,
140
+ )
141
+ : [...internalRecords, { dataIndex, value }];
63
142
 
64
- const filtered = useMemo(() => {
65
- const handled = filterRecords.filter(
66
- ({ dataIndex, value }) =>
67
- Boolean(dataIndex) &&
68
- columnMap.has(dataIndex) &&
69
- !isEmptyFilterValue(value),
70
- );
71
- if (!handled.length) {
72
- return data;
73
- }
74
- return data.filter((item) => {
75
- return handled.every(({ dataIndex, value }) => {
76
- const customFilter = columnMap.get(dataIndex)?.filter?.onFilter;
77
- if (customFilter) {
78
- return Array.isArray(value)
79
- ? value.some((filterValue) => customFilter(filterValue, item))
80
- : customFilter(value, item);
81
- }
82
-
83
- const targetValue = normalizeValue(item[dataIndex]);
84
-
85
- if (Array.isArray(value)) {
86
- return value.some(
87
- (filterValue) => targetValue === normalizeValue(filterValue),
88
- );
89
- }
90
-
91
- return targetValue.includes(normalizeValue(value));
92
- });
93
- });
94
- }, [columnMap, data, filterRecords]);
143
+ setInternalRecords(nextInternal);
144
+
145
+ const nextActive = mergeRecords(nextInternal, columnMap);
146
+ return {
147
+ data: applyFilters(data, nextActive, columnMap),
148
+ filterRecords: nextActive,
149
+ };
150
+ },
151
+ [internalRecords, columnMap, data],
152
+ );
95
153
 
96
154
  return {
97
155
  data: filtered,
98
- filterRecords,
156
+ filterRecords: activeRecords,
99
157
  onFilter,
100
158
  };
101
159
  }
@@ -22,6 +22,7 @@ A table displays rows of data.
22
22
  <code src="./demo/expand.tsx" title="Expandable" description="Expandable rows"></code>
23
23
  <code src="./demo/pagination.tsx" title="Pagination" description="Table with pagination"></code>
24
24
  <code src="./demo/column-filter.tsx" title="column filter" description="Table column filter"></code>
25
+ <code src="./demo/controlled-filter.tsx" title="controlled filter" description="Controlled filtering with filteredValue: server-side search and programmatic reset"></code>
25
26
  <code src="./demo/grouping.tsx" title="Grouping" description="Multi-level header"></code>
26
27
  <code src="./demo/fixed.tsx" title="Fixed Columns" description="Fix columns on both sides and scroll horizontally to see the effect"></code>
27
28
  <code src="./demo/resizable.tsx" title="Resizable Columns" description="Drag the right edge of a header cell to resize it; arrow keys work too"></code>
@@ -77,7 +78,7 @@ A table displays rows of data.
77
78
  | styles | `Partial<Record<ColumnSemanticType, React.CSSProperties>>` | Column semantic styles | - |
78
79
  | align | `'left' \| 'right' \| 'center'` | Alignment | - |
79
80
  | fixed | `'left' \| 'right' \| true` | Fixed column | - |
80
- | filter | `object` | Filter configuration | - |
81
+ | filter | `object` | Filter configuration, see [ColumnFilter](#columnfilter) below | - |
81
82
  | sorter | `boolean \| object` | Sorter configuration | - |
82
83
  | dataIndex | `string` | Data index | - |
83
84
  | key | `string \| number` | Unique key | - |
@@ -92,6 +93,26 @@ A table displays rows of data.
92
93
  | onHeaderCell | `(column: ColumnProps) => object` | Header cell props | - |
93
94
  | render | `(record: object, rowIndex: number) => React.ReactNode` | Custom render | - |
94
95
 
96
+ ### ColumnFilter
97
+
98
+ Filter configuration of `ColumnProps.filter`.
99
+
100
+ | Property | Type | Description | Default |
101
+ | --- | --- | --- | --- |
102
+ | filterMode | `'tree' \| 'menu'` | Filter menu mode (effective when `items` is set) | `menu` |
103
+ | items | `Array<Record<string, any>>` | Filter menu items; renders a checkable tree panel when set | - |
104
+ | searchable | `boolean` | Whether searchable; renders a search input when `items` is unset | `true` |
105
+ | filterOnClose | `boolean` | Whether to apply the filter when the panel closes | `true` |
106
+ | icon | `(filtered: boolean) => React.ReactNode` | Custom filter icon | - |
107
+ | render | `() => React.ReactNode` | Custom filter panel content | - |
108
+ | onFilter | `(value: FilterValue, record: object) => boolean` | Local filter function; falls back to default string-includes matching when unset | - |
109
+ | filtered | `boolean` | Force the filter icon highlight (takes precedence over `filteredValue`) | - |
110
+ | filteredValue | `FilterValue \| FilterValue[]` | (Controlled) Filter value. When set, the column's filter state is fully controlled: panel echo, icon highlight and the `filters` in `onChange` all derive from it; reset with `''` / `[]` | - |
111
+
112
+ > **Controlled + server-side filtering**: with `filteredValue` set and no `onFilter`, no local matching happens at all — whether the data is filtered is decided by refetching in your `onChange` handler (see the controlled filter demo below).
113
+ >
114
+ > When `action === 'filter'`, the key of the column that was just committed is always present in `filters` (with `''` / `[]` on clear), so parents can mirror filter state from the payload alone.
115
+
95
116
  ### RowSelectionType
96
117
 
97
118
  | Property | Type | Description | Default |
@@ -5,6 +5,7 @@ import Head from './theader';
5
5
 
6
6
  export type {
7
7
  ColumnProps,
8
+ FilterValue,
8
9
  HeaderCell,
9
10
  PaginationType,
10
11
  RowSelectionType,
@@ -29,6 +29,7 @@ toc: 'content'
29
29
  <code src="./demo/summary.tsx" title="统计" description="表格的统计行"></code>
30
30
  <code src="./demo/filter.tsx" title="筛选" description="表格的筛选功能"></code>
31
31
  <code src="./demo/column-filter.tsx" title="列筛选" description="表格的列筛选功能"></code>
32
+ <code src="./demo/controlled-filter.tsx" title="受控筛选" description="filteredValue 受控筛选,支持服务端搜索与程序化重置"></code>
32
33
  <code src="./demo/components-override.tsx" title="自定义表格元素" description="可被外部(如 dnd-kit)接管的 tbody / 行渲染"></code>
33
34
 
34
35
  ## API
@@ -82,7 +83,7 @@ toc: 'content'
82
83
  | styles | `Partial<Record<ColumnSemanticType, React.CSSProperties>>` | 列结构化样式 | - |
83
84
  | align | `'left' \| 'right' \| 'center'` | 对齐方式 | - |
84
85
  | fixed | `'left' \| 'right' \| true` | 固定列 | - |
85
- | filter | `object` | 过滤配置 | - |
86
+ | filter | `object` | 过滤配置,见下方 [ColumnFilter](#columnfilter) | - |
86
87
  | sorter | `boolean \| object` | 排序配置 | - |
87
88
  | dataIndex | `string` | 行数据索引 | - |
88
89
  | key | `string \| number` | 唯一key | - |
@@ -97,6 +98,26 @@ toc: 'content'
97
98
  | onHeaderCell | `(column: ColumnProps) => object` | 设置表头单元格属性 | - |
98
99
  | render | `(record: Record<string, any>, rowIndex: number) => React.ReactNode` | 自定义渲染函数 | - |
99
100
 
101
+ ### ColumnFilter
102
+
103
+ `ColumnProps.filter` 的过滤配置。
104
+
105
+ | 属性名 | 类型 | 描述 | 默认值 |
106
+ | ------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------ |
107
+ | filterMode | `'tree' \| 'menu'` | 筛选菜单模式(配置 `items` 时生效) | `menu` |
108
+ | items | `Array<Record<string, any>>` | 筛选菜单项,配置后渲染树形多选面板 | - |
109
+ | searchable | `boolean` | 是否可搜索;未配置 `items` 时渲染搜索输入框 | `true` |
110
+ | filterOnClose | `boolean` | 关闭筛选面板时是否自动应用筛选 | `true` |
111
+ | icon | `(filtered: boolean) => React.ReactNode` | 自定义筛选图标 | - |
112
+ | render | `() => React.ReactNode` | 自定义筛选面板内容 | - |
113
+ | onFilter | `(value: FilterValue, record: object) => boolean` | 本地过滤函数;不配置时使用默认的字符串包含匹配 | - |
114
+ | filtered | `boolean` | 强制筛选图标高亮(优先级高于由 `filteredValue` 派生的高亮) | - |
115
+ | filteredValue | `FilterValue \| FilterValue[]` | (受控)筛选值。配置后该列筛选状态完全受控:面板回显、图标高亮、`onChange` 的 `filters` 均以其为准;重置传 `''` / `[]` | - |
116
+
117
+ > **受控 + 服务端过滤**:列配置 `filteredValue` 且不配置 `onFilter` 时,不做任何本地过滤,数据是否过滤由你在 `onChange` 里重新请求决定,适合后台搜索场景(见下方「受控筛选」示例)。
118
+ >
119
+ > `action === 'filter'` 时,本次操作的列的 key 必在 `filters` 中(清空时值为 `''` / `[]`),父组件可直接据此镜像筛选状态。
120
+
100
121
  ### RowSelectionType
101
122
 
102
123
  | 属性名 | 类型 | 描述 | 默认值 |