Add a clipboard copy button and drop the clipboard package (#3060)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-16 16:43:16 +02:00
1 parent 0776b88863
commit 0e2faa00b7
22 files changed
+733 -103

No files matched your search

+2
View File
@@ -1,6 +1,8 @@
# Classes used in the rendered html that no stylesheet defines on purpose:
# JavaScript hooks and demo-only markers. One per line; delete a line when the hook goes.
callout-note
clipboard-feedback
clipboard-label
datagrid-content
datagrid-item
datatable
+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": minor
---
Added a `Clipboard` copy button and dropped the `clipboard` package; the browser API does the work.
+198
View File
@@ -0,0 +1,198 @@
/**
* --------------------------------------------------------------------------
* 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 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<ComponentConfig> & Record<string, unknown>
/**
* 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 Default: ComponentConfig = {
target: null,
text: null,
delay: 2000,
}
const DefaultType: Record<keyof ComponentConfig, string> = {
target: '(string|null)',
text: '(string|number|null)',
delay: 'number',
}
/**
* Helpers
*/
// The `hidden` DOM property belongs to HTMLElement, so an `<svg>` 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<keyof ComponentConfig, string> {
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 ? SelectorEngine.findOne(target) : null
if (!source) {
return ''
}
return source instanceof HTMLInputElement || source instanceof HTMLTextAreaElement ? source.value : (source.textContent ?? '').trim()
}
// Public
async copy(): Promise<void> {
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)
}
dispose(): void {
window.clearTimeout(this._timeout)
super.dispose()
}
// Private
_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
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
Clipboard.getOrCreateInstance(element)
}
// js-docs-end clipboard-init
export default Clipboard
+2
View File
@@ -1,4 +1,5 @@
import './src/autosize'
import './src/clipboard'
import './src/confetti'
import './src/countup'
import './src/input-mask'
@@ -19,6 +20,7 @@ export * from './src/bootstrap'
// Tabler's own components
export { default as Autosize } from './src/autosize'
export { default as Clipboard } from './src/clipboard'
export { default as Confetti } from './src/confetti'
export { default as CountUp } from './src/countup'
export { default as InputMask } from './src/input-mask'
+160
View File
@@ -0,0 +1,160 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import Clipboard from '../../src/clipboard'
const MARKUP = `
<div>
<input id="api-key" value="sk_live_1234">
<button type="button" data-bs-toggle="clipboard" data-bs-target="#api-key">
<span class="clipboard-label">Copy</span>
<span class="clipboard-feedback">Copied</span>
</button>
</div>`
describe('Clipboard', () => {
let fixtureEl: HTMLElement
let written: string[]
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
written = []
// The real clipboard needs a user gesture and a secure context.
vi.stubGlobal('navigator', {
clipboard: {
writeText: (text: string) => {
written.push(text)
return Promise.resolve()
},
},
})
fixtureEl.innerHTML = MARKUP
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
clearFixture()
})
const trigger = (): HTMLElement => fixtureEl.querySelector('button')!
const label = (): HTMLElement => fixtureEl.querySelector('.clipboard-label')!
const hidden = (el: Element): boolean => el.hasAttribute('hidden')
const feedback = (): HTMLElement => fixtureEl.querySelector('.clipboard-feedback')!
describe('NAME', () => {
it('should return plugin name', () => {
expect(Clipboard.NAME).toBe('clipboard')
})
})
describe('constructor', () => {
it('should hide the feedback and mark it as a status', () => {
new Clipboard(trigger())
expect(hidden(feedback())).toBe(true)
expect(feedback().getAttribute('role')).toBe('status')
expect(hidden(label())).toBe(false)
})
})
describe('copy', () => {
it('should copy the value of the target field and show the copied state', async () => {
const spy = vi.fn()
trigger().addEventListener('copied.bs.clipboard', spy)
const instance = new Clipboard(trigger(), { delay: 0 })
await instance.copy()
expect(written).toEqual(['sk_live_1234'])
expect(trigger().classList.contains('copied')).toBe(true)
expect(hidden(label())).toBe(true)
expect(hidden(feedback())).toBe(false)
expect(spy).toHaveBeenCalledTimes(1)
})
it('should read the text of a target that is not a field', async () => {
fixtureEl.innerHTML = '<div><code id="snippet"> npm install @tabler/core </code><button data-bs-toggle="clipboard" data-bs-target="#snippet"></button></div>'
await new Clipboard(trigger()).copy()
expect(written).toEqual(['npm install @tabler/core'])
})
it('should prefer a literal text over the target', async () => {
const instance = new Clipboard(trigger(), { text: 'literal' })
await instance.copy()
expect(written).toEqual(['literal'])
})
it('should copy on click', async () => {
new Clipboard(trigger())
trigger().click()
await vi.waitFor(() => expect(written).toEqual(['sk_live_1234']))
})
it('should go back to the label after the delay', async () => {
const instance = new Clipboard(trigger(), { delay: 50 })
await instance.copy()
expect(hidden(feedback())).toBe(false)
await vi.waitFor(() => expect(hidden(feedback())).toBe(true))
expect(trigger().classList.contains('copied')).toBe(false)
expect(hidden(label())).toBe(false)
})
})
describe('failure', () => {
it('should fire error.bs.clipboard when there is nothing to copy', async () => {
fixtureEl.innerHTML = '<div><button data-bs-toggle="clipboard" data-bs-target="#missing"></button></div>'
const spy = vi.fn()
trigger().addEventListener('error.bs.clipboard', spy)
await new Clipboard(trigger()).copy()
expect(written).toEqual([])
expect(spy).toHaveBeenCalledTimes(1)
})
it('should fire error.bs.clipboard when the browser refuses', async () => {
vi.stubGlobal('navigator', { clipboard: { writeText: () => Promise.reject(new Error('denied')) } })
const spy = vi.fn()
trigger().addEventListener('error.bs.clipboard', spy)
await new Clipboard(trigger()).copy()
expect(spy).toHaveBeenCalledTimes(1)
expect(trigger().classList.contains('copied')).toBe(false)
})
})
describe('svg children', () => {
it('should hide an icon with the attribute, not the DOM property', () => {
fixtureEl.innerHTML = '<div><button data-bs-toggle="clipboard" data-bs-text="x"><svg class="clipboard-label"></svg><svg class="clipboard-feedback"></svg><span class="clipboard-feedback">Copied</span></button></div>'
new Clipboard(trigger())
const icons = fixtureEl.querySelectorAll('.clipboard-feedback')
expect(icons.length).toBe(2)
for (const icon of icons) {
expect(icon.hasAttribute('hidden')).toBe(true)
}
})
})
describe('dispose', () => {
it('should stop the timer and remove the instance', async () => {
const instance = new Clipboard(trigger(), { delay: 50 })
await instance.copy()
instance.dispose()
expect(Clipboard.getInstance(trigger())).toBeNull()
expect(hidden(feedback())).toBe(false)
})
})
})
-6
View File
@@ -148,12 +148,6 @@
"dist/signature_pad.umd.min.js"
]
},
"clipboard": {
"npm": "clipboard",
"js": [
"dist/clipboard.min.js"
]
},
"fullcalendar": {
"npm": "fullcalendar",
"js": [
-1
View File
@@ -252,7 +252,6 @@
"@vitest/coverage-istanbul": "4.1.11",
"apexcharts": "7.0.0",
"choices.js": "^11.2.4",
"clipboard": "^2.0.11",
"driver.js": "^1.8.0",
"dropzone": "^6.0.0-beta.2",
"find-unused-sass-variables": "^6.2.1",
+4 -3
View File
@@ -16,9 +16,10 @@ const highlighted = await highlightCode(code, lang)
---
<div class="position-relative">
<button type="button" class="btn btn-icon btn-dark position-absolute m-2 top-0 end-0 z-3" data-clipboard-text={code} aria-label="Copy code">
<Icon name="clipboard" />
<Icon name="check" class="d-none" />
<button type="button" class="btn btn-icon btn-dark position-absolute m-2 top-0 end-0 z-3" data-bs-toggle="clipboard" data-bs-text={code} aria-label="Copy code">
<Icon name="clipboard" class="clipboard-label" />
<Icon name="check" class="clipboard-feedback" />
<span class="clipboard-feedback visually-hidden">Copied</span>
</button>
<Fragment set:html={highlighted} />
</div>
+5 -4
View File
@@ -1,5 +1,5 @@
---
// Example code from the `html` prop or the rendered slot; copy button uses data-clipboard-text.
// Example code from the `html` prop or the rendered slot; the copy button carries it in data-bs-text.
import Icon from '@ui/Icon.astro'
import { beautifyHtml, highlightCode, removeHref } from '@shared/lib/code-example'
@@ -76,9 +76,10 @@ const highlighted = hideCode ? '' : await highlightCode(beautifyHtml(code ?? htm
{
hasCode && (
<div class:list={['docs-example-code position-relative', hasDemo && 'docs-example-code-attached']}>
<button type="button" class="btn btn-icon btn-dark position-absolute m-2 top-0 end-0 z-3" data-clipboard-text={code ?? html} aria-label="Copy code">
<Icon name="clipboard" />
<Icon name="check" class="d-none" />
<button type="button" class="btn btn-icon btn-dark position-absolute m-2 top-0 end-0 z-3" data-bs-toggle="clipboard" data-bs-text={code ?? html} aria-label="Copy code">
<Icon name="clipboard" class="clipboard-label" />
<Icon name="check" class="clipboard-feedback" />
<span class="clipboard-feedback visually-hidden">Copied</span>
</button>
<Fragment set:html={highlighted} />
</div>
+130
View File
@@ -0,0 +1,130 @@
---
title: Clipboard
summary: A copy button puts the text of a field, a snippet or a literal string on the clipboard, and says so for a moment.
description: Add copy to clipboard buttons that copy a field, an element or a literal string, with a copied state and events.
added-in: '1.6.0'
related: [/ui/forms/elements]
---
import Example from '@components/Example.astro';
import CodeDocs from '@components/CodeDocs.astro';
## Overview
An API key, an invite link or an install command is there to be copied. Add `data-bs-toggle="clipboard"` to a button and point it at what to copy. Tabler writes the text with the browser's own clipboard API, so there is no library to load.
<Example centered>
<div class="input-group">
<input type="text" id="clipboard-key" class="form-control" value="tblr_live_8f2c41d0a7" readonly />
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-target="#clipboard-key">
<span class="clipboard-label">Copy</span>
<span class="clipboard-feedback">Copied</span>
</button>
</div>
</Example>
## Usage
Put the two states in the markup: a `clipboard-label` for the resting button and a `clipboard-feedback` for the moment after a copy. Tabler hides one and shows the other, so both words stay in your own language.
```html
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-target="#api-key">
<span class="clipboard-label">Copy</span>
<span class="clipboard-feedback">Copied</span>
</button>
```
`data-bs-target` takes any selector. A field is read from its value, anything else from its text.
<Example centered>
<code id="clipboard-snippet" class="me-2">npm install @tabler/core</code>
<button type="button" class="btn btn-sm" data-bs-toggle="clipboard" data-bs-target="#clipboard-snippet">
<span class="clipboard-label">Copy command</span>
<span class="clipboard-feedback">Copied</span>
</button>
</Example>
### Colour on the feedback
The feedback element takes any utility, so a `text-green` check confirms the copy in colour. The trigger itself carries a `copied` class while the state lasts, for styling that goes further.
<Example centered>
<div class="input-group input-group-flat">
<input type="text" id="clipboard-green" class="form-control" value="tblr_live_8f2c41d0a7" readonly />
<span class="input-group-text">
<button type="button" class="link-secondary input-group-link border-0 bg-transparent p-0" data-bs-toggle="clipboard" data-bs-target="#clipboard-green" aria-label="Copy the API key">
<svg xmlns="http://www.w3.org/2000/svg" class="icon clipboard-label" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2"/><path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon clipboard-feedback text-green"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M5 12l5 5l10 -10" /></svg>
<span class="clipboard-feedback visually-hidden">Copied</span>
</button>
</span>
</div>
</Example>
### A literal string
Use `data-bs-text` when the text is not on the page.
<Example centered>
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-text="https://tabler.io/">
<span class="clipboard-label">Copy link</span>
<span class="clipboard-feedback">Link copied</span>
</button>
</Example>
### How long the copied state lasts
`data-bs-delay` sets the milliseconds before the button goes back to its label. The default is 2000. Set it to `0` to keep the copied state until you reset it from code.
<Example centered>
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-text="Tabler" data-bs-delay="6000">
<span class="clipboard-label">Copy, six seconds</span>
<span class="clipboard-feedback">Copied</span>
</button>
</Example>
## Options
| Option | Default | Description |
| --- | --- | --- |
| `target` | none | Selector of the element to read. A field gives its value, anything else its text. |
| `text` | none | A literal string to copy. Wins over `target`. |
| `delay` | `2000` | How long the copied state lasts, in milliseconds. `0` keeps it. |
## JavaScript
Tabler ships a `Clipboard` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-bs-toggle="clipboard"`. This is the code that runs:
<CodeDocs name="clipboard-init" file="core/js/src/clipboard.ts" />
Copy from your own code, for example after building a string:
```js
const button = document.getElementById('share');
tabler.Clipboard.getOrCreateInstance(button, { text: window.location.href }).copy();
```
A successful copy fires `copied.bs.clipboard`, a failed one `error.bs.clipboard`:
```js
button.addEventListener('error.bs.clipboard', () => {
console.log('nothing copied');
});
```
| Method | Description |
| --- | --- |
| `copy()` | Copies the text and shows the copied state. Returns a promise. |
| `text` | Getter. The string the button would copy right now. |
| `dispose()` | Stops the timer and removes the component from the element. |
| `getInstance(element)` | Static. Returns the component for the element, or `null`. |
| `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |
## Accessibility
- Write both words in the markup. The feedback element is a `role="status"` and is revealed rather than restyled, so a screen reader announces the copy.
- Keep the button a real `button` with `type="button"`, so it is reachable by keyboard and does not submit a form.
- A button with only an icon needs an `aria-label` saying what it copies, for example `aria-label="Copy the API key"`.
## Browser support
The browser gives `navigator.clipboard` to secure contexts only: `https`, or `localhost` while you develop. On plain `http` the copy fails and `error.bs.clipboard` fires, so a page can say so instead of leaving the button silent.
-4
View File
@@ -1,9 +1,5 @@
/// <reference types="astro/client" />
// clipboard.js, loaded via a page lib (libs.json) as a classic <script> global, not an
// npm package — declared ambiently so `astro check` can resolve it in DocsLayout's copy script.
declare var ClipboardJS: new (selector: Element, options: Record<string, unknown>) => { on(event: string, callback: (e: { clearSelection(): void; trigger: HTMLElement }) => void): void }
declare namespace App {
interface Locals {
/** set by docs/lib/llms.ts while rendering a page for its .md mirror; <MarkdownSource /> emits only then */
+3 -42
View File
@@ -128,8 +128,8 @@ const relative = depth === 0 ? '.' : '../'.repeat(depth).slice(0, -1);
const editFilePath =
editPath ?? (docsUrl === '/' ? 'docs/content/index.mdx' : `docs/content${docsUrl.replace(/\/$/, '')}.mdx`);
// docs-libs + clipboard (always included). In dev, file names stay unchanged
// (e.g. dist/clipboard.min.js keeps .min).
// docs-libs for this page. In dev, file names stay unchanged
// (e.g. dist/plyr.min.js keeps .min).
type Lib = { npm?: string; js?: string[]; css?: string[] };
const libEntries = Object.entries(libs as Record<string, Lib>);
const libUrl = (lib: Lib, file: string) =>
@@ -140,7 +140,7 @@ const docsLibCss = libEntries
.filter(([name]) => docsLibs.includes(name))
.flatMap(([, lib]) => (lib.css ?? []).map((file) => libUrl(lib, file)));
const docsLibJs = libEntries
.filter(([name]) => docsLibs.includes(name) || name === 'clipboard')
.filter(([name]) => docsLibs.includes(name))
.flatMap(([, lib]) => (lib.js ?? []).map((file) => libUrl(lib, file)));
const opencollectiveUrl = 'https://opencollective.com/tabler';
@@ -500,45 +500,6 @@ const relatedPages = await Promise.all(
)
}
<!-- BEGIN PAGE SCRIPTS -->
<script is:inline>
document.addEventListener('DOMContentLoaded', function () {
const elements = document.querySelectorAll('[data-clipboard-text]');
elements.forEach(function (element) {
const clipboard = new ClipboardJS(element, {
text: function () {
return element.getAttribute('data-clipboard-text');
}
});
clipboard.on('success', function (e) {
e.clearSelection();
e.trigger.classList.add('btn-success');
e.trigger.classList.remove('btn-dark');
e.trigger.children[0].classList.add('d-none');
e.trigger.children[1].classList.remove('d-none');
// The checkmark is the only feedback a sighted user gets; the label
// carries the same message to a screen reader.
e.trigger.setAttribute('aria-label', 'Copied');
setTimeout(function () {
e.trigger.classList.remove('btn-success');
e.trigger.classList.add('btn-dark');
e.trigger.children[0].classList.remove('d-none');
e.trigger.children[1].classList.add('d-none');
e.trigger.setAttribute('aria-label', 'Copy code');
}, 2000);
});
clipboard.on('error', function (e) {
console.error('Error copying text: ', e);
});
});
})
</script>
<!-- END PAGE SCRIPTS -->
<!-- BEGIN GLOBAL MANDATORY SCRIPTS -->
<script is:inline src="/dist/js/tabler.js" defer></script>
+1 -1
View File
@@ -167,7 +167,7 @@ async function renderedPage(entry: CollectionEntry<'docs'>): Promise<RenderedPag
.slice(1)
.map((block) => {
// the copy button, or the wrapper attribute when the example hides its code panel
const markup = block.match(/data-clipboard-text="([^"]*)"/) ?? block.match(/data-example-markup="([^"]*)"/)
const markup = block.match(/data-bs-text="([^"]*)"/) ?? block.match(/data-example-markup="([^"]*)"/)
return markup ? beautifyHtml(decodeEntities(markup[1]!)) : null
})
+12
View File
@@ -613,3 +613,15 @@ html {
max-height: calc(100vh - var(--docs-header-height));
overflow-y: auto;
}
// The copy button on a code block turns green while `.copied` is on it
// (core/js/src/clipboard.ts). The hover and active variants are set too: the
// pointer is still over the button right after a click.
.btn-dark.copied {
--tblr-btn-bg: var(--tblr-success);
--tblr-btn-hover-bg: var(--tblr-success);
--tblr-btn-active-bg: var(--tblr-success);
--tblr-btn-border-color: var(--tblr-success);
--tblr-btn-hover-border-color: var(--tblr-success);
--tblr-btn-active-border-color: var(--tblr-success);
}
-34
View File
@@ -135,9 +135,6 @@ importers:
choices.js:
specifier: ^11.2.4
version: 11.2.4
clipboard:
specifier: ^2.0.11
version: 2.0.11
driver.js:
specifier: ^1.8.0
version: 1.8.0
@@ -2227,9 +2224,6 @@ packages:
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
engines: {node: '>= 10.0'}
clipboard@2.0.11:
resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
@@ -2399,9 +2393,6 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
delegate@3.2.0:
resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -2760,9 +2751,6 @@ packages:
globjoin@0.1.4:
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
good-listener@1.2.2:
resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -4058,9 +4046,6 @@ packages:
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
engines: {node: '>=11.0.0'}
select@1.1.2:
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -4349,9 +4334,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
tiny-emitter@2.1.0:
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
@@ -6779,12 +6761,6 @@ snapshots:
dependencies:
source-map: 0.6.1
clipboard@2.0.11:
dependencies:
good-listener: 1.2.2
select: 1.1.2
tiny-emitter: 2.1.0
cliui@9.0.1:
dependencies:
string-width: 7.2.0
@@ -6937,8 +6913,6 @@ snapshots:
delayed-stream@1.0.0: {}
delegate@3.2.0: {}
dequal@2.0.3: {}
destr@2.0.5: {}
@@ -7347,10 +7321,6 @@ snapshots:
globjoin@0.1.4: {}
good-listener@1.2.2:
dependencies:
delegate: 3.2.0
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -8996,8 +8966,6 @@ snapshots:
sax@1.6.1: {}
select@1.1.2: {}
semver@5.7.2: {}
semver@6.3.1: {}
@@ -9362,8 +9330,6 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
tiny-emitter@2.1.0: {}
tiny-inflate@1.0.3: {}
tinybench@2.9.0: {}
+147
View File
@@ -0,0 +1,147 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import FormGroup from '@ui/FormGroup.astro'
import Icon from '@ui/Icon.astro'
import DocsLink from '@ui/DocsLink.astro'
const apiKey = 'tblr_live_8f2c41d0a7'
const inviteLink = 'https://tabler.io/invite/9fbc2e'
---
<DefaultLayout title="Clipboard" pageMenu="base.clipboard" description="A copy button puts the text of a field, a snippet or a literal string on the clipboard, and says so for a moment.">
<DocsLink slot="page-header-actions" path="/ui/plugins/clipboard" />
<div class="row row-cards">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Button with a label</CardTitle>
<CardSubtitle>The button reads <code>data-bs-target</code> and swaps <code>clipboard-label</code> for <code>clipboard-feedback</code>.</CardSubtitle>
<FormGroup label="API key" for="clipboard-key" class="mb-0">
<div class="input-group">
<input type="text" id="clipboard-key" class="form-control" value={apiKey} readonly />
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-target="#clipboard-key">
<span class="clipboard-label">Copy</span>
<span class="clipboard-feedback">Copied</span>
</button>
</div>
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Button with an icon</CardTitle>
<CardSubtitle>An icon-only button needs an <code>aria-label</code> and a hidden word for the announcement.</CardSubtitle>
<FormGroup label="API key" for="clipboard-key-icon" class="mb-0">
<div class="input-group">
<input type="text" id="clipboard-key-icon" class="form-control" value={apiKey} readonly />
<button type="button" class="btn btn-icon" data-bs-toggle="clipboard" data-bs-target="#clipboard-key-icon" aria-label="Copy the API key">
<Icon name="clipboard" class="clipboard-label" />
<Icon name="check" class="clipboard-feedback" />
<span class="clipboard-feedback visually-hidden">Copied</span>
</button>
</div>
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Icon inside the field</CardTitle>
<CardSubtitle>With <code>input-group-flat</code> the button sits in an <code>input-group-text</code>, so it looks like part of the field.</CardSubtitle>
<FormGroup label="Invite link" for="clipboard-invite" class="mb-0">
<div class="input-group input-group-flat">
<input type="text" id="clipboard-invite" class="form-control" value={inviteLink} readonly />
<span class="input-group-text">
<button type="button" class="link-secondary input-group-link border-0 bg-transparent p-0" data-bs-toggle="clipboard" data-bs-target="#clipboard-invite" aria-label="Copy the invite link">
<Icon name="clipboard" class="clipboard-label" />
<Icon name="check" class="clipboard-feedback" />
<span class="clipboard-feedback visually-hidden">Copied</span>
</button>
</span>
</div>
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Green confirmation</CardTitle>
<CardSubtitle>The feedback element takes any utility, so a <code>text-green</code> check confirms the copy in colour.</CardSubtitle>
<FormGroup label="API key" for="clipboard-key-green" class="mb-0">
<div class="input-group input-group-flat">
<input type="text" id="clipboard-key-green" class="form-control" value={apiKey} readonly />
<span class="input-group-text">
<button type="button" class="link-secondary input-group-link border-0 bg-transparent p-0" data-bs-toggle="clipboard" data-bs-target="#clipboard-key-green" aria-label="Copy the API key">
<Icon name="clipboard" class="clipboard-label" />
<Icon name="check" class="clipboard-feedback text-green" />
<span class="clipboard-feedback visually-hidden">Copied</span>
</button>
</span>
</div>
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>A literal string</CardTitle>
<CardSubtitle>Use <code>data-bs-text</code> when the text is not on the page.</CardSubtitle>
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-text="https://tabler.io/">
<span class="clipboard-label"><Icon name="link" class="me-2" />Copy link</span>
<span class="clipboard-feedback"><Icon name="check" class="me-2" />Link copied</span>
</button>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Any element</CardTitle>
<CardSubtitle>A target that is not a field is copied from its text.</CardSubtitle>
<div class="d-flex align-items-center gap-2">
<code id="clipboard-command">npm install @tabler/core</code>
<button type="button" class="btn btn-sm btn-icon" data-bs-toggle="clipboard" data-bs-target="#clipboard-command" aria-label="Copy the install command">
<Icon name="clipboard" class="clipboard-label" />
<Icon name="check" class="clipboard-feedback" />
<span class="clipboard-feedback visually-hidden">Copied</span>
</button>
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>How long it lasts</CardTitle>
<CardSubtitle>Set <code>data-bs-delay</code> in milliseconds. The default is 2000.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-2">
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-text="Tabler" data-bs-delay="500">
<span class="clipboard-label">Half a second</span>
<span class="clipboard-feedback">Copied</span>
</button>
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-text="Tabler" data-bs-delay="6000">
<span class="clipboard-label">Six seconds</span>
<span class="clipboard-feedback">Copied</span>
</button>
</div>
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+43
View File
@@ -0,0 +1,43 @@
---
// Clipboard in its natural place: the API keys panel of a developer settings
// page. The last row is drawn in the state right after a click — a green check
// where the copy icon was — with the fake cursor on it, so the picture shows
// what the button does rather than what it looks like at rest.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import FormGroup from '@ui/FormGroup.astro'
import Icon from '@ui/Icon.astro'
const keys = [
{ id: 'shot-key-publishable', label: 'Publishable key', value: 'tblr_pk_live_2f91c0a7d4', copied: false },
{ id: 'shot-key-secret', label: 'Secret key', value: 'tblr_sk_live_8f2c41d0a7e6', copied: false },
{ id: 'shot-key-webhook', label: 'Webhook signing secret', value: 'whsec_4b17ca9e02d8', copied: true },
]
---
<ScreenshotLayout title="Clipboard" columns={2}>
<Card>
<CardBody class="p-4">
<CardTitle>API keys</CardTitle>
<CardSubtitle>Copy a key straight from the field. The secret keys are shown once.</CardSubtitle>
{
keys.map((key, index) => (
<FormGroup label={key.label} for={key.id} class={index === keys.length - 1 ? 'mb-0' : 'mb-3'}>
<div class="input-group input-group-flat">
<input type="text" id={key.id} class="form-control" value={key.value} readonly />
<span class="input-group-text">
<span class="link-secondary input-group-link position-relative" aria-label={`Copy the ${key.label.toLowerCase()}`}>
{key.copied ? <Icon name="check" class="text-green" /> : <Icon name="clipboard" />}
{key.copied && <span class="cursor" style="top: 90%; left: 70%" />}
</span>
</span>
</div>
</FormGroup>
))
}
</CardBody>
</Card>
</ScreenshotLayout>
+1 -1
View File
@@ -14,7 +14,7 @@ import Button from '@ui/Button.astro'
<ScreenshotLayout title="Password strength" columns={1.6}>
<Card>
<CardBody class="p-4">
<CardTitle class="mb-3">Create your account</CardTitle>
<CardTitle>Create your account</CardTitle>
<FormGroup label="Email address" for="shot-email">
<input type="email" id="shot-email" class="form-control" value="paweł@tabler.io" autocomplete="email" />
</FormGroup>
@@ -366,6 +366,16 @@ import FormGroup from '@ui/FormGroup.astro'
<Strength />
</FormGroup>
<FormGroup label="API key" for="fe5-clipboard-key" hint="The copy button reads the value of the field next to it.">
<div class="input-group">
<input type="text" id="fe5-clipboard-key" class="form-control" value="tblr_live_8f2c41d0a7" readonly />
<button type="button" class="btn" data-bs-toggle="clipboard" data-bs-target="#fe5-clipboard-key">
<span class="clipboard-label">Copy</span>
<span class="clipboard-feedback">Copied</span>
</button>
</div>
</FormGroup>
<FormGroup label="Autosize textarea" for="fe5-textarea-autosize">
<textarea class="form-control" id="fe5-textarea-autosize" data-bs-toggle="autosize" placeholder="Type something…"></textarea>
</FormGroup>
+4
View File
@@ -434,6 +434,10 @@
"title": "Chart",
"url": "/ui/plugins/chart"
},
{
"title": "Clipboard",
"url": "/ui/plugins/clipboard"
},
{
"title": "Color picker",
"url": "/ui/plugins/color-picker"
+4
View File
@@ -131,6 +131,10 @@
"title": "Carousel",
"url": "carousel.html"
},
"clipboard": {
"url": "clipboard.html",
"title": "Clipboard"
},
"colors": {
"url": "colors.html",
"title": "Colors"
-7
View File
@@ -12,13 +12,6 @@
"description": "The most popular front-end framework for developing responsive, mobile first projects on the web.",
"license": "MIT"
},
{
"name": "clipboard.js",
"npm": "clipboard",
"url": "https://clipboardjs.com/",
"description": "Copies text to the clipboard without Flash or a framework.",
"license": "MIT"
},
{
"name": "Coloris",
"npm": "@melloware/coloris",