diff --git a/src/components/dashboard/ServerRosterRow.tsx b/src/components/dashboard/ServerRosterRow.tsx index 235bae5..4a951a4 100644 --- a/src/components/dashboard/ServerRosterRow.tsx +++ b/src/components/dashboard/ServerRosterRow.tsx @@ -26,6 +26,7 @@ import { useIntl } from "react-intl"; +import { TruncatedText } from "@/components/ui/truncated-text"; import { formatLastSeen } from "@/utils/format"; import type { ClassifiedServer } from "./mcpServerRoster"; import { rowTone } from "./mcpServerRoster"; @@ -110,7 +111,7 @@ export function ServerRosterRowStacked({ classified }: ServerRosterRowProps) { return (
  • - {name} + {name} {showDetails && {componentsText}} {showDetails && {transportText}} diff --git a/src/components/gateways/SourceSelection.tsx b/src/components/gateways/SourceSelection.tsx index 1709b9a..bffb59d 100644 --- a/src/components/gateways/SourceSelection.tsx +++ b/src/components/gateways/SourceSelection.tsx @@ -20,6 +20,7 @@ import { MCPIcon } from "@/components/icons/MCPIcon"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Loading } from "@/components/ui/loading"; +import { TruncatedText } from "@/components/ui/truncated-text"; import type { ActionCard } from "@/components/gateways/types"; import { useQuery } from "@/hooks/useQuery"; import { cn } from "@/lib/utils"; @@ -404,9 +405,9 @@ export function SourceSelection({ - + {server.name} - +
    diff --git a/src/components/gateways/VirtualServerCard.tsx b/src/components/gateways/VirtualServerCard.tsx index e3bb937..71a0e8e 100644 --- a/src/components/gateways/VirtualServerCard.tsx +++ b/src/components/gateways/VirtualServerCard.tsx @@ -15,6 +15,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +import { TruncatedText } from "@/components/ui/truncated-text"; import type { VirtualServer } from "@/types/server"; import { Box, EllipsisVertical, MessageSquareCode, Plus, Wrench } from "lucide-react"; import { useIntl } from "react-intl"; @@ -63,7 +64,9 @@ export function VirtualServerCard({
    - {server.name} + + {server.name} + ))}
    - {formatServerTimestamp( server.updatedAt || server.createdAt, intl.formatMessage({ id: "gateways.card.notSyncedYet" }), )} - +
    )} diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 39dce7a..109a124 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -23,6 +23,9 @@ import { CopyButton } from "@/components/ui/copy-button"; import { InlineTagAdd } from "@/components/ui/inline-tag-add"; import { CopyValue } from "@/components/ui/copy-value"; import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { TruncatedText } from "@/components/ui/truncated-text"; +import { getTruncatedMiddle } from "@/components/ui/truncated-middle-text"; import { cn } from "@/lib/utils"; import type { MCPServer, VirtualServer } from "@/types/server"; import type { ComponentFilter } from "@/components/gateways/types"; @@ -31,7 +34,6 @@ import { formatServerDateTime, getTagDisplay, getVirtualServerEndpoint, - truncateMiddle, } from "@/components/gateways/utils"; import { useQuery } from "@/hooks/useQuery"; @@ -299,11 +301,16 @@ export function VirtualServerDetailsPanel({ const serverById = new Map(getMCPServers(sourcesData).map((source) => [source.id, source])); return sourceIds.map((id) => { const source = serverById.get(id); - return { - id, - label: source?.name ?? truncateMiddle(id, 32), - source, - }; + if (source?.name) { + return { + id, + label: source.name, + isTruncated: false, + fullValue: undefined as string | undefined, + }; + } + const { display, isTruncated } = getTruncatedMiddle(id, 32); + return { id, label: display, isTruncated, fullValue: id }; }); }, [sourceIds, sourcesData]); @@ -410,12 +417,12 @@ export function VirtualServerDetailsPanel({
    - +
    @@ -450,14 +457,14 @@ export function VirtualServerDetailsPanel({ { id: "all", label: intl.formatMessage({ id: "gateways.details.filter.allSources" }), + isTruncated: false, + fullValue: undefined as string | undefined, }, ...sourceTabs, ].map((source, index, sources) => { const isSelected = sourceFilter === source.id; - - return ( + const tabButton = ( ); diff --git a/src/components/prompts/PromptDefinitionTable.tsx b/src/components/prompts/PromptDefinitionTable.tsx index 29e8369..bdb3acf 100644 --- a/src/components/prompts/PromptDefinitionTable.tsx +++ b/src/components/prompts/PromptDefinitionTable.tsx @@ -18,7 +18,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { truncateMiddle } from "@/components/gateways/utils"; +import { TruncatedText } from "@/components/ui/truncated-text"; +import { TruncatedMiddleText } from "@/components/ui/truncated-middle-text"; export interface PromptDefinitionTableProps { prompts: NonNullable[]; @@ -67,21 +68,24 @@ export function PromptDefinitionTable({ className="border-0 bg-neutral-50 hover:bg-neutral-50 data-[state=selected]:bg-neutral-50 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/50 dark:data-[state=selected]:bg-neutral-800/50 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg" > - + + +
    - - {truncateMiddle(prompt.id, 40)} - + - +
    {selected && ( diff --git a/src/components/resources/ResourcesTable.test.tsx b/src/components/resources/ResourcesTable.test.tsx index 3e5b90a..7a2a6dd 100644 --- a/src/components/resources/ResourcesTable.test.tsx +++ b/src/components/resources/ResourcesTable.test.tsx @@ -228,17 +228,33 @@ describe("ResourcesTable", () => { expect(rows.length).toBeGreaterThan(1); // Header row + data rows }); - it("truncates a very long resource name to a single line instead of overflowing the table", () => { + it("truncates a very long resource name to a single line instead of overflowing the table", async () => { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + value: 200, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + value: 100, + }); + + const user = userEvent.setup(); const longTitle = "This is a very long resource title that should be truncated to one line, not wrapped or overflowed"; const resources = [createMockResource(1, { title: longTitle })]; render(); + // The button's accessible name is computed from its full text content + // regardless of CSS truncation, so `getByRole` still matches on longTitle. const nameButton = screen.getByRole("button", { name: longTitle }); expect(nameButton).toHaveClass("truncate"); - // The full name stays available (e.g. via native tooltip) even though - // it's visually clipped. - expect(nameButton).toHaveAttribute("title", longTitle); + + // The full name stays available via a hover tooltip (not a native + // `title`, which doesn't satisfy WCAG 1.4.13) even though it's visually + // clipped. + expect(nameButton).not.toHaveAttribute("title"); + await user.hover(nameButton); + expect(await screen.findByRole("tooltip")).toHaveTextContent(longTitle); // table-fixed + a percentage column width is what actually stops an // unbreakable long name from forcing the whole table to scroll — a diff --git a/src/components/resources/ResourcesTable.tsx b/src/components/resources/ResourcesTable.tsx index c52834c..de6870e 100644 --- a/src/components/resources/ResourcesTable.tsx +++ b/src/components/resources/ResourcesTable.tsx @@ -17,7 +17,8 @@ import { TableRow, } from "@/components/ui/table"; import type { ResourceRead } from "@/generated/types"; -import { truncateMiddle } from "@/components/gateways/utils"; +import { TruncatedText } from "@/components/ui/truncated-text"; +import { TruncatedMiddleText } from "@/components/ui/truncated-middle-text"; export function ResourcesTable({ resources, @@ -60,21 +61,24 @@ export function ResourcesTable({ className="border-0 bg-neutral-50 hover:bg-neutral-50 data-[state=selected]:bg-neutral-50 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/50 dark:data-[state=selected]:bg-neutral-800/50 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg" > - + + +
    - - {truncateMiddle(resource.uriTemplate || resource.uri, 28)} - +
    - - {truncateMiddle(resource.id, 18)} - +
    - +
    @@ -483,11 +484,11 @@ export function MCPServerDetailsPanel({ {title ? ( <> - + {title} - + - {identifier} + {identifier} - {identifier} + {identifier}
    - {server.id} + {server.id} { expect(rows.length).toBeGreaterThan(1); // Header row + data rows }); - it("truncates a very long tool name to a single line instead of overflowing the table", () => { + it("truncates a very long tool name to a single line instead of overflowing the table", async () => { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + value: 200, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + value: 100, + }); + + const user = userEvent.setup(); const longName = "This is a very long tool name that should be truncated to one line, not wrapped or overflowed"; const tools = [createMockTool(1, { displayName: longName })]; render(); + // The button's accessible name is computed from its full text content + // regardless of CSS truncation, so `getByRole` still matches on longName. const nameButton = screen.getByRole("button", { name: longName }); expect(nameButton).toHaveClass("truncate"); - expect(nameButton).toHaveAttribute("title", longName); + + // The full name stays available via a hover tooltip (not a native + // `title`, which doesn't satisfy WCAG 1.4.13) even though it's visually + // clipped. + expect(nameButton).not.toHaveAttribute("title"); + await user.hover(nameButton); + expect(await screen.findByRole("tooltip")).toHaveTextContent(longName); // table-fixed + a percentage column width is what actually stops an // unbreakable long name from forcing the whole table to scroll. diff --git a/src/components/tools/ToolsTable.tsx b/src/components/tools/ToolsTable.tsx index 246b1a9..c349b4c 100644 --- a/src/components/tools/ToolsTable.tsx +++ b/src/components/tools/ToolsTable.tsx @@ -18,7 +18,8 @@ import { TableRow, } from "@/components/ui/table"; import type { Tool } from "@/types/tool"; -import { truncateMiddle } from "@/components/gateways/utils"; +import { TruncatedText } from "@/components/ui/truncated-text"; +import { TruncatedMiddleText } from "@/components/ui/truncated-middle-text"; import { ToolSchemaDialog } from "@/components/tools/ToolSchemaDialog"; export function ToolsTable({ @@ -73,21 +74,22 @@ export function ToolsTable({ className="border-0 bg-neutral-50 hover:bg-neutral-50 data-[state=selected]:bg-neutral-50 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/50 dark:data-[state=selected]:bg-neutral-800/50 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg" > - + + +
    - + {tool.customName || tool.originalName} - +
    - - {truncateMiddle(tool.id, 18)} - + { const longValue = "abcdefghijklmnopqrstuvwxyz0123456789"; render(); - // The visible text is truncated (default max 24 chars), not the raw value. - expect(screen.queryByText(longValue)).not.toBeInTheDocument(); + // The visible text is truncated (default max 24 chars), not the raw + // value — the full value is present only in a visually-hidden span for + // screen readers. + const hidden = screen.getByText(longValue); + expect(hidden).toHaveClass("sr-only"); }); it("copies the full value (not the truncated display) when clicked", async () => { diff --git a/src/components/ui/copy-value.tsx b/src/components/ui/copy-value.tsx index edeeba8..be43a77 100644 --- a/src/components/ui/copy-value.tsx +++ b/src/components/ui/copy-value.tsx @@ -1,7 +1,7 @@ import { useIntl } from "react-intl"; import { CopyButton } from "@/components/ui/copy-button"; -import { truncateMiddle } from "@/components/gateways/utils"; +import { TruncatedMiddleText } from "@/components/ui/truncated-middle-text"; export interface CopyValueProps { /** Human-readable name of the value, used to build the copy button's accessible label. */ @@ -23,7 +23,7 @@ export function CopyValue({ label, value }: CopyValueProps) { const intl = useIntl(); return (
    - {truncateMiddle(value)} + { + it("reports no truncation for values within maxLength", () => { + expect(getTruncatedMiddle("short", 24)).toEqual({ display: "short", isTruncated: false }); + }); + + it("reports truncation for values beyond maxLength", () => { + const value = "abcdefghijklmnopqrstuvwxyz0123456789"; + const result = getTruncatedMiddle(value, 24); + + expect(result.isTruncated).toBe(true); + expect(result.display).not.toBe(value); + }); +}); + +describe("TruncatedMiddleText", () => { + it("shows short values verbatim with no hidden duplicate and no aria-label", () => { + render(); + + // Exactly one match: no sr-only duplicate is rendered for values that fit. + const display = screen.getAllByText("p-1"); + expect(display).toHaveLength(1); + // Not aria-label: a plain span's accessible name would otherwise be + // matchable by unrelated `getByLabel`/`getByRole(..., { name })` queries + // elsewhere on the page, which is exactly what broke in production for a + // resource URI template containing "{owner}". + expect(display[0].parentElement).not.toHaveAttribute("aria-label"); + }); + + it("middle-truncates long values and exposes the full value via a visually-hidden span", () => { + const longValue = "abcdefghijklmnopqrstuvwxyz0123456789"; + render(); + + // The truncated display text is a different string, so this uniquely + // matches the visually-hidden span carrying the full value. + const hidden = screen.getByText(longValue); + expect(hidden).toHaveClass("sr-only"); + expect(hidden.parentElement).not.toHaveAttribute("aria-label"); + }); + + it("shows a tooltip with the full value on hover when truncated", async () => { + const user = userEvent.setup(); + const longValue = "abcdefghijklmnopqrstuvwxyz0123456789"; + render(); + + const trigger = screen.getByText(longValue).parentElement; + expect(trigger).not.toBeNull(); + await user.hover(trigger!); + expect(await screen.findByRole("tooltip")).toHaveTextContent(longValue); + }); + + it("does not show a tooltip for values that already fit", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByText("p-1").parentElement; + expect(trigger).not.toBeNull(); + await user.hover(trigger!); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/truncated-middle-text.tsx b/src/components/ui/truncated-middle-text.tsx new file mode 100644 index 0000000..2512ca0 --- /dev/null +++ b/src/components/ui/truncated-middle-text.tsx @@ -0,0 +1,52 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { truncateMiddle } from "@/components/gateways/utils"; + +/** + * Middle-truncates `value` for display and reports whether it actually did. + * A plain function (not a hook) so it can run inside `.map()` for lists of + * tabs/rows without violating the rules of hooks. + */ +export function getTruncatedMiddle(value: string, maxLength = 24) { + return { display: truncateMiddle(value, maxLength), isTruncated: value.length > maxLength }; +} + +export interface TruncatedMiddleTextProps { + /** The full, untruncated value. */ + value: string; + /** Passed through to `truncateMiddle`. */ + maxLength?: number; + className?: string; +} + +/** + * Middle-truncates `value` for display; when truncation actually shortened + * it, wraps the result in a hover tooltip carrying the full value. + * + * Unlike `TruncatedText`, the full string never reaches the DOM here — it's + * shortened before render — so a visually-hidden span carries the full value + * for screen readers regardless of the tooltip's hover state. This is + * deliberately *not* `aria-label`: values here are often arbitrary data (IDs, + * URI templates, ...) that can coincidentally contain words matching an + * unrelated label query elsewhere on the page, and `aria-label` would give + * this plain `` an accessible name that generic label-based queries + * (`getByLabel`, `getByRole(..., { name })`) can match against. + */ +export function TruncatedMiddleText({ + value, + maxLength = 24, + className, +}: TruncatedMiddleTextProps) { + const { display, isTruncated } = getTruncatedMiddle(value, maxLength); + + return ( + + + + {display} + {isTruncated && {value}} + + + {isTruncated && {value}} + + ); +} diff --git a/src/components/ui/truncated-text.test.tsx b/src/components/ui/truncated-text.test.tsx new file mode 100644 index 0000000..725bcd0 --- /dev/null +++ b/src/components/ui/truncated-text.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders as render } from "@/test/test-utils"; +import { TruncatedText } from "./truncated-text"; + +/** jsdom never lays anything out, so scrollWidth/clientWidth are both 0 by + * default; stub them to simulate a clipped or unclipped element. */ +function mockOverflow(scrollWidth: number, clientWidth: number) { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + value: scrollWidth, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + value: clientWidth, + }); +} + +describe("TruncatedText", () => { + afterEach(() => { + mockOverflow(0, 0); + }); + + it("applies the truncate class and merges a custom className", () => { + render(value); + + const span = screen.getByText("value"); + expect(span).toHaveClass("truncate", "text-xs"); + }); + + it("forwards arbitrary props (e.g. aria-hidden, data-testid) to the span", () => { + render( + , + ); + + const span = screen.getByTestId("name"); + expect(span).toHaveAttribute("aria-hidden", "true"); + }); + + it("does not show a tooltip when the text is not clipped", async () => { + mockOverflow(50, 100); + const user = userEvent.setup(); + render(short value); + + await user.hover(screen.getByText("short value")); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("shows a tooltip with the full text when the text is clipped", async () => { + mockOverflow(200, 100); + const user = userEvent.setup(); + render(a very long value that gets clipped); + + await user.hover(screen.getByText("a very long value that gets clipped")); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "a very long value that gets clipped", + ); + }); +}); diff --git a/src/components/ui/truncated-text.tsx b/src/components/ui/truncated-text.tsx new file mode 100644 index 0000000..7118470 --- /dev/null +++ b/src/components/ui/truncated-text.tsx @@ -0,0 +1,81 @@ +import { useCallback, useRef, useState } from "react"; +import type { HTMLAttributes, ReactNode } from "react"; +import { Slot } from "radix-ui"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export interface TruncatedTextProps extends Omit, "children"> { + children: ReactNode; + /** Forwarded to `TooltipContent`. */ + side?: "top" | "right" | "bottom" | "left"; + /** + * Merge the truncation ref/behavior onto `children` instead of rendering a + * wrapping `` — for when the truncated element must itself be + * interactive (e.g. a `
    -
    + {displayName} -
    -
    + + {user.email} -
    +
    diff --git a/src/pages/CreateServer.tsx b/src/pages/CreateServer.tsx index ba9b04c..cebd1e8 100644 --- a/src/pages/CreateServer.tsx +++ b/src/pages/CreateServer.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/accordion"; import { Checkbox } from "@/components/ui/checkbox"; import { Loading } from "@/components/ui/loading"; +import { TruncatedText } from "@/components/ui/truncated-text"; import { api, ApiError } from "@/api/client"; import { useQuery } from "@/hooks/useQuery"; import { useRouter } from "@/router"; @@ -320,7 +321,7 @@ function ComponentCheckboxRow({ } aria-label={`Select ${label}`} /> - {label} + {label} ); } @@ -435,7 +436,9 @@ const MCPServerAccordionItem = memo(function MCPServerAccordionItem({ - {server.name} + + {server.name} + ({ @@ -121,7 +122,9 @@ function renderWithRouter(ui: ReactElement, path = "/app/users") { window.history.pushState({}, "", path); return render( - {ui} + + {ui} + , ); }