Skip to content

Commit 636432c

Browse files
fix(components): data-table row actions trigger visible at rest in a muted tone (#29030)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbNHpH8gM956qJ658EsM5n6b)_ Fixes #28923 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:通用 `data-table` 渲染器里每行的 ⋯ 操作按钮,静止时也画出来(灰色弱化色),鼠标移到该行、行内获得焦点、行被选中、按钮自己获得键盘焦点、菜单打开时,或在触屏上,变为正常前景色。与列表表格(#28871)的做法一致。菜单里有哪些项、各项的显示条件和顺序都不变。Console 列表名称旁的「整页打开」↗ 仍保持悬停才出现,只把它说「与行菜单一致」的注释改正。 - **为什么改**:表头「操作」一直在,但桌面端按钮在鼠标移上行之前是透明的,整列看起来是空的,读者会以为「没有可用操作」。 - **风险与回滚**:只改 `ui/packages/components` 一个按钮的样式类,另有一处 app-shell 注释;不动 spec、服务端、元数据和菜单逻辑。弱化色用文字色(`text-muted-foreground`),不是透明度。回滚 = revert 本 PR。 - **先看**:`ui/packages/components/src/renderers/complex/data-table.tsx` 里 `data-testid="data-table-row-actions"` 那个按钮的 `className`;再看新测试 `data-table-row-actions-atRest-28923.test.tsx`。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/components/src/renderers/complex/__tests__/data-table-row-actions-atRest-28923.test.tsx`,应 1 个文件 4 个用例全部通过;或打开任一相关列表(1440 px),不移动鼠标就能看到每行的 ⋯。 - **需要你做的**:无,席位落地。 ## What changes `data-table`'s row ⋯ trigger (`data-testid="data-table-row-actions"`, drawn per row when the schema's `rowActions` leave at least one item): - **At rest:** drawn on every row, on every pointer type, in `text-muted-foreground`. The `opacity-0` / `transition-opacity` pair is gone. - **Full strength (`text-foreground`)** on row hover (`group-hover/row`), focus within the row (`group-focus-within/row`), a selected row (`group-data-[state=selected]/row`), an open menu (`data-[state=open]`), its own keyboard focus (`focus-visible`) and a coarse pointer (`pointer-coarse`). This is the class set `RowActionMenu` carries since #28871 (PR #28922). `objectViewOpenAsPage.tsx` (the Console list's per-row ↗ "open as page"): triage's call, **kept hover-revealed**. It is an extra link beside the name with no header of its own, so the empty-column defect does not apply, and drawing it on every row would crowd the name column. Only its docblock changes: it no longer claims to match the row menu, and says why the two differ. `OPEN_AS_PAGE_CLASS` is unchanged. Unchanged: the menu's items, gates and order (`useDataTableRowMenuPlan`, #28434), the empty-menu guard, the header, the accessible name. No key, option or prop is added. Changeset: `@object-ui/components` patch (app-shell's change is a comment only, not published). ## Tests - `data-table-row-actions-atRest-28923.test.tsx` (new, 4 cases): at rest no `opacity-0` under any variant, `invisible`, `hidden` or `sr-only`, name kept; rests in `text-muted-foreground` with the five full-strength variants; the only `pointer-coarse:` class is `pointer-coarse:text-foreground`; control: opening the menu flips `data-state` to `open` and shows Edit. - `data-table-leadingCell-12423.test.tsx`: its row-actions-trigger case pinned the old `opacity-0` set and is repointed to the new one. Its checkbox cases are untouched. <details><summary>Ablation (scripts/ablation-replace.mjs, at 920a4d8, source-resolved, no dist)</summary> - `opacity-0` put back at rest (`text-muted-foreground transition-colors` to `text-muted-foreground opacity-0 transition-colors`): 1 failed ("is drawn at rest on every row"), 3 passed. Restored, blob `1d306e61` equal to HEAD, `git diff HEAD` empty. - Coarse-pointer leg put back to an opacity reveal (`pointer-coarse:text-foreground` to `pointer-coarse:opacity-100`): 1 failed (the every-pointer-type case), 3 passed. Restored, blob `1d306e61` equal to HEAD, `git diff HEAD` empty. </details> ## Acceptance notes - None beyond the open-as-page choice above. ## Verification All on the final head `920a4d85` (base `origin/main` `62b3ec36`), after `pnpm install --frozen-lockfile` and `turbo run build --filter='@object-ui/components^...'` plus formula, sdui-parser, lint and client (43 tasks, exit 0), under `scripts/pm/os-verify-lock.sh`. - The whole `packages/components/` vitest suite (`--maxWorkers=2`): 470 files passed, 1 skipped; 4481 tests passed, 24 skipped. The three `data-table` row-menu files plus `ObjectView.recordOpenDisplay-26681.test.tsx` (app-shell, the open-as-page link): 22 passed. - `pnpm --filter @object-ui/components run type-check` (both `tsc` programs, the test one included): exit 0. - ESLint on the four changed tsx files: exit 0, 0 errors. 53 warnings: 51 on untouched lines of `data-table.tsx`, one on the 12423 file's existing `ComponentRegistry.get(...) as any`, and one on the same cast in the new test, copied from it. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`): 34 of 34 run, each exit 0 (`check:type-check-debt` and `pnpm --dir ui exec vitest run scripts/` under the lock), including `node scripts/check-sdui-manifest.mjs` and the PR-body gates with this body as `GITHUB_EVENT_PATH`. `--ran` reconciles 34 derived, 34 run, 0 NOT-MEASURED. `check-partof-closing-keyword` with `PR_BODY`: exit 0. - Not run locally: app-shell `type-check` (the app-shell change is a docblock only) and the rest of app-shell's suite; CI's Type Check UI and Test UI run them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jSXEKkhP494Y6s2au2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 12aad14 commit 636432c

5 files changed

Lines changed: 147 additions & 16 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/components': patch
3+
---
4+
5+
fix(components): the data-table's row ⋯ trigger is visible at rest, in a muted tone, instead of appearing only under the pointer
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `data-table` renderer draws an Actions column header whenever its rows carry actions, but each row's ⋯ trigger was hidden by opacity on a fine pointer until the pointer reached the row, so a desktop table showed a headed column of cells that looked empty and read as "no actions available". The trigger is now drawn on every row at rest in the muted foreground tone, on every pointer type alike, and turns full strength on row hover, focus within the row, a selected row, its own keyboard focus, while its menu is open and on a coarse pointer, the same treatment the list grid's row menu has. The menu's items, their gates and their order are unchanged. The Console list's per-row "open as page" link keeps its hover reveal; only its code comment, which claimed it matched the row menu, is corrected. No key, option or prop is added.
11+
12+
What to do: nothing.

‎ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,14 @@
2727
* hands the page the way back to this list with the record open, which the
2828
* page offers as 「Collapse to drawer」 (objectstack#27545).
2929
*
30-
* Hidden by OPACITY until the row is hovered or holds focus, as the row's ⋮
31-
* menu is (`RowActionMenu`), so it keeps its Tab stop and its name; a coarse
32-
* pointer, which has no hover, always shows it.
30+
* Hidden by OPACITY until the row is hovered or holds focus, so it keeps its
31+
* Tab stop and its name; a coarse pointer, which has no hover, always shows
32+
* it. This is deliberately NOT the row ⋯ menu's treatment: that trigger
33+
* (`RowActionMenu`, `data-table`'s row actions) is drawn at rest in a muted
34+
* tone because an `Actions` header over empty-looking cells reads as "no
35+
* actions" (objectstack#28871, #28923). This ↗ is an extra beside the name,
36+
* under no header of its own, and drawn on every row it would crowd the name
37+
* column, so it stays hover-revealed.
3338
*/
3439

3540
import { ArrowUpRight } from 'lucide-react';

‎ui/packages/components/src/renderers/complex/__tests__/data-table-leadingCell-12423.test.tsx‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
* With selection and row numbers on, ONE leading cell holds both: the number
1313
* by default, the checkbox on row hover / focus-within / a coarse pointer, and
1414
* on every row while any row is selected. The header's select-all is always
15-
* there. The row-actions trigger is in the DOM for every row and hidden by
16-
* opacity only.
15+
* there. The row-actions trigger is in the DOM for every row; since
16+
* objectstack#28923 it is drawn at rest in a muted tone
17+
* (`data-table-row-actions-atRest-28923.test.tsx` pins that).
1718
*
1819
* jsdom applies no stylesheet, so "visible" is read off the classes that
1920
* decide it: `opacity-0` on the checkbox's wrapper hides it until a reveal
@@ -123,17 +124,17 @@ describe('data-table leading cell (objectui#12423)', () => {
123124
});
124125

125126
describe('data-table row-actions trigger (objectui#12423)', () => {
126-
it('is in the DOM for every row, revealed by opacity on hover, focus-within, a selected row and an open menu', () => {
127+
it('is in the DOM for every row, lifted to full strength on hover, focus-within, a selected row and an open menu', () => {
127128
renderTable();
128129
const triggers = screen.getAllByTestId('data-table-row-actions');
129130
expect(triggers).toHaveLength(ROWS.length);
130131
for (const trigger of triggers) {
131132
expect(trigger).toHaveClass(
132-
'opacity-0',
133-
'group-hover/row:opacity-100',
134-
'group-focus-within/row:opacity-100',
135-
'group-data-[state=selected]/row:opacity-100',
136-
'data-[state=open]:opacity-100',
133+
'text-muted-foreground',
134+
'group-hover/row:text-foreground',
135+
'group-focus-within/row:text-foreground',
136+
'group-data-[state=selected]/row:text-foreground',
137+
'data-[state=open]:text-foreground',
137138
);
138139
// Never display:none — it keeps its Tab stop and its name.
139140
expect(trigger).not.toHaveClass('hidden');
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#28923 — the `data-table` row ⋯ trigger is visible at rest.
11+
*
12+
* The table draws an `Actions` header whenever rows carry actions, so a
13+
* trigger hidden until the pointer reaches a row left a headed column of cells
14+
* that looked empty, read as "no actions available". As the list grid's
15+
* `RowActionMenu` does since objectstack#28871, the trigger is now drawn on
16+
* every row at rest in the muted tone, on every pointer type alike, and rises
17+
* to full strength on row hover, focus-within, a selected row, its own focus,
18+
* an open menu and a coarse pointer (which cannot hover).
19+
*
20+
* jsdom applies no stylesheet, so "visible" is read off the classes that decide
21+
* it: no `opacity-0` / `invisible` / `hidden` at rest, the muted tone as the
22+
* resting colour, and the variants that lift it.
23+
*/
24+
import { describe, it, expect, vi } from 'vitest';
25+
import { render, screen } from '@testing-library/react';
26+
import userEvent from '@testing-library/user-event';
27+
import '@testing-library/jest-dom';
28+
import React from 'react';
29+
import { ComponentRegistry } from '@object-ui/core';
30+
import { PredicateScopeProvider } from '@object-ui/react';
31+
import '../data-table';
32+
33+
const ROWS = [
34+
{ id: 'a', name: 'Ada' },
35+
{ id: 'b', name: 'Grace' },
36+
];
37+
38+
function renderTable() {
39+
const DataTable = ComponentRegistry.get('data-table') as any;
40+
if (!DataTable) throw new Error('data-table not registered');
41+
return render(
42+
<PredicateScopeProvider scope={{}}>
43+
<DataTable
44+
schema={{
45+
type: 'data-table',
46+
data: ROWS,
47+
pagination: false,
48+
searchable: false,
49+
columns: [{ header: 'Name', accessorKey: 'name' }],
50+
rowActions: true,
51+
onRowEdit: vi.fn(),
52+
}}
53+
/>
54+
</PredicateScopeProvider>,
55+
);
56+
}
57+
58+
const classesOf = (el: HTMLElement) => el.className.split(/\s+/).filter(Boolean);
59+
60+
describe('data-table row-actions trigger at rest (objectstack#28923)', () => {
61+
it('is drawn at rest on every row: nothing hides it before the pointer reaches the row', () => {
62+
renderTable();
63+
const triggers = screen.getAllByTestId('data-table-row-actions');
64+
expect(triggers).toHaveLength(ROWS.length);
65+
for (const trigger of triggers) {
66+
for (const hiding of ['opacity-0', 'invisible', 'hidden', 'sr-only']) {
67+
expect(trigger).not.toHaveClass(hiding);
68+
}
69+
// No zero opacity under any variant either: a hover reveal written as
70+
// `opacity-0 hover:opacity-100` is still empty at rest.
71+
expect(classesOf(trigger).filter((c) => /(^|:)opacity-0$/.test(c))).toEqual([]);
72+
expect(trigger).toHaveAccessibleName('Row actions');
73+
}
74+
});
75+
76+
it('rests in the muted tone and lifts to full strength on hover, focus, selection and an open menu', () => {
77+
renderTable();
78+
for (const trigger of screen.getAllByTestId('data-table-row-actions')) {
79+
expect(trigger).toHaveClass(
80+
'text-muted-foreground',
81+
'group-hover/row:text-foreground',
82+
'group-focus-within/row:text-foreground',
83+
'group-data-[state=selected]/row:text-foreground',
84+
'data-[state=open]:text-foreground',
85+
'focus-visible:text-foreground',
86+
);
87+
}
88+
});
89+
90+
it('is drawn at rest on every pointer type alike; a coarse pointer, which cannot hover, gets full strength', () => {
91+
renderTable();
92+
for (const trigger of screen.getAllByTestId('data-table-row-actions')) {
93+
// The coarse pointer only strengthens the tone; it is never the thing
94+
// that makes the trigger appear.
95+
expect(classesOf(trigger).filter((c) => c.startsWith('pointer-coarse:'))).toEqual([
96+
'pointer-coarse:text-foreground',
97+
]);
98+
}
99+
});
100+
101+
it('control: the open menu marks the trigger open, the state the full-strength variant keys on', async () => {
102+
renderTable();
103+
const [trigger] = screen.getAllByTestId('data-table-row-actions');
104+
expect(trigger).toHaveAttribute('data-state', 'closed');
105+
await userEvent.click(trigger);
106+
expect(trigger).toHaveAttribute('data-state', 'open');
107+
expect(await screen.findByRole('menuitem', { name: /edit/i })).toBeInTheDocument();
108+
});
109+
});

‎ui/packages/components/src/renderers/complex/data-table.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -842,11 +842,15 @@ const DataTableRowActionsMenu: React.FC<{
842842
<Button
843843
variant="ghost"
844844
size="icon"
845-
// objectui#12423 — shown on row hover, on focus-within, on a selected
846-
// row, while its menu is open and on a coarse pointer; hidden by
847-
// OPACITY only, so it keeps its Tab stop and its accessible name.
848-
// `RowActionMenu` (`@object-ui/plugin-grid`) carries the same set.
849-
className="h-8 w-8 opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100 group-data-[state=selected]/row:opacity-100 data-[state=open]:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
845+
// Drawn on every row at rest, on every pointer type, in the muted
846+
// tone (objectstack#28923): an `Actions` header over a column of cells
847+
// that look empty until the pointer reaches a row reads as "no
848+
// actions". Full strength on row hover, on focus-within, on a
849+
// selected row, on its own keyboard focus, while its menu is open and
850+
// on a coarse pointer, where no hover will ever lift it. The tone is a
851+
// text colour, never an opacity. `RowActionMenu`
852+
// (`@object-ui/plugin-grid`, objectstack#28871) carries the same set.
853+
className="h-8 w-8 text-muted-foreground transition-colors group-hover/row:text-foreground group-focus-within/row:text-foreground group-data-[state=selected]/row:text-foreground data-[state=open]:text-foreground focus-visible:text-foreground pointer-coarse:text-foreground"
850854
onClick={(e) => e.stopPropagation()}
851855
aria-label={t('table.rowActions')}
852856
data-testid="data-table-row-actions"

0 commit comments

Comments
 (0)