@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
@@ -68,9 +68,13 @@ export interface HeadCellProps extends Omit<WrapedColumnProps, 'title'> {
68
68
  tableActionBarShowOnHover?: boolean;
69
69
  sorters?: { [prop: string]: number };
70
70
  showSorterTooltip?: boolean;
71
- internalFilters?: { [dataIndex: string]: boolean }; // Whether this column is filtered
72
71
  onSort?: (dataIndex: string, sorter: any) => void;
73
- onInternalFilter?: (dataIndex: string, value: string) => void;
72
+ onInternalFilter?: (options: {
73
+ dataIndex: string;
74
+ value: FilterValue | FilterValue[];
75
+ type?: 'filter' | 'search';
76
+ column: WrapedColumnProps;
77
+ }) => void;
74
78
  /**
75
79
  * `resizeKey` of the column currently being dragged, if any
76
80
  */
@@ -215,6 +219,14 @@ export interface ColumnProps {
215
219
  * Whether searchable, when items is undefined, it acts as a search component
216
220
  */
217
221
  searchable?: boolean;
222
+ /**
223
+ * Controlled filter state. When defined, this column's filter state is
224
+ * controlled: the dropdown echo, icon highlight and the `filters` in
225
+ * onChange all derive from it. With no `onFilter`, local matching is
226
+ * skipped entirely (server mode — filter in your onChange handler).
227
+ * Reset with `[]` / `''`.
228
+ */
229
+ filteredValue?: FilterValue | FilterValue[];
218
230
  /**
219
231
  * Filter icon
220
232
  */
@@ -0,0 +1,343 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import React, { useState } from 'react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import Table from './table';
6
+
7
+ import type { FilterValue } from './interface';
8
+
9
+ const dataSource = [
10
+ { key: '1', name: 'John Brown', age: 32, gender: 'male' },
11
+ { key: '2', name: 'Jim Green', age: 42, gender: 'male' },
12
+ { key: '3', name: 'Joe Black', age: 32, gender: 'female' },
13
+ ];
14
+
15
+ afterEach(() => {
16
+ cleanup();
17
+ });
18
+
19
+ // jsdom has no ResizeObserver; @rc-component/trigger uses it for popup align.
20
+ if (typeof globalThis.ResizeObserver === 'undefined') {
21
+ globalThis.ResizeObserver = class {
22
+ observe() {}
23
+ unobserve() {}
24
+ disconnect() {}
25
+ } as unknown as typeof ResizeObserver;
26
+ }
27
+
28
+ const getTrigger = (container: HTMLElement, index = 0) =>
29
+ container.querySelectorAll('.yee-table-filter-trigger')[index] as HTMLElement;
30
+
31
+ const openFilter = (container: HTMLElement, index = 0) => {
32
+ fireEvent.click(getTrigger(container, index));
33
+ };
34
+
35
+ // Closed popovers stay mounted in the DOM, so the newest popup is last.
36
+ const getPopup = () =>
37
+ document.querySelectorAll('.yee-table-filter-content')[
38
+ document.querySelectorAll('.yee-table-filter-content').length - 1
39
+ ] as HTMLElement;
40
+
41
+ const commitSearch = (value: string) => {
42
+ const popup = getPopup();
43
+ fireEvent.change(popup.querySelector('input') as HTMLInputElement, {
44
+ target: { value },
45
+ });
46
+ const buttons = popup.querySelectorAll('button');
47
+ fireEvent.click(buttons[buttons.length - 1]);
48
+ };
49
+
50
+ const rowCount = (container: HTMLElement) =>
51
+ container.querySelectorAll('tbody tr').length;
52
+
53
+ describe('Table filter', () => {
54
+ it('filters locally with default includes matching and emits onChange once', () => {
55
+ const onChange = vi.fn();
56
+ const { container } = render(
57
+ <Table
58
+ columns={[
59
+ {
60
+ title: 'Name',
61
+ dataIndex: 'name',
62
+ key: 'name',
63
+ filter: { searchable: true },
64
+ },
65
+ ]}
66
+ dataSource={dataSource}
67
+ onChange={onChange}
68
+ />,
69
+ );
70
+
71
+ openFilter(container);
72
+ commitSearch('jim');
73
+
74
+ expect(rowCount(container)).toBe(1);
75
+ expect(container.textContent).toContain('Jim Green');
76
+ expect(onChange).toHaveBeenCalledTimes(1);
77
+ expect(onChange.mock.calls[0][0]).toMatchObject({
78
+ action: 'filter',
79
+ filters: { name: 'jim' },
80
+ });
81
+ });
82
+
83
+ it('uses the custom onFilter when provided', () => {
84
+ const { container } = render(
85
+ <Table
86
+ columns={[
87
+ {
88
+ title: 'Age',
89
+ dataIndex: 'age',
90
+ key: 'age',
91
+ filter: {
92
+ searchable: true,
93
+ onFilter: (value, record) => record.age === Number(value),
94
+ },
95
+ },
96
+ ]}
97
+ dataSource={dataSource}
98
+ />,
99
+ );
100
+
101
+ openFilter(container);
102
+ commitSearch('32');
103
+
104
+ // Default includes matching would find nothing ("32" is not in the names);
105
+ // the custom filter matches the two rows aged 32.
106
+ expect(rowCount(container)).toBe(2);
107
+ });
108
+
109
+ it('passes data through when controlled without onFilter and reports the controlled value in filters', () => {
110
+ const onChange = vi.fn();
111
+ const { container } = render(
112
+ <Table
113
+ columns={[
114
+ {
115
+ title: 'Name',
116
+ dataIndex: 'name',
117
+ key: 'name',
118
+ filter: { searchable: true, filteredValue: 'zzz' },
119
+ },
120
+ {
121
+ title: 'Age',
122
+ dataIndex: 'age',
123
+ key: 'age',
124
+ filter: { searchable: true },
125
+ },
126
+ ]}
127
+ dataSource={dataSource}
128
+ onChange={onChange}
129
+ />,
130
+ );
131
+
132
+ // "zzz" matches nothing locally, yet server mode must not drop any rows.
133
+ expect(rowCount(container)).toBe(3);
134
+
135
+ openFilter(container, 1);
136
+ commitSearch('32');
137
+
138
+ expect(rowCount(container)).toBe(2);
139
+ expect(onChange.mock.calls[0][0]).toMatchObject({
140
+ action: 'filter',
141
+ filters: { name: 'zzz', age: '32' },
142
+ });
143
+ });
144
+
145
+ it('filters locally with onFilter when controlled', () => {
146
+ const { container } = render(
147
+ <Table
148
+ columns={[
149
+ {
150
+ title: 'Name',
151
+ dataIndex: 'name',
152
+ key: 'name',
153
+ filter: {
154
+ searchable: true,
155
+ filteredValue: 'jim',
156
+ onFilter: (value, record) =>
157
+ String(record.name)
158
+ .toLowerCase()
159
+ .includes(String(value).toLowerCase()),
160
+ },
161
+ },
162
+ ]}
163
+ dataSource={dataSource}
164
+ />,
165
+ );
166
+
167
+ expect(rowCount(container)).toBe(1);
168
+ expect(container.textContent).toContain('Jim Green');
169
+ });
170
+
171
+ it('emits the committed value, highlights the trigger and echoes the value back', () => {
172
+ const onChange = vi.fn();
173
+ const Controlled = () => {
174
+ const [filteredValue, setFilteredValue] = useState<
175
+ FilterValue | FilterValue[]
176
+ >('');
177
+ const handleChange = (info: any) => {
178
+ onChange(info);
179
+ setFilteredValue(info.filters.name ?? '');
180
+ };
181
+ return (
182
+ <Table
183
+ columns={[
184
+ {
185
+ title: 'Name',
186
+ dataIndex: 'name',
187
+ key: 'name',
188
+ filter: { searchable: true, filteredValue },
189
+ },
190
+ ]}
191
+ dataSource={dataSource}
192
+ onChange={handleChange}
193
+ />
194
+ );
195
+ };
196
+ const { container } = render(<Controlled />);
197
+
198
+ // Server mode: data untouched, parent decides what to render.
199
+ expect(rowCount(container)).toBe(3);
200
+ expect(getTrigger(container).querySelector('.yee-btn-filled')).toBeNull();
201
+
202
+ openFilter(container);
203
+ commitSearch('jim');
204
+
205
+ expect(onChange).toHaveBeenCalledTimes(1);
206
+ expect(onChange.mock.calls[0][0]).toMatchObject({
207
+ action: 'filter',
208
+ filters: { name: 'jim' },
209
+ });
210
+
211
+ // Highlight derives from the controlled prop once the parent applies it.
212
+ expect(
213
+ getTrigger(container).querySelector('.yee-btn-filled'),
214
+ ).not.toBeNull();
215
+
216
+ // Re-opening the dropdown echoes the controlled value into the input.
217
+ openFilter(container);
218
+ const input = getPopup().querySelector('input') as HTMLInputElement;
219
+ expect(input.value).toBe('jim');
220
+ });
221
+
222
+ it('resets programmatically via filteredValue without emitting onChange', () => {
223
+ const onChange = vi.fn();
224
+ const Controlled = () => {
225
+ const [filteredValue, setFilteredValue] = useState<FilterValue[]>([
226
+ 'male',
227
+ ]);
228
+ return (
229
+ <>
230
+ <button type="button" onClick={() => setFilteredValue([])}>
231
+ reset-filters
232
+ </button>
233
+ <Table
234
+ columns={[
235
+ {
236
+ title: 'Gender',
237
+ dataIndex: 'gender',
238
+ key: 'gender',
239
+ filter: {
240
+ items: [
241
+ { label: 'Male', key: 'male' },
242
+ { label: 'Female', key: 'female' },
243
+ ],
244
+ filteredValue,
245
+ onFilter: (value, record) => value === record.gender,
246
+ },
247
+ },
248
+ ]}
249
+ dataSource={dataSource}
250
+ onChange={onChange}
251
+ />
252
+ </>
253
+ );
254
+ };
255
+ const { container, getByText } = render(<Controlled />);
256
+
257
+ expect(rowCount(container)).toBe(2);
258
+ expect(
259
+ getTrigger(container).querySelector('.yee-btn-filled'),
260
+ ).not.toBeNull();
261
+
262
+ fireEvent.click(getByText('reset-filters'));
263
+
264
+ expect(rowCount(container)).toBe(3);
265
+ expect(getTrigger(container).querySelector('.yee-btn-filled')).toBeNull();
266
+ expect(onChange).not.toHaveBeenCalled();
267
+ });
268
+
269
+ // Regression: server mode's onChange → fetch → setState round-trip is async,
270
+ // so commits must not leak into state while the prop has not been applied.
271
+ it('does not leak a stale controlled value into later onChange payloads when the parent ignores commits', () => {
272
+ const onChange = vi.fn();
273
+ const { container } = render(
274
+ <Table
275
+ columns={[
276
+ {
277
+ title: 'Name',
278
+ dataIndex: 'name',
279
+ key: 'name',
280
+ filter: { searchable: true, filteredValue: '' },
281
+ },
282
+ {
283
+ title: 'Age',
284
+ dataIndex: 'age',
285
+ key: 'age',
286
+ filter: { searchable: true },
287
+ },
288
+ ]}
289
+ dataSource={dataSource}
290
+ onChange={onChange}
291
+ />,
292
+ );
293
+
294
+ // User commits on the controlled column; the parent never applies it.
295
+ openFilter(container, 0);
296
+ commitSearch('jim');
297
+
298
+ // The controlled column itself: payload reports the clicked value only.
299
+ expect(onChange.mock.calls[0][0]).toMatchObject({
300
+ action: 'filter',
301
+ filters: { name: 'jim' },
302
+ });
303
+ // Server passthrough: rows must be untouched despite the commit.
304
+ expect(rowCount(container)).toBe(3);
305
+
306
+ // A commit on the other column must not carry the stale 'jim' along.
307
+ openFilter(container, 1);
308
+ commitSearch('32');
309
+
310
+ expect(onChange.mock.calls[1][0]).toMatchObject({
311
+ action: 'filter',
312
+ filters: { age: '32' },
313
+ });
314
+ expect(onChange.mock.calls[1][0].filters).not.toHaveProperty('name');
315
+ });
316
+
317
+ it('does not filter locally from unapplied commits on a controlled column with onFilter', () => {
318
+ const { container } = render(
319
+ <Table
320
+ columns={[
321
+ {
322
+ title: 'Age',
323
+ dataIndex: 'age',
324
+ key: 'age',
325
+ filter: {
326
+ searchable: true,
327
+ // Controlled as "no filter"; the parent ignores onChange.
328
+ filteredValue: [],
329
+ onFilter: (value, record) => record.age === Number(value),
330
+ },
331
+ },
332
+ ]}
333
+ dataSource={dataSource}
334
+ />,
335
+ );
336
+
337
+ openFilter(container);
338
+ commitSearch('32');
339
+
340
+ // The prop says no filter, so no local matching may happen.
341
+ expect(rowCount(container)).toBe(3);
342
+ });
343
+ });
@@ -21,6 +21,7 @@ import useFilter from './hooks/useFilter';
21
21
  import usePagination from './hooks/usePagination';
22
22
  import useSorter from './hooks/useSorter';
23
23
  import type {
24
+ FilterValue,
24
25
  PaginationType,
25
26
  TableProps,
26
27
  WrapedColumnProps,
@@ -207,6 +208,33 @@ const Table = React.forwardRef<HTMLDivElement, TableProps>((baseprops, ref) => {
207
208
  [onSortInternal, onChange, pagination, current, pageSize, filters],
208
209
  );
209
210
 
211
+ const handleFilter = React.useCallback(
212
+ (options: { dataIndex: string; value: FilterValue | FilterValue[] }) => {
213
+ const next = onFilterInternal(options);
214
+ // Remove-then-re-add: the committing column's entry must be reported
215
+ // even when the merged records hold no record for it (a controlled
216
+ // column whose prop value is still empty), so parents can mirror state
217
+ // from this payload alone. On clear the key is kept with its empty
218
+ // value, matching antd.
219
+ const emitted = next.filterRecords.filter(
220
+ (record) => record.dataIndex !== options.dataIndex,
221
+ );
222
+ emitted.push({ dataIndex: options.dataIndex, value: options.value });
223
+ onChange?.({
224
+ pagination: pagination === false ? undefined : { current, pageSize },
225
+ sorter: sorters,
226
+ filters: Object.fromEntries(
227
+ emitted.map(({ dataIndex, value }) => [dataIndex, value]),
228
+ ),
229
+ // Filtered but not yet re-sorted; symmetric with handleSort, whose
230
+ // payload is sorted but not re-paginated.
231
+ currentDataSource: next.data,
232
+ action: 'filter',
233
+ });
234
+ },
235
+ [onFilterInternal, onChange, pagination, current, pageSize, sorters],
236
+ );
237
+
210
238
  // Expanded row data
211
239
  const { expandedRowKeys, onExpand } = useExpand(
212
240
  expandable,
@@ -221,8 +249,6 @@ const Table = React.forwardRef<HTMLDivElement, TableProps>((baseprops, ref) => {
221
249
  onChange: onSelectionChange,
222
250
  } = useSelection({ pageData, dataSource, getRowKey, rowSelection, allKeys });
223
251
 
224
- // Track filter changes to emit onChange after filtered data is recalculated
225
- const prevFilterRecordsRef = useRef(filterRecords);
226
252
  const [horizontalScroll, setHorizontalScroll] = React.useState({
227
253
  left: false,
228
254
  right: false,
@@ -292,30 +318,6 @@ const Table = React.forwardRef<HTMLDivElement, TableProps>((baseprops, ref) => {
292
318
  });
293
319
  }, [wrapedColumnsRef]);
294
320
 
295
- useEffect(() => {
296
- if (prevFilterRecordsRef.current === filterRecords) return;
297
-
298
- prevFilterRecordsRef.current = filterRecords;
299
- if (!onChange) return;
300
-
301
- onChange({
302
- pagination: pagination === false ? undefined : { current, pageSize },
303
- sorter: sorters,
304
- filters,
305
- currentDataSource: sortedData,
306
- action: 'filter',
307
- });
308
- }, [
309
- filterRecords,
310
- filters,
311
- sortedData,
312
- pagination,
313
- current,
314
- pageSize,
315
- sorters,
316
- onChange,
317
- ]);
318
-
319
321
  // Observers are attached once: both callbacks are referentially stable.
320
322
  useEffect(() => {
321
323
  const content = contentRef.current;
@@ -380,7 +382,7 @@ const Table = React.forwardRef<HTMLDivElement, TableProps>((baseprops, ref) => {
380
382
  sorters={sorters}
381
383
  onSort={handleSort}
382
384
  onCheckAll={onCheckAll}
383
- onInternalFilter={onFilterInternal}
385
+ onInternalFilter={handleFilter}
384
386
  resizingKey={resizingKey}
385
387
  onResizeStart={onResizeStart}
386
388
  onResizeStep={onResizeStep}
@@ -24,7 +24,6 @@ const HeadCell: React.FC<HeadCellProps> = (props) => {
24
24
  classNames,
25
25
  showSorterTooltip = true,
26
26
  tableActionBarShowOnHover,
27
- internalFilters,
28
27
  style,
29
28
  width,
30
29
  colSpan,
@@ -122,7 +121,6 @@ const HeadCell: React.FC<HeadCellProps> = (props) => {
122
121
  {...rest}
123
122
  filter={filter}
124
123
  column={props}
125
- internalFilters={internalFilters}
126
124
  onInternalFilter={onInternalFilter}
127
125
  dataIndex={dataIndex as string}
128
126
  />
@@ -59,6 +59,27 @@ const HeaderFilter = React.memo((props: any) => {
59
59
  const [selectNodes, setSelectNodes] = useState<Array<string | number>>([]);
60
60
  const [searchValue, setSearchValue] = useState('');
61
61
 
62
+ // Controlled columns echo `filteredValue` into the local display state.
63
+ // Only sync while the dropdown is closed so in-progress edits are kept; on
64
+ // close the committed ref is synced too, so `filterOnClose`'s value diff
65
+ // doesn't re-commit the echoed value as if the user had changed it.
66
+ const controlled = filter.filteredValue !== undefined;
67
+ useEffect(() => {
68
+ if (!controlled || open) return;
69
+
70
+ const value = filter.filteredValue as FilterInput;
71
+ if (Array.isArray(value)) {
72
+ setSelectNodes([...value]);
73
+ setSearchValue('');
74
+ } else {
75
+ setSearchValue(String(value));
76
+ setSelectNodes([]);
77
+ }
78
+ const committed: FilterInput = Array.isArray(value) ? [...value] : value;
79
+ committedValueRef.current = committed;
80
+ setCommittedValue(committed);
81
+ }, [controlled, filter.filteredValue, open]);
82
+
62
83
  useEffect(() => {
63
84
  if (!open) return;
64
85
 
@@ -204,7 +225,11 @@ const HeaderFilter = React.memo((props: any) => {
204
225
  setOpen(nextOpen);
205
226
  };
206
227
 
207
- const isFiltered = filtered ?? hasFilterValue(committedValue);
228
+ const isFiltered =
229
+ filtered ??
230
+ (controlled
231
+ ? hasFilterValue((filter.filteredValue ?? '') as FilterInput)
232
+ : hasFilterValue(committedValue));
208
233
 
209
234
  const renderTrigger = () => {
210
235
  const trigger =
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@ declare global {
4
4
  }
5
5
  }
6
6
 
7
- export const version = "0.12.2";
7
+ export const version = "0.14.0";
8
8
 
9
9
  if (typeof window !== 'undefined') {
10
10
  window.__yee_c_version__ = version;