Skip to content

Commit a6b6045

Browse files
committed
feat: add ColumnSetting component for managing table column visibility
1 parent 280cdea commit a6b6045

File tree

7 files changed

+351
-1
lines changed

7 files changed

+351
-1
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
# Changelog
44

5+
## 1.5.0
6+
7+
2025-11-17
8+
9+
### Features
10+
11+
- 🔥 Add `ColumnSetting` component for table column visibility management.
12+
513
## 1.4.23
614

715
2025-11-11

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tiny-codes/react-easy",
3-
"version": "1.4.23",
3+
"version": "1.5.0",
44
"description": "Simplify React and AntDesign development with practical components and hooks",
55
"keywords": [
66
"react",
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
2+
import { Button, Checkbox, ConfigProvider, Divider, Dropdown, Space, Typography } from 'antd';
3+
import type { ButtonProps, DropdownProps } from 'antd';
4+
import type { ColumnType } from 'antd/es/table';
5+
import classNames from 'classnames';
6+
import { useLocalStorage } from 'react-use';
7+
import { ReloadOutlined, SettingOutlined } from '@ant-design/icons';
8+
import { useRefFunction, useRefValue } from '../../hooks';
9+
import useT from '../../hooks/useT';
10+
import useStyle from './style';
11+
12+
export interface ColumnSettingProps<T extends ColumnSettingItem = ColumnSettingItem> {
13+
/**
14+
* - **EN:** The columns to be displayed in the column setting.
15+
* - **CN:** 列设置中要显示的列。
16+
*/
17+
columns: T[];
18+
/**
19+
* - **EN:** Callback function triggered when the selected columns change.
20+
* - **CN:** 选中列变化时触发的回调函数。
21+
*/
22+
onChange?: (nextColumns: T[]) => void;
23+
/**
24+
* - **EN:** Local storage key for persisting column settings.
25+
* - **CN:** 用于持久化列设置的本地存储键。
26+
*/
27+
storageKey?: string;
28+
/**
29+
* - **EN:** Function to render custom column titles.
30+
* - **CN:** 自定义列标题的渲染函数。
31+
*/
32+
renderColumnTitle?: (col: ColumnSettingItem, index: number) => React.ReactNode;
33+
/**
34+
* - **EN:** Props for the button that triggers the dropdown.
35+
* - **CN:** 触发下拉菜单的按钮属性。
36+
*/
37+
triggerProps?: ButtonProps;
38+
/**
39+
* - **EN:** Props for the dropdown component.
40+
* - **CN:** 下拉菜单组件的属性。
41+
*/
42+
dropdownProps?: DropdownProps;
43+
/**
44+
* - **EN:** Props for the dropdown popup container.
45+
* - **CN:** 下拉菜单弹出层容器的属性。
46+
*/
47+
popupProps?: React.HTMLAttributes<HTMLDivElement>;
48+
/**
49+
* - **EN:** Props for the "Check All" button.
50+
* - **CN:** “全选”按钮的属性。
51+
*/
52+
checkAllProps?: ButtonProps;
53+
/**
54+
* - **EN:** Props for the "Reset" button.
55+
* - **CN:** “重置”按钮的属性。
56+
*/
57+
resetProps?: ButtonProps;
58+
prefixCls?: string;
59+
}
60+
61+
/**
62+
* - **EN:** A component for configuring table column visibility.
63+
* - **CN:** 用于配置表格列可见性的组件。
64+
*/
65+
function ColumnSetting<T extends ColumnSettingItem = ColumnSettingItem>(props: ColumnSettingProps<T>) {
66+
const {
67+
columns,
68+
storageKey,
69+
triggerProps,
70+
dropdownProps,
71+
popupProps,
72+
prefixCls: prefixClsInProps,
73+
checkAllProps,
74+
resetProps,
75+
onChange,
76+
renderColumnTitle,
77+
} = props;
78+
const { getPrefixCls } = useContext(ConfigProvider.ConfigContext);
79+
const prefixCls = getPrefixCls('column-setting', prefixClsInProps);
80+
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);
81+
const initialSelectedRef = useRef<string[]>(normalizeToSelectedKeys(columns));
82+
const [open, setOpen] = useState(false);
83+
const [selectedKeys, setSelectedKeys] = useState<string[]>(initialSelectedRef.current);
84+
const t = useT();
85+
const [selectedKeysFromStorage, setSelectedKeysFromStorage] = useLocalStorage<string[]>(
86+
storageKey ?? '',
87+
initialSelectedRef.current
88+
);
89+
const storageRef = useRefValue(storageKey);
90+
const selectedKeysFromStorageRef = useRefValue(selectedKeysFromStorage);
91+
const hasChange = useMemo(() => {
92+
return [...selectedKeys].sort().join(',') !== initialSelectedRef.current.join(',');
93+
}, [selectedKeys]);
94+
95+
// Compute keys and selectable keys
96+
const keys = useMemo(() => columns.map((c, i) => String(getColKey(c, i))), [columns]);
97+
const allSelectableKeys = useMemo(
98+
() =>
99+
columns
100+
.map((c, i) => ({ k: String(getColKey(c, i)), disabled: !!c.disabled }))
101+
.filter((x) => !x.disabled)
102+
.map((x) => x.k),
103+
[columns]
104+
);
105+
const isAllChecked = allSelectableKeys.length > 0 && allSelectableKeys.every((k) => selectedKeys.includes(k));
106+
const isIndeterminate = selectedKeys.length > 0 && !isAllChecked;
107+
108+
// Fire change event when selectedKeysFromStorage changes
109+
const change = useRefFunction((nextSelected: string[], fireEvent?: boolean) => {
110+
setSelectedKeys(nextSelected);
111+
if (storageKey) {
112+
setSelectedKeysFromStorage(nextSelected);
113+
}
114+
if (fireEvent) {
115+
const next = columns.map((col, i) => {
116+
const k = String(getColKey(col, i));
117+
const visible = nextSelected.includes(k);
118+
return { ...col, hidden: !visible } as T;
119+
});
120+
onChange?.(next as T[]);
121+
}
122+
});
123+
124+
// Toggle one column change
125+
const toggleOne = (key: string, checked: boolean) => {
126+
const next = new Set(selectedKeys);
127+
if (checked) {
128+
next.add(key);
129+
} else {
130+
// Keep at least one visible column
131+
if (selectedKeys.length <= 1) return;
132+
next.delete(key);
133+
}
134+
const nextArr = Array.from(next);
135+
change(nextArr, true);
136+
};
137+
138+
// Toggle all columns change
139+
const handleCheckAll = (checked: boolean) => {
140+
const nextArr = checked
141+
? Array.from(new Set([...selectedKeys, ...allSelectableKeys]))
142+
: selectedKeys.filter((k) => !allSelectableKeys.includes(k)).slice(0, 1);
143+
const ensured = checked ? nextArr : nextArr.length > 0 ? nextArr : [keys[0]];
144+
change(ensured, true);
145+
};
146+
147+
// Reset to initial selected columns
148+
const handleReset = () => {
149+
const next = initialSelectedRef.current.length > 0 ? initialSelectedRef.current : [keys[0]];
150+
change(next, true);
151+
};
152+
153+
// Sync when columns change
154+
useEffect(() => {
155+
const next = normalizeToSelectedKeys(columns);
156+
initialSelectedRef.current = next;
157+
change(next, false);
158+
}, [columns]);
159+
160+
// Fire change on mount if storage exists and differs from initial values
161+
useEffect(() => {
162+
if (
163+
storageRef.current &&
164+
selectedKeysFromStorageRef.current &&
165+
selectedKeysFromStorageRef.current.join(',') !== initialSelectedRef.current.join(',')
166+
) {
167+
change(selectedKeysFromStorageRef.current, true);
168+
}
169+
}, []);
170+
171+
const dropdownRender = () => (
172+
<div
173+
{...popupProps}
174+
className={classNames(`${prefixCls}-popup`, popupProps?.className)}
175+
onClick={(e) => {
176+
e.stopPropagation();
177+
popupProps?.onClick?.(e);
178+
}}
179+
>
180+
<Typography.Text className={`${prefixCls}-popup-title`}>{t('components.ColumnSetting.title')}</Typography.Text>
181+
<Space direction="vertical" className={`${prefixCls}-column-list`} size={12}>
182+
{columns.map((col, idx) => {
183+
const k = String(getColKey(col, idx));
184+
const label = col.title ?? col.dataIndex ?? k;
185+
const checked = selectedKeys.includes(k);
186+
const disabled = !!col.disabled;
187+
const disableUncheck = !disabled && checked && selectedKeys.length <= 1; // 禁止取消最后一个
188+
return (
189+
<Checkbox
190+
key={k}
191+
className={`${prefixCls}-column-item`}
192+
disabled={disabled || disableUncheck}
193+
checked={checked}
194+
onChange={(e) => toggleOne(k, e.target.checked)}
195+
>
196+
<span className={`${prefixCls}-column-item-title`}>{renderColumnTitle?.(col, idx) ?? label}</span>
197+
</Checkbox>
198+
);
199+
})}
200+
</Space>
201+
<Divider className={`${prefixCls}-divider`} />
202+
<div className={`${prefixCls}-footer`}>
203+
<Button
204+
type="text"
205+
{...checkAllProps}
206+
className={classNames(`${prefixCls}-select-all`, checkAllProps?.className)}
207+
onClick={(e) => {
208+
handleCheckAll(!isAllChecked);
209+
checkAllProps?.onClick?.(e);
210+
}}
211+
>
212+
<Checkbox checked={isAllChecked} indeterminate={isIndeterminate}></Checkbox>
213+
{t('components.ColumnSetting.selectAll')}
214+
</Button>
215+
<Button
216+
type="text"
217+
icon={<ReloadOutlined />}
218+
disabled={!hasChange}
219+
{...resetProps}
220+
className={classNames(`${prefixCls}-reset`, resetProps?.className)}
221+
onClick={(e) => {
222+
handleReset();
223+
resetProps?.onClick?.(e);
224+
}}
225+
>
226+
{t('components.ColumnSetting.reset')}
227+
</Button>
228+
</div>
229+
</div>
230+
);
231+
232+
return wrapCSSVar(
233+
<Dropdown
234+
open={open}
235+
onOpenChange={setOpen}
236+
trigger={['click']}
237+
dropdownRender={dropdownRender}
238+
popupRender={dropdownRender}
239+
placement="bottomRight"
240+
{...dropdownProps}
241+
rootClassName={classNames(hashId, cssVarCls, prefixCls, `${prefixCls}-dropdown`, dropdownProps?.className)}
242+
>
243+
<Button
244+
icon={<SettingOutlined />}
245+
{...triggerProps}
246+
className={classNames(hashId, cssVarCls, prefixCls, `${prefixCls}-trigger`, triggerProps?.className)}
247+
/>
248+
</Dropdown>
249+
);
250+
}
251+
252+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
253+
export interface ColumnSettingItem<T = any> extends ColumnType<T> {
254+
/**
255+
* - **EN:** Disable toggling visibility for this column.
256+
* - **CN:** 禁止切换此列的可见性。
257+
*/
258+
disabled?: boolean;
259+
}
260+
261+
function getColKey(col: ColumnType, idx: number): React.Key {
262+
return col.key ?? (col.dataIndex as string) ?? idx;
263+
}
264+
265+
function normalizeToSelectedKeys(cols: ColumnType[]): string[] {
266+
return cols
267+
.map((c, i) => ({ key: String(getColKey(c, i)), hidden: !!c.hidden }))
268+
.filter((c) => !c.hidden)
269+
.map((c) => c.key)
270+
.sort();
271+
}
272+
273+
export default ColumnSetting;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { genStyleHooks } from 'antd/es/theme/internal';
2+
import type { AliasToken, GenerateStyle } from 'antd/es/theme/internal';
3+
import type { CSSObject } from '@ant-design/cssinjs';
4+
import type { FullToken } from '@ant-design/cssinjs-utils';
5+
6+
type ColumnSettingToken = FullToken<{ ''?: object }, AliasToken, ''>;
7+
8+
const genStyle: GenerateStyle<ColumnSettingToken> = (token): CSSObject => {
9+
const { componentCls } = token;
10+
return {
11+
[`${componentCls}-dropdown`]: {
12+
[`${componentCls}-popup`]: {
13+
display: 'flex',
14+
flexDirection: 'column',
15+
gap: 8,
16+
padding: '12px 16px',
17+
borderRadius: token.borderRadiusLG,
18+
boxShadow: token.boxShadow,
19+
width: 260,
20+
maxHeight: 500,
21+
background: token.colorBgContainer,
22+
23+
[`${componentCls}-popup-title`]: {
24+
marginBottom: 4,
25+
fontWeight: '600',
26+
},
27+
28+
[`${componentCls}-column-list`]: {
29+
width: '100%',
30+
overflow: 'auto',
31+
flex: 'auto',
32+
minInlineSize: 0,
33+
padding: '0 8px',
34+
35+
[`${componentCls}-column-item`]: {
36+
[`${componentCls}-column-item-title`]: {
37+
wordBreak: 'break-all',
38+
},
39+
},
40+
},
41+
42+
[`& ${componentCls}-divider`]: {
43+
margin: 0,
44+
},
45+
46+
[`${componentCls}-footer`]: {
47+
display: 'flex',
48+
justifyContent: 'center',
49+
alignItems: 'center',
50+
gap: 12,
51+
},
52+
},
53+
},
54+
[`${componentCls}-trigger`]: {},
55+
};
56+
};
57+
58+
export default genStyleHooks('ColumnSetting' as never, genStyle);

