diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6f3648b..0f5449fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- #### Copy to clipboard + - New `igc-copy-to-clipboard` component. It overlays a copy button on its slotted content and writes the rendered text to the clipboard. The button appears on hover or when focus enters the component. + - `format` selects the serialization: `plain` (default) collapses whitespace into a single body of text, and `preserve` keeps paragraph breaks and code indentation. + - `disable-interaction` hides the button. The `--copy` command from the Invoker Commands API still triggers the copy action. + - The `copy-icon`, `success-icon` and `error-icon` slots replace the default icons. A visually hidden live region announces the result of the copy action. - #### QR code - `toBlob()` serializes the rendered code to an `image/svg+xml` blob. Theme colors become plain `fill` attributes and a logo that is not a data URI is fetched and inlined, so the output renders the same outside the component. - `toImage(options)` exports the code as a `File` in `svg`, `png`, `jpeg` or `webp` format. The `scale` option multiplies the component `size`, thus a 256px code with `scale: 2` gives a 512x512 image. Set `download: true` to open the browser download dialog. The `QrCodeExportFormat` and `QrCodeExportOptions` types are exported from the package entry point. 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 000000000..d3c373a89 --- /dev/null +++ b/src/components/copy-to-clipboard/copy-to-clipboard.spec.ts @@ -0,0 +1,523 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; +import { type SinonFakeTimers, stub, useFakeTimers } from 'sinon'; +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + simulateClick, + simulatePointerEnter, + simulatePointerLeave, +} from '#internals/testing/simulate.spec.js'; +import IgcCopyToClipboardComponent from './copy-to-clipboard.js'; + +describe('Copy to clipboard', () => { + before(() => { + defineComponents(IgcCopyToClipboardComponent); + }); + + let element: IgcCopyToClipboardComponent; + let copyButton: HTMLElement; + let writeTextStub: ReturnType; + + function getButton() { + return element.renderRoot.querySelector('igc-icon-button') as HTMLElement; + } + + function getLiveRegion() { + return element.renderRoot.querySelector('[role="status"]') as HTMLElement; + } + + /** Clicks the copy button and waits for the clipboard write to settle. */ + async function clickCopy() { + simulateClick(getButton()); + await nextFrame(); + } + + /** Renders `template`, copies its content and returns the text written to the clipboard. */ + async function copy(template: ReturnType) { + element = await fixture(template); + await clickCopy(); + return writeTextStub.firstCall.args[0] as string; + } + + function dispatchCopyCommand() { + element.dispatchEvent( + Object.assign(new Event('command'), { command: '--copy' }) + ); + } + + beforeEach(() => { + 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 = getButton(); + expect(copyButton).to.exist; + expect(copyButton.tagName.toLowerCase()).to.equal('igc-icon-button'); + }); + + it('should have copy button with screen reader label', async () => { + element = await fixture( + html`Text` + ); + + copyButton = getButton(); + expect(copyButton.textContent.trim()).to.equal( + 'Copy content to clipboard. Click to copy.' + ); + }); + + describe('User Interaction', () => { + beforeEach(async () => { + element = await fixture( + html`Sample text` + ); + copyButton = getButton(); + }); + + it('should keep the copy button enabled while hidden', () => { + expect(copyButton.part.contains('visible')).to.be.false; + expect(copyButton).to.not.have.attribute('disabled'); + }); + + it('should show copy button on pointer enter', async () => { + simulatePointerEnter(element); + await elementUpdated(element); + + expect(copyButton.part.contains('visible')).to.be.true; + }); + + 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; + }); + + it('should show copy button on focus', async () => { + element.dispatchEvent(new FocusEvent('focusin')); + await elementUpdated(element); + + expect(copyButton.part.contains('visible')).to.be.true; + }); + + 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; + }); + + it('should hide copy button when disableInteraction is set while hovered', async () => { + simulatePointerEnter(element); + await elementUpdated(element); + expect(copyButton.part.contains('visible')).to.be.true; + + element.disableInteraction = true; + await elementUpdated(element); + + 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).to.have.attribute('disabled'); + + element.dispatchEvent(new FocusEvent('focusin')); + 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'; + const copied = await copy( + html`${text}` + ); + + expect(writeTextStub).to.have.been.calledOnce; + expect(copied).to.equal(text); + }); + + it('should copy text with normalized whitespace', async () => { + const copied = await copy( + html` + Text with multiple spaces and indentation + ` + ); + + expect(copied).to.equal('Text with multiple spaces and indentation'); + }); + + it('should strip excessive whitespace from multi-line content', async () => { + const copied = await copy( + html` +
+Line 1
+
+
+Line 2
+
` + ); + + expect(copied).to.equal('Line 1\nLine 2'); + }); + + it('should separate adjacent block elements in plain format', async () => { + const copied = await copy( + html`

