/** * -------------------------------------------------------------------------- * Tabler clipboard.ts * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) * -------------------------------------------------------------------------- */ import BaseComponent from './bootstrap/base-component' import EventHandler from './bootstrap/dom/event-handler' import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions' import { getElement } from './bootstrap/util/index' import type { ElementSelector } from './bootstrap/types' type ComponentConfig = { /** selector of the element to read the text from */ target: string | null /** literal text to copy; wins over `target` */ text: string | null /** how long the copied state lasts, in milliseconds */ delay: number } type ComponentConfigInput = Partial & Record /** * Constants */ const NAME = 'clipboard' const DATA_KEY = `bs.${NAME}` const EVENT_KEY = `.${DATA_KEY}` const EVENT_COPIED = `copied${EVENT_KEY}` const EVENT_ERROR = `error${EVENT_KEY}` const CLASS_NAME_COPIED = 'copied' const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]` const SELECTOR_LABEL = `.${NAME}-label` const SELECTOR_FEEDBACK = `.${NAME}-feedback` const ATTRIBUTE_TEXT = 'data-bs-text' const ATTRIBUTE_TEXT_ALIAS = 'data-tblr-text' const Default: ComponentConfig = { target: null, text: null, delay: 2000, } const DefaultType: Record = { target: '(string|null)', text: '(string|number|null)', delay: 'number', } /** * Helpers */ // The `hidden` DOM property belongs to HTMLElement, so an `` ignores it. // The attribute works on any element. const toggleHidden = (element: Element, hidden: boolean): void => { if (hidden) { element.setAttribute('hidden', '') } else { element.removeAttribute('hidden') } } /** * Class definition * * Copies text to the clipboard when the trigger is clicked, then shows the * copied state for a moment. Every word lives in the markup: the trigger holds * a `clipboard-label` and a `clipboard-feedback`, and the component only swaps * which of them is hidden. * * The browser gives `navigator.clipboard` to secure contexts only, so on plain * http a copy fails and `error.bs.clipboard` fires. */ class Clipboard extends BaseComponent { declare _element: HTMLElement declare _config: ComponentConfig _labels: Element[] = [] _feedbacks: Element[] = [] _timeout = 0 constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) if (!this._element) { return } // Several of each are allowed: an icon-only button pairs a check icon with // a visually hidden word, and both switch together. this._labels = SelectorEngine.find(SELECTOR_LABEL, this._element) this._feedbacks = SelectorEngine.find(SELECTOR_FEEDBACK, this._element) for (const feedback of this._feedbacks) { // Revealed rather than restyled, so a screen reader announces it. toggleHidden(feedback, true) if (!feedback.hasAttribute('role')) { feedback.setAttribute('role', 'status') } } EventHandler.on(this._element, `click${EVENT_KEY}`, (event: Event) => { event.preventDefault() void this.copy() }) } // Getters static get Default(): ComponentConfig { return Default } static get DefaultType(): Record { return DefaultType } static get NAME(): string { return NAME } get text(): string { const { text, target } = this._config if (text !== null && text !== '') { return String(text) } const source = target ? getElement(target) : null if (!source) { return '' } if (source instanceof HTMLInputElement || source instanceof HTMLTextAreaElement) { return source.value } // innerText keeps line breaks and skips hidden text such as a // visually-hidden label; textContent is the fallback for SVG. return (source instanceof HTMLElement ? source.innerText : ((source as Element).textContent ?? '')).trim() } // Public async copy(): Promise { const { text } = this if (!text || !navigator.clipboard) { EventHandler.trigger(this._element, EVENT_ERROR) return } try { await navigator.clipboard.writeText(text) } catch { EventHandler.trigger(this._element, EVENT_ERROR) return } this._showCopied() EventHandler.trigger(this._element, EVENT_COPIED) } /** Ends the copied state, for a `delay` of 0 or an early reset. */ reset(): void { window.clearTimeout(this._timeout) this._toggleCopied(false) } dispose(): void { window.clearTimeout(this._timeout) super.dispose() } // Private _mergeConfigObj(config?: Record, element?: HTMLElement): Record { // The text is copied as written: `data-bs-text="007"` or `"true"` must not // be read as a number or a boolean the way other data attributes are. const raw = element?.getAttribute(ATTRIBUTE_TEXT) ?? element?.getAttribute(ATTRIBUTE_TEXT_ALIAS) const dataOptions: Record = raw === null || raw === undefined ? {} : { text: raw } return super._mergeConfigObj({ ...dataOptions, ...config }, element) } _showCopied(): void { window.clearTimeout(this._timeout) this._toggleCopied(true) if (this._config.delay > 0) { this._timeout = window.setTimeout(() => this._toggleCopied(false), this._config.delay) } } _toggleCopied(copied: boolean): void { this._element.classList.toggle(CLASS_NAME_COPIED, copied) for (const label of this._labels) { toggleHidden(label, copied) } for (const feedback of this._feedbacks) { toggleHidden(feedback, !copied) } } } /** * Data API implementation */ // js-docs-start clipboard-init initAll(SELECTOR_DATA_TOGGLE, Clipboard) // js-docs-end clipboard-init export default Clipboard