diff --git a/package-lock.json b/package-lock.json index 72bb9d4e4..0589fcb2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5168,6 +5168,10 @@ "resolved": "packages/uui-tag", "link": true }, + "node_modules/@umbraco-ui/uui-text-copy": { + "resolved": "packages/uui-text-copy", + "link": true + }, "node_modules/@umbraco-ui/uui-textarea": { "resolved": "packages/uui-textarea", "link": true @@ -24570,6 +24574,14 @@ "@umbraco-ui/uui-base": "1.12.2" } }, + "packages/uui-text-copy": { + "name": "@umbraco-ui/uui-text-copy", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@umbraco-ui/uui-base": "1.12.2" + } + }, "packages/uui-textarea": { "name": "@umbraco-ui/uui-textarea", "version": "1.12.2", diff --git a/packages/uui-text-copy/README.md b/packages/uui-text-copy/README.md new file mode 100644 index 000000000..dc0226b89 --- /dev/null +++ b/packages/uui-text-copy/README.md @@ -0,0 +1,31 @@ +# uui-text-copy + +![npm](https://img.shields.io/npm/v/@umbraco-ui/uui-text-copy?logoColor=%231B264F) + +Umbraco style text-copy component. + +## Installation + +### ES imports + +```zsh +npm i @umbraco-ui/uui-text-copy +``` + +Import the registration of `` via: + +```javascript +import '@umbraco-ui/uui-text-copy'; +``` + +When looking to leverage the `UUITextCopyElement` base class as a type and/or for extension purposes, do so via: + +```javascript +import { UUITextCopyElement } from '@umbraco-ui/uui-text-copy'; +``` + +## Usage + +```html + +``` diff --git a/packages/uui-text-copy/lib/UUITextCopyEvent.ts b/packages/uui-text-copy/lib/UUITextCopyEvent.ts new file mode 100644 index 000000000..332b81e3f --- /dev/null +++ b/packages/uui-text-copy/lib/UUITextCopyEvent.ts @@ -0,0 +1,21 @@ +import { UUIEvent } from '@umbraco-ui/uui-base/lib/events'; +import { UUITextCopyElement } from './uui-text-copy.element'; + +interface UUITextCopyEventInit extends EventInit { + detail?: { text: string }; +} + +export class UUITextCopyEvent extends UUIEvent< + { text: string }, + UUITextCopyElement +> { + public static readonly COPIED: string = 'copied'; + public static readonly COPYING: string = 'copying'; + + constructor(evName: string, eventInit: UUITextCopyEventInit = {}) { + super(evName, { + ...{ bubbles: true }, + ...eventInit, + }); + } +} diff --git a/packages/uui-text-copy/lib/index.ts b/packages/uui-text-copy/lib/index.ts new file mode 100644 index 000000000..62e5ff0fb --- /dev/null +++ b/packages/uui-text-copy/lib/index.ts @@ -0,0 +1,2 @@ +export * from './uui-text-copy.element'; +export * from './UUITextCopyEvent'; diff --git a/packages/uui-text-copy/lib/uui-text-copy.element.ts b/packages/uui-text-copy/lib/uui-text-copy.element.ts new file mode 100644 index 000000000..67fa9983f --- /dev/null +++ b/packages/uui-text-copy/lib/uui-text-copy.element.ts @@ -0,0 +1,171 @@ +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { defineElement } from '@umbraco-ui/uui-base/lib/registration'; +import { demandCustomElement } from '@umbraco-ui/uui-base/lib/utils'; +import { UUIButtonElement } from '@umbraco-ui/uui-button/lib'; +import { UUIInterfaceColor, UUIInterfaceLook } from '@umbraco-ui/uui-base'; +import { LabelMixin } from '@umbraco-ui/uui-base/lib/mixins'; +import { UUITextCopyEvent } from './UUITextCopyEvent'; + +/** + * @summary A button to trigger text content to be copied to the clipboard + * @element uui-text-copy + * @dependency uui-button + * @dependency uui-icon + * @fires {UUITextCopyEvent} copying - Fires before the content is about to copied to the clipboard and can be used to transform or modify the data before its added to the clipboard + * @fires {UUITextCopyEvent} copied - Fires when the content is copied to the clipboard + * @slot - Use to replace the default content of 'Copy' and the copy icon + */ +@defineElement('uui-text-copy') +export class UUITextCopyElement extends LabelMixin('', LitElement) { + /** + * Set a string you wish to copy to the clipboard + * @type {string} + * @default '' + */ + @property({ type: String }) + value = ''; + + /** + * Disables the button + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean }) + disabled = false; + + /** + * Copies the text content from another element by specifying the ID of the element + * The ID of the element does not need to start with # like a CSS selector + * If this property is set, the value property is ignored + * @type {string} + * @attr + * @default '' + * @example copy-from="element-id" + */ + @property({ type: String, attribute: 'copy-from' }) + copyFrom = ''; + + /** + * Changes the look of the button to one of the predefined, symbolic looks. + * @type {"default" | "primary" | "secondary" | "outline" | "placeholder"} + * @attr + * @default "default" + */ + @property() + look: UUIInterfaceLook = 'default'; + + /** + * Changes the look of the button to one of the predefined, symbolic looks. For example - set this to positive if you want nice, green "confirm" button. + * @type {"default" | "positive" | "warning" | "danger"} + * @attr + * @default "default" + */ + @property({ reflect: true }) + color: UUIInterfaceColor = 'default'; + + /** + * Makes the left and right padding of the button narrower. + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean }) + compact = false; + + /** + * The delay in milliseconds before the button returns to its default state after a successful copy + * @type {number} + * @attr + * @default 250 + */ + @property({ type: Number, attribute: 'animation-state-delay' }) + animationStateDelay = 250; + + constructor() { + super(); + demandCustomElement(this, 'uui-button'); + demandCustomElement(this, 'uui-icon'); + } + + // Used to store the value that will be copied to the clipboard + #valueToCopy = ''; + + readonly #onClick = async (e: Event) => { + const button = e.target as UUIButtonElement; + button.state = 'waiting'; + + // By default use the value property + this.#valueToCopy = this.value; + + // If copy-from is set use that instead + if (this.copyFrom) { + // Try & find an element with the ID + const el = document.getElementById(this.copyFrom); + if (el) { + this.#valueToCopy = el.textContent ?? el.innerText ?? ''; + + // Override the value to copy ,if the element has a value property + // Such as uui-input or uui-textarea or native inout elements + if ('value' in el) { + this.#valueToCopy = (el as any).value; + } + } else { + console.error(`Element ID ${this.copyFrom} not found to copy from`); + button.state = 'failed'; + return; + } + } + + const beforeCopyEv = new UUITextCopyEvent(UUITextCopyEvent.COPYING, { + detail: { text: this.#valueToCopy }, + }); + this.dispatchEvent(beforeCopyEv); + + if (beforeCopyEv.detail.text != null) { + this.#valueToCopy = beforeCopyEv.detail.text; + } + + try { + await navigator.clipboard.writeText(this.#valueToCopy); + this.dispatchEvent( + new UUITextCopyEvent(UUITextCopyEvent.COPIED, { + detail: { text: this.#valueToCopy }, + }), + ); + setTimeout(() => { + button.state = 'success'; + }, this.animationStateDelay); + } catch (err) { + button.state = 'failed'; + console.error('Error copying to clipboard', err); + } + }; + + render() { + return html` + Copy + `; + } + + static readonly styles = [ + css` + slot { + pointer-events: none; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'uui-text-copy': UUITextCopyElement; + } +} diff --git a/packages/uui-text-copy/lib/uui-text-copy.story.ts b/packages/uui-text-copy/lib/uui-text-copy.story.ts new file mode 100644 index 000000000..fff1a8b5d --- /dev/null +++ b/packages/uui-text-copy/lib/uui-text-copy.story.ts @@ -0,0 +1,265 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; + +import './uui-text-copy.element'; +import type { UUITextCopyElement } from './uui-text-copy.element'; +import readme from '../README.md?raw'; + +// For the story to show the example of using inside an uui-input +import '@umbraco-ui/uui-input/lib'; + +import '@umbraco-ui/uui-button/lib'; +import '@umbraco-ui/uui-loader-circle/lib'; + +import { UUITextCopyEvent } from './UUITextCopyEvent'; + +const meta: Meta = { + id: 'uui-text-copy', + title: 'Buttons/Text Copy', + component: 'uui-text-copy', + parameters: { + readme: { markdown: readme }, + }, + args: { + value: 'Hey stop copying me 🥸', + color: 'default', + look: 'default', + }, + argTypes: { + look: { + options: ['default', 'primary', 'secondary', 'outline', 'placeholder'], + control: 'select', + }, + color: { + options: ['default', 'positive', 'warning', 'danger'], + control: 'select', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Overview: Story = { + name: 'Simple Copy', + args: { + value: 'Hey stop copying me 🥸', + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export const WithLabelSet: Story = { + name: 'Simple Copy with an A11y Label set', + args: { + value: 'Hey stop copying me 🥸', + disabled: false, + label: 'This is my A11y label I want', + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export const Disabled: Story = { + name: 'Disabled State', + args: { + value: 'You cannot copy this', + disabled: true, + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export const CustomSlotContent: Story = { + name: 'Custom Slot Content', + args: { + value: 'Custom slot content', + }, + render: args => html` + Custom Copy Text + `, + parameters: { + docs: { + source: { + code: `Custom Copy Text`, + }, + }, + }, +}; + +export const ColorAndLook: Story = { + name: 'Color and Look', + args: { + value: 'Copy this text', + color: 'positive', + look: 'primary', + }, + render: args => html` + + Copy + + `, + parameters: { + docs: { + source: { + code: ` + + `, + }, + }, + }, +}; + +export const CopiedEvent: Story = { + name: 'Copied Event', + args: { + value: 'Copy this text', + }, + render: args => html` + { + alert(`Copied text: ${event.detail.text}`); + }}> + `, + parameters: { + docs: { + source: { + code: ` + + + `, + }, + }, + }, +}; + +export const ModifyClipboardContent: Story = { + name: 'Modify Clipboard Content', + args: { + value: 'Original text', + }, + render: args => html` + { + event.detail.text += ' - Modified before copying'; + }}> + `, + parameters: { + docs: { + source: { + code: ` + + + `, + }, + }, + }, +}; + +export const EmptyValueErrorState: Story = { + name: 'Not Found Element set in Copy From - shows an Error State', + args: { + copyFrom: 'idSelectorDoesNotExist', + }, + render: args => html` + + `, + parameters: { + docs: { + source: { + code: ` + + `, + }, + }, + }, +}; + +export const CopyFromInput: Story = { + name: 'Copy From uui-input', + render: () => html` + + + + + + `, + parameters: { + docs: { + source: { + code: ` + + + + + + `, + }, + }, + }, +}; + +export const AnimationDelay: Story = { + name: 'Animation Delay', + args: { + value: 'A long 3 second delay', + animationStateDelay: 3000, + }, + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +export const WithDifferentIcon: Story = { + name: 'Different Icon', + args: { + value: 'I have used a different icon', + }, + render: args => html` + + Copy to Clipboard + + `, + parameters: { + docs: { + source: { + code: ` + + Copy to Clipboard +`, + }, + }, + }, +}; diff --git a/packages/uui-text-copy/lib/uui-text-copy.test.ts b/packages/uui-text-copy/lib/uui-text-copy.test.ts new file mode 100644 index 000000000..f91e25e43 --- /dev/null +++ b/packages/uui-text-copy/lib/uui-text-copy.test.ts @@ -0,0 +1,30 @@ +import { html, fixture, expect } from '@open-wc/testing'; +import { UUITextCopyElement } from './uui-text-copy.element'; + +describe('UUITextCopyElement', () => { + let element: UUITextCopyElement; + + beforeEach(async () => { + element = await fixture( + html``, + ); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UUITextCopyElement); + }); + + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(); + }); + + it('renders correctly', async () => { + expect(element).shadowDom.to.equal(` + + + Copy + + + `); + }); +}); diff --git a/packages/uui-text-copy/package.json b/packages/uui-text-copy/package.json new file mode 100644 index 000000000..8ce050d1a --- /dev/null +++ b/packages/uui-text-copy/package.json @@ -0,0 +1,44 @@ +{ + "name": "@umbraco-ui/uui-text-copy", + "version": "0.0.0", + "license": "MIT", + "keywords": [ + "Umbraco", + "Custom elements", + "Web components", + "UI", + "Lit", + "Text Copy" + ], + "description": "Umbraco UI text-copy component", + "repository": { + "type": "git", + "url": "https://github.com/umbraco/Umbraco.UI.git", + "directory": "packages/uui-text-copy" + }, + "bugs": { + "url": "https://github.com/umbraco/Umbraco.UI/issues" + }, + "main": "./lib/index.js", + "module": "./lib/index.js", + "types": "./lib/index.d.ts", + "type": "module", + "customElements": "custom-elements.json", + "files": [ + "lib/**/*.d.ts", + "lib/**/*.js", + "custom-elements.json" + ], + "dependencies": { + "@umbraco-ui/uui-base": "1.12.2" + }, + "scripts": { + "build": "npm run analyze && tsc --build --force && rollup -c rollup.config.js", + "clean": "tsc --build --clean && rimraf -g dist lib/*.js lib/**/*.js *.tgz lib/**/*.d.ts custom-elements.json", + "analyze": "web-component-analyzer **/*.element.ts --outFile custom-elements.json" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://uui.umbraco.com/?path=/story/uui-text-copy" +} diff --git a/packages/uui-text-copy/rollup.config.js b/packages/uui-text-copy/rollup.config.js new file mode 100644 index 000000000..34524a90d --- /dev/null +++ b/packages/uui-text-copy/rollup.config.js @@ -0,0 +1,5 @@ +import { UUIProdConfig } from '../rollup-package.config.mjs'; + +export default UUIProdConfig({ + entryPoints: ['index'], +}); diff --git a/packages/uui-text-copy/tsconfig.json b/packages/uui-text-copy/tsconfig.json new file mode 100644 index 000000000..40d176776 --- /dev/null +++ b/packages/uui-text-copy/tsconfig.json @@ -0,0 +1,17 @@ +// Don't edit this file directly. It is generated by /scripts/generate-ts-config.js + +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./lib", + "composite": true + }, + "include": ["./**/*.ts"], + "exclude": ["./**/*.test.ts", "./**/*.story.ts"], + "references": [ + { + "path": "../uui-base" + } + ] +} diff --git a/packages/uui/lib/index.ts b/packages/uui/lib/index.ts index 7a6290236..db377e0a0 100644 --- a/packages/uui/lib/index.ts +++ b/packages/uui/lib/index.ts @@ -80,3 +80,4 @@ export * from '@umbraco-ui/uui-toast-notification-layout/lib'; export * from '@umbraco-ui/uui-toast-notification/lib'; export * from '@umbraco-ui/uui-toggle/lib'; export * from '@umbraco-ui/uui-visually-hidden/lib'; +export * from '@umbraco-ui/uui-text-copy/lib/';