feat(workbench): full-content reveal for truncated table cells (#749)
Workbench grid tables truncate dense cells (proposal ids, source kinds, case ids/reasons, lexicon/morphology rows, artifact paths) with `...`, leaving the full value unreachable except via a native title tooltip. Add a reusable `TruncatedCell` (design/components): keeps the compact display, attaches one hover/focus-revealed trigger that opens an accessible Radix popover with the complete value (selectable, scrollable) plus one-click copy, and — for long/structured values — an "Open full view" button into a dynamic Radix Dialog modal. One component delivers both the lightweight reveal and the interactive modal. The reveal trigger calls stopPropagation, so it never steals a row's click/select behaviour — revealing a value and selecting the row stay independent. Digests keep `DigestBadge` (already copy + full-value title). Wired into the non-virtualized + contents grid tables: Proposal queue (id, source), eval wrong=0 case ledger (case id, reason), CORE-Logos lexicon/glosses/morphology rows, and proposal artifact paths. Trace propagation edges (short, right-aligned stage names) and single-column selection rails / detail cards keep current behaviour. Evidence: workbench-ui `pnpm build` (tsc -b) green; full vitest suite 525 passed / 59 files, including 5 new TruncatedCell tests (display + title, popover reveal + copy, modal-only-for-long, no row-click bubble). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d3711ef413
commit
9b3c291d22
7 changed files with 309 additions and 17 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { TruncatedCell } from "../../design/components/TruncatedCell";
|
||||||
import type { EvalRunResult } from "../../types/api";
|
import type { EvalRunResult } from "../../types/api";
|
||||||
|
|
||||||
interface CaseRecord {
|
interface CaseRecord {
|
||||||
|
|
@ -196,7 +197,12 @@ export function EvalWrongZeroLedger({ result }: { result: EvalRunResult }) {
|
||||||
<ul className="m-0 mt-2 grid list-none gap-1 p-0">
|
<ul className="m-0 mt-2 grid list-none gap-1 p-0">
|
||||||
{ledger.refusalReasons.map((item) => (
|
{ledger.refusalReasons.map((item) => (
|
||||||
<li key={item.id} className="grid grid-cols-[minmax(0,10rem)_1fr] gap-2 text-xs">
|
<li key={item.id} className="grid grid-cols-[minmax(0,10rem)_1fr] gap-2 text-xs">
|
||||||
<span className="truncate font-mono text-[var(--color-text-primary)]">{item.id}</span>
|
<TruncatedCell
|
||||||
|
value={item.id}
|
||||||
|
label="case id"
|
||||||
|
mono
|
||||||
|
className="text-[var(--color-text-primary)]"
|
||||||
|
/>
|
||||||
<span className="text-[var(--color-text-secondary)]">{item.reason}</span>
|
<span className="text-[var(--color-text-secondary)]">{item.reason}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -214,7 +220,12 @@ export function EvalWrongZeroLedger({ result }: { result: EvalRunResult }) {
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="grid grid-cols-[minmax(0,10rem)_5rem_1fr] gap-2 text-xs"
|
className="grid grid-cols-[minmax(0,10rem)_5rem_1fr] gap-2 text-xs"
|
||||||
>
|
>
|
||||||
<span className="truncate font-mono text-[var(--color-text-primary)]">{item.id}</span>
|
<TruncatedCell
|
||||||
|
value={item.id}
|
||||||
|
label="case id"
|
||||||
|
mono
|
||||||
|
className="text-[var(--color-text-primary)]"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
item.kind === "wrong"
|
item.kind === "wrong"
|
||||||
|
|
@ -226,7 +237,12 @@ export function EvalWrongZeroLedger({ result }: { result: EvalRunResult }) {
|
||||||
>
|
>
|
||||||
{item.kind}
|
{item.kind}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-[var(--color-text-secondary)]">{item.reason}</span>
|
<TruncatedCell
|
||||||
|
value={item.reason}
|
||||||
|
label="reason"
|
||||||
|
wrap="pre-wrap"
|
||||||
|
className="text-[var(--color-text-secondary)]"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
|
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
|
||||||
|
import { TruncatedCell } from "../../design/components/TruncatedCell";
|
||||||
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
|
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
|
||||||
import type {
|
import type {
|
||||||
LogosGlossRow,
|
LogosGlossRow,
|
||||||
|
|
@ -159,9 +160,12 @@ export function LexiconTab({
|
||||||
renderItem={(row, _index, focused) => (
|
renderItem={(row, _index, focused) => (
|
||||||
<RowShell selected={focused || row.entry_id === selectedEntryId} onSelect={() => onSelect(row)}>
|
<RowShell selected={focused || row.entry_id === selectedEntryId} onSelect={() => onSelect(row)}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">
|
<TruncatedCell
|
||||||
{row.surface} · {row.lemma}
|
value={`${row.surface} · ${row.lemma}`}
|
||||||
</span>
|
label="surface · lemma"
|
||||||
|
mono
|
||||||
|
className="text-sm text-[var(--color-text-primary)]"
|
||||||
|
/>
|
||||||
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">{row.entry_id}</span>
|
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">{row.entry_id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
|
@ -224,7 +228,12 @@ export function GlossesTab({
|
||||||
renderItem={(row, _index, focused) => (
|
renderItem={(row, _index, focused) => (
|
||||||
<RowShell selected={focused || row.gloss_id === selectedGlossId} onSelect={() => onSelect(row)}>
|
<RowShell selected={focused || row.gloss_id === selectedGlossId} onSelect={() => onSelect(row)}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">{row.lemma}</span>
|
<TruncatedCell
|
||||||
|
value={row.lemma}
|
||||||
|
label="lemma"
|
||||||
|
mono
|
||||||
|
className="text-sm text-[var(--color-text-primary)]"
|
||||||
|
/>
|
||||||
{row.pos ? <Tag>{row.pos}</Tag> : null}
|
{row.pos ? <Tag>{row.pos}</Tag> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="m-0 text-xs text-[var(--color-text-secondary)] [text-wrap:balance]">{row.gloss}</p>
|
<p className="m-0 text-xs text-[var(--color-text-secondary)] [text-wrap:balance]">{row.gloss}</p>
|
||||||
|
|
@ -301,9 +310,12 @@ export function MorphologyTab({
|
||||||
onSelect={() => onSelect(row)}
|
onSelect={() => onSelect(row)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">
|
<TruncatedCell
|
||||||
{row.surface} · {row.lemma}
|
value={`${row.surface} · ${row.lemma}`}
|
||||||
</span>
|
label="surface · lemma"
|
||||||
|
mono
|
||||||
|
className="text-sm text-[var(--color-text-primary)]"
|
||||||
|
/>
|
||||||
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
|
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||||
{row.morphology_id}
|
{row.morphology_id}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
|
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
|
||||||
|
import { TruncatedCell } from "../../design/components/TruncatedCell";
|
||||||
import type { ProposalDetail } from "../../types/api";
|
import type { ProposalDetail } from "../../types/api";
|
||||||
import { jsonSource } from "./proposalView";
|
import { jsonSource } from "./proposalView";
|
||||||
import { SuggestedCLIBox } from "./SuggestedCLIBox";
|
import { SuggestedCLIBox } from "./SuggestedCLIBox";
|
||||||
|
|
@ -30,7 +31,11 @@ export function ProposalProvenanceViewer({ proposal }: { proposal: ProposalDetai
|
||||||
key={artifact.artifact_id}
|
key={artifact.artifact_id}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-[var(--color-text-primary)]">{artifact.artifact_id}</span>
|
<span className="font-mono text-[var(--color-text-primary)]">{artifact.artifact_id}</span>
|
||||||
<span className="truncate text-[var(--color-text-secondary)]">{artifact.path}</span>
|
<TruncatedCell
|
||||||
|
value={artifact.path}
|
||||||
|
label="artifact path"
|
||||||
|
className="text-[var(--color-text-secondary)]"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { EmptyState } from "../../design/components/states/EmptyState";
|
import { EmptyState } from "../../design/components/states/EmptyState";
|
||||||
|
import { TruncatedCell } from "../../design/components/TruncatedCell";
|
||||||
import type { ProposalSummary } from "../../types/api";
|
import type { ProposalSummary } from "../../types/api";
|
||||||
import { ProposalReplayBadge } from "./ProposalReplayBadge";
|
import { ProposalReplayBadge } from "./ProposalReplayBadge";
|
||||||
import { ProposalStateBadge } from "./ProposalStateBadge";
|
import { ProposalStateBadge } from "./ProposalStateBadge";
|
||||||
|
|
@ -71,14 +72,20 @@ export function ProposalTable({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs" title={proposal.proposal_id}>
|
<TruncatedCell
|
||||||
{shortProposalId(proposal.proposal_id)}
|
value={proposal.proposal_id}
|
||||||
</span>
|
display={shortProposalId(proposal.proposal_id)}
|
||||||
|
label="proposal_id"
|
||||||
|
mono
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
<ProposalStateBadge value={proposal.state} />
|
<ProposalStateBadge value={proposal.state} />
|
||||||
<ProposalReplayBadge value={proposal.replay_equivalent} />
|
<ProposalReplayBadge value={proposal.replay_equivalent} />
|
||||||
<span className="truncate text-[var(--color-text-secondary)]">
|
<TruncatedCell
|
||||||
{provenanceLabel(proposal)}
|
value={provenanceLabel(proposal)}
|
||||||
</span>
|
label="source"
|
||||||
|
className="text-[var(--color-text-secondary)]"
|
||||||
|
/>
|
||||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||||
{formatTimestamp(proposal.created_at)}
|
{formatTimestamp(proposal.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { TruncatedCell } from "./TruncatedCell";
|
||||||
|
|
||||||
|
const LONG = "x".repeat(200);
|
||||||
|
|
||||||
|
describe("TruncatedCell", () => {
|
||||||
|
it("shows the compact display and keeps the full value in title", () => {
|
||||||
|
render(
|
||||||
|
<TruncatedCell value="6ea18d4e5e9f1c2a3b4c" display="6ea18d4e5e…" label="proposal_id" />,
|
||||||
|
);
|
||||||
|
const display = screen.getByText("6ea18d4e5e…");
|
||||||
|
expect(display).toBeInTheDocument();
|
||||||
|
expect(display).toHaveAttribute("title", "6ea18d4e5e9f1c2a3b4c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals the full value in a popover and copies it", async () => {
|
||||||
|
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TruncatedCell value="6ea18d4e5e9f1c2a3b4c" display="6ea18d4e5e…" label="proposal_id" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /show full proposal_id/i }));
|
||||||
|
// full value now visible in the revealed popover
|
||||||
|
expect(screen.getByText("6ea18d4e5e9f1c2a3b4c")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /copy proposal_id/i }));
|
||||||
|
expect(writeText).toHaveBeenCalledWith("6ea18d4e5e9f1c2a3b4c");
|
||||||
|
|
||||||
|
writeText.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer a modal for short values", async () => {
|
||||||
|
render(<TruncatedCell value="short" label="source" />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /show full source/i }));
|
||||||
|
expect(screen.queryByRole("button", { name: /open full view/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a modal with the full value for long values", async () => {
|
||||||
|
render(<TruncatedCell value={LONG} label="source" />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /show full source/i }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /open full view/i }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "source" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bubble clicks to a surrounding row handler", async () => {
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<div role="button" tabIndex={0} onClick={onRowClick}>
|
||||||
|
<TruncatedCell value="6ea18d4e5e9f1c2a3b4c" label="proposal_id" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /show full proposal_id/i }));
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { type ReactNode, useState } from "react";
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { Check, Copy, Expand, Maximize2 } from "lucide-react";
|
||||||
|
import { cn } from "../../lib";
|
||||||
|
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncated table cell with a full-content escape hatch.
|
||||||
|
*
|
||||||
|
* Every grid "table" in the workbench truncates dense values (proposal ids,
|
||||||
|
* source kinds, digests, paths) to keep rows scannable. That hides the full
|
||||||
|
* value. `TruncatedCell` keeps the compact display but attaches one
|
||||||
|
* hover/focus-revealed trigger that opens a popover showing the *complete*
|
||||||
|
* value — selectable, scrollable, copyable — and, for long/structured values,
|
||||||
|
* an "Open full view" button into a roomy modal.
|
||||||
|
*
|
||||||
|
* One component, two reveals: a lightweight popover for the common case and a
|
||||||
|
* dynamic modal for the long case. Drop it in wherever a `truncate` span lives.
|
||||||
|
*
|
||||||
|
* The reveal trigger calls `stopPropagation`, so it never steals a row's
|
||||||
|
* click/select behaviour — opening the full value and selecting the row stay
|
||||||
|
* independent affordances.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MODAL_THRESHOLD = 160;
|
||||||
|
|
||||||
|
export interface TruncatedCellProps {
|
||||||
|
/** Full value — the source of truth shown in the reveal/modal and copied. */
|
||||||
|
value: string;
|
||||||
|
/**
|
||||||
|
* Optional compact display node. Defaults to `value` rendered with the
|
||||||
|
* `truncate` ellipsis from the parent cell width.
|
||||||
|
*/
|
||||||
|
display?: ReactNode;
|
||||||
|
/** Column/field name — labels the trigger, popover, and modal for a11y. */
|
||||||
|
label?: string;
|
||||||
|
/** Render display + full value in the monospace face. */
|
||||||
|
mono?: boolean;
|
||||||
|
/**
|
||||||
|
* How to render the full value. `break-all` suits opaque ids/digests;
|
||||||
|
* `pre-wrap` preserves whitespace for prose/JSON. Defaults to `break-all`.
|
||||||
|
*/
|
||||||
|
wrap?: "break-all" | "pre-wrap";
|
||||||
|
/** Class applied to the inline display span. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldOfferModal(value: string) {
|
||||||
|
return value.length > MODAL_THRESHOLD || value.includes("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyAction({ value, label }: { value: string; label: string }) {
|
||||||
|
const { copied, copy } = useCopyToClipboard();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copy(value)}
|
||||||
|
aria-label={copied ? "Copied" : `Copy ${label}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border-subtle)] bg-transparent px-2 py-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={12} aria-hidden /> : <Copy size={12} aria-hidden />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullValue({
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
wrap,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
wrap: "break-all" | "pre-wrap";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-h-64 overflow-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-2 text-sm text-[var(--color-text-primary)] select-text",
|
||||||
|
mono && "font-mono",
|
||||||
|
wrap === "break-all" ? "break-all" : "whitespace-pre-wrap break-words",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TruncatedCell({
|
||||||
|
value,
|
||||||
|
display,
|
||||||
|
label = "value",
|
||||||
|
mono,
|
||||||
|
wrap = "break-all",
|
||||||
|
className,
|
||||||
|
}: TruncatedCellProps) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const offerModal = shouldOfferModal(value);
|
||||||
|
|
||||||
|
const stop = (event: { stopPropagation: () => void }) => event.stopPropagation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn("group/cell flex min-w-0 items-center gap-1", mono && "font-mono")}>
|
||||||
|
<span className={cn("truncate", className)} title={value}>
|
||||||
|
{display ?? value}
|
||||||
|
</span>
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stop}
|
||||||
|
onKeyDown={stop}
|
||||||
|
aria-label={`Show full ${label}`}
|
||||||
|
className="inline-flex shrink-0 items-center rounded-sm p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-primary)] focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] group-hover/cell:opacity-100 data-[state=open]:opacity-100"
|
||||||
|
>
|
||||||
|
<Expand size={12} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={4}
|
||||||
|
collisionPadding={8}
|
||||||
|
onClick={stop}
|
||||||
|
onKeyDown={stop}
|
||||||
|
className="z-50 w-[min(420px,calc(100vw-32px))] rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] p-3 shadow-[var(--shadow-overlay)] focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<div className="mb-2 text-xs font-medium uppercase tracking-normal text-[var(--color-text-muted)]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<FullValue value={value} mono={mono} wrap={wrap} />
|
||||||
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
|
{offerModal ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border-subtle)] bg-transparent px-2 py-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} aria-hidden />
|
||||||
|
Open full view
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<CopyAction value={value} label={label} />
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
<Dialog.Root open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55" />
|
||||||
|
<Dialog.Content
|
||||||
|
onClick={stop}
|
||||||
|
aria-label={label}
|
||||||
|
className="fixed left-1/2 top-[12vh] z-50 flex max-h-[76vh] w-[min(720px,calc(100vw-32px))] -translate-x-1/2 flex-col rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] p-4 shadow-[var(--shadow-overlay)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||||
|
>
|
||||||
|
<Dialog.Title className="mb-3 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{label}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description className="sr-only">
|
||||||
|
Full contents of the {label} cell.
|
||||||
|
</Dialog.Description>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-3 text-sm text-[var(--color-text-primary)] select-text",
|
||||||
|
mono && "font-mono",
|
||||||
|
wrap === "break-all" ? "break-all" : "whitespace-pre-wrap break-words",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<CopyAction value={value} label={label} />
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded-md border border-[var(--color-border-subtle)] bg-transparent px-2 py-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { TruncatedCell } from "./TruncatedCell";
|
||||||
|
export type { TruncatedCellProps } from "./TruncatedCell";
|
||||||
Loading…
Reference in a new issue