kopia lustrzana https://github.com/shoelace-style/shoelace
feat(clipboard): add new component sl-clipboard (#1473)
* feat(clipboard): add new component sl-clipboard * using slots * using a single copyStatus * feat(clipboard): support inputs/textarea/links and shadow dom * fix(clipboard): add area-live to announce copied * feat(clipboard): support any component with a value propertypull/1482/head
rodzic
75b2da9eab
commit
16f3e256b0
|
@ -0,0 +1,241 @@
|
|||
---
|
||||
meta:
|
||||
title: Clipboard
|
||||
description: Enables you to save content into the clipboard providing visual feedback.
|
||||
layout: component
|
||||
---
|
||||
|
||||
```html:preview
|
||||
<p>Clicking the clipboard button will put "shoelace rocks" into your clipboard</p>
|
||||
<sl-clipboard value="shoelace rocks"></sl-clipboard>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { SlClipboard } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<p>Clicking the clipboard button will put "shoelace rocks" into your clipboard</p>
|
||||
<SlClipboard value="shoelace rocks"></SlClipboard>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Use your own button
|
||||
|
||||
```html:preview
|
||||
<sl-clipboard value="shoelace rocks">
|
||||
<button type="button">Copy to clipboard</button>
|
||||
<button slot="copied">Copied</button>
|
||||
<button slot="error">Error</button>
|
||||
</sl-clipboard>
|
||||
<br>
|
||||
<sl-clipboard value="shoelace rocks">
|
||||
<sl-button>Copy</sl-button>
|
||||
<sl-button slot="copied">Copied</sl-button>
|
||||
<sl-button slot="error">Error</sl-button>
|
||||
</sl-clipboard>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { SlClipboard } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<SlClipboard value="shoelace rocks">
|
||||
<button type="button">Copy to clipboard</button>
|
||||
<div slot="copied">copied</div>
|
||||
<button slot="error">Error</button>
|
||||
</SlClipboard>
|
||||
<SlClipboard value="shoelace rocks">
|
||||
<sl-button>Copy</sl-button>
|
||||
<sl-button slot="copied">Copied</sl-button>
|
||||
<sl-button slot="error">Error</sl-button>
|
||||
</SlClipboard>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Get the textValue from a different element
|
||||
|
||||
```html:preview
|
||||
<div class="row">
|
||||
<dl>
|
||||
<dt>Phone Number</dt>
|
||||
<dd id="phone-value">+1 234 456789</dd>
|
||||
</dl>
|
||||
<sl-clipboard for="phone-value"></sl-clipboard>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
dl, .row {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { SlClipboard } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const css = `
|
||||
dl, .row {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<div class="row">
|
||||
<dl>
|
||||
<dt>Phone Number</dt>
|
||||
<dd id="phone-value">+1 234 456789</dd>
|
||||
</dl>
|
||||
<SlClipboard for="phone-value"></SlClipboard>
|
||||
</div>
|
||||
|
||||
<style>{css}</style>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Copy an input/textarea or link
|
||||
|
||||
```html:preview
|
||||
<input type="text" value="input rocks" id="input-rocks">
|
||||
<sl-clipboard for="input-rocks"></sl-clipboard>
|
||||
<br>
|
||||
|
||||
<textarea id="textarea-rocks">textarea
|
||||
rocks</textarea>
|
||||
<sl-clipboard for="textarea-rocks"></sl-clipboard>
|
||||
<br>
|
||||
|
||||
<a href="https://shoelace.style/" id="link-rocks">Shoelace</a>
|
||||
<sl-clipboard for="link-rocks"></sl-clipboard>
|
||||
<br>
|
||||
|
||||
<sl-input value="sl-input rocks" id="sl-input-rocks"></sl-input>
|
||||
<sl-clipboard for="sl-input-rocks"></sl-clipboard>
|
||||
<br>
|
||||
|
||||
<sl-textarea value="sl-textarea rocks" id="sl-textarea-rocks"></sl-textarea>
|
||||
<sl-clipboard for="sl-textarea-rocks"></sl-clipboard>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { SlClipboard } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<input type="text" value="input rocks" id="input-rocks">
|
||||
<SlClipboard for="input-rocks"></SlClipboard>
|
||||
<br>
|
||||
<textarea id="textarea-rocks">textarea
|
||||
rocks</textarea>
|
||||
<SlClipboard for="textarea-rocks"></SlClipboard>
|
||||
<br>
|
||||
<a href="https://shoelace.style/" id="link-rocks">Shoelace</a>
|
||||
<SlClipboard for="input-rocks"></SlClipboard>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### 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
|
||||
<sl-clipboard for="not-found"></sl-clipboard>
|
||||
<br>
|
||||
<sl-clipboard for="not-found">
|
||||
<sl-button>Copy</sl-button>
|
||||
<sl-button slot="copied">Copied</sl-button>
|
||||
<sl-button slot="error">Error</sl-button>
|
||||
</sl-clipboard>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { SlClipboard } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<SlClipboard for="not-found"></SlClipboard>
|
||||
<SlClipboard for="not-found">
|
||||
<sl-button>Copy</sl-button>
|
||||
<sl-button slot="copied">Copied</sl-button>
|
||||
<sl-button slot="error">Error</sl-button>
|
||||
</SlClipboard>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Change duration of reset to copy button
|
||||
|
||||
```html:preview
|
||||
<sl-clipboard value="shoelace rocks" reset-timeout="500"></sl-clipboard>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { SlClipboard } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<SlClipboard value="shoelace rocks" reset-timeout="500"></SlClipboard>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Supports Shadow Dom
|
||||
|
||||
```html:preview
|
||||
<sl-copy-demo-el></sl-copy-demo-el>
|
||||
|
||||
<script>
|
||||
customElements.define('sl-copy-demo-el', class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<p id="copy-me">copy me (inside shadow root)</p>
|
||||
<sl-clipboard for="copy-me"></sl-clipboard>
|
||||
`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { SlClipboard } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<sl-copy-demo-el></sl-copy-demo-el>
|
||||
</>
|
||||
);
|
||||
|
||||
customElements.define('sl-copy-demo-el', class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<p id="copy-me">copy me (inside shadow root)</p>
|
||||
<sl-clipboard for="copy-me"></sl-clipboard>
|
||||
`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
The public API is partially inspired by https://github.com/github/clipboard-copy-element
|
|
@ -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`
|
||||
<div
|
||||
part="base"
|
||||
aria-live="polite"
|
||||
class=${classMap({
|
||||
clipboard: true,
|
||||
[`clipboard--${this.copyStatus}`]: true
|
||||
})}
|
||||
>
|
||||
<slot id="default" @click=${this.handleClick}>
|
||||
<sl-tooltip content="Copy">
|
||||
<sl-icon-button name="files" label="Copy"></sl-icon-button>
|
||||
</sl-tooltip>
|
||||
</slot>
|
||||
<slot name="copied" @click=${this.handleClick}>
|
||||
<sl-tooltip content="Copied">
|
||||
<sl-icon-button class="green" name="file-earmark-check" label="Copied"></sl-icon-button>
|
||||
</sl-tooltip>
|
||||
</slot>
|
||||
<slot name="error" @click=${this.handleClick}>
|
||||
<sl-tooltip content="Failed to copy">
|
||||
<sl-icon-button class="red" name="file-earmark-x" label="Failed to copy"></sl-icon-button>
|
||||
</sl-tooltip>
|
||||
</slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-clipboard': SlClipboard;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,29 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { aTimeout, expect, fixture, html } from '@open-wc/testing';
|
||||
import type SlClipboard from './clipboard.js';
|
||||
|
||||
describe('<sl-clipboard>', () => {
|
||||
let el: SlClipboard;
|
||||
|
||||
describe('when provided no parameters', () => {
|
||||
before(async () => {
|
||||
el = await fixture<SlClipboard>(html`<sl-clipboard value="something"></sl-clipboard> `);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,4 @@
|
|||
import SlClipboard from './clipboard.component.js';
|
||||
export * from './clipboard.component.js';
|
||||
export default SlClipboard;
|
||||
SlClipboard.define('sl-clipboard');
|
|
@ -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<SlTreeItem> | Map<PropertyKey, unknown>) {
|
||||
protected willUpdate(changedProperties: PropertyValues<SlTreeItem> | Map<PropertyKey, unknown>) {
|
||||
if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) {
|
||||
this.indeterminate = false;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export type SlCopiedEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-copied': SlCopiedEvent;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export type SlCopyingEvent = CustomEvent<Record<PropertyKey, never>>;
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
'sl-copying': SlCopyingEvent;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
Ładowanie…
Reference in New Issue