Describe the tables as TanStack columns

Add ui/DataTable, which builds ReUI's DataGrid from `columns` and `data`, and move
all 23 tables onto it. What the grid does not take (a class on a row or cell,
`aria-sort`, `style`, `colSpan`, `role`, `hidden`) is put on the elements it drew
after every render. The column definitions keep their identity between renders, so
cells are not mounted again, and the grid's own paging is off.

The stacked rows on a phone, the column labels and the sort order of the original
work as before. In Blocking > Lists a group heading is a row whose first cell spans
the four columns. docs/reui-migration-plan.md records it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:40:53 +07:00
1 parent f3531c64f9
commit 4c0a5de7e7
24 files changed
+1611 -1175

No files matched your search

+166 -112
View File
@@ -5,7 +5,8 @@ import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
import { nextUpdateText } from '../settings/panes/Blocking'
import { SectionHeader } from '../../ui/SectionHeader'
import { Panel, Body } from '../../ui/Panel'
import { RowAction, Table } from '../../ui/Table'
import { DataTable } from '../../ui/DataTable'
import { RowAction } from '../../ui/Table'
import { Field, Input, Select } from '../../ui/Field'
import { Button } from '../../ui/Button'
import { PermissionButton } from '../../ui/PermissionButton'
@@ -478,128 +479,96 @@ export function BlockLists({
/* The lists of each group, to say how many it holds. */
const groupSize = (g: number) => (heads[g + 1] ?? lines.length) - (g < 0 ? 0 : heads[g] + 1)
function headingRow(key: string, name: string, count: number, controls: ReactNode) {
return (
<tr key={key} className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<div className={styles.groupHead}>
<span className={styles.groupName}>{name}</span>
<span className={styles.groupCount}>{plural(count, 'list', 'lists')}</span>
{controls != null && <span className={styles.spacer} />}
{controls}
</div>
</th>
</tr>
)
/* What the table draws, one item per row: a group heading (its content spans the
whole row) or a line. The grid draws cells, so a heading is a row whose first cell
takes the width of all four and whose other cells are not drawn. */
type Entry = ListLine & { kind: 'block' | 'allow' }
type Item =
| { key: string; kind: 'heading'; node: ReactNode }
| { key: string; kind: 'line'; l: Entry; i: number }
function heading(key: string, name: string, count: number, controls: ReactNode): Item {
return {
key,
kind: 'heading',
node: (
<div className={styles.groupHead}>
<span className={styles.groupName}>{name}</span>
<span className={styles.groupCount}>{plural(count, 'list', 'lists')}</span>
{controls != null && <span className={styles.spacer} />}
{controls}
</div>
),
}
}
/* One row per line. A key that follows the line and not its place, so a list that
moves or switches keeps its row, and the focus stays on the button pressed. */
const seen = new Map<string, number>()
const rows: ReactNode[] = []
if (heads.length > 0 && heads[0] > 0) rows.push(headingRow('none', 'No group', groupSize(-1), null))
const items: Item[] = []
if (heads.length > 0 && heads[0] > 0) items.push(heading('none', 'No group', groupSize(-1), null))
lines.forEach((l, i) => {
if (l.kind === 'comment') {
const g = heads.indexOf(i)
const name = nameOf(l)
rows.push(
edit != null && edit.group === g ? (
<tr key={`H:${g}`} className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<HeadingEditor
editing={edit}
verb="Rename"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
</th>
</tr>
) : (
headingRow(`H:${g}`, name, groupSize(g), (
<>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Rename group ${name}`}
onClick={() => setEditing({ group: g, value: headingName(l), problem: null })}>
Rename
</PermissionButton>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Ungroup ${name}`}
onClick={() => ungroup(g)}>
Ungroup
</PermissionButton>
</>
))
),
items.push(
edit != null && edit.group === g
? {
key: `H:${g}`,
kind: 'heading',
node: (
<HeadingEditor
editing={edit}
verb="Rename"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
),
}
: heading(`H:${g}`, name, groupSize(g), (
<>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Rename group ${name}`}
onClick={() => setEditing({ group: g, value: headingName(l), problem: null })}>
Rename
</PermissionButton>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Ungroup ${name}`}
onClick={() => ungroup(g)}>
Ungroup
</PermissionButton>
</>
)),
)
return
}
const id = `${l.kind}:${l.url}`
const n = seen.get(id) ?? 0
seen.set(id, n + 1)
const name = l.url == null ? null : listName(l.url, named)
const faded = !l.enabled ? styles.off : undefined
const what = `${l.kind} list ${l.url ?? ''}`
rows.push(
<tr key={`L:${id}:${n}`}>
<td>
{canToggle(l) && (
<label className={styles.switch}>
<input
type="checkbox"
aria-label={`Enabled ${what}`}
checked={l.enabled}
disabled={off}
onChange={() => setLines((all) => all.map((x, j) => (j === i ? toggleLine(x) : x)))}
/>
</label>
)}
</td>
<td className={faded}>
{name != null && <div className={styles.name}>{name}</div>}
<div className={styles.url}><UrlText url={l.url ?? l.raw} /></div>
{/* The kind under the URL where the Type column is folded away. */}
<span className={`${styles.kind} ${styles.kindInline} ${KIND_CLASS[l.kind]}`}>
<Icon name={KIND_ICON[l.kind]} size={14} />
{KIND_LABEL[l.kind]}
</span>
</td>
<td className={`${faded ?? ''} ${styles.kindCell}`}>
<span className={`${styles.kind} ${KIND_CLASS[l.kind]}`}>
<Icon name={KIND_ICON[l.kind]} size={14} />
{KIND_LABEL[l.kind]}
</span>
</td>
<td className={styles.actions}>
<div className={styles.rowActions}>
<RowAction icon="moveUp" name="Move up" aria-label={`Move up ${what}`}
disabled={off || !canMove(lines, i, -1)} onClick={() => setLines((all) => moveLine(all, i, -1))} />
<RowAction icon="moveDown" name="Move down" aria-label={`Move down ${what}`}
disabled={off || !canMove(lines, i, 1)} onClick={() => setLines((all) => moveLine(all, i, 1))} />
<PermissionButton size="sm" variant="danger" permission={modifyNeed}
aria-label={`Remove ${l.url ?? l.raw}`}
onClick={() => setLines((all) => all.filter((_, j) => j !== i))}>
Remove
</PermissionButton>
</div>
</td>
</tr>,
)
items.push({ key: `L:${id}:${n}`, kind: 'line', l: l as Entry, i })
})
if (edit != null && edit.group === 'new') {
rows.push(
<tr key="H:new" className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<HeadingEditor
editing={edit}
verb="Add group"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
</th>
</tr>,
)
items.push({
key: 'H:new',
kind: 'heading',
node: (
<HeadingEditor
editing={edit}
verb="Add group"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
),
})
}
/* What a line's cells share. */
const lineCells = (l: Entry) => ({
name: l.url == null ? null : listName(l.url, named),
faded: !l.enabled ? styles.off : undefined,
what: `${l.kind} list ${l.url ?? ''}`,
})
return (
<>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
@@ -778,15 +747,100 @@ export function BlockLists({
) : saved == null ? (
<Body><Loading /></Body>
) : (
<Table
header={<><th>Enabled</th><th>List</th><th className={styles.kindCell}>Type</th><th /></>}
isEmpty={lines.length === 0 && edit?.group !== 'new'}
emptyText="No lists"
columns={4}
<DataTable<Item>
data={items}
getRowId={(item) => item.key}
rowProps={(item) => (item.kind === 'heading' ? { className: styles.groupRow } : undefined)}
emptyText={lines.length === 0 && edit?.group !== 'new' ? 'No lists' : undefined}
className={shared.inPanel}
>
{rows}
</Table>
columns={[
{
id: 'enabled',
header: 'Enabled',
td: (item) =>
item.kind === 'heading'
? { colSpan: 4, scope: 'colgroup', role: 'columnheader' }
: undefined,
cell: (item) => {
if (item.kind === 'heading') return item.node
const { l, i } = item
const { what } = lineCells(l)
return (
canToggle(l) && (
<label className={styles.switch}>
<input
type="checkbox"
aria-label={`Enabled ${what}`}
checked={l.enabled}
disabled={off}
onChange={() => setLines((all) => all.map((x, j) => (j === i ? toggleLine(x) : x)))}
/>
</label>
)
)
},
},
{
id: 'list',
header: 'List',
td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: lineCells(item.l).faded }),
cell: (item) => {
if (item.kind === 'heading') return null
const { l } = item
const { name } = lineCells(l)
return (
<>
{name != null && <div className={styles.name}>{name}</div>}
<div className={styles.url}><UrlText url={l.url ?? l.raw} /></div>
{/* The kind under the URL where the Type column is folded away. */}
<span className={`${styles.kind} ${styles.kindInline} ${KIND_CLASS[l.kind]}`}>
<Icon name={KIND_ICON[l.kind]} size={14} />
{KIND_LABEL[l.kind]}
</span>
</>
)
},
},
{
id: 'kind',
th: { className: styles.kindCell },
header: 'Type',
td: (item) =>
item.kind === 'heading'
? { hidden: true }
: { className: `${lineCells(item.l).faded ?? ''} ${styles.kindCell}` },
cell: (item) =>
item.kind === 'heading' ? null : (
<span className={`${styles.kind} ${KIND_CLASS[item.l.kind]}`}>
<Icon name={KIND_ICON[item.l.kind]} size={14} />
{KIND_LABEL[item.l.kind]}
</span>
),
},
{
id: 'actions',
td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: styles.actions }),
cell: (item) => {
if (item.kind === 'heading') return null
const { l, i } = item
const { what } = lineCells(l)
return (
<div className={styles.rowActions}>
<RowAction icon="moveUp" name="Move up" aria-label={`Move up ${what}`}
disabled={off || !canMove(lines, i, -1)} onClick={() => setLines((all) => moveLine(all, i, -1))} />
<RowAction icon="moveDown" name="Move down" aria-label={`Move down ${what}`}
disabled={off || !canMove(lines, i, 1)} onClick={() => setLines((all) => moveLine(all, i, 1))} />
<PermissionButton size="sm" variant="danger" permission={modifyNeed}
aria-label={`Remove ${l.url ?? l.raw}`}
onClick={() => setLines((all) => all.filter((_, j) => j !== i))}>
Remove
</PermissionButton>
</div>
)
},
},
]}
/>
)}
<div className={styles.foot}>
<PermissionButton size="sm" permission={modifyNeed} disabled={loading || edit?.group === 'new'}