First

+

Second

+
    +
  • a
  • +
  • b
  • +
` + ); + + expect(copied).to.equal('First\nSecond\na\nb'); + }); + + it('should not copy text of hidden elements', async () => { + const copied = await copy( + html` +

Visible

+ +
` + ); + + expect(copied).to.equal('Visible'); + }); + + it('should copy content from nested elements', async () => { + const copied = await copy( + html` +
First line
+
Second line
+
` + ); + + expect(copied).to.equal('First line\nSecond line'); + }); + + it('should handle empty content', async () => { + const copied = await copy( + html`` + ); + + expect(copied).to.equal(''); + }); + + it('should handle content with only whitespace', async () => { + const copied = await copy( + html` ` + ); + + expect(copied).to.equal(''); + }); + }); + + describe('Copy Status', () => { + let clock: SinonFakeTimers; + + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + element = await fixture( + html`Text` + ); + copyButton = getButton(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should show the success icon and announce the result', async () => { + expect(copyButton.part.contains('copy-button')).to.be.true; + expect(copyButton.getAttribute('name')).to.equal('copy_content'); + expect(getLiveRegion().textContent.trim()).to.equal(''); + + await clickCopy(); + + expect(copyButton.part.contains('success-button')).to.be.true; + expect(copyButton.getAttribute('name')).to.equal('copy_success'); + expect(getLiveRegion().textContent.trim()).to.equal( + 'Content copied to clipboard successfully.' + ); + }); + + it('should show the error icon and announce the failure', async () => { + writeTextStub.rejects(new Error('Permission denied')); + + await clickCopy(); + + expect(writeTextStub).to.have.been.calledOnce; + expect(copyButton.part.contains('error-button')).to.be.true; + expect(copyButton.getAttribute('name')).to.equal('error'); + expect(getLiveRegion().textContent.trim()).to.equal( + 'Failed to copy content to clipboard. Please try again.' + ); + }); + + it('should keep the button label while the status changes', async () => { + await clickCopy(); + + expect(copyButton.textContent.trim()).to.equal( + 'Copy content to clipboard. Click to copy.' + ); + }); + + it('should return to the copy icon after the reset delay', async () => { + await clickCopy(); + expect(copyButton.part.contains('success-button')).to.be.true; + + await clock.tickAsync(1000); + await elementUpdated(element); + + expect(copyButton.part.contains('copy-button')).to.be.true; + expect(getLiveRegion().textContent.trim()).to.equal(''); + }); + + it('should restart the reset delay when a copy action repeats', async () => { + await clickCopy(); + await clock.tickAsync(800); + + dispatchCopyCommand(); + await nextFrame(); + await clock.tickAsync(400); + await elementUpdated(element); + + expect(copyButton.part.contains('success-button')).to.be.true; + + await clock.tickAsync(600); + await elementUpdated(element); + + expect(copyButton.part.contains('copy-button')).to.be.true; + }); + + it('should copy through the --copy command when interaction is disabled', async () => { + element.disableInteraction = true; + await elementUpdated(element); + + dispatchCopyCommand(); + await nextFrame(); + + expect(writeTextStub).to.have.been.calledOnceWith('Text'); + }); + }); + + describe('Custom Icon Slot', () => { + it('should support custom copy icon', async () => { + element = await fixture( + html` + Text + + + + ` + ); + + copyButton = 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'); + expect(copyButton).to.not.have.attribute('name'); + }); + + it('should not copy the content of the icon slots', async () => { + const copied = await copy( + html` + Copy! +

