diff --git a/docs/pages/components/clipboard.md b/docs/pages/components/clipboard.md new file mode 100644 index 00000000..08e007db --- /dev/null +++ b/docs/pages/components/clipboard.md @@ -0,0 +1,241 @@ +--- +meta: + title: Clipboard + description: Enables you to save content into the clipboard providing visual feedback. +layout: component +--- + +```html:preview +

Clicking the clipboard button will put "shoelace rocks" into your clipboard

+ +``` + +```jsx:react +import { SlClipboard } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + <> +

Clicking the clipboard button will put "shoelace rocks" into your clipboard

+ + +); +``` + +## Examples + +### Use your own button + +```html:preview + + + + + +
+ + Copy + Copied + Error + +``` + +```jsx:react +import { SlClipboard } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + <> + + +
copied
+ +
+ + Copy + Copied + Error + + +); +``` + +### Get the textValue from a different element + +```html:preview +
+
+
Phone Number
+
+1 234 456789
+
+ +
+ + +``` + +```jsx:react +import { SlClipboard } from '@shoelace-style/shoelace/dist/react'; + +const css = ` + dl, .row { + display: flex; + margin: 0; + } +`; + +const App = () => ( + <> +
+
+
Phone Number
+
+1 234 456789
+
+ +
+ + + +); +``` + +### Copy an input/textarea or link + +```html:preview + + +
+ + + +
+ +Shoelace + +
+ + + +
+ + + +``` + +```jsx:react +import { SlClipboard } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + <> + + +
+ + +
+ Shoelace + + +); +``` + +### Error if copy fails + +For example if a `for` target element is not found or if not using `https`. +An empty string value like `value=""` will also result in an error. + +```html:preview + +
+ + Copy + Copied + Error + +``` + +```jsx:react +import { SlClipboard } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + <> + + + Copy + Copied + Error + + +); +``` + +### Change duration of reset to copy button + +```html:preview + +``` + +```jsx:react +import { SlClipboard } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + <> + + +); +``` + +### Supports Shadow Dom + +```html:preview + + + +``` + +```jsx:react +import { SlClipboard } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + <> + + +); + +customElements.define('sl-copy-demo-el', class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot.innerHTML = ` +

copy me (inside shadow root)

+ + `; + } +}); +``` + +## Disclaimer + +The public API is partially inspired by https://github.com/github/clipboard-copy-element diff --git a/src/components/clipboard/clipboard.component.ts b/src/components/clipboard/clipboard.component.ts new file mode 100644 index 00000000..05710202 --- /dev/null +++ b/src/components/clipboard/clipboard.component.ts @@ -0,0 +1,116 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import SlIconButton from '../icon-button/icon-button.component.js'; +import SlTooltip from '../tooltip/tooltip.component.js'; +import styles from './clipboard.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Enables you to save content into the clipboard providing visual feedback. + * @documentation https://shoelace.style/components/clipboard + * @status experimental + * @since 2.0 + * + * @dependency sl-icon-button + * @dependency sl-tooltip + * + * @event sl-copying - Event when copying starts. + * @event sl-copied - Event when copying finished. + * + * @slot - The content that gets clicked to copy. + * @slot copied - The content shown after a successful copy. + * @slot error - The content shown if an error occurs. + */ +export default class SlClipboard extends ShoelaceElement { + static styles: CSSResultGroup = styles; + static dependencies = { 'sl-tooltip': SlTooltip, 'sl-icon-button': SlIconButton }; + + /** + * Indicates the current status the copy action is in. + */ + @property({ type: String }) copyStatus: 'trigger' | 'copied' | 'error' = 'trigger'; + + /** Value to copy. */ + @property({ type: String }) value = ''; + + /** Id of the element to copy the text value from. */ + @property({ type: String }) for = ''; + + /** Duration in milliseconds to reset to the trigger state. */ + @property({ type: Number, attribute: 'reset-timeout' }) resetTimeout = 2000; + + private handleClick() { + if (this.copyStatus === 'copied') return; + this.copy(); + } + + /** Copies the clipboard */ + async copy() { + if (this.for) { + const root = this.getRootNode() as ShadowRoot | Document; + const target = 'getElementById' in root ? root.getElementById(this.for) : false; + if (target) { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + this.value = target.value; + } else if (target instanceof HTMLAnchorElement && target.hasAttribute('href')) { + this.value = target.href; + } else if ('value' in target) { + this.value = String(target.value); + } else { + this.value = target.textContent || ''; + } + } + } + if (this.value) { + try { + this.emit('sl-copying'); + await navigator.clipboard.writeText(this.value); + this.emit('sl-copied'); + this.copyStatus = 'copied'; + } catch (error) { + this.copyStatus = 'error'; + } + } else { + this.copyStatus = 'error'; + } + + setTimeout(() => (this.copyStatus = 'trigger'), this.resetTimeout); + } + + render() { + return html` +
+ + + + + + + + + + + + + + + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-clipboard': SlClipboard; + } +} diff --git a/src/components/clipboard/clipboard.styles.ts b/src/components/clipboard/clipboard.styles.ts new file mode 100644 index 00000000..e99391b5 --- /dev/null +++ b/src/components/clipboard/clipboard.styles.ts @@ -0,0 +1,54 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles.js'; + +export default css` + ${componentStyles} + + :host { + display: inline-block; + } + + /* successful copy */ + slot[name='copied'] { + display: none; + } + .clipboard--copied #default { + display: none; + } + .clipboard--copied slot[name='copied'] { + display: block; + } + + .green::part(base) { + color: var(--sl-color-success-600); + } + .green::part(base):hover, + .green::part(base):focus { + color: var(--sl-color-success-600); + } + .green::part(base):active { + color: var(--sl-color-success-600); + } + + /* failed to copy */ + slot[name='error'] { + display: none; + } + .clipboard--error #default { + display: none; + } + .clipboard--error slot[name='error'] { + display: block; + } + + .red::part(base) { + color: var(--sl-color-danger-600); + } + .red::part(base):hover, + .red::part(base):focus { + color: var(--sl-color-danger-600); + } + .red::part(base):active { + color: var(--sl-color-danger-600); + } +`; diff --git a/src/components/clipboard/clipboard.test.ts b/src/components/clipboard/clipboard.test.ts new file mode 100644 index 00000000..86093ff4 --- /dev/null +++ b/src/components/clipboard/clipboard.test.ts @@ -0,0 +1,29 @@ +import '../../../dist/shoelace.js'; +import { aTimeout, expect, fixture, html } from '@open-wc/testing'; +import type SlClipboard from './clipboard.js'; + +describe('', () => { + let el: SlClipboard; + + describe('when provided no parameters', () => { + before(async () => { + el = await fixture(html` `); + }); + + it('should pass accessibility tests', async () => { + await expect(el).to.be.accessible(); + }); + + it('should initially be in the trigger status', () => { + expect(el.copyStatus).to.equal('trigger'); + }); + + it('should reset copyStatus after 2 seconds', async () => { + expect(el.copyStatus).to.equal('trigger'); + await el.copy(); // this will result in an error as copy needs to always be called from a user action + expect(el.copyStatus).to.equal('error'); + await aTimeout(2100); + expect(el.copyStatus).to.equal('trigger'); + }); + }); +}); diff --git a/src/components/clipboard/clipboard.ts b/src/components/clipboard/clipboard.ts new file mode 100644 index 00000000..390f5940 --- /dev/null +++ b/src/components/clipboard/clipboard.ts @@ -0,0 +1,4 @@ +import SlClipboard from './clipboard.component.js'; +export * from './clipboard.component.js'; +export default SlClipboard; +SlClipboard.define('sl-clipboard'); diff --git a/src/components/tree-item/tree-item.component.ts b/src/components/tree-item/tree-item.component.ts index 2fed06ce..076b009b 100644 --- a/src/components/tree-item/tree-item.component.ts +++ b/src/components/tree-item/tree-item.component.ts @@ -12,7 +12,7 @@ import SlCheckbox from '../checkbox/checkbox.component.js'; import SlIcon from '../icon/icon.component.js'; import SlSpinner from '../spinner/spinner.component.js'; import styles from './tree-item.styles.js'; -import type { CSSResultGroup, PropertyValueMap } from 'lit'; +import type { CSSResultGroup, PropertyValues } from 'lit'; /** * @summary A tree item serves as a hierarchical node that lives inside a [tree](/components/tree). @@ -139,7 +139,7 @@ export default class SlTreeItem extends ShoelaceElement { this.isLeaf = !this.lazy && this.getChildrenItems().length === 0; } - protected willUpdate(changedProperties: PropertyValueMap | Map) { + protected willUpdate(changedProperties: PropertyValues | Map) { if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) { this.indeterminate = false; } diff --git a/src/events/events.ts b/src/events/events.ts index 913ee987..322b9c8d 100644 --- a/src/events/events.ts +++ b/src/events/events.ts @@ -8,6 +8,8 @@ export type { default as SlChangeEvent } from './sl-change'; export type { default as SlClearEvent } from './sl-clear'; export type { default as SlCloseEvent } from './sl-close'; export type { default as SlCollapseEvent } from './sl-collapse'; +export type { SlCopyingEvent } from './sl-copying'; +export type { SlCopiedEvent } from './sl-copied'; export type { default as SlErrorEvent } from './sl-error'; export type { default as SlExpandEvent } from './sl-expand'; export type { default as SlFinishEvent } from './sl-finish'; diff --git a/src/events/sl-copied.ts b/src/events/sl-copied.ts new file mode 100644 index 00000000..6293ba8e --- /dev/null +++ b/src/events/sl-copied.ts @@ -0,0 +1,7 @@ +export type SlCopiedEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-copied': SlCopiedEvent; + } +} diff --git a/src/events/sl-copying.ts b/src/events/sl-copying.ts new file mode 100644 index 00000000..33ad22ad --- /dev/null +++ b/src/events/sl-copying.ts @@ -0,0 +1,7 @@ +export type SlCopyingEvent = CustomEvent>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-copying': SlCopyingEvent; + } +} diff --git a/src/shoelace.ts b/src/shoelace.ts index 1fff365f..69c2e647 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -12,6 +12,7 @@ export { default as SlCard } from './components/card/card.js'; export { default as SlCarousel } from './components/carousel/carousel.js'; export { default as SlCarouselItem } from './components/carousel-item/carousel-item.js'; export { default as SlCheckbox } from './components/checkbox/checkbox.js'; +export { default as SlClipboard } from './components/clipboard/clipboard.js'; export { default as SlColorPicker } from './components/color-picker/color-picker.js'; export { default as SlDetails } from './components/details/details.js'; export { default as SlDialog } from './components/dialog/dialog.js';