Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,16 @@ describe("ContentViewer", () => {
type: "resource_link",
uri: "ui://app",
name: "Cool App",
description: "An app",
mimeType: "text/html",
};
renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByText("Cool App")).toBeInTheDocument();
expect(screen.getByText("An app")).toBeInTheDocument();
expect(screen.getByText("text/html")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
// The URI copy button is present, but there's no expand control.
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /^Expand resource/ }),
).not.toBeInTheDocument();
});

it("renders nothing for unknown block types", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,6 @@ function BlockContent({
<ResourceLinkInfo
uri={block.uri}
name={block.name}
description={block.description}
mimeType={block.mimeType}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,30 @@ describe("ExpandToggle", () => {
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(onToggle).toHaveBeenCalledTimes(1);
});

it("uses ariaLabel as the accessible name when provided", () => {
renderWithMantine(
<ExpandToggle
expanded={false}
onToggle={vi.fn()}
ariaLabel="Expand resource demo://r/1"
/>,
);
expect(
screen.getByRole("button", { name: "Expand resource demo://r/1" }),
).toBeInTheDocument();
});

it("exposes aria-expanded=false when collapsed", () => {
renderWithMantine(<ExpandToggle expanded={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toHaveAttribute(
"aria-expanded",
"false",
);
});

it("exposes aria-expanded=true when expanded", () => {
renderWithMantine(<ExpandToggle expanded={true} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
});
});
21 changes: 17 additions & 4 deletions clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,28 @@ export interface ExpandToggleProps {
/** Whether the owning entry is currently expanded. */
expanded: boolean;
onToggle: () => void;
/**
* Overrides the accessible name (aria-label). Defaults to the tooltip text
* ("Expand"/"Collapse"). Pass a per-entry name (e.g. including the resource
* URI) when several toggles sit in one list, so assistive tech can tell them
* apart; the visible tooltip stays the plain verb.
*/
ariaLabel?: string;
}

/**
* Icon toggle for a per-entry expand/collapse control (Protocol, Network, and
* Task cards). Uses the same expand/collapse-vertical icons as the list-level
* ListToggle: collapsed shows the expand icon, expanded shows the collapse
* icon. The aria-label stays "Expand"/"Collapse" so it reads the same as the
* text button it replaced.
* icon. The tooltip stays "Expand"/"Collapse" (the same verb as the text button
* it replaced); `aria-expanded` exposes the disclosure state and `ariaLabel`
* can distinguish sibling toggles.
*/
export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) {
export function ExpandToggle({
expanded,
onToggle,
ariaLabel,
}: ExpandToggleProps) {
const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine;
const label = expanded ? "Collapse" : "Expand";
return (
Expand All @@ -23,7 +35,8 @@ export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) {
variant="subtle"
color="gray"
size="md"
aria-label={label}
aria-label={ariaLabel ?? label}
aria-expanded={expanded}
onClick={onToggle}
>
<Icon size={16} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export const Full: Story = {
args: {
uri: "file:///docs/readme.md",
name: "Readme",
description: "Project documentation",
mimeType: "text/markdown",
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,12 @@ import { ResourceLinkInfo } from "./ResourceLinkInfo";
const URI = "file:///docs/readme.md";

describe("ResourceLinkInfo", () => {
it("renders uri, name, description, and mimeType", () => {
it("renders uri, name, and mimeType", () => {
renderWithMantine(
<ResourceLinkInfo
uri={URI}
name="Readme"
description="The project readme"
mimeType="text/markdown"
/>,
<ResourceLinkInfo uri={URI} name="Readme" mimeType="text/markdown" />,
);
expect(screen.getByText(URI)).toBeInTheDocument();
expect(screen.getByText("Readme")).toBeInTheDocument();
expect(screen.getByText("The project readme")).toBeInTheDocument();
expect(screen.getByText("text/markdown")).toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import type { ReactNode } from "react";
import { Badge, Group, Stack, Text } from "@mantine/core";
import { CopyButton } from "../CopyButton/CopyButton";

export interface ResourceLinkInfoProps {
/** The linked resource's URI (always shown). */
/** The linked resource's URI (always shown, with a copy button). */
uri: string;
/** Optional human-friendly name shown beneath the URI. */
/** Optional human-friendly name shown above the URI. */
name?: string;
/** Optional description shown beneath the name. */
description?: string;
/** Optional MIME type shown as a badge. */
mimeType?: string;
/**
* Optional trailing element placed at the end of the URI row — e.g. an
* expand/collapse indicator supplied by an interactive wrapper.
* Optional trailing element placed at the end of the URI row (opposite the
* copy button) — e.g. an expand/collapse control supplied by an interactive
* wrapper. Mirrors ProtocolEntry, whose toggle sits on the row below the
* badges.
*/
action?: ReactNode;
}
Expand All @@ -21,70 +22,97 @@ const HeaderStack = Stack.withProps({
gap: 4,
});

// Name (left) + MIME badge (right). `justify` is set per-instance: spread when
// a name is present, otherwise the badge hugs the right.
const HeaderRow = Group.withProps({
wrap: "nowrap",
gap: "xs",
align: "center",
});

// Copy control + URI (left) and the optional expand/collapse control (right),
// on the line below the header — mirroring ProtocolEntry's controls row.
const UriRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
gap: "xs",
align: "flex-start",
align: "center",
});

// Copy button + URI cluster; flexes so the URI fills and the action stays right.
const UriCluster = Group.withProps({
wrap: "nowrap",
gap: "xs",
align: "center",
flex: 1,
miw: 0,
});

// Match how ProtocolEntry/NetworkEntry render a URL: `sm` / `fw: 500`, in the
// default sans-serif face and text color (not a blue monospace "link"). The
// `monoBreak` variant only adds `word-break: break-all` so a long URI wraps
// within the card instead of overflowing.
const UriText = Text.withProps({
size: "sm",
c: "blue",
ff: "monospace",
fw: 500,
variant: "monoBreak",
flex: 1,
miw: 0,
});

const MetaGroup = Group.withProps({
gap: "xs",
wrap: "nowrap",
});

const MimeBadge = Badge.withProps({
size: "sm",
variant: "light",
color: "blue",
// Match the point size of the ProtocolEntry method/status badges; the
// lowercase MIME text reads smaller than their uppercase labels at `sm`.
size: "md",
radius: "sm",
// MIME types are conventionally lowercase; keep them as-is rather than
// letting Badge's default uppercase transform mangle them.
tt: "none",
autoContrast: false,
// Light mode: the tinted blue-light chip (unchanged). Dark mode: a solid
// dark-blue fill with white text — matching the solid ProtocolEntry badges
// rather than a washed-out translucent tint.
bg: "light-dark(var(--mantine-color-blue-light), var(--mantine-color-blue-9))",
c: "light-dark(var(--mantine-color-blue-light-color), var(--mantine-color-white))",
});

const NameText = Text.withProps({
size: "sm",
fw: 600,
});

const DescriptionText = Text.withProps({
size: "sm",
c: "dimmed",
flex: 1,
miw: 0,
});

/**
* Pure-display metadata for a `resource_link`: the URI (monospace, link-styled),
* optional name / description, and a MIME-type badge. The optional `action`
* slot lets an interactive wrapper (e.g. {@link ResourceLink}) place an
* expand/collapse indicator in the URI row.
* Pure-display metadata for a `resource_link`: an optional name and MIME-type
* badge on the header row, then the URI on the line below with a copy button.
* The URI is styled like ProtocolEntry/NetworkEntry URLs (`sm` / `fw: 500`,
* default sans-serif face and color), not a blue monospace link. The optional
* `action` slot lets an interactive wrapper (e.g. {@link ResourceLink}) place
* an expand/collapse control at the end of the URI row.
*/
export function ResourceLinkInfo({
uri,
name,
description,
mimeType,
action,
}: ResourceLinkInfoProps) {
const hasHeader = Boolean(name || mimeType);
return (
<HeaderStack>
<UriRow>
<UriText>{uri}</UriText>
<MetaGroup>
{hasHeader && (
<HeaderRow justify={name ? "space-between" : "flex-end"}>
{name && <NameText>{name}</NameText>}
{mimeType && <MimeBadge>{mimeType}</MimeBadge>}
{action}
</MetaGroup>
</HeaderRow>
)}
<UriRow>
<UriCluster>
<CopyButton value={uri} />
<UriText>{uri}</UriText>
</UriCluster>
{action}
</UriRow>
{name && <NameText>{name}</NameText>}
{description && <DescriptionText>{description}</DescriptionText>}
</HeaderStack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,32 +41,33 @@ export const Static: Story = {
args: {
uri: URI,
name: "Readme",
description: "Project documentation",
mimeType: "text/markdown",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(URI)).toBeInTheDocument();
expect(canvas.queryByRole("button")).not.toBeInTheDocument();
// Copy button is present; there's no expand control in the static card.
expect(canvas.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: /^Expand resource/ }),
).not.toBeInTheDocument();
},
};

export const Expandable: Story = {
args: {
uri: URI,
name: "Readme",
description: "Click to read on demand",
mimeType: "text/markdown",
onReadResource: readMarkdown,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", {
name: `Expand resource ${URI}`,
});
await userEvent.click(button);
await userEvent.click(
canvas.getByRole("button", { name: `Expand resource ${URI}` }),
);
await waitFor(() =>
expect(canvas.getByText("Resource:")).toBeInTheDocument(),
expect(canvas.getByText(/Read on demand/)).toBeInTheDocument(),
);
},
};
Expand All @@ -77,7 +78,6 @@ export const LargeResult: Story = {
args: {
uri: BLOB_URI,
name: "Blob Resource",
description: "A large gzipped resource",
mimeType: "application/gzip",
onReadResource: readLargeBlob,
},
Expand All @@ -86,8 +86,10 @@ export const LargeResult: Story = {
await userEvent.click(
canvas.getByRole("button", { name: `Expand resource ${BLOB_URI}` }),
);
// The `"blob"` key appears only in the expanded read result's JSON (not in
// the metadata badge), so it confirms the inline result rendered.
await waitFor(() =>
expect(canvas.getByText("Resource:")).toBeInTheDocument(),
expect(canvas.getByText(/"blob":/)).toBeInTheDocument(),
);
},
};
Loading
Loading