diff --git a/docs/pages/components/copy-button.md b/docs/pages/components/copy-button.md new file mode 100644 index 00000000..f5a1f156 --- /dev/null +++ b/docs/pages/components/copy-button.md @@ -0,0 +1,252 @@ +--- +meta: + title: Copy Button + description: Copies data to the clipboard when the user clicks the button. +layout: component +--- + +```html:preview + +``` + +```jsx:react +import { SlCopyButton } from '@shoelace-style/shoelace/dist/react/sl-copy-button'; + +const App = () => ( + +); +``` + +## Examples + +### Custom Labels + +Copy Buttons display feedback labels in a tooltip. You can change the labels by setting the `copy-label`, `success-label`, and `error-label` attributes. + +```html:preview + +``` + +```jsx:react +import { SlCopyButton } from '@shoelace-style/shoelace/dist/react/sl-copy-button'; + +const App = () => ( + +); +``` + +### Custom Icons + +Use the `copy-icon`, `success-icon`, and `error-icon` slots to customize the icons that get displayed for each state. You can use [``](/components/icon) or your own images. + +```html:preview + + + + + +``` + +```jsx:react +import { SlCopyButton } from '@shoelace-style/shoelace/dist/react/sl-copy-button'; +import { SlIcon } from '@shoelace-style/shoelace/dist/react/sl-icon'; + +const App = () => ( + <> + + + + + + +); +``` + +### Copying Values From Other Elements + +Normally, the data that gets copied will come from the component's `value` attribute, but you can copy data from any element within the same document by providing its `id` to the `from` attribute. + +When using the `from` attribute, the element's [`textContent`](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent) will be copied by default. Passing an attribute or property modifier will let you copy data from one of the element's attributes or properties instead. + +To copy data from an attribute, use `from="id[attr]"` where `id` is the id of the target element and `attr` is the name of the attribute you'd like to copy. To copy data from a property, use `from="id.prop"` where `id` is the id of the target element and `prop` is the name of the property you'd like to copy. + +```html:preview + ++1 (234) 456-7890 + + +

+ + + + + +

+ + +Shoelace Website + +``` + +```jsx:react +import { SlCopyButton } from '@shoelace-style/shoelace/dist/react/sl-copy-button'; +import { SlInput } from '@shoelace-style/shoelace/dist/react/sl-input'; + +const App = () => ( + <> + {/* Copies the span's textContent */} + +1 (234) 456-7890 + + +

+ + {/* Copies the input's "value" property */} + + + +

+ + {/* Copies the link's "href" attribute */} + Shoelace Website + + +); +``` + +### Handling Errors + +Copy errors occur if the value is an empty string, if the `from` attribute points to an id that doesn't exist, or if the browser rejects the operation for any reason. This example shows what happens when a copy error occurs. + +```html:preview + +``` + +```jsx:react +import { SlCopyButton } from '@shoelace-style/shoelace/dist/react/sl-copy-button'; + +const App = () => ( + +); +``` + +### Disabled + +Copy buttons can be disabled by adding the `disabled` attribute. + +```html:preview + +``` + +```jsx:react +import { SlCopyButton } from '@shoelace-style/shoelace/dist/react/sl-copy-button'; + +const App = () => ( + +); +``` + +### Changing Feedback Duration + +A success indicator is briefly shown after copying. You can customize the length of time the indicator is shown using the `feedback-duration` attribute. + +```html:preview + +``` + +```jsx:react +import { SlCopyButton } from '@shoelace-style/shoelace/dist/react/sl-copy-button'; + +const App = () => ( + +); +``` + +### Custom Styles + +You can customize the button to your liking with CSS. + +```html:preview + + + +``` + +```jsx:react +import { SlCopyButton } from '@shoelace-style/shoelace/dist/react/sl-copy-button'; + +const css = ` + .custom-styles { + --success-color: white; + --error-color: white; + color: white; + } + + .custom-styles::part(button) { + background-color: #ff1493; + border: solid 4px #ff7ac1; + border-right-color: #ad005c; + border-bottom-color: #ad005c; + border-radius: 0; + transition: 100ms scale ease-in-out, 100ms translate ease-in-out; + } + + .custom-styles::part(button):hover { + scale: 1.1; + } + + .custom-styles::part(button):active { + translate: 0 2px; + } + + .custom-styles::part(button):focus-visible { + outline: dashed 2px deeppink; + outline-offset: 4px; + } +`; + +const App = () => ( + <> + + + + +); +``` diff --git a/docs/pages/components/copy.md b/docs/pages/components/copy.md deleted file mode 100644 index 0206f012..00000000 --- a/docs/pages/components/copy.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -meta: - title: Copy - description: Copies data to the clipboard when the user clicks or taps the trigger. -layout: component ---- - -```html:preview - -``` - -```jsx:react -import { SlCopy } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - -); -``` - -## Examples - -### Custom Buttons - -Use the default slot to customize the copy trigger. You can also customize the success and error messages using the respective slots. - -```html:preview - - Copy - Copied! - Error - -``` - -```jsx:react -import { SlButton, SlCopy } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - <> - - Copy - Copied! - Error - - -); -``` - -### Copying the Value From Other Elements - -By default, the data to copy will come from the `value` attribute. You - -```html:preview -+1 (234) 456-7890 - - -

