From 34d9729f1560fa6b2ec2ad174230fc59ea1a2920 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 13 Mar 2026 16:51:01 +0200 Subject: [PATCH 1/3] feat: Added copy-to-clipboard component --- .../copy-to-clipboard.spec.ts | 453 ++++++++++++++++++ .../copy-to-clipboard/copy-to-clipboard.ts | 215 +++++++++ src/components/types.ts | 1 + src/index.ts | 1 + stories/copy-to-clipboard.stories.ts | 281 +++++++++++ 5 files changed, 951 insertions(+) create mode 100644 src/components/copy-to-clipboard/copy-to-clipboard.spec.ts create mode 100644 src/components/copy-to-clipboard/copy-to-clipboard.ts create mode 100644 stories/copy-to-clipboard.stories.ts diff --git a/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts b/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts new file mode 100644 index 0000000000..543b881d1a --- /dev/null +++ b/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts @@ -0,0 +1,453 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; +import { stub } from 'sinon'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { + simulateClick, + simulatePointerEnter, + simulatePointerLeave, +} from '../common/utils.spec.js'; +import IgcCopyToClipboardComponent from './copy-to-clipboard.js'; + +describe('Copy Content', () => { + before(() => { + defineComponents(IgcCopyToClipboardComponent); + }); + + let element: IgcCopyToClipboardComponent; + let copyButton: HTMLElement; + let writeTextStub: ReturnType; + + async function getButton() { + return element.shadowRoot!.querySelector( + '[part~="copy-button"]' + ) as HTMLElement; + } + + beforeEach(async () => { + // Stub the clipboard API + writeTextStub = stub(navigator.clipboard, 'writeText').resolves(); + }); + + afterEach(() => { + writeTextStub.restore(); + }); + + it('passes the a11y audit', async () => { + element = await fixture( + html`Sample text` + ); + + await expect(element).shadowDom.to.be.accessible(); + await expect(element).to.be.accessible(); + }); + + it('should initialize with default values', async () => { + element = await fixture( + html`` + ); + + expect(element.format).to.equal('plain'); + expect(element).dom.to.equal( + '' + ); + }); + + it('should render content inside default slot', async () => { + const content = 'Text to copy'; + element = await fixture( + html`${content}` + ); + + expect(element).dom.to.have.text(content); + }); + + it('should render copy button in shadow DOM', async () => { + element = await fixture( + html`Text` + ); + + copyButton = await getButton(); + expect(copyButton).to.exist; + expect(copyButton.tagName.toLowerCase()).to.equal('igc-icon-button'); + }); + + it('should have copy button with proper aria-label', async () => { + element = await fixture( + html`Text` + ); + + copyButton = await getButton(); + expect(copyButton.getAttribute('aria-label')).to.equal( + 'Copy content to clipboard' + ); + }); + + describe('User Interaction', () => { + beforeEach(async () => { + element = await fixture( + html`Sample text` + ); + copyButton = await getButton(); + }); + + it('should show copy button on pointer enter', async () => { + expect(copyButton.part.contains('visible')).to.be.false; + expect(copyButton.getAttribute('tabindex')).to.equal('-1'); + + simulatePointerEnter(element); + await elementUpdated(element); + + expect(copyButton.part.contains('visible')).to.be.true; + expect(copyButton.getAttribute('tabindex')).to.equal('0'); + }); + + it('should hide copy button on pointer leave', async () => { + simulatePointerEnter(element); + await elementUpdated(element); + expect(copyButton.part.contains('visible')).to.be.true; + + simulatePointerLeave(element); + await elementUpdated(element); + + expect(copyButton.part.contains('visible')).to.be.false; + expect(copyButton.getAttribute('tabindex')).to.equal('-1'); + }); + + it('should show copy button on focus', async () => { + expect(copyButton.part.contains('visible')).to.be.false; + + element.dispatchEvent(new FocusEvent('focusin')); + await elementUpdated(element); + + expect(copyButton.part.contains('visible')).to.be.true; + expect(copyButton.getAttribute('tabindex')).to.equal('0'); + }); + + it('should hide copy button on blur', async () => { + element.dispatchEvent(new FocusEvent('focusin')); + await elementUpdated(element); + expect(copyButton.part.contains('visible')).to.be.true; + + element.dispatchEvent(new FocusEvent('focusout')); + await elementUpdated(element); + + expect(copyButton.part.contains('visible')).to.be.false; + }); + }); + + describe('Copy Functionality', () => { + it('should copy simple text content', async () => { + const text = 'Simple text to copy'; + element = await fixture( + html`${text}` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + expect(writeTextStub).to.have.been.calledOnce; + expect(writeTextStub).to.have.been.calledWith(text); + }); + + it('should copy text with normalized whitespace', async () => { + element = await fixture( + html` + Text with multiple spaces and indentation + ` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + expect(writeTextStub).to.have.been.calledOnce; + const copiedText = writeTextStub.firstCall.args[0] as string; + + // Should preserve newlines but trim whitespace + expect(copiedText).to.include('Text with multiple spaces'); + expect(copiedText).to.include('and indentation'); + // Should not have multiple consecutive spaces within lines + expect(copiedText).to.not.match(/ {2,}/); + // Should not have leading/trailing whitespace around newlines + expect(copiedText).to.not.match(/\n /); + expect(copiedText).to.not.match(/ \n/); + }); + + it('should strip excessive whitespace from multi-line content', async () => { + element = await fixture( + html` +
+Line 1
+
+
+Line 2
+
` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + const copiedText = writeTextStub.firstCall.args[0] as string; + + // Should not have multiple consecutive newlines + expect(copiedText).to.not.match(/\n\n+/); + expect(copiedText).to.equal('Line 1\nLine 2'); + }); + + it('should copy content from nested elements', async () => { + element = await fixture( + html` +
First line
+
Second line
+
` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + expect(writeTextStub).to.have.been.calledOnce; + const copiedText = writeTextStub.firstCall.args[0] as string; + expect(copiedText).to.include('First line'); + expect(copiedText).to.include('Second line'); + }); + + it('should handle empty content', async () => { + element = await fixture( + html`` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + expect(writeTextStub).to.have.been.calledOnce; + expect(writeTextStub).to.have.been.calledWith(''); + }); + + it('should handle content with only whitespace', async () => { + element = await fixture( + html` ` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + expect(writeTextStub).to.have.been.calledOnce; + expect(writeTextStub).to.have.been.calledWith(''); + }); + + it('should fail gracefully when clipboard API rejects', async () => { + writeTextStub.restore(); + const rejectStub = stub(navigator.clipboard, 'writeText').rejects( + new Error('Permission denied') + ); + + element = await fixture( + html`Text` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + + // Wait for the async handler to settle + await nextFrame(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(rejectStub).to.have.been.calledOnce; + + rejectStub.restore(); + }); + }); + + describe('Custom Icon Slot', () => { + it('should support custom copy icon', async () => { + element = await fixture( + html` + Text + + + + ` + ); + + copyButton = await getButton(); + const slot = copyButton.querySelector('slot[name="copy-icon"]'); + expect(slot).to.exist; + + const assignedNodes = (slot as HTMLSlotElement).assignedElements(); + expect(assignedNodes).to.have.lengthOf(1); + expect(assignedNodes[0].tagName.toLowerCase()).to.equal('svg'); + }); + }); + + describe('Whitespace Normalization', () => { + it('should collapse multiple spaces to single space', async () => { + element = await fixture( + html`Text with spaces` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + expect(writeTextStub).to.have.been.calledWith('Text with spaces'); + }); + + it('should collapse tabs to single space', async () => { + element = await fixture( + html`Text with tabs` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + const copiedText = writeTextStub.firstCall.args[0] as string; + expect(copiedText).to.equal('Text with tabs'); + }); + + it('should preserve single newlines', async () => { + element = await fixture( + html` +
+Line 1
+Line 2
+Line 3
+
` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + const copiedText = writeTextStub.firstCall.args[0] as string; + expect(copiedText).to.equal('Line 1\nLine 2\nLine 3'); + }); + + it('should trim leading and trailing whitespace', async () => { + element = await fixture( + html` Trimmed content ` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + const copiedText = writeTextStub.firstCall.args[0] as string; + expect(copiedText).to.equal('Trimmed content'); + expect(copiedText.startsWith(' ')).to.be.false; + expect(copiedText.endsWith(' ')).to.be.false; + }); + + it('should handle mixed whitespace scenarios', async () => { + element = await fixture( + html` +
+First    line   with   spaces
+
+
+Second		line	with	tabs
+
+Third line
+
` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + const copiedText = writeTextStub.firstCall.args[0] as string; + expect(copiedText).to.equal( + 'First line with spaces\nSecond line with tabs\nThird line' + ); + }); + }); + + describe('Copy Format', () => { + it('should default to plain format', async () => { + element = await fixture( + html`Text` + ); + + expect(element.format).to.equal('plain'); + expect(element.getAttribute('format')).to.equal('plain'); + }); + + it('can be set to preserve via attribute', async () => { + element = await fixture( + html`Text` + ); + + expect(element.format).to.equal('preserve'); + }); + + it('plain format collapses whitespace', async () => { + element = await fixture( + html` +

First paragraph

+

Second paragraph

+
` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + const copiedText = writeTextStub.firstCall.args[0] as string; + expect(copiedText).to.not.match(/\n\n+/); + }); + + it('preserve format retains paragraph structure', async () => { + element = await fixture( + html` +

First paragraph

+

Second paragraph

+
` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + const copiedText = writeTextStub.firstCall.args[0] as string; + expect(copiedText).to.include('First paragraph'); + expect(copiedText).to.include('Second paragraph'); + expect(copiedText).to.match(/First paragraph\n+Second paragraph/); + }); + + it('preserve format retains code block indentation', async () => { + element = await fixture( + html` +
+  function hello() {
+    return 1;
+  }
+
` + ); + + copyButton = await getButton(); + simulateClick(copyButton); + await nextFrame(); + + const copiedText = writeTextStub.firstCall.args[0] as string; + expect(copiedText).to.include(' function hello() {'); + expect(copiedText).to.include(' return 1;'); + }); + }); +}); diff --git a/src/components/copy-to-clipboard/copy-to-clipboard.ts b/src/components/copy-to-clipboard/copy-to-clipboard.ts new file mode 100644 index 0000000000..967e18996b --- /dev/null +++ b/src/components/copy-to-clipboard/copy-to-clipboard.ts @@ -0,0 +1,215 @@ +import { css, html, LitElement } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; +import { shadowOptions } from '../common/decorators/shadow-options.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { partMap } from '../common/part-map.js'; +import { addSafeEventListener, bindIf } from '../common/util.js'; +import type { CopyFormat } from '../types.js'; + +// Regex patterns for normalizing whitespace in copied content +/** Matches multiple consecutive spaces or tabs */ +const MULTIPLE_HORIZONTAL_WHITESPACE = /[ \t]+/g; +/** Matches whitespace around newlines (indentation and trailing spaces) */ +const WHITESPACE_AROUND_NEWLINES = /[ \t]*\n[ \t]*/g; +/** Matches multiple consecutive newlines */ +const MULTIPLE_NEWLINES = /\n+/g; + +/** + * A component that overlays a copy button on top of its slotted content, + * allowing users to copy the text to the clipboard with a single click. + * + * @element igc-copy-to-clipboard + * + * @remarks + * The copy button is hidden by default and becomes visible when the user hovers + * over the component or moves keyboard focus inside it. The `format` attribute + * controls how the content is serialized before being written to the clipboard: + * - **`plain`** (default) — collapses all whitespace and newlines into a single, + * normalized body of text. Suitable for prose content. + * - **`preserve`** — uses the browser's `innerText` algorithm to retain the + * visual structure of the content: paragraph breaks, indentation in `
`
+ *   blocks, etc. Ideal for code snippets or structured content.
+ *
+ * @slot - The content to be displayed and copied. Accepts any HTML.
+ * @slot copy-icon - Overrides the default copy icon inside the copy button.
+ *
+ * @csspart copy-button - The copy icon-button positioned over the slotted content.
+ *
+ * @example
+ * ```html
+ * 
+ * 
+ *   

Some text the user can copy to the clipboard.

+ *
+ * ``` + * + * @example + * ```html + * + * + *
function greet(name) {
+ *   return `Hello, ${name}!`;
+ * }
+ *
+ * ``` + * + * @example + * ```html + * + * + * + *

Content to copy.

+ *
+ * ``` + * + * @example + * ```html + * + * + *
const x = 42;
+ *
+ * ``` + */ +@shadowOptions({ delegatesFocus: true }) +export default class IgcCopyToClipboardComponent extends LitElement { + public static readonly tagName = 'igc-copy-to-clipboard'; + public static override styles = css` + :host { + display: block; + position: relative; + } + + [part~='copy-button'] { + position: absolute; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease-in-out; + inset-block-start: 0.25rem; + inset-inline-end: 0.25rem; + padding: 0.25rem; + border-radius: 4px; + } + + [part~='visible'] { + opacity: 1; + pointer-events: auto; + } + `; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcCopyToClipboardComponent, IgcIconButtonComponent); + } + + //#region Internal state and properties + + private readonly _slots = addSlotController(this, { + slots: setSlots('copy-icon'), + }); + + @state() + private _hasUserInteraction = false; + + //#endregion + + //#region Public properties and attributes + + /** + * Controls how the text content is formatted when copied to the clipboard. + * - `plain`: Normalizes all whitespace into a flat body of text (default). + * - `preserve`: Retains the visual structure such as paragraphs and code indentation. + * + * @attr format + * @default 'plain' + */ + @property({ reflect: true }) + public format: CopyFormat = 'plain'; + + //#endregion + + constructor() { + super(); + + addSafeEventListener(this, 'pointerenter', this._setUserInteraction); + addSafeEventListener(this, 'pointerleave', this._unsetUserInteraction); + addSafeEventListener(this, 'focusin', this._setUserInteraction); + addSafeEventListener(this, 'focusout', this._unsetUserInteraction); + } + + //#region Event handlers + + private async _handleClick(): Promise { + try { + await navigator.clipboard.writeText(this._getContentToCopy()); + } catch { + // Clipboard API unavailable or permission denied — fail gracefully. + } + } + + private _setUserInteraction(): void { + this._hasUserInteraction = true; + } + + private _unsetUserInteraction(): void { + this._hasUserInteraction = false; + } + + //#endregion + + //#region Content processing + + private _getContentToCopy(): string { + return this.format === 'preserve' + ? this._getPreservedContent() + : this._getPlainContent(); + } + + private _getPlainContent(): string { + return this._slots + .getAssignedNodes('[default]', true) + .map((node) => node.textContent ?? '') + .join('') + .replaceAll(MULTIPLE_HORIZONTAL_WHITESPACE, ' ') + .replaceAll(WHITESPACE_AROUND_NEWLINES, '\n') + .replaceAll(MULTIPLE_NEWLINES, '\n') + .trim(); + } + + private _getPreservedContent(): string { + return this.innerText.replace(/^\n+|\n+$/g, ''); + } + + //#endregion + + protected _renderButton() { + return html` + + + + `; + } + + protected override render() { + return html`${this._renderButton()}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-copy-to-clipboard': IgcCopyToClipboardComponent; + } +} diff --git a/src/components/types.ts b/src/components/types.ts index 8f213462ce..be65184317 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -21,6 +21,7 @@ export type TreeSelection = 'none' | 'multiple' | 'cascade'; //#region component-specific export type AvatarShape = 'square' | 'circle' | 'rounded'; +export type CopyFormat = 'plain' | 'preserve'; export type BadgeShape = 'rounded' | 'square'; export type ButtonGroupSelection = 'single' | 'single-required' | 'multiple'; export type ButtonVariant = 'contained' | 'flat' | 'outlined' | 'fab'; diff --git a/src/index.ts b/src/index.ts index d6f2b812e5..7eae95fc2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,7 @@ export { default as IgcStepperComponent } from './components/stepper/stepper.js' export { default as IgcStepComponent } from './components/stepper/step.js'; export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; export { default as IgcThemeProviderComponent } from './components/theme-provider/theme-provider.js'; +export { default as IgcCopyToClipboardComponent } from './components/copy-to-clipboard/copy-to-clipboard.js'; // definitions export { defineComponents } from './components/common/definitions/defineComponents.js'; diff --git a/stories/copy-to-clipboard.stories.ts b/stories/copy-to-clipboard.stories.ts new file mode 100644 index 0000000000..f98902ddb2 --- /dev/null +++ b/stories/copy-to-clipboard.stories.ts @@ -0,0 +1,281 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; + +import { + IgcCopyToClipboardComponent, + IgcTextareaComponent, + defineComponents, +} from 'igniteui-webcomponents'; +import { disableStoryControls } from './story.js'; + +defineComponents(IgcCopyToClipboardComponent, IgcTextareaComponent); +// region default +const metadata: Meta = { + title: 'CopyToClipboard', + component: 'igc-copy-to-clipboard', + parameters: { + docs: { + description: { + component: + 'A component that overlays a copy button on top of its slotted content,\nallowing users to copy the text to the clipboard with a single click.', + }, + }, + }, + argTypes: { + format: { + type: 'string', + description: + 'Controls how the text content is formatted when copied to the clipboard.\n- `plain`: Normalizes all whitespace into a flat body of text (default).\n- `preserve`: Retains the visual structure such as paragraphs and code indentation.', + options: ['plain', 'preserve'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'plain' } }, + }, + }, + args: { format: 'plain' }, +}; + +export default metadata; + +interface IgcCopyToClipboardArgs { + /** + * Controls how the text content is formatted when copied to the clipboard. + * - `plain`: Normalizes all whitespace into a flat body of text (default). + * - `preserve`: Retains the visual structure such as paragraphs and code indentation. + */ + format: 'plain' | 'preserve'; +} +type Story = StoryObj; + +// endregion + +export const Default: Story = { + render: (args) => html` + + +
+ +
+

This is some text that can be copied to the clipboard.

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Odio + quibusdam at non exercitationem labore nostrum. Magni repudiandae + maxime perferendis hic laudantium, fuga dolor, consequatur odio + minima repellendus error, eum amet. +

+

+ Try clicking the copy button above, then paste into the field below. +

+
+
+ + +
+ `, +}; + +export const PreservedFormat: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` + + +
+ +
+

+ With preserve format, paragraph breaks and + indentation are retained in the copied text. +

+

+ Paste into the field below to verify the structure is preserved. +

+
+function greet(name) {
+  const message = \`Hello, \${name}!\`;
+  console.log(message);
+  return message;
+}
+
+
+ + +
+ `, +}; + +export const CodeSnippet: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` + + + + + +
+

+ Hover over the code block below and click the copy button to copy the + snippet. Paste it into the text field to verify the indentation is + preserved. +

+ + +
+
import { registerIconFromText } from 'igniteui-webcomponents';
+
+const heartSvg = \`
+  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.27 2 8.5
+             2 5.41 4.42 3 7.5 3c1.74 0 3.41 .81 4.5 2.08
+             C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.41 22 8.5
+             c0 3.77-3.4 6.86-8.55 11.53L12 21.35z"/>
+  </svg>\`;
+
+registerIconFromText('heart', heartSvg, 'my-icons');
+
+
+ + +
+ `, +}; + +export const WithCustomIcon: Story = { + render: (args) => html` + + +
+ + + + + + + + +
+

This story uses a custom SVG icon for the copy button.

+

Paste into the field below to see the copied content.

+
+
+ + +
+ `, +}; From 4063bd1cbc393060e3dc5482c1448047f95dfcf9 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 23 Jun 2026 16:28:56 +0300 Subject: [PATCH 2/3] feat: Added integration with Invoker Commands API - Introducded Invoker Commands API to enable command execution from the copy-to-clipboard component. - Updated the copy button to include a visually hidden label for screen readers, enhancing accessibility. - Added disable-interaction attribute to allow disabling user interaction with the component. - Added tests --- .../copy-to-clipboard.spec.ts | 64 +++++++------ .../copy-to-clipboard/copy-to-clipboard.ts | 89 ++++++++++++++++--- stories/copy-to-clipboard.stories.ts | 82 +++++++++++++++-- 3 files changed, 191 insertions(+), 44 deletions(-) diff --git a/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts b/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts index 543b881d1a..acc7cd3532 100644 --- a/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts +++ b/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts @@ -23,8 +23,8 @@ describe('Copy Content', () => { let copyButton: HTMLElement; let writeTextStub: ReturnType; - async function getButton() { - return element.shadowRoot!.querySelector( + function getButton() { + return element.renderRoot.querySelector( '[part~="copy-button"]' ) as HTMLElement; } @@ -72,20 +72,18 @@ describe('Copy Content', () => { html`Text` ); - copyButton = await getButton(); + copyButton = getButton(); expect(copyButton).to.exist; expect(copyButton.tagName.toLowerCase()).to.equal('igc-icon-button'); }); - it('should have copy button with proper aria-label', async () => { + it('should have copy button with screen reader label', async () => { element = await fixture( html`Text` ); - copyButton = await getButton(); - expect(copyButton.getAttribute('aria-label')).to.equal( - 'Copy content to clipboard' - ); + copyButton = getButton(); + expect(copyButton.textContent.trim()).to.equal('Copy content to clipboard'); }); describe('User Interaction', () => { @@ -93,7 +91,7 @@ describe('Copy Content', () => { element = await fixture( html`Sample text` ); - copyButton = await getButton(); + copyButton = getButton(); }); it('should show copy button on pointer enter', async () => { @@ -139,6 +137,22 @@ describe('Copy Content', () => { expect(copyButton.part.contains('visible')).to.be.false; }); + + it('should not show copy button when disableInteraction is true', async () => { + element.disableInteraction = true; + await elementUpdated(element); + + simulatePointerEnter(element); + await elementUpdated(element); + + expect(copyButton.part.contains('visible')).to.be.false; + expect(copyButton.getAttribute('tabindex')).to.equal('-1'); + + element.dispatchEvent(new FocusEvent('focusin')); + await elementUpdated(element); + + expect(copyButton.part.contains('visible')).to.be.false; + }); }); describe('Copy Functionality', () => { @@ -148,7 +162,7 @@ describe('Copy Content', () => { html`${text}` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -163,7 +177,7 @@ describe('Copy Content', () => { ` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -192,7 +206,7 @@ Line 2
` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -211,7 +225,7 @@ Line 2` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -226,7 +240,7 @@ Line 2` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -239,7 +253,7 @@ Line 2 ` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -257,7 +271,7 @@ Line 2Text` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); // Wait for the async handler to settle @@ -281,7 +295,7 @@ Line 2` ); - copyButton = await getButton(); + copyButton = getButton(); const slot = copyButton.querySelector('slot[name="copy-icon"]'); expect(slot).to.exist; @@ -297,7 +311,7 @@ Line 2Text with spaces` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -309,7 +323,7 @@ Line 2Text with tabs` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -328,7 +342,7 @@ Line 3` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -341,7 +355,7 @@ Line 3 Trimmed content ` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -365,7 +379,7 @@ Third line` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -404,7 +418,7 @@ Third line` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -420,7 +434,7 @@ Third line` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); @@ -441,7 +455,7 @@ Third line` ); - copyButton = await getButton(); + copyButton = getButton(); simulateClick(copyButton); await nextFrame(); diff --git a/src/components/copy-to-clipboard/copy-to-clipboard.ts b/src/components/copy-to-clipboard/copy-to-clipboard.ts index 967e18996b..e437320a5a 100644 --- a/src/components/copy-to-clipboard/copy-to-clipboard.ts +++ b/src/components/copy-to-clipboard/copy-to-clipboard.ts @@ -1,12 +1,20 @@ -import { css, html, LitElement } from 'lit'; +import { + css, + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; import { property, state } from 'lit/decorators.js'; import IgcIconButtonComponent from '../button/icon-button.js'; +import { addCommandController } from '../common/controllers/command.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { shadowOptions } from '../common/decorators/shadow-options.js'; import { registerComponent } from '../common/definitions/register.js'; import { partMap } from '../common/part-map.js'; import { addSafeEventListener, bindIf } from '../common/util.js'; import type { CopyFormat } from '../types.js'; +import IgcVisuallyHiddenComponent from '../visually-hidden/visually-hidden.js'; // Regex patterns for normalizing whitespace in copied content /** Matches multiple consecutive spaces or tabs */ @@ -32,6 +40,11 @@ const MULTIPLE_NEWLINES = /\n+/g; * visual structure of the content: paragraph breaks, indentation in `
`
  *   blocks, etc. Ideal for code snippets or structured content.
  *
+ * It also supports disabling user interaction via the `disable-interaction` attribute,
+ * which prevents the copy button from appearing and disables its functionality.
+ *
+ * It is integrated with the Invoker Commands API, allowing the copy action to be triggered programmatically through commands.
+ *
  * @slot - The content to be displayed and copied. Accepts any HTML.
  * @slot copy-icon - Overrides the default copy icon inside the copy button.
  *
@@ -71,6 +84,25 @@ const MULTIPLE_NEWLINES = /\n+/g;
  *   
const x = 42;
* * ``` + * + * @example + * ```html + * + * + *

No default copy button is shown on hover or focus.

+ *
+ * ``` + * + * @example + * ```html + * + * + *

Some text to copy.

+ *
+ * + * ``` */ @shadowOptions({ delegatesFocus: true }) export default class IgcCopyToClipboardComponent extends LitElement { @@ -100,7 +132,11 @@ export default class IgcCopyToClipboardComponent extends LitElement { /* blazorSuppress */ public static register(): void { - registerComponent(IgcCopyToClipboardComponent, IgcIconButtonComponent); + registerComponent( + IgcCopyToClipboardComponent, + IgcIconButtonComponent, + IgcVisuallyHiddenComponent + ); } //#region Internal state and properties @@ -116,6 +152,15 @@ export default class IgcCopyToClipboardComponent extends LitElement { //#region Public properties and attributes + /** + * Disables the copy button and prevents it from appearing on hover or focus. + * + * @attr disable-interaction + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'disable-interaction' }) + public disableInteraction = false; + /** * Controls how the text content is formatted when copied to the clipboard. * - `plain`: Normalizes all whitespace into a flat body of text (default). @@ -132,12 +177,22 @@ export default class IgcCopyToClipboardComponent extends LitElement { constructor() { super(); + addCommandController(this).set('--copy', this._handleClick); + addSafeEventListener(this, 'pointerenter', this._setUserInteraction); addSafeEventListener(this, 'pointerleave', this._unsetUserInteraction); addSafeEventListener(this, 'focusin', this._setUserInteraction); addSafeEventListener(this, 'focusout', this._unsetUserInteraction); } + protected override update(properties: PropertyValues): void { + if (properties.has('disableInteraction') && this.disableInteraction) { + this._hasUserInteraction = false; + } + + super.update(properties); + } + //#region Event handlers private async _handleClick(): Promise { @@ -149,10 +204,12 @@ export default class IgcCopyToClipboardComponent extends LitElement { } private _setUserInteraction(): void { + if (this.disableInteraction) return; this._hasUserInteraction = true; } private _unsetUserInteraction(): void { + if (this.disableInteraction) return; this._hasUserInteraction = false; } @@ -183,27 +240,31 @@ export default class IgcCopyToClipboardComponent extends LitElement { //#endregion - protected _renderButton() { + protected _renderButton(): TemplateResult { + const hasInteraction = this._hasUserInteraction; + const iconName = bindIf( + !this._slots.hasAssignedNodes('copy-icon'), + 'copy_content' + ); + const parts = partMap({ + 'copy-button': true, + visible: hasInteraction, + }); + return html` + Copy content to clipboard `; } - protected override render() { + protected override render(): TemplateResult { return html`${this._renderButton()}`; } } diff --git a/stories/copy-to-clipboard.stories.ts b/stories/copy-to-clipboard.stories.ts index f98902ddb2..139db2ef49 100644 --- a/stories/copy-to-clipboard.stories.ts +++ b/stories/copy-to-clipboard.stories.ts @@ -2,13 +2,18 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { html } from 'lit'; import { + IgcButtonComponent, IgcCopyToClipboardComponent, IgcTextareaComponent, defineComponents, } from 'igniteui-webcomponents'; import { disableStoryControls } from './story.js'; -defineComponents(IgcCopyToClipboardComponent, IgcTextareaComponent); +defineComponents( + IgcButtonComponent, + IgcCopyToClipboardComponent, + IgcTextareaComponent +); // region default const metadata: Meta = { title: 'CopyToClipboard', @@ -22,8 +27,15 @@ const metadata: Meta = { }, }, argTypes: { + disableInteraction: { + type: 'boolean', + description: + 'Disables the copy button and prevents it from appearing on hover or focus.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, format: { - type: 'string', + type: '"plain" | "preserve"', description: 'Controls how the text content is formatted when copied to the clipboard.\n- `plain`: Normalizes all whitespace into a flat body of text (default).\n- `preserve`: Retains the visual structure such as paragraphs and code indentation.', options: ['plain', 'preserve'], @@ -31,12 +43,14 @@ const metadata: Meta = { table: { defaultValue: { summary: 'plain' } }, }, }, - args: { format: 'plain' }, + args: { disableInteraction: false, format: 'plain' }, }; export default metadata; interface IgcCopyToClipboardArgs { + /** Disables the copy button and prevents it from appearing on hover or focus. */ + disableInteraction: boolean; /** * Controls how the text content is formatted when copied to the clipboard. * - `plain`: Normalizes all whitespace into a flat body of text (default). @@ -66,7 +80,10 @@ export const Default: Story = {
- +

This is some text that can be copied to the clipboard.

@@ -245,7 +262,10 @@ export const WithCustomIcon: Story = {

- + `, }; + +export const ExternalCommandCopyStory: Story = { + argTypes: disableStoryControls(metadata), + render: (args) => html` + + +
+ +
+

+ This story demonstrates using an external button to trigger the copy + action. The component's copy button is disabled and hidden, but the + content can still be copied by clicking the external button below + using the Invoker Commands API. +

+

Click the button below to copy the content above.

+
+
+ + Copy paragraphs above + + +
+ `, +}; From ced583067ceecb04d437ee1366af88d8c6e78ef6 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 24 Jun 2026 19:49:17 +0300 Subject: [PATCH 3/3] feat: add slots for copy-to-clipboard icons --- .../copy-to-clipboard.spec.ts | 4 +- .../copy-to-clipboard/copy-to-clipboard.ts | 74 ++++++++++++++++--- stories/copy-to-clipboard.stories.ts | 14 ++++ 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts b/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts index acc7cd3532..837237db94 100644 --- a/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts +++ b/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts @@ -83,7 +83,9 @@ describe('Copy Content', () => { ); copyButton = getButton(); - expect(copyButton.textContent.trim()).to.equal('Copy content to clipboard'); + expect(copyButton.textContent.trim()).to.equal( + 'Copy content to clipboard. Click to copy.' + ); }); describe('User Interaction', () => { diff --git a/src/components/copy-to-clipboard/copy-to-clipboard.ts b/src/components/copy-to-clipboard/copy-to-clipboard.ts index e437320a5a..6600ab8541 100644 --- a/src/components/copy-to-clipboard/copy-to-clipboard.ts +++ b/src/components/copy-to-clipboard/copy-to-clipboard.ts @@ -6,6 +6,7 @@ import { type TemplateResult, } from 'lit'; import { property, state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import { addCommandController } from '../common/controllers/command.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; @@ -47,8 +48,12 @@ const MULTIPLE_NEWLINES = /\n+/g; * * @slot - The content to be displayed and copied. Accepts any HTML. * @slot copy-icon - Overrides the default copy icon inside the copy button. + * @slot success-icon - Overrides the default success icon shown after a successful copy. + * @slot error-icon - Overrides the default error icon shown if the copy action fails. * * @csspart copy-button - The copy icon-button positioned over the slotted content. + * @csspart success-button - The icon displayed when the copy action succeeds. + * @csspart error-button - The icon displayed when the copy action fails. * * @example * ```html @@ -113,7 +118,9 @@ export default class IgcCopyToClipboardComponent extends LitElement { position: relative; } - [part~='copy-button'] { + [part~='copy-button'], + [part~='success-button'], + [part~='error-button'] { position: absolute; opacity: 0; pointer-events: none; @@ -130,6 +137,12 @@ export default class IgcCopyToClipboardComponent extends LitElement { } `; + private static readonly _statusIcons = { + copy: 'copy_content', + success: 'attach_document', + error: 'thumb_down_active', + } as const; + /* blazorSuppress */ public static register(): void { registerComponent( @@ -142,9 +155,12 @@ export default class IgcCopyToClipboardComponent extends LitElement { //#region Internal state and properties private readonly _slots = addSlotController(this, { - slots: setSlots('copy-icon'), + slots: setSlots('copy-icon', 'success-icon', 'error-icon'), }); + @state() + private _copyStatus: 'copy' | 'success' | 'error' = 'copy'; + @state() private _hasUserInteraction = false; @@ -198,8 +214,14 @@ export default class IgcCopyToClipboardComponent extends LitElement { private async _handleClick(): Promise { try { await navigator.clipboard.writeText(this._getContentToCopy()); + this._copyStatus = 'success'; + await new Promise((resolve) => setTimeout(resolve, 1000)); + this._copyStatus = 'copy'; } catch { // Clipboard API unavailable or permission denied — fail gracefully. + this._copyStatus = 'error'; + await new Promise((resolve) => setTimeout(resolve, 1000)); + this._copyStatus = 'copy'; } } @@ -240,14 +262,18 @@ export default class IgcCopyToClipboardComponent extends LitElement { //#endregion - protected _renderButton(): TemplateResult { + protected _renderButton( + status: 'copy' | 'success' | 'error', + message: string + ): TemplateResult { const hasInteraction = this._hasUserInteraction; + const iconName = bindIf( - !this._slots.hasAssignedNodes('copy-icon'), - 'copy_content' + !this._slots.hasAssignedNodes(`${status}-icon`), + IgcCopyToClipboardComponent._statusIcons[status] ); const parts = partMap({ - 'copy-button': true, + [`${status}-button`]: true, visible: hasInteraction, }); @@ -255,17 +281,45 @@ export default class IgcCopyToClipboardComponent extends LitElement { - Copy content to clipboard - + ${message} + `; } protected override render(): TemplateResult { - return html`${this._renderButton()}`; + return html` + + ${choose(this._copyStatus, [ + [ + 'copy', + () => + this._renderButton( + 'copy', + 'Copy content to clipboard. Click to copy.' + ), + ], + [ + 'success', + () => + this._renderButton( + 'success', + 'Content copied to clipboard successfully.' + ), + ], + [ + 'error', + () => + this._renderButton( + 'error', + 'Failed to copy content to clipboard. Please try again.' + ), + ], + ])} + `; } } diff --git a/stories/copy-to-clipboard.stories.ts b/stories/copy-to-clipboard.stories.ts index 139db2ef49..c3501ede8e 100644 --- a/stories/copy-to-clipboard.stories.ts +++ b/stories/copy-to-clipboard.stories.ts @@ -284,6 +284,20 @@ export const WithCustomIcon: Story = { + + +

This story uses a custom SVG icon for the copy button.

Paste into the field below to see the copied content.