src/components/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
export type { BreakLinesProps } from './BreakLines';
22
export { default as BreakLines } from './BreakLines';
33

4+
export type { ColumnSettingProps } from './ColumnSetting';
5+
export { default as ColumnSetting } from './ColumnSetting';
6+
47
export type { ConfigProviderProps } from './ConfigProvider';
58
export { default as ConfigProvider } from './ConfigProvider';
69
export type { ReactEasyContextProps } from './ConfigProvider/context';

src/locales/langs/en-US.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ const enUS = {
44
'components.EditableText.edit': 'Edit',
55
'components.EditableText.save': 'Save',
66
'components.EditableText.cancel': 'Cancel',
7+
'components.ColumnSetting.title': 'Column Setting',
8+
'components.ColumnSetting.selectAll': 'Select All',
9+
'components.ColumnSetting.reset': 'Reset',
10+
711
'validation.rule.number.message': 'Please enter a number',
812
'validation.rule.floatNumber.message': 'Please enter a number',
913
'validation.rule.email.message': 'Please enter the correct email address',

src/locales/langs/zh-CN.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ const zhCN = {
44
'components.EditableText.edit': '编辑',
55
'components.EditableText.save': '保存',
66
'components.EditableText.cancel': '取消',
7+
'components.ColumnSetting.title': '列设置',
8+
'components.ColumnSetting.selectAll': '全选',
9+
'components.ColumnSetting.reset': '重置',
10+
711
'validation.rule.number.message': '请输入数字',
812
'validation.rule.floatNumber.message': '请输入数字',
913
'validation.rule.email.message': '请输入正确的邮箱地址',

0 commit comments

Comments
 (0)