- - - - -

- -Shoelace Website - -``` - -```jsx:react -import { SlCopy, SlInput } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - <> - +1 (234) 456-7890 - - -

- - - - -

- - Shoelace Website - - -); -``` - -### Displaying Copy Errors - -Copy errors can occur if the value is an empty string, if the `from` attribute points to an id that doesn't exist, or if the browser rejects the operation. You can customize the error that's shown by populating the `error` slot with your own content. - -```html:preview - - -

- - - Copy - Copied - Error - -``` - -```jsx:react -import { SlCopy } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - <> - - -

- - - Copy - Copied - Error - - -); -``` - -### Showing Tooltips - -You can wrap a tooltip around `` to provide a hint to users. - -```html:preview - - - -``` - -```jsx:react -import { SlCopy, SlTooltip } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - - - -); -``` - -### Changing Feedback Duration - -A success indicator is briefly shown after copying. You can customize the length of time the indicator is shown using the `feedback-duration` attribute. - -```html:preview - -``` - -```jsx:react -import { SlCopy } from '@shoelace-style/shoelace/dist/react'; - -const App = () => ( - -); -``` diff --git a/docs/pages/resources/changelog.md b/docs/pages/resources/changelog.md index 0a4184b0..d5550563 100644 --- a/docs/pages/resources/changelog.md +++ b/docs/pages/resources/changelog.md @@ -14,9 +14,10 @@ New versions of Shoelace are released as-needed and generally occur when a criti ## Next -- Added the `` component [#1473] +- Added the `` component [#1473] - Fixed a bug in `` where pressing [[Up]] or [[Down]] when focused on the trigger wouldn't focus the first/last menu items [#1472] - Improved the behavior of the clear button in `` to prevent the component's width from shifting when toggled [#1496] +- Improved `` to prevent user selection so the tooltip doesn't get highlighted when dragging selections - Removed `sideEffects` key from `package.json`. Update React docs to use cherry-picking. [#1485] - Updated Bootstrap Icons to 1.10.5 diff --git a/src/components/copy-button/copy-button.component.ts b/src/components/copy-button/copy-button.component.ts new file mode 100644 index 00000000..fa205640 --- /dev/null +++ b/src/components/copy-button/copy-button.component.ts @@ -0,0 +1,234 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query, state } from 'lit/decorators.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import SlIcon from '../icon/icon.component.js'; +import SlTooltip from '../tooltip/tooltip.component.js'; +import styles from './copy-button.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Copies text data to the clipboard when the user clicks the trigger. + * @documentation https://shoelace.style/components/copy + * @status experimental + * @since 2.7 + * + * @dependency sl-icon + * @dependency sl-tooltip + * + * @event sl-copy - Emitted when the data has been copied. + * @event sl-error - Emitted when the data could not be copied. + * + * @slot copy-icon - The icon to show in the default copy state. Works best with ``. + * @slot success-icon - The icon to show when the content is copied. Works best with ``. + * @slot error-icon - The icon to show when a copy error occurs. Works best with ``. + * + * @csspart button - The internal ` + + `; + } +} + +setDefaultAnimation('copy.in', { + keyframes: [ + { scale: '.25', opacity: '.25' }, + { scale: '1', opacity: '1' } + ], + options: { duration: 100 } +}); + +setDefaultAnimation('copy.out', { + keyframes: [ + { scale: '1', opacity: '1' }, + { scale: '.25', opacity: '0' } + ], + options: { duration: 100 } +}); + +declare global { + interface HTMLElementTagNameMap { + 'sl-copy-button': SlCopyButton; + } +} diff --git a/src/components/copy-button/copy-button.styles.ts b/src/components/copy-button/copy-button.styles.ts new file mode 100644 index 00000000..29cd4cfb --- /dev/null +++ b/src/components/copy-button/copy-button.styles.ts @@ -0,0 +1,49 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles.js'; + +export default css` + ${componentStyles} + + :host { + --error-color: var(--sl-color-danger-600); + --success-color: var(--sl-color-success-600); + + display: inline-block; + } + + .copy-button__button { + flex: 0 0 auto; + display: flex; + align-items: center; + background: none; + border: none; + border-radius: var(--sl-border-radius-medium); + font-size: inherit; + color: inherit; + padding: var(--sl-spacing-x-small); + cursor: pointer; + transition: var(--sl-transition-x-fast) color; + } + + .copy-button--success .copy-button__button { + color: var(--success-color); + } + + .copy-button--error .copy-button__button { + color: var(--error-color); + } + + .copy-button__button:focus-visible { + outline: var(--sl-focus-ring); + outline-offset: var(--sl-focus-ring-offset); + } + + .copy-button__button[disabled] { + opacity: 0.5; + cursor: not-allowed !important; + } + + slot { + display: inline-flex; + } +`; diff --git a/src/components/copy-button/copy-button.test.ts b/src/components/copy-button/copy-button.test.ts new file mode 100644 index 00000000..79eb344c --- /dev/null +++ b/src/components/copy-button/copy-button.test.ts @@ -0,0 +1,20 @@ +import '../../../dist/shoelace.js'; +import { expect, fixture, html } from '@open-wc/testing'; +import type SlCopyButton from './copy-button.js'; + +// We use aria-live to announce labels via tooltips +const ignoredRules = ['button-name']; + +describe('', () => { + let el: SlCopyButton; + + describe('when provided no parameters', () => { + before(async () => { + el = await fixture(html` `); + }); + + it('should pass accessibility tests', async () => { + await expect(el).to.be.accessible({ ignoredRules }); + }); + }); +}); diff --git a/src/components/copy-button/copy-button.ts b/src/components/copy-button/copy-button.ts new file mode 100644 index 00000000..0283a1e8 --- /dev/null +++ b/src/components/copy-button/copy-button.ts @@ -0,0 +1,4 @@ +import SlCopyButton from './copy-button.component.js'; +export * from './copy-button.component.js'; +export default SlCopyButton; +SlCopyButton.define('sl-copy-button'); diff --git a/src/components/copy/copy.component.ts b/src/components/copy/copy.component.ts deleted file mode 100644 index 61b99da1..00000000 --- a/src/components/copy/copy.component.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js'; -import { html } from 'lit'; -import { LocalizeController } from '../../utilities/localize.js'; -import { property, query } from 'lit/decorators.js'; -import ShoelaceElement from '../../internal/shoelace-element.js'; -import SlIconButton from '../icon-button/icon-button.component.js'; -import styles from './copy.styles.js'; -import type { CSSResultGroup } from 'lit'; - -/** - * @summary Copies data to the clipboard when the user clicks or taps the trigger. - * @documentation https://shoelace.style/components/copy - * @status experimental - * @since 2.7 - * - * @dependency sl-icon-button - * - * @event sl-copied - Emitted when the data has been copied. - * @event sl-error - Emitted when the data could not be copied. - * - * @slot - A button that triggers copying. - * @slot success - A button to briefly show when copying is successful. - * @slot error - A button to briefly show when a copy error occurs. - * - * @animation copy.in - The animation to use when copy buttons animate in. - * @animation copy.out - The animation to use when copy buttons animate out. - */ -export default class SlCopy extends ShoelaceElement { - static styles: CSSResultGroup = styles; - static dependencies = { - 'sl-icon-button': SlIconButton - }; - - private readonly localize = new LocalizeController(this); - - @query('slot:not([name])') defaultSlot: HTMLSlotElement; - @query('slot[name="success"]') successSlot: HTMLSlotElement; - @query('slot[name="error"]') errorSlot: HTMLSlotElement; - - /** The text value to copy. */ - @property({ type: String }) value = ''; - - /** The length of time to show feedback before restoring the default trigger. */ - @property({ attribute: 'feedback-duration', type: Number }) feedbackDuration = 1000; - - /** - * An id that references an element in the same document from which data will be copied. If the element is a link, the - * `href` will be copied. If the element is a form control or has a `value` property, its `value` will be copied. - * Otherwise, the element's text content will be copied. - */ - @property({ type: String }) from = ''; - - connectedCallback() { - super.connectedCallback(); - this.setAttribute('aria-live', 'polite'); - } - - private async handleCopy() { - // Copy the value by default - let valueToCopy = this.value; - - // If an element is specified, copy from that instead - if (this.from) { - const root = this.getRootNode() as ShadowRoot | Document; - const target = 'getElementById' in root ? root.getElementById(this.from) : false; - - if (target) { - if (target instanceof HTMLAnchorElement && target.hasAttribute('href')) { - valueToCopy = target.href; - } else if ('value' in target) { - valueToCopy = String(target.value); - } else { - valueToCopy = target.textContent || ''; - } - } else { - this.showStatus('error'); - this.emit('sl-error'); - } - } - - // Copy from the value property otherwise - if (valueToCopy) { - try { - await navigator.clipboard.writeText(valueToCopy); - this.showStatus('success'); - this.emit('sl-copied'); - } catch (error) { - this.showStatus('error'); - this.emit('sl-error'); - } - } - } - - private async showStatus(status: 'success' | 'error') { - const target = status === 'success' ? this.successSlot : this.errorSlot; - const showAnimation = getAnimation(this, 'copy.in', { dir: 'ltr' }); - const hideAnimation = getAnimation(this, 'copy.out', { dir: 'ltr' }); - - await this.defaultSlot.animate(hideAnimation.keyframes, hideAnimation.options).finished; - this.defaultSlot.hidden = true; - - target.hidden = false; - await target.animate(showAnimation.keyframes, showAnimation.options).finished; - - setTimeout(async () => { - await target.animate(hideAnimation.keyframes, hideAnimation.options).finished; - target.hidden = true; - this.defaultSlot.hidden = false; - this.defaultSlot.animate(showAnimation.keyframes, showAnimation.options); - }, this.feedbackDuration); - } - - render() { - return html` - - - - - - - - `; - } -} - -setDefaultAnimation('copy.in', { - keyframes: [ - { scale: '.25', opacity: '.25' }, - { scale: '1', opacity: '1' } - ], - options: { duration: 125 } -}); - -setDefaultAnimation('copy.out', { - keyframes: [ - { scale: '1', opacity: '1' }, - { scale: '.25', opacity: '0' } - ], - options: { duration: 125 } -}); - -declare global { - interface HTMLElementTagNameMap { - 'sl-copy': SlCopy; - } -} diff --git a/src/components/copy/copy.styles.ts b/src/components/copy/copy.styles.ts deleted file mode 100644 index 77cbf72c..00000000 --- a/src/components/copy/copy.styles.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { css } from 'lit'; -import componentStyles from '../../styles/component.styles.js'; - -export default css` - ${componentStyles} - - :host { - display: inline-block; - cursor: pointer; - } - - slot { - display: inline-flex; - } - - .copy { - background: none; - border: none; - font: inherit; - color: inherit; - padding: 0; - margin: 0; - } -`; diff --git a/src/components/copy/copy.test.ts b/src/components/copy/copy.test.ts deleted file mode 100644 index 91349632..00000000 --- a/src/components/copy/copy.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import '../../../dist/shoelace.js'; -import { expect, fixture, html } from '@open-wc/testing'; -import type SlCopy from './copy.js'; - -describe('', () => { - let el: SlCopy; - - 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/copy/copy.ts b/src/components/copy/copy.ts deleted file mode 100644 index ce56b309..00000000 --- a/src/components/copy/copy.ts +++ /dev/null @@ -1,4 +0,0 @@ -import SlCopy from './copy.component.js'; -export * from './copy.component.js'; -export default SlCopy; -SlCopy.define('sl-copy'); diff --git a/src/components/icon/library.system.ts b/src/components/icon/library.system.ts index 0dedf7e6..e822055f 100644 --- a/src/components/icon/library.system.ts +++ b/src/components/icon/library.system.ts @@ -41,8 +41,8 @@ const icons = { `, copy: ` - - + + `, eye: ` diff --git a/src/components/tooltip/tooltip.styles.ts b/src/components/tooltip/tooltip.styles.ts index a88bf1f6..c4d9d6db 100644 --- a/src/components/tooltip/tooltip.styles.ts +++ b/src/components/tooltip/tooltip.styles.ts @@ -51,5 +51,6 @@ export default css` color: var(--sl-tooltip-color); padding: var(--sl-tooltip-padding); pointer-events: none; + user-select: none; } `; diff --git a/src/events/events.ts b/src/events/events.ts index 8786b25d..739fcd67 100644 --- a/src/events/events.ts +++ b/src/events/events.ts @@ -8,7 +8,7 @@ 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 { default as SlCopiedEvent } from './sl-copied'; +export type { default as SlCopyEvent } from './sl-copy'; 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 deleted file mode 100644 index b170d5cb..00000000 --- a/src/events/sl-copied.ts +++ /dev/null @@ -1,9 +0,0 @@ -type SlCopiedEvent = CustomEvent>; - -declare global { - interface GlobalEventHandlersEventMap { - 'sl-copied': SlCopiedEvent; - } -} - -export default SlCopiedEvent; diff --git a/src/events/sl-copy.ts b/src/events/sl-copy.ts new file mode 100644 index 00000000..65e697b5 --- /dev/null +++ b/src/events/sl-copy.ts @@ -0,0 +1,9 @@ +type SlCopyEvent = CustomEvent<{ value: string }>; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-copy': SlCopyEvent; + } +} + +export default SlCopyEvent; diff --git a/src/shoelace.ts b/src/shoelace.ts index 5c02bdfd..e6660e9d 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -13,7 +13,7 @@ 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 SlColorPicker } from './components/color-picker/color-picker.js'; -export { default as SlCopy } from './components/copy/copy.js'; +export { default as SlCopyButton } from './components/copy-button/copy-button.js'; export { default as SlDetails } from './components/details/details.js'; export { default as SlDialog } from './components/dialog/dialog.js'; export { default as SlDivider } from './components/divider/divider.js';