diff --git a/.build/markup-classes-baseline.txt b/.build/markup-classes-baseline.txt index 092507a37..090026cf6 100644 --- a/.build/markup-classes-baseline.txt +++ b/.build/markup-classes-baseline.txt @@ -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 diff --git a/.changeset/clipboard.md b/.changeset/clipboard.md new file mode 100644 index 000000000..6c005341d --- /dev/null +++ b/.changeset/clipboard.md @@ -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. diff --git a/core/js/src/clipboard.ts b/core/js/src/clipboard.ts new file mode 100644 index 000000000..993fbf7c1 --- /dev/null +++ b/core/js/src/clipboard.ts @@ -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 & 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 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 ? SelectorEngine.findOne(target) : null + if (!source) { + return '' + } + + return source instanceof HTMLInputElement || source instanceof HTMLTextAreaElement ? source.value : (source.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) + } + + 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 diff --git a/core/js/tabler.ts b/core/js/tabler.ts index 85b77d3be..f4cbd1cde 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -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' diff --git a/core/js/tests/unit/clipboard.spec.ts b/core/js/tests/unit/clipboard.spec.ts new file mode 100644 index 000000000..905acbba2 --- /dev/null +++ b/core/js/tests/unit/clipboard.spec.ts @@ -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 = ` +
+ + +
` + +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 = '
npm install @tabler/core
' + + 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 = '
' + 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 = '
' + + 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) + }) + }) +}) diff --git a/core/libs.json b/core/libs.json index d9499b5cc..f6766d67a 100644 --- a/core/libs.json +++ b/core/libs.json @@ -148,12 +148,6 @@ "dist/signature_pad.umd.min.js" ] }, - "clipboard": { - "npm": "clipboard", - "js": [ - "dist/clipboard.min.js" - ] - }, "fullcalendar": { "npm": "fullcalendar", "js": [ diff --git a/core/package.json b/core/package.json index c8359a112..c8cc36b3d 100644 --- a/core/package.json +++ b/core/package.json @@ -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", diff --git a/docs/components/CodeSnippet.astro b/docs/components/CodeSnippet.astro index 78e003078..09fe3e28b 100644 --- a/docs/components/CodeSnippet.astro +++ b/docs/components/CodeSnippet.astro @@ -16,9 +16,10 @@ const highlighted = await highlightCode(code, lang) ---
-
diff --git a/docs/components/Example.astro b/docs/components/Example.astro index 318902dca..bef3dd00b 100644 --- a/docs/components/Example.astro +++ b/docs/components/Example.astro @@ -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 && (
-
diff --git a/docs/content/ui/plugins/clipboard.mdx b/docs/content/ui/plugins/clipboard.mdx new file mode 100644 index 000000000..aa67a1ee9 --- /dev/null +++ b/docs/content/ui/plugins/clipboard.mdx @@ -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. + + +
+ + +
+
+ +## 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 + +``` + +`data-bs-target` takes any selector. A field is read from its value, anything else from its text. + + +npm install @tabler/core + + + +### 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. + + +
+ + + + +
+
+ +### A literal string + +Use `data-bs-text` when the text is not on the page. + + + + + +### 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. + + + + + +## 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: + + + +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. diff --git a/docs/env.d.ts b/docs/env.d.ts index 80691bc80..18051a1be 100644 --- a/docs/env.d.ts +++ b/docs/env.d.ts @@ -1,9 +1,5 @@ /// -// clipboard.js, loaded via a page lib (libs.json) as a classic - diff --git a/docs/lib/llms.ts b/docs/lib/llms.ts index 5e76688f5..24a6b8ef7 100644 --- a/docs/lib/llms.ts +++ b/docs/lib/llms.ts @@ -167,7 +167,7 @@ async function renderedPage(entry: CollectionEntry<'docs'>): Promise { // 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 }) diff --git a/docs/scss/docs.scss b/docs/scss/docs.scss index a30a7d3b6..a1fa8cb89 100644 --- a/docs/scss/docs.scss +++ b/docs/scss/docs.scss @@ -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); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 251b81fa8..501fcf8ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/preview/pages/clipboard.astro b/preview/pages/clipboard.astro new file mode 100644 index 000000000..647a5f9fa --- /dev/null +++ b/preview/pages/clipboard.astro @@ -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' +--- + + + + +
+
+ + + Button with a label + The button reads data-bs-target and swaps clipboard-label for clipboard-feedback. + +
+ + +
+
+
+
+
+ +
+ + + Button with an icon + An icon-only button needs an aria-label and a hidden word for the announcement. + +
+ + +
+
+
+
+
+ +
+ + + Icon inside the field + With input-group-flat the button sits in an input-group-text, so it looks like part of the field. + +
+ + + + +
+
+
+
+
+ +
+ + + Green confirmation + The feedback element takes any utility, so a text-green check confirms the copy in colour. + +
+ + + + +
+
+
+
+
+ +
+ + + A literal string + Use data-bs-text when the text is not on the page. + + + +
+ +
+ + + Any element + A target that is not a field is copied from its text. +
+ npm install @tabler/core + +
+
+
+
+ +
+ + + How long it lasts + Set data-bs-delay in milliseconds. The default is 2000. +
+ + +
+
+
+
+
+
diff --git a/screenshots/pages/clipboard.astro b/screenshots/pages/clipboard.astro new file mode 100644 index 000000000..267477654 --- /dev/null +++ b/screenshots/pages/clipboard.astro @@ -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 }, +] +--- + + + + + API keys + Copy a key straight from the field. The secret keys are shown once. + { + keys.map((key, index) => ( + +
+ + + + {key.copied ? : } + {key.copied && } + + +
+
+ )) + } +
+
+
diff --git a/screenshots/pages/password-strength.astro b/screenshots/pages/password-strength.astro index e81f5d75e..0bff764de 100644 --- a/screenshots/pages/password-strength.astro +++ b/screenshots/pages/password-strength.astro @@ -14,7 +14,7 @@ import Button from '@ui/Button.astro' - Create your account + Create your account diff --git a/shared/components/forms/FormElements5.astro b/shared/components/forms/FormElements5.astro index 2260ff2b0..6147811e5 100644 --- a/shared/components/forms/FormElements5.astro +++ b/shared/components/forms/FormElements5.astro @@ -366,6 +366,16 @@ import FormGroup from '@ui/FormGroup.astro' + +
+ + +
+
+ diff --git a/shared/data/docs.json b/shared/data/docs.json index 6d5aab22a..a97d14311 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -434,6 +434,10 @@ "title": "Chart", "url": "/ui/plugins/chart" }, + { + "title": "Clipboard", + "url": "/ui/plugins/clipboard" + }, { "title": "Color picker", "url": "/ui/plugins/color-picker" diff --git a/shared/data/menu.json b/shared/data/menu.json index c54af9ad1..632dd3796 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -131,6 +131,10 @@ "title": "Carousel", "url": "carousel.html" }, + "clipboard": { + "url": "clipboard.html", + "title": "Clipboard" + }, "colors": { "url": "colors.html", "title": "Colors" diff --git a/shared/data/open-source.json b/shared/data/open-source.json index 7b2e03360..f0c914348 100644 --- a/shared/data/open-source.json +++ b/shared/data/open-source.json @@ -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",