Body

+
` + ); + + expect(copied).to.equal('Body'); + }); + + it('should not copy the content of the icon slots in preserve format', async () => { + const copied = await copy( + html` + Copy! +

First

+

Second

+
` + ); + + expect(copied).to.equal('First\n\nSecond'); + }); + }); + + describe('Whitespace Normalization', () => { + it('should collapse multiple spaces to single space', async () => { + const copied = await copy( + html`Text with spaces` + ); + + expect(copied).to.equal('Text with spaces'); + }); + + it('should collapse tabs to single space', async () => { + const copied = await copy( + html`Text with tabs` + ); + + expect(copied).to.equal('Text with tabs'); + }); + + it('should preserve single newlines', async () => { + const copied = await copy( + html` +
+Line 1
+Line 2
+Line 3
+
` + ); + + expect(copied).to.equal('Line 1\nLine 2\nLine 3'); + }); + + it('should trim leading and trailing whitespace', async () => { + const copied = await copy( + html` Trimmed content ` + ); + + expect(copied).to.equal('Trimmed content'); + }); + + it('should handle mixed whitespace scenarios', async () => { + const copied = await copy( + html` +
+First    line   with   spaces
+
+
+Second		line	with	tabs
+
+Third line
+
` + ); + + expect(copied).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 () => { + const copied = await copy( + html` +

First paragraph

+

Second paragraph

+
` + ); + + expect(copied).to.equal('First paragraph\nSecond paragraph'); + }); + + it('preserve format retains paragraph structure', async () => { + const copied = await copy( + html` +

First paragraph

+

Second paragraph

