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';