diff --git a/.changeset/inline-copy-text.md b/.changeset/inline-copy-text.md new file mode 100644 index 000000000..dc635d9af --- /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. 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/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..6341ee10d --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/InlineCopyTextDemo.tsx @@ -0,0 +1,46 @@ +import { InlineCopyText } from "@cloudflare/kumo"; + +/** Compact copy control for a short identifier. */ +export function InlineCopyTextBasicDemo() { + return ( + + f86b3f10-32e9-4db7-ae95-84a1b2c3d4e5 + + ); +} + +/** Inline copy text inside a dense, hoverable resource row. */ +export function InlineCopyTextResourceRowDemo() { + return ( +
+ + Production database + + + f86b3f10-32e9-4db7-ae95-84a1b2c3d4e5 + +
+ ); +} + +/** 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 new file mode 100644 index 000000000..04051946f --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/components/inline-copy-text.mdx @@ -0,0 +1,90 @@ +--- +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 { + InlineCopyTextBasicDemo, + InlineCopyTextResourceRowDemo, + InlineCopyTextRichContentDemo, +} 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 ( + + f86b3f10-32e9-4db7-ae95-84a1b2c3d4e5 + + ); +} +``` + + + + + +## Examples + +### Resource row + +An enclosing Tailwind `group` can reveal the copy icon when the entire row is hovered or receives focus within. + + + + + +### Rich content + +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. + + + + + + + + + +## 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/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/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..ceeb02271 --- /dev/null +++ b/packages/kumo/src/components/inline-copy-text/inline-copy-text.test.tsx @@ -0,0 +1,180 @@ +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 string children as an accessible copy button", () => { + render(namespace-id); + + expect(screen.getByText("namespace-id")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Copy to clipboard" }), + ).toBeTruthy(); + }); + + it("copies string children and announces success", async () => { + render(namespace-id); + + await clickCopyButton(); + + expect(writeText).toHaveBeenCalledWith("namespace-id"); + expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy(); + expect(screen.getByText("Copied")).toBeTruthy(); + }); + + it("copies value instead of string children when provided", async () => { + render( + visible-id, + ); + + await clickCopyButton(); + + 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( + 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( + + namespace-id + , + ); + + await clickCopyButton(); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onCopy).toHaveBeenCalledTimes(1); + }); + + it("does not copy when the consumer prevents the click", async () => { + render( + event.preventDefault()}> + namespace-id + , + ); + + 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(namespace-id); + + 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(namespace-id); + 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( + + namespace-id + , + ); + + 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..da799a86b --- /dev/null +++ b/packages/kumo/src/components/inline-copy-text/inline-copy-text.tsx @@ -0,0 +1,250 @@ +import { CheckIcon, CopySimpleIcon } from "@phosphor-icons/react"; +import { + forwardRef, + useCallback, + useEffect, + 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. + */ +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 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; +} + +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 type InlineCopyTextProps = Omit< + ComponentPropsWithoutRef<"button">, + "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 + * 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 +>( + ( + { + children, + value, + variant = "mono-secondary", + size, + bold, + truncate = true, + as = "span", + className, + onClick, + onCopy, + labels: { + copyAction = "Copy to clipboard", + copied: copiedLabel = "Copied", + } = {}, + ...props + }, + ref, + ) => { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + const valueToCopy = resolveCopyValue(children, value); + + 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(valueToCopy); + 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, 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 ( + + ); + }, +); + +InlineCopyText.displayName = "InlineCopyText"; diff --git a/packages/kumo/src/components/inline-copy-text/inline-copy-text.type-spec.tsx b/packages/kumo/src/components/inline-copy-text/inline-copy-text.type-spec.tsx new file mode 100644 index 000000000..12ad4850e --- /dev/null +++ b/packages/kumo/src/components/inline-copy-text/inline-copy-text.type-spec.tsx @@ -0,0 +1,58 @@ +/** + * Type-level specification for InlineCopyText's conditional value prop. + * + * String children provide the copied value by default. Every other React node + * must be paired with an explicit string value. + */ + +import { InlineCopyText, type InlineCopyTextProps } from "./inline-copy-text"; + +const stringChildren = namespace-id; +const stringChildrenWithValue = ( + visible-id +); +const richChildren = ( + + Visible resource + +); +const textProps = ( + + namespace-id + +); + +const stringProps: InlineCopyTextProps = { children: "namespace-id" }; +const richProps: InlineCopyTextProps = { + children: Visible resource, + value: "complete-resource-id", +}; + +const richChildrenWithoutValue = ( + // @ts-expect-error - non-string children require an explicit value. + + Visible resource + +); + +// @ts-expect-error - heading variants are not supported by InlineCopyText. +const headingVariant = Title; + +const deprecatedHeadingVariant = ( + // @ts-expect-error - deprecated heading variants are also not supported. + + Title + +); + +export const __typeSpec = { + stringChildren, + stringChildrenWithValue, + richChildren, + textProps, + stringProps, + richProps, + richChildrenWithoutValue, + headingVariant, + deprecatedHeadingVariant, +}; 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,