+
` + ); + + expect(copied).to.equal('First paragraph\n\nSecond paragraph'); + }); + + it('preserve format retains code block indentation', async () => { + const copied = await copy( + html` +
+  function hello() {
+    return 1;
+  }
+
` + ); + + expect(copied).to.equal(' function hello() {\n return 1;\n }'); + }); + }); +}); 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 000000000..9116318f8 --- /dev/null +++ b/src/components/copy-to-clipboard/copy-to-clipboard.ts @@ -0,0 +1,284 @@ +import { html, LitElement, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { addCommandController } from '#internals/controllers/command.js'; +import { addSlotController, setSlots } from '#internals/controllers/slot.js'; +import { shadowOptions } from '#internals/decorators/shadow-options.js'; +import { registerComponent } from '#internals/definitions/register.js'; +import { partMap } from '#internals/part-map.js'; +import { createTimer } from '#internals/timing.js'; +import { addSafeEventListener } from '#internals/utils/events.js'; +import { bindIf } from '#internals/utils/lit.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import type { CopyFormat } from '../types.js'; +import IgcVisuallyHiddenComponent from '../visually-hidden/visually-hidden.js'; +import { styles } from './themes/copy-to-clipboard.base.css.js'; + +type CopyStatus = 'copy' | 'success' | 'error'; + +/** Time in milliseconds the success/error icon stays before the copy icon returns. */ +const STATUS_RESET_DELAY = 1000; + +const BUTTON_LABEL = 'Copy content to clipboard. Click to copy.'; + +/** Events that show (enter/focusin) or hide (leave/focusout) the copy button. */ +const INTERACTION_EVENTS = [ + 'pointerenter', + 'pointerleave', + 'focusin', + 'focusout', +] as const; + +const STATUS_ICONS: Record = { + copy: 'copy_content', + success: 'copy_success', + error: 'error', +}; + +/** Messages for the live region. The default state announces nothing. */ +const STATUS_MESSAGES: Record = { + copy: '', + success: 'Content copied to clipboard successfully.', + error: 'Failed to copy content to clipboard. Please try again.', +}; + +/** + * 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 button itself is part + * of the tab sequence, so keyboard users reach it even when the slotted content + * has no focusable element. + * + * Both `format` values read the rendered text of the content (the `innerText` + * algorithm), so hidden elements and custom icons are never copied. + * + * The copy action can also be triggered through the `--copy` command of the + * Invoker Commands API, which works even when `disable-interaction` is set. + * The result of every copy action is announced through a visually hidden live region. + * + * @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 button - The icon-button positioned over the slotted content. + * @csspart copy-button - The icon-button while it shows the copy icon. + * @csspart success-button - The icon-button while it shows the success icon after a copy action succeeds. + * @csspart error-button - The icon-button while it shows the error icon after a copy action fails. + * @csspart visible - Applied to the icon-button while it is shown on hover or focus. + * + * @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 + * + * + *

Some text to copy.

+ *
+ * + * ``` + */ +@shadowOptions({ delegatesFocus: true }) +export default class IgcCopyToClipboardComponent extends LitElement { + public static readonly tagName = 'igc-copy-to-clipboard'; + public static override styles = styles; + + /* blazorSuppress */ + public static register(): void { + registerComponent( + IgcCopyToClipboardComponent, + IgcIconButtonComponent, + IgcVisuallyHiddenComponent + ); + } + + //#region Internal state and properties + + private readonly _slots = addSlotController(this, { + slots: setSlots('copy-icon', 'success-icon', 'error-icon'), + }); + + private readonly _resetTimer = createTimer(() => { + this._copyStatus = 'copy'; + }, STATUS_RESET_DELAY); + + @state() + private _copyStatus: CopyStatus = 'copy'; + + @state() + private _hasUserInteraction = false; + + //#endregion + + //#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`: Collapses whitespace into single spaces and block boundaries into single newlines (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(); + + addCommandController(this).set('--copy', this._handleClick); + + for (const type of INTERACTION_EVENTS) { + addSafeEventListener(this, type, this._handleInteraction); + } + } + + //#region Lit lifecycle + + public override disconnectedCallback(): void { + this._resetTimer.stop(); + super.disconnectedCallback(); + } + + //#endregion + + //#region Event handlers + + private async _handleClick(): Promise { + let status: CopyStatus = 'success'; + + try { + await navigator.clipboard.writeText(this._getContentToCopy()); + } catch { + // Clipboard API unavailable or permission denied — fail gracefully. + status = 'error'; + } + + this._copyStatus = status; + this._resetTimer.start(); + } + + private _handleInteraction(event: Event): void { + this._hasUserInteraction = + event.type === 'pointerenter' || event.type === 'focusin'; + } + + //#endregion + + //#region Internal API + + private _getContentToCopy(): string { + const text = this._getRenderedText(); + + return this.format === 'preserve' + ? text.replaceAll(/^\n+|\n+$/g, '') + : text + .replaceAll(/[ \t]+/g, ' ') + .replaceAll(/[ \t]*\n[ \t]*/g, '\n') + .replaceAll(/\n+/g, '\n') + .trim(); + } + + /** + * Returns the rendered text of the slotted content. + * + * `innerText` walks the light DOM, so content assigned to the icon slot + * would be included as well. The slot is hidden for the duration of + * the read, which removes its assigned nodes from the rendered text. + */ + private _getRenderedText(): string { + const iconSlot = + this.renderRoot.querySelector('slot[name]'); + + if (iconSlot) { + iconSlot.hidden = true; + } + + try { + return this.innerText; + } finally { + if (iconSlot) { + iconSlot.hidden = false; + } + } + } + + //#endregion + + protected override render(): TemplateResult { + const status = this._copyStatus; + const parts = partMap({ + button: true, + [`${status}-button`]: true, + visible: this._hasUserInteraction && !this.disableInteraction, + }); + const iconName = bindIf( + !this._slots.hasAssignedNodes(`${status}-icon`), + STATUS_ICONS[status] + ); + + return html` + + + ${BUTTON_LABEL} + + + + ${STATUS_MESSAGES[status]} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-copy-to-clipboard': IgcCopyToClipboardComponent; + } +} diff --git a/src/components/copy-to-clipboard/themes/copy-to-clipboard.base.scss b/src/components/copy-to-clipboard/themes/copy-to-clipboard.base.scss new file mode 100644 index 000000000..ed1bec65d --- /dev/null +++ b/src/components/copy-to-clipboard/themes/copy-to-clipboard.base.scss @@ -0,0 +1,26 @@ +@use 'styles/utilities' as *; + +:host { + display: block; + position: relative; +} + +[part~='button'] { + position: absolute; + inset-block-start: rem(4px); + inset-inline-end: rem(4px); + padding: rem(4px); + border-radius: rem(4px); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease-in-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +[part~='visible'] { + opacity: 1; + pointer-events: auto; +} diff --git a/src/components/icon/icon-references.ts b/src/components/icon/icon-references.ts index 11ba203b5..2dd50b7ed 100644 --- a/src/components/icon/icon-references.ts +++ b/src/components/icon/icon-references.ts @@ -70,6 +70,7 @@ const ICON_ALIASES: Record>> = { thumb_down_inactive: { default: 'thumb_down_empty' }, regenerate: { default: 'reload' }, copy_content: { default: 'copy' }, + copy_success: { default: 'chip_select', indigo: 'indigo_check' }, collapse: { default: 'keyboard_arrow_up', indigo: 'indigo_chevron_up' }, eye_dropper: { default: 'colorize' }, arrow_prev: { diff --git a/src/components/types.ts b/src/components/types.ts index 616ab243e..5abd5b9bf 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -22,6 +22,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 74ec9e7bf..96f050841 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ export { default as IgcStepComponent } from './components/stepper/step.js'; export { default as IgcHighlightComponent } from './components/highlight/highlight.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'; export { default as IgcQrCodeComponent } from './components/qr-code/qr-code.js'; export { default as IgcVirtualScrollComponent } from './components/virtualization/virtualization.js'; export type { diff --git a/src/internals/definitions/defineAllComponents.ts b/src/internals/definitions/defineAllComponents.ts index 0ffdca5c8..0187609bb 100644 --- a/src/internals/definitions/defineAllComponents.ts +++ b/src/internals/definitions/defineAllComponents.ts @@ -21,6 +21,7 @@ import IgcSwitchComponent from '../../components/checkbox/switch.js'; import IgcChipComponent from '../../components/chip/chip.js'; import IgcColorPickerComponent from '../../components/color-picker/color-picker.js'; import IgcComboComponent from '../../components/combo/combo.js'; +import IgcCopyToClipboardComponent from '../../components/copy-to-clipboard/copy-to-clipboard.js'; import IgcDatePickerComponent from '../../components/date-picker/date-picker.js'; import IgcDateRangePickerComponent from '../../components/date-range-picker/date-range-picker.js'; import IgcDateTimeInputComponent from '../../components/date-time-input/date-time-input.js'; @@ -100,6 +101,7 @@ const allComponents: IgniteComponent[] = [ IgcColorPickerComponent, IgcFileInputComponent, IgcComboComponent, + IgcCopyToClipboardComponent, IgcDatePickerComponent, IgcDateRangePickerComponent, IgcDropdownComponent, diff --git a/stories/copy-to-clipboard.stories.ts b/stories/copy-to-clipboard.stories.ts new file mode 100644 index 000000000..fbea45359 --- /dev/null +++ b/stories/copy-to-clipboard.stories.ts @@ -0,0 +1,366 @@ +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( + IgcButtonComponent, + 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: { + 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: { name: 'enum', value: ['plain', 'preserve'] }, + description: + 'Controls how the text content is formatted when copied to the clipboard.\n- `plain`: Collapses whitespace into single spaces and block boundaries into single newlines (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: { 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`: Collapses whitespace into single spaces and block boundaries into single newlines (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.

+
+
+ + +
+ `, +}; + +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 + + +
+ `, +};