From 0932df6c87562c19070b298b7adb39f7a55ea8c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:09:04 +0000 Subject: [PATCH 1/2] fix(app-shell): pin a metadata list row's controls and say an unreadable activation state in words (#13091) The declared sharing rules list put Clone off-screen and cut the Active switch at 1440 px, and showed `?` in the Active column for a principal the activation ledger read refuses. - The list table scrolls inside the shared HorizontalOverflowBox (objectui#12708). The Active and Clone cells are pinned to its right edge, Clone at a fixed width with Active just left of it, and the first pinned cell draws the seam shadow (objectui#12463). Source moves ahead of Active so the pinned pair is the row's trailing pair. - A refused or failed ledger read shows "Not visible to you", with the server's sentence (or the HTTP status) in the tooltip. - Pins in ResourceListPage.declaredSharingRules-12982.test.tsx. Claude-Session: https://claude.ai/code/session_016ixQuukBQtUAZdo8ih3w9Z Co-authored-by: Claude --- .changeset/13091-declared-rules-reach.md | 10 ++ ...stPage.declaredSharingRules-12982.test.tsx | 91 ++++++++++- .../views/metadata-admin/ResourceListPage.tsx | 141 +++++++++++++----- .../src/views/metadata-admin/i18n.ts | 2 + 4 files changed, 206 insertions(+), 38 deletions(-) create mode 100644 .changeset/13091-declared-rules-reach.md diff --git a/.changeset/13091-declared-rules-reach.md b/.changeset/13091-declared-rules-reach.md new file mode 100644 index 0000000000..448ed6f869 --- /dev/null +++ b/.changeset/13091-declared-rules-reach.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +Setup's metadata lists keep a row's controls in reach on a narrow screen, and say in words when the activation state cannot be read (objectui#13091). + +- **Pinned controls.** The list table scrolls inside the shared horizontal-overflow box. In the environment scope, the Active switch and, on the declared sharing rules, Clone are pinned to its right edge, with the seam shadow on the first of them while columns remain to the right. The Source column now comes before Active, so the pinned pair is the row's last two cells. At 1440 px the declared sharing rules no longer put Clone off-screen or cut the Active switch. +- **Unreadable state.** When the activation ledger read is refused or fails, the Active cell reads "Not visible to you" instead of `?`. Its tooltip carries "Activation state could not be read." followed by the server's own sentence for why, or the HTTP status when the server gave none. + +One new string in the metadata-admin designer's own tables (en and zh): `engine.catalog.active.notVisible`. No export, prop or type member changes. diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.declaredSharingRules-12982.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.declaredSharingRules-12982.test.tsx index fa56ac9893..18fc35729c 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.declaredSharingRules-12982.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.declaredSharingRules-12982.test.tsx @@ -26,7 +26,13 @@ * declared rule off, which goes through the door; a rule switched off (no * projection served) continues in the pre-filled New form; * - the control: the permission-set catalog keeps its create affordance and - * shows no Clone column. + * shows no Clone column; + * - objectui#13091: the row's controls (the Active switch, Clone) are pinned + * to the right edge of the table's overflow box, which draws the seam + * shadow, so a 1440 or 1024 screen reaches them (the measured reach is + * `e2e/live/declared-sharing-rules-reach-13091.spec.ts`); and a principal + * the ledger read refuses reads a plain-language state, with the server's + * own sentence for why, never a bare "?". */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, cleanup, screen, waitFor, fireEvent, within } from '@testing-library/react'; @@ -156,6 +162,8 @@ vi.mock('@object-ui/permissions', async (importOriginal) => { }); let ledgerRows: Record[] = []; +/** The ledger read's answer when it is refused; `null` answers `ledgerRows`. */ +let ledgerRefusal: { status: number; body: unknown } | null = null; let doorAnswer: { status: number; body: unknown } = { status: 200, body: {} }; /** `GET /sharing/rules/:name` per name; a missing name answers the recorded 404. */ let projections: Record = {}; @@ -172,6 +180,7 @@ beforeEach(() => { canAuthor = true; canCreateRule = true; ledgerRows = []; + ledgerRefusal = null; doorAnswer = { status: 200, body: { success: true, data: { type: 'sharing_rule', enabled: false } } }; projections = { share_key_account_qualified_contacts_with_managers: KEY_ACCOUNT_PROJECTION }; createAnswer = (record) => ({ id: 'rec_1', ...record }); @@ -186,6 +195,7 @@ beforeEach(() => { const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); if (method === 'GET' && u.startsWith('/api/v1/data/sys_metadata_activation')) { + if (ledgerRefusal) return json(ledgerRefusal.status, ledgerRefusal.body); return json(200, { object: 'sys_metadata_activation', records: ledgerRows, total: ledgerRows.length, hasMore: false }); } if (method === 'POST' && u.startsWith('/api/v1/security/_activation/')) { @@ -399,3 +409,82 @@ describe('Clone: a new-named organization rule pre-filled from the declaration', expect(created).toEqual([]); }); }); + +/** The data door's refusal of a ledger read to a principal without read on it. */ +const LEDGER_READ_REFUSAL = "Permission denied: you cannot read 'sys_metadata_activation'."; + +describe('a principal the ledger read refuses (objectui#13091)', () => { + it("reads a plain-language state per rule, with the server's sentence for why — never a bare '?'", async () => { + ledgerRefusal = { status: 403, body: { success: false, error: { code: 'PERMISSION_DENIED', message: LEDGER_READ_REFUSAL } } }; + mount(); + const cell = await screen.findByTestId(`catalog-active-unreadable-${KEY}`); + expect(cell).toHaveTextContent(t('engine.catalog.active.notVisible', ADMIN_LOCALE)); + expect(cell.getAttribute('title')).toContain(LEDGER_READ_REFUSAL); + expect(cell.getAttribute('title')).toContain(t('engine.catalog.active.unknown', ADMIN_LOCALE)); + expect(screen.getByTestId(`catalog-active-unreadable-${INQ}`)).toHaveTextContent( + t('engine.catalog.active.notVisible', ADMIN_LOCALE), + ); + expect(screen.queryByText('?')).toBeNull(); + // No switch is drawn on a state nobody read. + expect(screen.queryByTestId(`catalog-active-${KEY}`)).toBeNull(); + }); + + it('a refusal without a sentence still reads in words, with the status for why', async () => { + ledgerRefusal = { status: 403, body: {} }; + mount(); + const cell = await screen.findByTestId(`catalog-active-unreadable-${KEY}`); + expect(cell).toHaveTextContent(t('engine.catalog.active.notVisible', ADMIN_LOCALE)); + expect(cell.getAttribute('title')).toContain('HTTP 403'); + }); + + it('control: a readable ledger draws the switch, and no unreadable state', async () => { + mount(); + await screen.findByTestId(`catalog-active-${KEY}`); + expect(screen.queryByTestId(`catalog-active-unreadable-${KEY}`)).toBeNull(); + }); +}); + +describe("the row's controls stay in reach on a narrow screen (objectui#13091)", () => { + /** The cell (`td` / `th`) holding `el`. */ + const cellOf = (el: Element) => el.closest('td, th') as HTMLElement; + + it('Clone and the Active switch sit in cells pinned to the right edge of the overflow box', async () => { + mount(); + const clone = cellOf(await screen.findByTestId(`catalog-clone-${KEY}`)); + const active = cellOf(await screen.findByTestId(`catalog-active-${KEY}`)); + const scroller = screen.getByTestId('metadata-list-scroller'); + expect(scroller).toContainElement(clone); + // Clone holds the right edge at its fixed width; Active sits just left of it. + expect(clone).toHaveClass('sticky', 'right-0', 'w-28'); + expect(active).toHaveClass('sticky', 'right-28'); + // The pinned pair is the row's trailing pair, Active then Clone. + const row = clone.parentElement!; + expect(row.lastElementChild).toBe(clone); + expect(clone.previousElementSibling).toBe(active); + // Their header cells are pinned the same way, so the column heads stay with them. + const headers = Array.from(scroller.querySelectorAll('thead th')); + expect(headers[headers.length - 1]).toHaveClass('sticky', 'right-0'); + expect(headers[headers.length - 2]).toHaveClass('sticky', 'right-28'); + }); + + it('the first pinned cell draws the seam shadow, and the box leaves the shadow to it', async () => { + mount(); + const active = cellOf(await screen.findByTestId(`catalog-active-${KEY}`)); + const clone = cellOf(screen.getByTestId(`catalog-clone-${KEY}`)); + expect(active.className).toContain('group-data-[overflow-right=true]/hscroll:before:opacity-100'); + expect(clone.className).not.toContain('group-data-[overflow-right=true]/hscroll:before:opacity-100'); + // `seam="pinned"`: no edge overlay competes with the seam. + expect(screen.getByTestId('metadata-list-scroller')).toHaveClass('group/hscroll'); + expect(screen.queryByTestId('horizontal-overflow-shadow')).toBeNull(); + }); + + it('without Clone, the Active column alone holds the right edge and draws the seam', async () => { + canCreateRule = false; + mount(); + const active = cellOf(await screen.findByTestId(`catalog-active-${KEY}`)); + expect(active).toHaveClass('sticky', 'right-0'); + expect(active).not.toHaveClass('right-28'); + expect(active.className).toContain('group-data-[overflow-right=true]/hscroll:before:opacity-100'); + expect(active.parentElement!.lastElementChild).toBe(active); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index cff4fd2023..1fafb1ae09 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -15,7 +15,16 @@ import * as React from 'react'; import { Link, Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Plus, Search, RefreshCw, AlertTriangle, Lock, Copy } from 'lucide-react'; -import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from '@object-ui/components'; +import { + Button, + HorizontalOverflowBox, + PINNED_RIGHT_SEAM_SHADOW_CLASSES, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + cn, +} from '@object-ui/components'; import { Input } from '@object-ui/components'; import { Badge } from '@object-ui/components'; import { Switch } from '@object-ui/components'; @@ -171,8 +180,10 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string const [statusFilter, setStatusFilter] = React.useState<'all' | 'active' | 'inactive'>('all'); // The activation ledger's answer for the switch column, with the names the // door refuses to switch (`catalog-activation.ts`). `null` while unread, - // `'error'` when the read was refused or failed. + // `'error'` when the read was refused or failed, with the server's words for + // why (objectui#13091) beside it. const [ledger, setLedger] = React.useState(null); + const [ledgerRefusal, setLedgerRefusal] = React.useState(null); const [switching, setSwitching] = React.useState(null); const [switchError, setSwitchError] = React.useState(null); // objectui#12982 — a declaration-only catalog (declared sharing rules): the @@ -335,15 +346,20 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string React.useEffect(() => { if (!activation) { setLedger(null); + setLedgerRefusal(null); return; } const controller = new AbortController(); Promise.all([readCatalogActivation(type, controller.signal), audienceAnchorsOf(type)]) .then(([states, anchors]) => { - if (!controller.signal.aborted) setLedger({ states, anchors }); + if (controller.signal.aborted) return; + setLedger({ states, anchors }); + setLedgerRefusal(null); }) - .catch(() => { - if (!controller.signal.aborted) setLedger('error'); + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setLedger('error'); + setLedgerRefusal(err instanceof Error ? err.message : String(err)); }); return () => controller.abort(); }, [activation, type, refreshKey]); @@ -535,6 +551,17 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string ); + // objectui#13091 — the row's controls pinned to the table's right edge: the + // Clone column at `right-0`, at its fixed width, and the Active column just + // left of it. The first pinned column draws the seam shadow. + const pinned = activation || !!CloneDialog; + const cloneCellPin = CloneDialog + ? cn(PINNED_CELL_CLASSES, 'right-0 w-28 min-w-28', !activation && PINNED_SEAM_CLASSES) + : undefined; + const activeCellPin = activation + ? cn(PINNED_CELL_CLASSES, CloneDialog ? 'right-28' : 'right-0', PINNED_SEAM_CLASSES) + : undefined; + const body = (
)} {!loading && filtered.length > 0 && ( -
+ // objectui#13091 — a table wider than its box says so (the shared + // overflow affordance, objectui#12708), and the row's controls (the + // Active switch, Clone) stay pinned at its right edge, the seam + // shadow on the first of them (objectui#12463). + {/* Under Setup the table reads as the console's record list (objectui#12606): sentence-case headers, the grid's 36px @@ -696,7 +731,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string : 'bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground' } > - + {columns.map((c) => ( {activation && ( - + )} - {CloneDialog && ( - )} @@ -760,14 +797,14 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string return ( {columns.map((c, ci) => { const value = row.item[c.key]; @@ -807,18 +844,6 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string ); })} - {activation && ( - - )} + {activation && ( + + )} {CloneDialog && ( - )} @@ -884,7 +923,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string })}
))} + {t('engine.list.col.source', locale)}{t('engine.catalog.col.active', locale)} + {t('engine.catalog.col.active', locale)} + {t('engine.list.col.source', locale)} + {t('engine.list.col.actions', locale)}
- - {(row.item._lock as string | undefined) && row.item._lock !== 'none' && ( )} + + +
-
+ )}
); @@ -982,6 +1021,24 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string ); } +/** + * objectui#13091 — a cell pinned to the right edge of the list's overflow box. + * It paints an opaque background, because the other columns scroll beneath + * it, and layers its row's fill (`--os-row-tint`, the convention `data-table` + * keeps for its sticky cells) over it, so a pinned cell reads as part of its + * row. The Clone column is the fixed `w-28` the Active column's `right-28` + * offset counts on; its button truncates its label to stay inside it. + * + * Complete literals: Tailwind v4 builds only from scanned source text. + */ +const PINNED_CELL_CLASSES = + 'sticky z-10 bg-background bg-[image:linear-gradient(var(--os-row-tint,transparent),var(--os-row-tint,transparent))]'; +const PINNED_SEAM_CLASSES = `border-l border-border ${PINNED_RIGHT_SEAM_SHADOW_CLASSES}`; +const HEAD_ROW_TINT = '[--os-row-tint:color-mix(in_oklab,var(--color-muted)_30%,transparent)]'; +const INVALID_ROW_TINT = '[--os-row-tint:color-mix(in_oklab,var(--color-destructive)_4%,transparent)]'; +const WARN_ROW_TINT = '[--os-row-tint:color-mix(in_oklab,var(--color-amber-500)_5%,transparent)]'; +const HOVER_ROW_TINT = 'hover:[--os-row-tint:color-mix(in_oklab,var(--color-accent)_50%,transparent)]'; + /** The activation ledger's states for a type, and the names its door refuses. */ interface CatalogLedger { states: CatalogActivation; @@ -992,12 +1049,13 @@ interface CatalogLedger { * objectui#7611 — one item's active switch in the environment scope, showing * the activation ledger's state (see `catalog-activation.ts`). Its non-switch * states are each said, never rendered as a guessed "on": the state is still - * loading, or the read failed. An audience anchor shows its state on a + * loading, or the read failed (said in words, with the server's reason). An audience anchor shows its state on a * disabled switch with the reason, because the door refuses to switch it. */ function CatalogActiveCell({ itemName, ledger, + refusal, canSwitch, busy, locale, @@ -1005,6 +1063,8 @@ function CatalogActiveCell({ }: { itemName: string; ledger: CatalogLedger | 'error' | null; + /** Why the read failed: the server's refusal, as `actionErrorDetail` read it. */ + refusal: string | null; canSwitch: boolean; busy: boolean; locale: string; @@ -1012,9 +1072,16 @@ function CatalogActiveCell({ }) { if (ledger === null) return …; if (ledger === 'error') { + // objectui#13091 — said in plain words, never a bare "?"; the server's + // own sentence for why rides in the title. + const why = [t('engine.catalog.active.unknown', locale), refusal].filter(Boolean).join(' '); return ( - - ? + + {t('engine.catalog.active.notVisible', locale)} ); } diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index d10d6b3bb8..2a7bc758f1 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -262,6 +262,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.catalog.active.anchor': 'An audience anchor: it carries the baseline every principal of the deployment holds, so it cannot be switched off. Change what it grants through the permission sets bound to it.', 'engine.catalog.active.unknown': 'Activation state could not be read.', + 'engine.catalog.active.notVisible': 'Not visible to you', 'engine.catalog.active.failed': 'The switch was not saved: {message}', 'engine.catalog.readOnly.single': 'Read-only for your account. {type} items are defined by the platform administrator (the `manage_metadata` capability); your organization assigns them.', @@ -3896,6 +3897,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.catalog.active.anchor': '受众锚点:它承载此部署中每个主体都持有的基线,因此不能停用。请通过绑定到它的权限集来调整它授予的内容。', 'engine.catalog.active.unknown': '无法读取启用状态。', + 'engine.catalog.active.notVisible': '对你不可见', 'engine.catalog.active.failed': '开关未保存:{message}', 'engine.catalog.readOnly.single': '您的账号只能查看。{type}由平台管理员定义(需要 `manage_metadata` 能力);您的组织负责分配。', From 9e609bc33f158e1da84e0af8c328439a2c0f590d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:23:27 +0000 Subject: [PATCH 2/2] fix(app-shell): separated table borders under pinned cells, and the live reach measurement at 1440 (#13091) Measured live at 1024 px: in a collapsed-border table Chrome let the Object column's text, scrolling beneath the pinned Active cell, show through that cell's right edge. The list table now draws separated borders with zero spacing, and the row dividers move from `divide-y` on the rows to a top border on the cells. Adds e2e/live/declared-sharing-rules-reach-13091.spec.ts: at 1440x1000, the first Clone button and Active switch lie inside the viewport and the table's scroll box with the table unscrolled. Local live lane only; it is not in the test:e2e:live:ci allowlist. Claude-Session: https://claude.ai/code/session_016ixQuukBQtUAZdo8ih3w9Z Co-authored-by: Claude --- .changeset/13091-declared-rules-reach.md | 2 +- ...declared-sharing-rules-reach-13091.spec.ts | 56 +++++++++++++++++++ ...stPage.declaredSharingRules-12982.test.tsx | 3 + .../views/metadata-admin/ResourceListPage.tsx | 7 ++- 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 e2e/live/declared-sharing-rules-reach-13091.spec.ts diff --git a/.changeset/13091-declared-rules-reach.md b/.changeset/13091-declared-rules-reach.md index 448ed6f869..ccaee39e4c 100644 --- a/.changeset/13091-declared-rules-reach.md +++ b/.changeset/13091-declared-rules-reach.md @@ -4,7 +4,7 @@ Setup's metadata lists keep a row's controls in reach on a narrow screen, and say in words when the activation state cannot be read (objectui#13091). -- **Pinned controls.** The list table scrolls inside the shared horizontal-overflow box. In the environment scope, the Active switch and, on the declared sharing rules, Clone are pinned to its right edge, with the seam shadow on the first of them while columns remain to the right. The Source column now comes before Active, so the pinned pair is the row's last two cells. At 1440 px the declared sharing rules no longer put Clone off-screen or cut the Active switch. +- **Pinned controls.** The list table scrolls inside the shared horizontal-overflow box. In the environment scope, the Active switch and, on the declared sharing rules, Clone are pinned to its right edge, with the seam shadow on the first of them while columns remain to the right. The Source column now comes before Active, so the pinned pair is the row's last two cells. At 1440 px the declared sharing rules no longer put Clone off-screen or cut the Active switch. The table draws its borders separated, with the row dividers on the cells, so no text scrolling beneath a pinned cell shows through its edge. - **Unreadable state.** When the activation ledger read is refused or fails, the Active cell reads "Not visible to you" instead of `?`. Its tooltip carries "Activation state could not be read." followed by the server's own sentence for why, or the HTTP status when the server gave none. One new string in the metadata-admin designer's own tables (en and zh): `engine.catalog.active.notVisible`. No export, prop or type member changes. diff --git a/e2e/live/declared-sharing-rules-reach-13091.spec.ts b/e2e/live/declared-sharing-rules-reach-13091.spec.ts new file mode 100644 index 0000000000..c2a114c556 --- /dev/null +++ b/e2e/live/declared-sharing-rules-reach-13091.spec.ts @@ -0,0 +1,56 @@ +import { test, expect, type Locator } from '@playwright/test'; + +/** + * objectui#13091 — the declared sharing rules list at 1440 px: the row's + * controls are in reach. Measured before the fix on a fresh showcase, signed in + * as admin: the first Clone button's box started at x=1515 (off-screen) and the + * table's scroll box cut the Active switch at its right edge. + * + * What is measured here, in a real browser against the running stack: + * - the first Clone button and the first Active switch both lie wholly inside + * the viewport AND inside the visible box of the table's scroller, with the + * table not scrolled at all; + * - when the table is wider than its box, the box says so + * (`data-overflow-right`, which drives the seam shadow on the pinned cells). + * + * The unit pins for the same page are in + * `ResourceListPage.declaredSharingRules-12982.test.tsx`. + */ +const LIST = '/apps/setup/metadata/sharing_rule?scope=environment'; + +async function boxOf(locator: Locator) { + const box = await locator.boundingBox(); + expect(box, 'the element has a layout box').not.toBeNull(); + return box!; +} + +test.use({ viewport: { width: 1440, height: 1000 } }); + +test('at 1440 px, Clone and the Active switch are inside the viewport and the table box', async ({ page }) => { + await page.goto(LIST); + // The table's own scroll box, found by structure rather than by a test id, + // so the measurement reads the same element before and after the fix. + const scroller = page.locator('table').first().locator('xpath=..'); + await expect(scroller).toBeVisible(); + const clone = page.locator('[data-testid^="catalog-clone-"]').first(); + const active = page.locator('[data-testid^="catalog-active-"]').first(); + await expect(clone).toBeVisible(); + await expect(active).toBeVisible(); + expect(await scroller.evaluate((el) => el.scrollLeft)).toBe(0); + + const viewport = page.viewportSize()!; + const frame = await boxOf(scroller); + for (const [what, control] of [['Clone', clone], ['Active', active]] as const) { + const b = await boxOf(control); + expect(b.x, `${what} starts inside the viewport`).toBeGreaterThanOrEqual(0); + expect(b.x + b.width, `${what} ends inside the viewport`).toBeLessThanOrEqual(viewport.width); + expect(b.x, `${what} starts inside the table box`).toBeGreaterThanOrEqual(frame.x); + expect(b.x + b.width, `${what} ends inside the table box`).toBeLessThanOrEqual(frame.x + frame.width + 0.5); + } + + // The box reports its overflow, which is what draws the seam shadow. + const widths = await scroller.evaluate((el) => ({ scroll: el.scrollWidth, client: el.clientWidth })); + if (widths.scroll > widths.client + 1) { + await expect(scroller).toHaveAttribute('data-overflow-right', 'true'); + } +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.declaredSharingRules-12982.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.declaredSharingRules-12982.test.tsx index 18fc35729c..abdb321dfd 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.declaredSharingRules-12982.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.declaredSharingRules-12982.test.tsx @@ -465,6 +465,9 @@ describe("the row's controls stay in reach on a narrow screen (objectui#13091)", const headers = Array.from(scroller.querySelectorAll('thead th')); expect(headers[headers.length - 1]).toHaveClass('sticky', 'right-0'); expect(headers[headers.length - 2]).toHaveClass('sticky', 'right-28'); + // Separated borders: in a collapsed-border table Chrome lets the text + // scrolling beneath a sticky cell show through its edge (measured live). + expect(scroller.querySelector('table')).toHaveClass('border-separate', 'border-spacing-0'); }); it('the first pinned cell draws the seam shadow, and the box leaves the shadow to it', async () => { diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index 1fafb1ae09..a827fd929e 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -720,7 +720,10 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string outerClassName="border rounded-lg overflow-hidden" data-testid="metadata-list-scroller" > - + {/* Separated borders: Chrome paints a sticky cell of a collapsed-border + table without covering the text scrolling beneath its edge + (objectui#13091), so the row dividers are drawn on the cells. */} +
{/* Under Setup the table reads as the console's record list (objectui#12606): sentence-case headers, the grid's 36px rows, middle-aligned cells. */} @@ -754,7 +757,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string )} - + {filtered.map((row, i) => { const pk = config.primaryKey ?? 'name'; const name = String(row.item[pk] ?? `(unnamed-${i})`);