列控制(列显隐 + 密度)
列一多表格就挤。这个形状在工具栏加一个「列」popover:复选框逐列切换显隐,身份列锁死不可藏,外加舒适/紧凑两档密度;偏好持久化,下次进来还是你的布局。它只管「怎么看」,不碰「看什么」——排序、筛选照常工作。
试这几下:
- 点工具栏的列——逐列勾选显隐,表格立刻重排;「城市」默认就是藏着的,勾上它试试
- popover 里找不到姓名——身份列
enableHiding: false,不给用户藏掉「这一行是谁」 - 切密度到紧凑——行高与内边距肉眼可见地变小;重置恢复默认列显隐
- 藏几列、换个密度,然后刷新页面(iframe 内右键 → 重新加载框架)——偏好还在(localStorage)
- 先按工单数排序、再搜个名字,然后随便藏列——排序与筛选不受任何影响
规矩
- 显隐与密度都是受控状态,不留内部副本:显隐是一张
列 id → bool的映射,密度归父组件所有;popover 只是读写这两个状态的开关面板。demo 用一个 React state + localStorage,真实版是 TanStack Table 的columnVisibility(state+onColumnVisibilityChange)接用户偏好持久化 - 身份列退出隐藏:
enableHiding: false的列直接不进 popover(对应column.getCanHide()过滤)——用户可以少看,但不能把行的身份藏掉 - 开关的名字读
meta.label:每个列定义带一个人类可读标签,与表头渲染解耦——表头可以是任意组件,复选框的名字始终干净 - 列控制与查询状态各管各的:显隐/密度是「怎么看」(视图偏好),排序/筛选/分页是「看什么」(列表参数)——两套状态互不引用,藏掉排序列也不影响排序结果
蓝本:
add-table-columns.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-table-columns.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:列控制</title>
<!--
demo-table-columns.html —— 「列控制」形状的最小可玩实现(蓝本:open-dashboard 的 ColumnControls.tsx + add-table-columns)。
复现的不变量:
- 列显隐与密度都是受控状态:显隐是一张 id → bool 的映射(对应 TanStack 的 columnVisibility),
密度归父组件所有,popover 内部不留任何副本
- 身份列(姓名)以 enableHiding: false 退出隐藏;popover 只列出可隐藏的列(对应 column.getCanHide() 过滤)
- 复选框的名字读列定义的 label(对应蓝本的 columnDef.meta.label),与表头渲染解耦
- demo 额外把两项偏好持久到 localStorage(真实版接用户偏好持久化);排序/筛选与列显隐互不干扰
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
.arrow { font-size: 10px; }
/* 密度:紧凑档收紧行高与内边距(舒适档即 demo.css 默认) */
table.list.compact thead th { padding: 4px 12px; }
table.list.compact tbody td { padding: 3px 12px; font-size: 12.5px; }
/* 「列」popover */
.colwrap { position: relative; }
.cnt { color: var(--muted); font-size: 12px; }
.pop { position: absolute; right: 0; top: calc(100% + 6px); z-index: 15; width: 232px;
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; box-shadow: 0 8px 24px rgba(0,0,0,.16);
display: flex; flex-direction: column; gap: 8px; }
.pop h3 { margin: 0; font-size: 12px; font-weight: 500; color: var(--muted); }
.pop-head { display: flex; justify-content: space-between; align-items: center; }
.pop label.col { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
.pop .lock { margin: 0; font-size: 12px; color: var(--muted); }
.pop .hr { height: 1px; background: var(--border); margin: 2px 0; }
.dens { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.dens button { padding: 5px 8px; font: inherit; font-size: 12.5px; cursor: pointer;
border: 1px solid var(--border); border-radius: 8px;
background: var(--card); color: var(--fg); }
.dens button[aria-pressed="true"] { background: var(--primary); border-color: var(--primary);
color: var(--primary-fg); }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect, useRef } = React;
const html = htm.bind(React.createElement);
const STATUS = {
active: { text: "活跃", cls: "chip-on" },
invited: { text: "邀请中", cls: "chip-warn" },
suspended: { text: "停用", cls: "chip-off" },
};
// 列定义:label 即蓝本的 meta.label(popover 复选框的名字,与表头渲染解耦);
// 姓名是身份列,enableHiding: false 让它退出隐藏。9 列故意偏多。
const COLUMNS = [
{ id: "name", label: "姓名", enableHiding: false, sortable: true,
cell: r => html`<span style=${{ fontWeight: 550 }}>${r.name}</span>` },
{ id: "email", label: "邮箱", cell: r => r.email },
{ id: "team", label: "团队", cell: r => r.team },
{ id: "role", label: "角色", cell: r => r.role },
{ id: "status", label: "状态",
cell: r => html`<span class=${"chip " + STATUS[r.status].cls}>${STATUS[r.status].text}</span>` },
{ id: "city", label: "城市", cell: r => r.city },
{ id: "joined", label: "加入日期", numeric: true, sortable: true, cell: r => r.joined },
{ id: "lastActive", label: "最近活跃", cell: r => r.lastActive },
{ id: "tickets", label: "工单数", numeric: true, sortable: true, cell: r => r.tickets },
];
const DATA = [
{ id: 1, name: "陈禾", email: "chenhe@corp.cn", team: "平台", role: "负责人", status: "active", city: "上海", joined: "2026-03-14", lastActive: "2 小时前", tickets: 12 },
{ id: 2, name: "李慕白", email: "mubai@corp.cn", team: "平台", role: "管理员", status: "active", city: "北京", joined: "2026-05-02", lastActive: "昨天", tickets: 5 },
{ id: 3, name: "王小满", email: "xiaoman@corp.cn", team: "研发", role: "工程师", status: "invited", city: "深圳", joined: "2025-11-20", lastActive: "—", tickets: 0 },
{ id: 4, name: "赵青", email: "zhaoqing@corp.cn", team: "研发", role: "分析师", status: "active", city: "杭州", joined: "2026-01-08", lastActive: "4 天前", tickets: 23 },
{ id: 5, name: "周芷", email: "zhouzhi@corp.cn", team: "设计", role: "设计师", status: "active", city: "成都", joined: "2026-06-17", lastActive: "刚刚", tickets: 8 },
{ id: 6, name: "吴限", email: "wuxian@corp.cn", team: "运维", role: "工程师", status: "suspended", city: "广州", joined: "2025-09-30", lastActive: "上个月", tickets: 31 },
{ id: 7, name: "郑舟", email: "zhengzhou@corp.cn", team: "平台", role: "工程师", status: "active", city: "上海", joined: "2026-02-25", lastActive: "3 小时前", tickets: 17 },
{ id: 8, name: "冯霁", email: "fengji@corp.cn", team: "设计", role: "设计师", status: "invited", city: "武汉", joined: "2026-04-11", lastActive: "—", tickets: 0 },
{ id: 9, name: "褚遂", email: "chusui@corp.cn", team: "研发", role: "工程师", status: "active", city: "南京", joined: "2026-05-29", lastActive: "昨天", tickets: 9 },
{ id: 10, name: "卫疏", email: "weishu@corp.cn", team: "运维", role: "分析师", status: "suspended", city: "西安", joined: "2025-12-05", lastActive: "2 周前", tickets: 26 },
];
// 与蓝本一致:默认藏一列(模板里是 location: false),演示「默认隐藏 + 用户可召回」
const DEFAULT_VISIBILITY = { city: false };
const DEFAULT_DENSITY = "comfortable";
const LS_KEY = "demo-table-columns:prefs";
function loadPrefs() {
try {
const p = JSON.parse(localStorage.getItem(LS_KEY) || "null");
if (!p || typeof p !== "object") return null;
const visibility = {};
COLUMNS.forEach(c => {
if (c.enableHiding !== false && p.visibility && p.visibility[c.id] === false) visibility[c.id] = false;
});
return { visibility, density: p.density === "compact" ? "compact" : "comfortable" };
} catch (e) { return null; }
}
const SAVED = loadPrefs();
// 「列」popover:只读写父级传下来的显隐映射与密度,自己不留副本(蓝本不变量)。
function ColumnControls({ columns, isVisible, onToggle, onReset, density, onDensityChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onDown = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
const onKey = e => { if (e.key === "Escape") setOpen(false); };
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const hideable = columns.filter(c => c.enableHiding !== false); // 对应 column.getCanHide()
const locked = columns.filter(c => c.enableHiding === false);
const shown = columns.filter(isVisible).length;
return html`
<div class="colwrap" ref=${ref}>
<button class="btn" aria-expanded=${open} onClick=${() => setOpen(o => !o)}>
列 <span class="cnt">${shown}/${columns.length}</span>
</button>
${open && html`
<div class="pop" role="dialog" aria-label="列控制">
<div class="pop-head">
<h3>显示列</h3>
<button class="link" onClick=${onReset}>重置</button>
</div>
${hideable.map(c => html`
<label class="col" key=${c.id}>
<input type="checkbox" checked=${isVisible(c)} onChange=${() => onToggle(c.id)} />
${c.label}
</label>`)}
<p class="lock">${locked.map(c => "「" + c.label + "」").join("")}为身份列,不可隐藏</p>
<div class="hr"></div>
<h3>密度</h3>
<div class="dens">
<button aria-pressed=${density === "comfortable"}
onClick=${() => onDensityChange("comfortable")}>舒适</button>
<button aria-pressed=${density === "compact"}
onClick=${() => onDensityChange("compact")}>紧凑</button>
</div>
</div>`}
</div>`;
}
function App() {
// 列显隐(id → bool 映射,对应 TanStack columnVisibility)与密度:受控 + 持久化
const [visibility, setVisibility] = useState(SAVED ? SAVED.visibility : { ...DEFAULT_VISIBILITY });
const [density, setDensity] = useState(SAVED ? SAVED.density : DEFAULT_DENSITY);
// 查询状态(「看什么」):与列控制(「怎么看」)各管各的,互不引用
const [q, setQ] = useState("");
const [status, setStatus] = useState("全部");
const [sort, setSort] = useState({ by: "joined", dir: "desc" });
const [toast, setToast] = useState("");
useEffect(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify({ visibility, density })); } catch (e) {}
}, [visibility, density]);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(t);
}, [toast]);
const isVisible = c => visibility[c.id] !== false;
const visibleCols = COLUMNS.filter(isVisible);
const rows = useMemo(() => {
const kw = q.trim().toLowerCase();
const list = DATA.filter(r =>
(status === "全部" || STATUS[r.status].text === status) &&
(r.name.toLowerCase().includes(kw) || r.email.toLowerCase().includes(kw)));
const mul = sort.dir === "asc" ? 1 : -1;
return [...list].sort((a, b) => (a[sort.by] < b[sort.by] ? -1 : a[sort.by] > b[sort.by] ? 1 : 0) * mul);
}, [q, status, sort]);
const toggleColumn = id => setVisibility(v => ({ ...v, [id]: v[id] === false }));
const resetColumns = () => { setVisibility({ ...DEFAULT_VISIBILITY }); setToast("已恢复默认列显隐"); };
const toggleSort = by =>
setSort(s => ({ by, dir: s.by === by && s.dir === "asc" ? "desc" : "asc" }));
const SortTh = ({ by, children }) => html`
<th class="sortable" onClick=${() => toggleSort(by)}>
${children} <span class="arrow">${sort.by === by ? (sort.dir === "asc" ? "▲" : "▼") : ""}</span>
</th>`;
return html`
<div class="page">
<header class="head">
<div>
<h1>成员总表</h1>
<p class="sub">9 列故意偏多 —— 点「列」挑你要看的;偏好存 localStorage,刷新仍在</p>
</div>
</header>
<div class="toolbar">
<input class="grow" placeholder="搜索姓名或邮箱…" value=${q} onChange=${e => setQ(e.target.value)} />
<select value=${status} onChange=${e => setStatus(e.target.value)}>
<option>全部</option><option>活跃</option><option>邀请中</option><option>停用</option>
</select>
<${ColumnControls} columns=${COLUMNS} isVisible=${isVisible} onToggle=${toggleColumn}
onReset=${resetColumns} density=${density} onDensityChange=${setDensity} />
</div>
<div style=${{ overflowX: "auto" }}>
<table class=${"list" + (density === "compact" ? " compact" : "")}>
<thead>
<tr>
${visibleCols.map(c => c.sortable
? html`<${SortTh} key=${c.id} by=${c.id}>${c.label}<//>`
: html`<th key=${c.id}>${c.label}</th>`)}
</tr>
</thead>
<tbody>
${rows.map(r => html`
<tr key=${r.id}>
${visibleCols.map(c => html`<td key=${c.id} class=${c.numeric ? "num" : ""}>${c.cell(r)}</td>`)}
</tr>`)}
${rows.length === 0 && html`<tr><td class="empty" colSpan=${visibleCols.length}>没有匹配的成员</td></tr>`}
</tbody>
</table>
</div>
<footer class="pager">
<span>共 ${rows.length} 行 · 显示 ${visibleCols.length} / ${COLUMNS.length} 列 ·
密度:${density === "compact" ? "紧凑" : "舒适"}</span>
</footer>
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>