From 47ecea7ea735357611628cceb0575701d5e5aab4 Mon Sep 17 00:00:00 2001 From: nanda Date: Tue, 15 Sep 2026 11:29:18 -0700 Subject: [PATCH 1/2] feat(inline-copy-text): add compact copy control --- .changeset/inline-copy-text.md | 5 + .../src/components/SearchDialog.tsx | 2 + .../src/components/SidebarNav.tsx | 1 + .../components/demos/InlineCopyTextDemo.tsx | 40 +++++ .../src/pages/components/inline-copy-text.mdx | 89 ++++++++++ packages/kumo/package.json | 4 + .../scripts/component-registry/discovery.ts | 1 + .../src/components/inline-copy-text/index.ts | 8 + .../inline-copy-text.test.tsx | 153 +++++++++++++++++ .../inline-copy-text/inline-copy-text.tsx | 155 ++++++++++++++++++ packages/kumo/src/index.ts | 10 +- packages/kumo/vite.config.ts | 5 +- 12 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 .changeset/inline-copy-text.md create mode 100644 packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx create mode 100644 packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx create mode 100644 packages/kumo/src/components/inline-copy-text/index.ts create mode 100644 packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx create mode 100644 packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx diff --git a/.changeset/inline-copy-text.md b/.changeset/inline-copy-text.md new file mode 100644 index 000000000..a3582f8d4 --- /dev/null +++ b/.changeset/inline-copy-text.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add `InlineCopyText`, a compact borderless copy control for short values in dense interfaces and table cells. The component supports localized accessible labels, alternate copied text, inline success feedback, and hover or focus icon reveal. diff --git a/packages/kumo-docs-astro/src/components/SearchDialog.tsx b/packages/kumo-docs-astro/src/components/SearchDialog.tsx index ccd2e6464..202c0eb1b 100644 --- a/packages/kumo-docs-astro/src/components/SearchDialog.tsx +++ b/packages/kumo-docs-astro/src/components/SearchDialog.tsx @@ -187,6 +187,8 @@ const COMPONENT_DESCRIPTIONS: Record = { checkbox: "A control that allows the user to toggle between checked and not checked.", "clipboard-text": "A text component with a copy-to-clipboard button.", + "inline-copy-text": + "A compact, borderless copy control for short inline values.", collapsible: "A vertically stacked set of interactive headings that each reveal content.", combobox: diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index 852d63b0e..2c6268cc2 100644 --- a/packages/kumo-docs-astro/src/components/SidebarNav.tsx +++ b/packages/kumo-docs-astro/src/components/SidebarNav.tsx @@ -58,6 +58,7 @@ const componentItems: NavItem[] = [ { label: "Empty", href: "/components/empty" }, { label: "Flow", href: "/components/flow" }, { label: "Grid", href: "/components/grid" }, + { label: "Inline Copy Text", href: "/components/inline-copy-text" }, { label: "Input", href: "/components/input" }, { label: "InputArea", href: "/components/input-area" }, { label: "InputGroup", href: "/components/input-group" }, diff --git a/packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx new file mode 100644 index 000000000..c7c7b7a1e --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx @@ -0,0 +1,40 @@ +import { InlineCopyText } from "@cloudflare/kumo"; + +/** Compact copy control for a short identifier. */ +export function InlineCopyTextBasicDemo() { + return ( + + ); +} + +/** Inline copy text inside a dense, hoverable resource row. */ +export function InlineCopyTextResourceRowDemo() { + return ( +
+ + Production database + + +
+ ); +} + +/** Display one value while copying a different value. */ +export function InlineCopyTextAlternateTextDemo() { + return ( + + ); +} diff --git a/packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx b/packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx new file mode 100644 index 000000000..c30ddac4e --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx @@ -0,0 +1,89 @@ +--- +layout: ~/layouts/MdxDocLayout.astro +title: "Inline Copy Text" +description: "A compact, borderless copy control for short inline values." +sourceFile: "components/inline-copy-text" +--- + +import ComponentExample from "~/components/docs/ComponentExample.astro"; +import ComponentSection from "~/components/docs/ComponentSection.astro"; +import PropsTable from "~/components/docs/PropsTable.astro"; +import { + InlineCopyTextAlternateTextDemo, + InlineCopyTextBasicDemo, + InlineCopyTextResourceRowDemo, +} from "~/components/demos/InlineCopyTextDemo"; + + + + + + + + + +## Installation + +### Barrel + +```tsx +import { InlineCopyText } from "@cloudflare/kumo"; +``` + +### Granular + +```tsx +import { InlineCopyText } from "@cloudflare/kumo/components/inline-copy-text"; +``` + + + + + +## Usage + +Use `InlineCopyText` for short identifiers and values in dense interfaces such as table cells. Use [`ClipboardText`](/components/clipboard-text) when the value should appear in a read-only, input-like field with an always-visible copy button. + +```tsx +import { InlineCopyText } from "@cloudflare/kumo"; + +export default function Example() { + return ( + + ); +} +``` + + + + + +## Examples + +### Resource row + +An enclosing Tailwind `group` can reveal the copy icon when the entire row is hovered or receives focus within. + + + + + +### Copy alternate text + +Use `textToCopy` when the displayed label and copied value differ. + + + + + + + + + +## API Reference + + + diff --git a/packages/kumo/package.json b/packages/kumo/package.json index aac0c2733..2c812dd58 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -144,6 +144,10 @@ "types": "./dist/components/grid.d.ts", "import": "./dist/components/grid.js" }, + "./components/inline-copy-text": { + "types": "./dist/components/inline-copy-text.d.ts", + "import": "./dist/components/inline-copy-text.js" + }, "./components/input": { "types": "./dist/components/input.d.ts", "import": "./dist/components/input.js" diff --git a/packages/kumo/scripts/component-registry/discovery.ts b/packages/kumo/scripts/component-registry/discovery.ts index 0b4ec0990..59eb7d4f5 100644 --- a/packages/kumo/scripts/component-registry/discovery.ts +++ b/packages/kumo/scripts/component-registry/discovery.ts @@ -32,6 +32,7 @@ export const CATEGORY_MAP: Record = { button: "Action", "button-group": "Action", "clipboard-text": "Action", + "inline-copy-text": "Action", // Display badge: "Display", breadcrumbs: "Display", diff --git a/packages/kumo/src/components/inline-copy-text/index.ts b/packages/kumo/src/components/inline-copy-text/index.ts new file mode 100644 index 000000000..dfeedd739 --- /dev/null +++ b/packages/kumo/src/components/inline-copy-text/index.ts @@ -0,0 +1,8 @@ +export { + InlineCopyText, + KUMO_INLINE_COPY_TEXT_DEFAULT_VARIANTS, + KUMO_INLINE_COPY_TEXT_STYLING, + KUMO_INLINE_COPY_TEXT_VARIANTS, + type InlineCopyTextLabels, + type InlineCopyTextProps, +} from "./inline-copy-text"; diff --git a/packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx b/packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx new file mode 100644 index 000000000..021b14d56 --- /dev/null +++ b/packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx @@ -0,0 +1,153 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; +import { createRef } from "react"; +import { InlineCopyText } from "./inline-copy-text"; + +describe("InlineCopyText", () => { + let writeText: ReturnType; + + beforeEach(() => { + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const clickCopyButton = async ( + button = screen.getByRole("button", { name: "Copy to clipboard" }), + ) => { + fireEvent.click(button); + await act(() => Promise.resolve()); + }; + + it("renders the text as an accessible copy button", () => { + render(); + + expect(screen.getByText("namespace-id")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Copy to clipboard" }), + ).toBeTruthy(); + }); + + it("copies the text and announces success", async () => { + render(); + + await clickCopyButton(); + + expect(writeText).toHaveBeenCalledWith("namespace-id"); + expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy(); + expect(screen.getByText("Copied")).toBeTruthy(); + }); + + it("copies textToCopy instead of the displayed text", async () => { + render( + , + ); + + await clickCopyButton(); + + expect(writeText).toHaveBeenCalledWith("complete-resource-id"); + }); + + it("supports localized accessible labels", async () => { + render( + , + ); + + await clickCopyButton( + screen.getByRole("button", { name: "Copy namespace ID" }), + ); + + expect(screen.getByRole("button", { name: "ID copied" })).toBeTruthy(); + expect(screen.getByText("ID copied")).toBeTruthy(); + }); + + it("calls consumer click and copy handlers", async () => { + const onClick = vi.fn(); + const onCopy = vi.fn(); + render( + , + ); + + await clickCopyButton(); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onCopy).toHaveBeenCalledTimes(1); + }); + + it("does not copy when the consumer prevents the click", async () => { + render( + event.preventDefault()} + />, + ); + + await clickCopyButton(); + + expect(writeText).not.toHaveBeenCalled(); + }); + + it("keeps the copy label when writing to the clipboard fails", async () => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + writeText.mockRejectedValue(new Error("Copy failed")); + render(); + + await clickCopyButton(); + + expect( + screen.getByRole("button", { name: "Copy to clipboard" }), + ).toBeTruthy(); + expect(warning).toHaveBeenCalledWith( + "Clipboard copy failed", + expect.any(Error), + ); + }); + + it("resets copied feedback after the last click", async () => { + vi.useFakeTimers(); + render(); + const button = screen.getByRole("button", { name: "Copy to clipboard" }); + + await clickCopyButton(button); + await act(async () => vi.advanceTimersByTime(1000)); + fireEvent.click(button); + await act(() => Promise.resolve()); + await act(async () => vi.advanceTimersByTime(1000)); + + expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy(); + + await act(async () => vi.advanceTimersByTime(500)); + + expect( + screen.getByRole("button", { name: "Copy to clipboard" }), + ).toBeTruthy(); + }); + + it("forwards its ref and merges custom classes", () => { + const ref = createRef(); + render( + , + ); + + expect(ref.current?.tagName).toBe("BUTTON"); + expect(ref.current?.classList.contains("custom-class")).toBe(true); + expect(ref.current?.dataset.kumoComponent).toBe("InlineCopyText"); + }); +}); diff --git a/packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx b/packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx new file mode 100644 index 000000000..72172bf30 --- /dev/null +++ b/packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx @@ -0,0 +1,155 @@ +import { CheckIcon, CopySimpleIcon } from "@phosphor-icons/react"; +import { + forwardRef, + useCallback, + useEffect, + useRef, + useState, + type ComponentPropsWithoutRef, +} from "react"; +import { cn } from "../../utils/cn"; + +const COPIED_FEEDBACK_MS = 1500; + +/** + * InlineCopyText has no visual variants. The required exports are kept for the + * Kumo variant standard. + */ +export const KUMO_INLINE_COPY_TEXT_VARIANTS = {} as const; + +export const KUMO_INLINE_COPY_TEXT_DEFAULT_VARIANTS = {} as const; + +/** Base classes shared by every InlineCopyText. */ +export const KUMO_INLINE_COPY_TEXT_STYLING = { + baseClasses: + "group/inline-copy flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-xs border-0 bg-transparent p-0 font-mono text-sm text-kumo-subtle hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand", +} as const; + +export interface InlineCopyTextLabels { + /** Accessible name before the text is copied. @default "Copy to clipboard" */ + copyAction?: string; + /** Accessible name and live-region message after copying. @default "Copied" */ + copied?: string; +} + +/** + * InlineCopyText component props. + * + * @example + * ```tsx + * + * ``` + */ +export interface InlineCopyTextProps extends Omit< + ComponentPropsWithoutRef<"button">, + "children" | "onCopy" +> { + /** The text to display and copy to the clipboard. */ + text: string; + /** If provided, this text is copied instead of the displayed `text`. */ + textToCopy?: string; + /** Callback fired after text is copied successfully. */ + onCopy?: () => void; + /** Accessible labels for localization. */ + labels?: InlineCopyTextLabels; +} + +/** + * Compact, borderless copy control for IDs and other short values displayed + * inline or inside dense table cells. + * + * The copy icon appears when the control is hovered or focused. It also + * responds to an enclosing unnamed Tailwind `group`, allowing table rows to + * reveal the icon when the row is hovered. After a successful copy, the icon + * changes to a checkmark and the copied message is announced. + */ +export const InlineCopyText = forwardRef< + HTMLButtonElement, + InlineCopyTextProps +>( + ( + { + text, + textToCopy, + className, + onClick, + onCopy, + labels: { + copyAction = "Copy to clipboard", + copied: copiedLabel = "Copied", + } = {}, + ...props + }, + ref, + ) => { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimeoutRef.current !== null) { + clearTimeout(resetTimeoutRef.current); + } + }; + }, []); + + const copyToClipboard = useCallback(async () => { + if (resetTimeoutRef.current !== null) { + clearTimeout(resetTimeoutRef.current); + } + + try { + await navigator.clipboard.writeText(textToCopy ?? text); + setCopied(true); + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPIED_FEEDBACK_MS); + onCopy?.(); + } catch (error) { + setCopied(false); + console.warn("Clipboard copy failed", error); + } + }, [onCopy, text, textToCopy]); + + return ( + + ); + }, +); + +InlineCopyText.displayName = "InlineCopyText"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 7599991e4..f410d0638 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -10,7 +10,7 @@ * - Wrap your app with the kumo CSS import: `import "@cloudflare/kumo/styles"`. * * **Component categories:** - * - **Action:** Button, ClipboardText + * - **Action:** Button, ClipboardText, InlineCopyText * - **Display:** Badge, Breadcrumbs, Code, Empty, LayerCard, Meter, Surface (deprecated), Text * - **Feedback:** Banner, Loader, Toast * - **Input:** Checkbox, Combobox, DateRangePicker, Field, Input, Radio, Select, SensitiveInput, Switch @@ -50,6 +50,14 @@ export { type CheckboxChangeEventDetails, } from "./components/checkbox"; export { ClipboardText } from "./components/clipboard-text"; +export { + InlineCopyText, + KUMO_INLINE_COPY_TEXT_DEFAULT_VARIANTS, + KUMO_INLINE_COPY_TEXT_STYLING, + KUMO_INLINE_COPY_TEXT_VARIANTS, + type InlineCopyTextLabels, + type InlineCopyTextProps, +} from "./components/inline-copy-text"; export { Code, CodeBlock } from "./components/code"; export { Combobox } from "./components/combobox"; export { diff --git a/packages/kumo/vite.config.ts b/packages/kumo/vite.config.ts index b9eeb2c33..b3777f928 100644 --- a/packages/kumo/vite.config.ts +++ b/packages/kumo/vite.config.ts @@ -59,7 +59,10 @@ const packEntries = { "src/components/collapsible/index.ts", ), "components/field": resolve(__dirname, "src/components/field/index.ts"), - + "components/inline-copy-text": resolve( + __dirname, + "src/components/inline-copy-text/index.ts", + ), "components/input": resolve(__dirname, "src/components/input/index.ts"), "components/input-group": resolve( __dirname, From 96158468a90928eca81a1245b511f414a7b6e033 Mon Sep 17 00:00:00 2001 From: nanda Date: Wed, 16 Sep 2026 10:55:13 -0700 Subject: [PATCH 2/2] feat(inline-copy-text): support children and value --- .changeset/inline-copy-text.md | 2 +- .../components/demos/InlineCopyTextDemo.tsx | 24 ++-- .../src/pages/components/inline-copy-text.mdx | 15 +- .../scripts/component-registry/metadata.ts | 37 +++++ .../inline-copy-text.test.tsx | 59 +++++--- .../inline-copy-text/inline-copy-text.tsx | 135 +++++++++++++++--- .../inline-copy-text.type-spec.tsx | 58 ++++++++ 7 files changed, 277 insertions(+), 53 deletions(-) create mode 100644 packages/kumo/src/components/inline-copy-text/inline-copy-text.type-spec.tsx diff --git a/.changeset/inline-copy-text.md b/.changeset/inline-copy-text.md index a3582f8d4..dc635d9af 100644 --- a/.changeset/inline-copy-text.md +++ b/.changeset/inline-copy-text.md @@ -2,4 +2,4 @@ "@cloudflare/kumo": minor --- -Add `InlineCopyText`, a compact borderless copy control for short values in dense interfaces and table cells. The component supports localized accessible labels, alternate copied text, inline success feedback, and hover or focus icon reveal. +Add `InlineCopyText`, a compact borderless copy control for short values in dense interfaces and table cells. It accepts string or rich children, Text typography props excluding heading variants, an explicit copied value for rich content, localized accessible labels, inline success feedback, and hover or focus icon reveal. diff --git a/packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx index c7c7b7a1e..6341ee10d 100644 --- a/packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx @@ -4,9 +4,10 @@ import { InlineCopyText } from "@cloudflare/kumo"; export function InlineCopyTextBasicDemo() { return ( + > + f86b3f10-32e9-4db7-ae95-84a1b2c3d4e5 + ); } @@ -18,23 +19,28 @@ export function InlineCopyTextResourceRowDemo() { Production database + > + f86b3f10-32e9-4db7-ae95-84a1b2c3d4e5 + ); } -/** Display one value while copying a different value. */ -export function InlineCopyTextAlternateTextDemo() { +/** Display rich content while copying its underlying value. */ +export function InlineCopyTextRichContentDemo() { return ( + > + + Database ID: f86b3f10… + + ); } diff --git a/packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx b/packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx index c30ddac4e..04051946f 100644 --- a/packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx +++ b/packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx @@ -9,9 +9,9 @@ import ComponentExample from "~/components/docs/ComponentExample.astro"; import ComponentSection from "~/components/docs/ComponentSection.astro"; import PropsTable from "~/components/docs/PropsTable.astro"; import { - InlineCopyTextAlternateTextDemo, InlineCopyTextBasicDemo, InlineCopyTextResourceRowDemo, + InlineCopyTextRichContentDemo, } from "~/components/demos/InlineCopyTextDemo"; @@ -50,9 +50,10 @@ import { InlineCopyText } from "@cloudflare/kumo"; export default function Example() { return ( + > + f86b3f10-32e9-4db7-ae95-84a1b2c3d4e5 + ); } ``` @@ -71,12 +72,12 @@ An enclosing Tailwind `group` can reveal the copy icon when the entire row is ho -### Copy alternate text +### Rich content -Use `textToCopy` when the displayed label and copied value differ. +String children are copied by default. When the children are not a string, provide the required `value` prop with the value to copy. `InlineCopyText` also accepts Text props such as `variant`, `size`, `bold`, `truncate`, and `as`; heading variants are not supported. - - + + diff --git a/packages/kumo/scripts/component-registry/metadata.ts b/packages/kumo/scripts/component-registry/metadata.ts index c78928eb9..904c83330 100644 --- a/packages/kumo/scripts/component-registry/metadata.ts +++ b/packages/kumo/scripts/component-registry/metadata.ts @@ -238,6 +238,43 @@ export const ADDITIONAL_COMPONENT_PROPS: Record< string, Record > = { + InlineCopyText: { + children: { + type: "ReactNode", + required: true, + description: + "Content to display. String children are copied unless `value` is provided.", + }, + value: { + type: "string", + description: + "The value to copy. Required when `children` is not a string.", + }, + variant: { + type: '"body" | "secondary" | "success" | "error" | "mono" | "mono-secondary"', + optional: true, + description: + "Text style variant. Supports every Text variant except heading variants.", + values: [ + "body", + "secondary", + "success", + "error", + "mono", + "mono-secondary", + ], + default: '"mono-secondary"', + }, + size: { + type: '"xs" | "sm" | "base" | "lg"', + description: "Text size. Supported values depend on the text variant.", + }, + bold: { + type: "boolean", + description: + "Whether to use medium font weight. Only applies to body text variants.", + }, + }, Meter: { value: { type: "number", diff --git a/packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx b/packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx index 021b14d56..ceeb02271 100644 --- a/packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx +++ b/packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx @@ -33,8 +33,8 @@ describe("InlineCopyText", () => { await act(() => Promise.resolve()); }; - it("renders the text as an accessible copy button", () => { - render(); + it("renders string children as an accessible copy button", () => { + render(namespace-id); expect(screen.getByText("namespace-id")).toBeTruthy(); expect( @@ -42,8 +42,8 @@ describe("InlineCopyText", () => { ).toBeTruthy(); }); - it("copies the text and announces success", async () => { - render(); + it("copies string children and announces success", async () => { + render(namespace-id); await clickCopyButton(); @@ -52,9 +52,9 @@ describe("InlineCopyText", () => { expect(screen.getByText("Copied")).toBeTruthy(); }); - it("copies textToCopy instead of the displayed text", async () => { + it("copies value instead of string children when provided", async () => { render( - , + visible-id, ); await clickCopyButton(); @@ -62,12 +62,36 @@ describe("InlineCopyText", () => { expect(writeText).toHaveBeenCalledWith("complete-resource-id"); }); + it("renders rich children with Text props and copies value", async () => { + render( + + Visible resource + , + ); + + const text = screen.getByText("Visible resource").closest("strong"); + expect(text).toBeTruthy(); + expect(text?.classList.contains("text-lg/[inherit]")).toBe(true); + expect(text?.classList.contains("font-medium")).toBe(true); + + await clickCopyButton(); + + expect(writeText).toHaveBeenCalledWith("complete-resource-id"); + }); + it("supports localized accessible labels", async () => { render( , + > + namespace-id + , ); await clickCopyButton( @@ -82,7 +106,9 @@ describe("InlineCopyText", () => { const onClick = vi.fn(); const onCopy = vi.fn(); render( - , + + namespace-id + , ); await clickCopyButton(); @@ -93,10 +119,9 @@ describe("InlineCopyText", () => { it("does not copy when the consumer prevents the click", async () => { render( - event.preventDefault()} - />, + event.preventDefault()}> + namespace-id + , ); await clickCopyButton(); @@ -107,7 +132,7 @@ describe("InlineCopyText", () => { it("keeps the copy label when writing to the clipboard fails", async () => { const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); writeText.mockRejectedValue(new Error("Copy failed")); - render(); + render(namespace-id); await clickCopyButton(); @@ -122,7 +147,7 @@ describe("InlineCopyText", () => { it("resets copied feedback after the last click", async () => { vi.useFakeTimers(); - render(); + render(namespace-id); const button = screen.getByRole("button", { name: "Copy to clipboard" }); await clickCopyButton(button); @@ -143,7 +168,9 @@ describe("InlineCopyText", () => { it("forwards its ref and merges custom classes", () => { const ref = createRef(); render( - , + + namespace-id + , ); expect(ref.current?.tagName).toBe("BUTTON"); diff --git a/packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx b/packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx index 72172bf30..da799a86b 100644 --- a/packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx +++ b/packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx @@ -6,11 +6,27 @@ import { useRef, useState, type ComponentPropsWithoutRef, + type ReactNode, } from "react"; import { cn } from "../../utils/cn"; +import { + Text, + type KumoTextSize, + type KumoTextVariant, + type TextProps, +} from "../text/text"; const COPIED_FEEDBACK_MS = 1500; +function resolveCopyValue(children: ReactNode, value: string | undefined) { + if (value !== undefined) return value; + if (typeof children === "string") return children; + + throw new Error( + "InlineCopyText requires a value prop when children is not a string.", + ); +} + /** * InlineCopyText has no visual variants. The required exports are kept for the * Kumo variant standard. @@ -22,7 +38,7 @@ export const KUMO_INLINE_COPY_TEXT_DEFAULT_VARIANTS = {} as const; /** Base classes shared by every InlineCopyText. */ export const KUMO_INLINE_COPY_TEXT_STYLING = { baseClasses: - "group/inline-copy flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-xs border-0 bg-transparent p-0 font-mono text-sm text-kumo-subtle hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand", + "group/inline-copy flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-xs border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand", } as const; export interface InlineCopyTextLabels { @@ -32,30 +48,75 @@ export interface InlineCopyTextLabels { copied?: string; } +type InlineCopyTextHeadingVariant = Extract< + KumoTextVariant, + `heading${string}` +>; +type InlineCopyTextVariant = Exclude< + KumoTextVariant, + InlineCopyTextHeadingVariant +>; +type InlineCopyTextCopyVariant = Exclude< + InlineCopyTextVariant, + "mono" | "mono-secondary" +>; +type InlineCopyTextMonospaceVariant = Extract< + InlineCopyTextVariant, + "mono" | "mono-secondary" +>; + +type InlineCopyTextSharedTextProps = Pick; + +type InlineCopyTextTextProps = + | (InlineCopyTextSharedTextProps & { + variant: InlineCopyTextCopyVariant; + size?: KumoTextSize; + bold?: boolean; + }) + | (InlineCopyTextSharedTextProps & { + /** @default "mono-secondary" */ + variant?: InlineCopyTextMonospaceVariant; + size?: "lg"; + bold?: never; + }); + +type InlineCopyTextContentProps = + | { + /** Text content to display. Its value is copied unless `value` is provided. */ + children: string; + /** The value to copy. Defaults to `children` when `children` is a string. */ + value?: string; + } + | { + /** Rich content to display. */ + children: Exclude; + /** The value to copy. Required when `children` is not a string. */ + value: string; + }; + /** * InlineCopyText component props. * * @example * ```tsx * + * > + * 0c239dd2 + * * ``` */ -export interface InlineCopyTextProps extends Omit< +export type InlineCopyTextProps = Omit< ComponentPropsWithoutRef<"button">, - "children" | "onCopy" -> { - /** The text to display and copy to the clipboard. */ - text: string; - /** If provided, this text is copied instead of the displayed `text`. */ - textToCopy?: string; - /** Callback fired after text is copied successfully. */ - onCopy?: () => void; - /** Accessible labels for localization. */ - labels?: InlineCopyTextLabels; -} + "children" | "onCopy" | "value" +> & + InlineCopyTextTextProps & + InlineCopyTextContentProps & { + /** Callback fired after text is copied successfully. */ + onCopy?: () => void; + /** Accessible labels for localization. */ + labels?: InlineCopyTextLabels; + }; /** * Compact, borderless copy control for IDs and other short values displayed @@ -72,8 +133,13 @@ export const InlineCopyText = forwardRef< >( ( { - text, - textToCopy, + children, + value, + variant = "mono-secondary", + size, + bold, + truncate = true, + as = "span", className, onClick, onCopy, @@ -87,6 +153,7 @@ export const InlineCopyText = forwardRef< ) => { const [copied, setCopied] = useState(false); const resetTimeoutRef = useRef | null>(null); + const valueToCopy = resolveCopyValue(children, value); useEffect(() => { return () => { @@ -102,7 +169,7 @@ export const InlineCopyText = forwardRef< } try { - await navigator.clipboard.writeText(textToCopy ?? text); + await navigator.clipboard.writeText(valueToCopy); setCopied(true); resetTimeoutRef.current = setTimeout(() => { setCopied(false); @@ -113,7 +180,35 @@ export const InlineCopyText = forwardRef< setCopied(false); console.warn("Clipboard copy failed", error); } - }, [onCopy, text, textToCopy]); + }, [onCopy, valueToCopy]); + + const textHoverClasses = + variant === "mono-secondary" + ? "group-hover/inline-copy:text-kumo-default group-focus-visible/inline-copy:text-kumo-default" + : undefined; + const renderedText = + variant === "mono" || variant === "mono-secondary" ? ( + + as={as} + size={size === "lg" ? size : undefined} + truncate={truncate} + variant={variant} + DANGEROUS_className={textHoverClasses} + > + {children} + + ) : ( + + as={as} + bold={bold} + size={size} + truncate={truncate} + variant={variant} + DANGEROUS_className={textHoverClasses} + > + {children} + + ); return (